Skip to main content
Glama

Durable MCP Connector

MCP connector for Temporal Nexus. The MCP client can be a Temporal Workflow or any other MCP client.

  • Inbound: the MCP server is a Nexus service.

  • Outbound: a Nexus proxy fronts upstream MCP servers over HTTP or stdio. Every tool call runs in a standalone Activity. Discovery runs outside Activities.

This repository is a prototype. See ARCHITECTURE.md for the design.

flowchart LR
  C["MCP client<br>not on Temporal"] -->|"MCP"| K["Connector"]
  W["Agent Workflow"] --> A["in_workflow_client"]
  K -->|"Nexus"| S["Inbound:<br>Nexus service with MCP tools"]
  A -->|"Nexus"| S
  K -->|"Nexus"| P["Outbound:<br>Nexus proxy"]
  A -->|"Nexus"| P
  P -->|"tool calls: standalone Activities"| U["Upstream MCP servers<br>HTTP or stdio"]
  D["Background discovery"] -->|"tools/list"| U
  D -->|"tool snapshot"| P

Callers use the same connector and in-Workflow client for both. The tool name is the Nexus operation name in both cases.

Components

Directory

Feature

Languages

src/inbound/

MCP server over HTTP or stdio. A library and binary for callers outside Temporal.

Go

src/workflow_transport/

List and call tools from Workflow code through Nexus.

Go, Python

src/authoring/

Expose native Nexus operations as MCP tools.

Go, Python

src/outbound/

Worker plugins that front upstream MCP servers. Tool calls run in standalone Activities.

Go, Python

Implementation code lives under src/. Go uses one module at the repository root. Python uses a uv workspace with one package per feature. examples/ holds runnable hosts and callers. tests/ holds cross-component integration tests. Unit tests stay with each implementation.

Related MCP server: SelfHeal MCP

Requirements

  • Go 1.26 or later

  • Python 3.11 or later and uv

  • just

  • Temporal CLI 1.9.1 or later. The dev server must enable standalone Nexus operations, standalone activities, and activity callbacks. just temporal does this.

The prototype pins Temporal and Nexus RPC SDK fork commits for dynamic service composition. Python pins are in pyproject.toml and uv.lock; Go replacements are in go.mod. Imports keep their normal SDK package names. The first Python install builds the Temporal Rust bridge and requires Rust and Protocol Buffers (protoc).

Build and test

uv sync
just build   # builds the connector and Go examples
just test    # Go and Python unit tests
just fmt     # format Go source and test files

For integration tests, run just temporal in a separate terminal, then just test-integration. Use just test-all to run unit and integration tests together. See tests/README.md.

Author a tool service

Go authors use authoring.NewService and authoring.RegisterTool with native Nexus operations. See the Go guide and tool Worker. Run just nexus-backed-go from examples/ in place of the Python tool Worker.

Keep the nexusrpc decorators. Add the MCP decorators below them. Handlers use Temporal's unified operation API in both languages.

from datetime import timedelta

import nexus_backed_mcp as nexus_mcp
import nexusrpc
import nexusrpc.handler
import temporalio.nexus


@nexusrpc.service(name="my-tools")
@nexus_mcp.service                      # declares list_tools
class MyService:
    lookup: nexusrpc.Operation[LookupInput, str]
    run_report: nexusrpc.Operation[ReportInput, Report]


@nexusrpc.handler.service_handler(service=MyService)
@nexus_mcp.service_handler              # implements list_tools
class MyTools:
    @nexus_mcp.tool(title="Look up a key")
    @temporalio.nexus.temporal_operation
    async def lookup(
        self,
        ctx: temporalio.nexus.TemporalStartOperationContext,
        client: temporalio.nexus.TemporalNexusClient,
        input: LookupInput,
    ) -> temporalio.nexus.TemporalOperationResult[str]:
        """Short tool. A sync Nexus operation."""
        return temporalio.nexus.TemporalOperationResult.sync("Found a value.")

    @nexus_mcp.tool(title="Run a report", schedule_to_close_timeout=timedelta(minutes=30))
    @temporalio.nexus.temporal_operation
    async def run_report(
        self,
        ctx: temporalio.nexus.TemporalStartOperationContext,
        client: temporalio.nexus.TemporalNexusClient,
        input: ReportInput,
    ) -> temporalio.nexus.TemporalOperationResult[Report]:
        """Long tool. ReportWorkflow does the work."""
        return await client.start_workflow(ReportWorkflow.run, input, id=f"report-{ctx.request_id}")

