Skip to content

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

Open
trey-herrington wants to merge 7 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 7 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.

@cecilphillip cecilphillip left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for taking this on. This looks like a useful, well-contained extension point, and I appreciate that the existing process-local behavior remains the default.

I reviewed it from the perspective of integrating an external durable runtime. The executor/store separation looks workable: the executor can handle durable submission, while a runtime-aware task store can handle status, results, cancellation, and input responses. I do not think the SDK needs to incorporate runtime-specific lifecycle concepts.

I left a few comments where I think the external-execution boundaries could be explained more explicitly. Most are documentation suggestions rather than requests to expand the SDK's responsibilities. I also noticed one small concurrency issue in McpTaskExecutionContext.

With those points addressed, I think this would provide a solid foundation for durable external runtimes.

Comment thread docs/concepts/tasks/tasks.md Outdated
Comment thread docs/concepts/tasks/tasks.md
Comment thread docs/concepts/tasks/tasks.md
Comment thread docs/concepts/tasks/tasks.md
Comment thread src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionContext.cs Outdated
Spell out what the original tools/call receives when StartAsync throws
(a CreateTaskResult, with the failure surfacing on the client's first
tasks/get poll), the crash window between CreateTaskAsync and StartAsync
returning and the recovery strategies integrations must own, that the
execution context's CancellationToken only signals cancellation in the
creating process, and that a pure handoff to an external runtime
bypasses the filters that run inside RunToolPipelineAsync.
RunToolPipelineAsync and DisposeAsync both used a plain check-and-set on
_disposed, so concurrent callers could both observe false — running the
tool pipeline twice or disposing the context while execution was in
flight. Replace the flag with an Interlocked.CompareExchange transition
so exactly one caller wins, and add a regression test that races two
concurrent pipeline starts and asserts only one execution of the tool.
@trey-herrington

Copy link
Copy Markdown
Author

@cecilphillip I believe I have addressed all comments if you wanna give it another look. I'll leave the conversations unresolved. I am new to open source, so I'm unware on the etiquette of if you need to resolve them vs. me. So I'll leave them alone just to make sure.

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.

🟡 Changes recommended

There are correctness and contract-alignment issues to address (notably guarding start-failure writes from overwriting terminal task states, and adjusting the new executor tests to reflect the non-blocking StartAsync contract).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

tests/ModelContextProtocol.Tests/Server/McpServerTaskExecutorTests.cs:318

  • CallbackTaskExecutor.StartAsync awaits RunToolPipelineAsync, which means tools/call won’t return the CreateTaskResult until the tool finishes. That conflicts with the IMcpTaskExecutor.StartAsync contract (durably started, not completed) and reduces test coverage of the asynchronous task path.

This issue also appears on line 402 of the same file.

            if (test._runPipelineLocally)
            {
                await context.RunToolPipelineAsync(context.CancellationToken).ConfigureAwait(false);
            }

tests/ModelContextProtocol.Tests/Server/McpServerTaskExecutorTests.cs:406

  • DiTaskExecutor.StartAsync returns RunToolPipelineAsync directly, so tools/call blocks until the tool finishes instead of returning once execution is started. This can mask regressions in the intended async task execution flow.
        public ValueTask StartAsync(McpTaskExecutionContext context, CancellationToken cancellationToken)
        {
            test._executorInvoked.TrySetResult(context);
            return context.RunToolPipelineAsync(context.CancellationToken);
        }
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment on lines +279 to +284
try
{
var error = new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, Message = exception.Message };
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcErrorDetail>());
await _store.SetFailedAsync(context.TaskId, errorJson).ConfigureAwait(false);
}
@cecilphillip

Copy link
Copy Markdown

Thanks @trey-herrington. You move fast. Ultimately, you should resolve them if you can. Also, at some point, one of the maintainers will reach out with their own feedback/comments.

In the meantime, I'm going spike a few more scenarios and let you know if I find anything else.

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

3 participants