Skip to content

Add IMcpTaskExecutor for delegating task execution to an external runtime - #1843

Open
trey-herrington wants to merge 5 commits into
modelcontextprotocol:mainfrom
trey-herrington:task-executor-extension-point
Open

Add IMcpTaskExecutor for delegating task execution to an external runtime#1843
trey-herrington wants to merge 5 commits into
modelcontextprotocol:mainfrom
trey-herrington:task-executor-extension-point

Conversation

@trey-herrington

Copy link
Copy Markdown

Fixes #1820.

What

Adds a stable extension point that lets WithTasks delegate execution to a durable system (Temporal, Orleans, Hangfire, an external queue) after the task record is created, instead of always running the tool in-process.

public interface IMcpTaskExecutor
{
    ValueTask StartAsync(McpTaskExecutionContext context, CancellationToken cancellationToken);
}

McpTaskExecutionContext exposes the task ID and info, the matched tool request bound to a fresh execution scope, a RunToolPipelineAsync helper that runs the normal tool pipeline locally and records the outcome in the store, a cancellation token wired to tasks/cancel, and DisposeAsync for releasing scope-bound services when execution is handed off externally.

Registration

Either on options:

builder.WithTasks(store, options =>
{
    options.TaskExecutor = new TemporalTaskExecutor(...);
});

or via DI:

builder.Services.AddSingleton<IMcpTaskExecutor, TemporalTaskExecutor>();
builder.WithTasks(store);

When neither is configured, tasks execute in-process on the thread pool exactly as before, and tasks/get, tasks/update, and tasks/cancel remain entirely on IMcpTaskStore.

Semantics

  • StartAsync returns once execution is durably started (e.g. the external runtime accepted the job), mirroring the durability requirement SEP-2663 §306 places on CreateTaskAsync. It does not wait for completion; after a successful StartAsync the SDK stops tracking the task and the store is the single source of truth.
  • If StartAsync throws, the task is marked failed via SetFailedAsync so the client never polls a zombie task.
  • Cancellation: tasks/cancel still calls SetCancelledAsync and fires the context's CancellationToken. Executors that run the pipeline locally observe it exactly as today; external executors can register on the token to propagate cancellation to their runtime.
  • Scope ownership: RunToolPipelineAsync disposes the execution scope on completion; executors that hand off externally call DisposeAsync once they no longer need Request (idempotent).
  • Filter and authorization ordering is unchanged — the executor is invoked after CreateTaskAsync and after scope/interceptor wiring, so ordering is identical whether or not a custom executor is configured.
  • Alternate-result types do not leak into the contract: the pipeline is reached through RunToolPipelineAsync, not a raw next delegate.

Known limitation

Elicitation and sampling issued from outside the process that owns the client session cannot be routed through the task's input-request channel. External workers that need multi-round-trip input should rely on the store's InputResponseReceived event or run the pipeline locally from the session-owning process. Documented in the new docs section.

Commits

  1. Add IMcpTaskExecutor with an execution context for task delegation — new types only, no behavior change
  2. Route WithTasks execution through IMcpTaskExecutor — dispatch rewire; options → DI → process-local default
  3. Add tests for custom task executors — 9 tests
  4. Document delegating task execution to an external runtime

Tests

New McpServerTaskExecutorTests (9 tests) cover: context contents (task ID, working status, matched primitive, scope-bound services, tool not started until executor runs the pipeline), external completion without in-process execution, local pipeline execution recording results in the store, scope disposal after completion, StartAsync throwing marks the task Failed, tasks/cancel firing the executor's token, DisposeAsync releasing the scope idempotently, RunToolPipelineAsync after dispose throwing ObjectDisposedException, and late pipeline start observing cancellation.

Existing task suites pass unchanged: ModelContextProtocol.Tests net10.0 full run 2366 passed / 2 failed (both DockerEverythingServerTests container startup timeouts, unrelated — Docker environment), AspNetCore task integration tests 18/18.

Happy to adjust the interface shape based on API review feedback. The design discussion is on #1820.

Replace the hard-coded process-local Task.Run dispatch with executor
selection: McpTasksOptions.TaskExecutor, then a single IMcpTaskExecutor
registered in DI, then the process-local default. StartAsync failures
mark the task failed via SetFailedAsync on the existing background
recording path. Behavior with no custom executor is unchanged.
Copilot AI lite review requested due to automatic review settings August 28, 2026 03:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new server-side extension point to delegate MCP Tasks execution to an external runtime while preserving the existing in-process default behavior and keeping task state authoritative in IMcpTaskStore.

Changes:

  • Introduces IMcpTaskExecutor and McpTaskExecutionContext to allow task execution handoff (or local pipeline execution via RunToolPipelineAsync).
  • Rewires WithTasks background execution to dispatch through the executor (options override → DI → process-local default).
  • Adds tests and documentation covering external delegation, cancellation, scope ownership, and failure semantics.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/ModelContextProtocol.Tests/Server/McpServerTaskExecutorTests.cs Adds coverage for custom executors, scope disposal, cancellation wiring, and start-failure behavior.
src/ModelContextProtocol.Extensions.Tasks/Server/ProcessLocalMcpTaskExecutor.cs Implements the default process-local executor using the existing pipeline behavior.
src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs Adds an options-level TaskExecutor override with DI fallback semantics.
src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs Routes task execution start through IMcpTaskExecutor and adds start-failure recording and disposal helpers.
src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionContext.cs Defines the context contract for executor implementations (request scope, cancellation, pipeline helper, disposal).
src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskExecutor.cs Adds the executor interface contract and semantics documentation.
docs/concepts/tasks/tasks.md Documents delegating task execution and known limitations (elicitation/sampling outside session-owning process).
Suppressed comments (1)

src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs:202

  • The executor selection uses _registeredExecutor, which is resolved once from the root service provider. This breaks DI lifetime expectations (scoped/transient executors won’t behave as intended) and prevents per-task scoped dependencies in executor constructors. Resolve the executor from the task’s execution scope instead (it will still return a singleton if registered as such).
            var executor = _taskOptions.TaskExecutor ?? _registeredExecutor ?? ProcessLocalMcpTaskExecutor.Instance;

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

…ures inline

Resolving IMcpTaskExecutor per-task from the execution scope instead of
eagerly from the root provider gives scoped and transient registrations
correct lifetimes, and resolution happens before the task record is
created so a DI misconfiguration fails tools/call rather than leaving a
stuck Working task. StartAsync failures are now recorded inline before
the task alternate is returned, so a client's first poll observes the
terminal state instead of racing it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a stable server-side MCP Task execution extension point

2 participants