Decorator

Put it

Does

@nexus_mcp.service

Below @nexusrpc.service

Adds the list_tools operation to the service definition

@nexus_mcp.service_handler

Below @nexusrpc.handler.service_handler

Adds the list_tools implementation to the handler

@nexus_mcp.tool(...)

Above @temporalio.nexus.temporal_operation

Marks the operation as a tool. Sets title, annotations, metadata, and schedule_to_close_timeout.

@nexus_mcp.exclude

Above @temporalio.nexus.temporal_operation

Keeps the operation out of the tools of an expose="all" handler

Tools are opt-in by default. An operation without @nexus_mcp.tool is not a tool. With @nexus_mcp.service_handler(expose="all"), every operation is a tool, except those with @nexus_mcp.exclude. The tool name is the Nexus operation name. The description defaults to the method docstring.

schedule_to_close_timeout bounds each call of the tool. The connector and the in-Workflow client set it on the Nexus operation.

Outbound: front an upstream MCP server

MCPProxyPlugin(upstreams=[...]) registers one Nexus service per upstream. Services can share a Worker and endpoint. The upstream servers need no changes.

Go provides the same proxy through outbound.NewPlugin([]outbound.Upstream). See the Go library guide and example Worker. Run just nexus-proxy-go from examples/ to use it instead of the Python Worker.

from datetime import timedelta

from nexus_proxy_mcp import MCPProxyPlugin, Upstream, http_client_factory
from temporalio.common import RetryPolicy
from temporalio.worker import Worker

proxy = MCPProxyPlugin(upstreams=[
    Upstream(
        service="weather-tools",
        client_factory=http_client_factory("https://mcp.example.com/mcp",
            headers={"Authorization": f"Bearer {token}"}),
        activity_options_overrides={
            "get_weather": {"start_to_close_timeout": timedelta(seconds=3)},
            "get_forecast_report": {
                "start_to_close_timeout": timedelta(hours=48),
                "retry_policy": RetryPolicy(maximum_attempts=3),
            },
        },
    ),
])
worker = Worker(client, task_queue="weather-proxy", plugins=[proxy])
  • Each Upstream takes a factory for an official MCP SDK client. The factory owns HTTP or stdio, authentication, and cleanup. Credentials stay in the Worker.

  • Native SAA options go on Upstream.activity_options or in activity_options_overrides[tool_name]. Only supplied keys change. Priority, fairness, search attributes, summary, and start delay use SDK types.

  • The proxy refreshes each tool catalog in the background. list_tools returns its latest usable snapshot. Discovery does not schedule Activities.

  • The default attempt timeout is one minute. Read-only and other eligible tools use the default Temporal retry policy, with no attempt cap. Explicit destructive, non-idempotent tools get one attempt. An explicit retry policy replaces inference.

  • Existing calls keep their recorded inputs/options after discovery changes. A retried Nexus start reattaches before reading discovery.

  • Tool results keep their text, images, resources, structured content, metadata, and isError flag. Transport failures use stable Temporal error types.

See Activity defaults, error handling, and the proxy example, which includes HTTP and stdio factories in one plugin.

Call the tools

From Workflow code, use InWorkflowClient. It returns MCP tool definitions and MCP tool results:

from in_workflow_client import InWorkflowClient

client = InWorkflowClient(service="my-tools", endpoint="my-tools-endpoint")
tools = await client.list_tools()
result = await client.call_tool("lookup", {"key": "a"})

Go Workflows use workflowtransport.NewClient, ListTools(ctx), and CallTool(ctx, name, arguments), with workflow.Context. See the Go guide and caller Worker. Run just workflow-caller-go from examples/ for a call without an LLM.

Use in_workflow_client.openai_agents.WorkflowNexusMCPServer(service=..., endpoint=...) for the OpenAI Agents SDK. The core client has no AI SDK dependency. temporal_agent.py uses this binding.

From any MCP host, add the connector as a stdio MCP server:

{
  "mcpServers": {
    "my-tools": {
      "command": "durable-mcp-connector",
      "args": ["--route", "{service: my-tools, endpoint: my-tools-endpoint}"],
      "env": {"TEMPORAL_ADDRESS": "localhost:7233", "TEMPORAL_NAMESPACE": "default"}
    }
  }
}

Connector flags

Flag

Default

Meaning

--route '{service: SERVICE, endpoint: ENDPOINT}'

none

One complete Nexus service/endpoint route. Required; repeat for more routes.

--transport

stdio

stdio or http (Streamable HTTP)

--addr

127.0.0.1:8080

Listen address for http

--task-after

2s

Initial wait before returning a task to a task-capable caller. Other callers wait until completion or recognized cancellation.

--connector-name

none

Logical deployment name, shared by replicas and included in operation IDs.

--operation-id-prefix

generated

Override the prefix before the random request ID. It is for naming and visibility; task access does not depend on it.

--codec-endpoint

none

URL of a remote codec server. Set it when the Nexus handler encodes payloads, for example to encrypt them. The codec must match the codec of the handler.

--auth-config

none

YAML file with inbound OAuth resource-server settings. Requires --transport http.

The connector reads Temporal connection settings from the environment and from a temporal.toml profile: TEMPORAL_ADDRESS, TEMPORAL_NAMESPACE, TEMPORAL_PROFILE, TEMPORAL_CONFIG_FILE, and related variables. Set the caller namespace with TEMPORAL_NAMESPACE.

Each --route has named service and endpoint fields. Repeat the flag to expose multiple services. Quote each object in the shell. JSON objects also work.

Inbound HTTP authentication

Use --auth-config to require access tokens from your identity provider:

# Generate once. Store this secret and load the same value on every replica.
export MCP_TASK_SIGNING_KEY="$(openssl rand -base64 32)"
bin/durable-mcp-connector --transport http --addr 127.0.0.1:8080 \
    --route '{service: my-tools, endpoint: my-tools-endpoint}' \
    --auth-config examples/inbound_auth.yaml

Set the public resource URL, trusted issuer, and API audience in inbound_auth.yaml. Configure the identity provider to issue access tokens for that audience and the required scopes. Terminate HTTPS at your reverse proxy. MCP is served at the path in auth.resource, /mcp in the example. The metadata endpoint is public.

The MCP client handles login and token refresh. The connector checks each request's token and keeps it out of Temporal calls and history. Tasks are scoped to the verified issuer and subject. Token renewal and another replica keep the same scope. All callers with valid tokens and the required scopes can use the configured tools. Hosts can add MCP middleware for finer permissions.

Each task ID contains a signed reference to the Temporal namespace, operation ID, run ID, and caller hash. The client retains this string and sends it back to poll or cancel. The connector verifies its signature and owner before accessing Temporal. It keeps no task-to-owner table. The JSON inside the task ID is readable; its HMAC signature prevents changes. The caller hash and JWT are not sent to Temporal.

HTTP requires MCP_TASK_SIGNING_KEY: at least 32 random bytes encoded as base64. Share the key and namespace across replicas and keep them across restarts. Replacing the key invalidates existing task IDs. This key is separate from the identity provider's signing keys. For local stdio examples, an unset variable selects a generated process-local key; set it for cross-process task access.

Without --auth-config, the existing HTTP and stdio modes keep their behavior. See the sign-in flow and library options.

Use the connector as a Go library

Call inbound.NewServer with the Temporal client, matching Namespace, service routes, and TaskSigningKey (at least 32 random bytes). The result is a standard *mcp.Server. The host supplies credentials, namespace, TLS, and the data converter or codec on the Temporal client. The host then serves HTTP or stdio and supplies its own middleware. For built-in HTTP OAuth, use inbound.NewHTTPHandler(server, inbound.HTTPOptions{Auth: authConfig}). Auth configuration uses inboundauth.Config from src/inbound/go/auth. See the HTTP middleware example.

For HTTP, use NewHTTPHandler to support current and legacy clients. Current requests are stateless; closing the response stream cancels that call. Older HTTP clients use temporary SDK sessions. Route each session to the same replica so explicit cancellation notifications reach the active call. Stdio clients also use explicit MCP cancellation. After a task is returned, use tasks/cancel.

Examples

See examples.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers