Pipelines Docs is in beta — content is actively being added.
AgentsSimulation and Tools

MCP tools for your agent

Use Model Context Protocol servers as tool sources for an agent registered with Pipelines — discover tools, wire the shim into your framework, manage the per-dispatch lifecycle.

This page is for agent operators who want their registered agent to use tools served over MCP. If instead you want Pipelines to call an MCP server directly from a pipeline field (as a Tool Endpoint), see MCP Server Integration.

Why a shim. During Odyssey tests, the platform simulates tool responses so the agent doesn't hit production. In live runs, the same tool calls go through the per-run proxy. The MCP shim (pipelines-mcp-shim) is the small stdio MCP server you point your framework at — it bridges those two worlds without your agent caring which mode it's in.

The one swap (mental model)

You already have a working MCP agent: it opens a session to a live MCP server (say Notion's hosted server over OAuth), lists tools, and runs a model loop that calls them. Porting it to Pipelines changes exactly one thing — where that MCP session points.

Your agent todayUnder Pipelines
Transportyour live server (HTTP+OAuth, or stdio)pipelines-mcp-shim over stdio
Who answers tools/callthe live serverthe per-run proxy (simulated, or passed through to the live server)
Credentialsyour OAuth token, in the agentnone in the agent — the per-run token scopes the shim

Everything downstream of the MCP session — tools/list, your model loop, how you dispatch each call — is byte-for-byte identical. The loop cannot tell which transport it got. So the whole port is two steps:

  1. Point the MCP session at the shim (the factories in §2 do this in one line).
  2. Wrap your entrypoint in a POST /dispatch route so Pipelines can drive it and hand it the per-run token (see Porting your agent).

A clean way to keep your live agent runnable and Odyssey-testable is to keep both transports in one function and branch on whether a dispatch envelope is present (envelope is None → live; envelope present → shim). The two branches differ only in the transport lines; both hand back the same session object.

1. Discover the MCP server's tools

Before registering the agent, populate tools_schema from the MCP server so trace validation and Odyssey simulation know the contract.

# HTTP MCP server (discovered server-side — pass your org API key)
pipelines odyssey mcp introspect --http https://mcp.example.com/mcp \
  --api-key pk_live_... > tools_schema.json

# Local stdio MCP server (runs locally; no API key, no platform call)
pipelines odyssey mcp introspect --stdio "npx -y @example/mcp-server" > tools_schema.json

# A server you've already connected on the platform (uses its stored
# credentials/OAuth, and returns tools that already carry passthrough_binding).
# Pass the endpoint NAME (case-insensitive) or its UUID — both resolve.
pipelines odyssey mcp introspect --from-endpoint notion --refresh \
  --api-key pk_live_... > tools_schema.json

# Planned MCPs that don't exist yet — author the tool definitions inline
pipelines odyssey mcp introspect --manifest ./my-tools.json > tools_schema.json

The source flag is required and mutually exclusive: exactly one of --http, --stdio, --manifest, or --from-endpoint. The --http, --manifest, and --from-endpoint paths call the platform and need --api-key (or PIPELINES_API_KEY); --stdio runs entirely locally. --from-endpoint takes the endpoint's name (unique per org, matched case-insensitively) or its UUID — you never have to look up the internal id.

The output is a JSON array of {name, description, input_schema} entries — the exact shape tools_schema expects. Two ways to get it onto the agent:

  • Dashboard: on the agent form, click Import from MCP — pick a server you've already connected ("Saved server") or enter an HTTP URL and we'll discover it for you. To paste a raw tools JSON array (e.g. the output of pipelines odyssey mcp introspect --stdio … or --manifest), use the Import JSON button on the tools editor instead.
  • API: POST /api/agents/preview-tools-from-mcp with {transport, url} or the pasted JSON. The response feeds directly into tools_schema on the create call.

Anonymous subset trap. Several MCP servers return a subset of tools to anonymous callers (notably Hugging Face), and OAuth-gated servers (e.g. Notion) reject anonymous discovery outright. --http has no way to attach credentials. To discover with auth, connect the server on the platform first (storing its credentials / completing OAuth), then introspect it by id: pipelines odyssey mcp introspect --from-endpoint <name> --refresh. That returns the full catalog and tags each tool with a passthrough_binding.

2. Wire the shim into your framework

The shim is a stdio MCP server. Most modern frameworks accept stdio MCP servers via config. The exact key differs per framework; the command + env shape is identical.

Anthropic TypeScript SDK

import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic({
  mcp_servers: {
    pipelines: {
      command: 'pipelines-mcp-shim',
      env: {
        PIPELINES_ODYSSEY_PROXY_URL: process.env.PIPELINES_ODYSSEY_PROXY_URL!,
        PIPELINES_API_URL: process.env.PIPELINES_API_URL!,
        PIPELINES_RUN_TOKEN: process.env.PIPELINES_RUN_TOKEN!,
      },
    },
  },
});

Claude Desktop / Cursor (mcpServers JSON)

{
  "mcpServers": {
    "pipelines": {
      "command": "pipelines-mcp-shim",
      "env": {
        "PIPELINES_ODYSSEY_PROXY_URL": "${PIPELINES_ODYSSEY_PROXY_URL}",
        "PIPELINES_API_URL": "${PIPELINES_API_URL}",
        "PIPELINES_RUN_TOKEN": "${PIPELINES_RUN_TOKEN}"
      }
    }
  }
}

Python (mcp-use, Strands, etc.)

Use the SDK helper make_subprocess_env(envelope) to build the env dict instead of copying os.environ — it strips your local secrets and adds only the variables the shim needs.

import subprocess
from pipelines.odyssey import make_subprocess_env

env = make_subprocess_env(envelope)
proc = subprocess.Popen(
    ["pipelines-mcp-shim"],
    env=env,
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
)
# Hand `proc.stdin` / `proc.stdout` to your MCP client of choice.

Object-constructed MCP clients (per framework)

If your agent builds its MCP client object in code (not from an mcpServers config dict), swap one constructor for a shim-pointed factory. Each reuses shim_server_config under the hood, so the stale-schema strip and the per-run token are handled for you. The server key is optional — omit it for an unscoped shim that serves all of the agent's tools (the right default for a single-MCP-server agent; see Scoping the shim below).

OpenAI Agents SDKMCPServerStdio:

from agents import Agent, Runner
from pipelines.odyssey.adapters.openai_agents import shim_mcp_server

async with shim_mcp_server(envelope=envelope) as server:  # unscoped: all tools
    agent = Agent(name="A", mcp_servers=[server])
    result = await Runner.run(agent, envelope.user_instruction)

LangChainMultiServerMCPClient (pip install 'pipelines-sdk[langchain-mcp]'):

from pipelines.odyssey.adapters.langchain import shim_mcp_client

client = shim_mcp_client(envelope=envelope)        # unscoped: all tools
# multi-server agent? scope per server by friendly name:
# client = shim_mcp_client(["notion", "github"], envelope=envelope)
tools = await client.get_tools()  # list[BaseTool]

StrandsMCPClient:

from strands import Agent
from pipelines.odyssey.adapters.strands import shim_mcp_client

client = shim_mcp_client(envelope=envelope)  # unscoped: all tools
with client:
    agent = Agent(tools=client.list_tools_sync())
    agent(envelope.user_instruction)

Anthropic & custom agents — local MCP client (pip install 'pipelines-sdk[mcp]'). Anthropic's mcp_servers= API is remote-URL only and can't launch the local shim, so drive a local mcp client and feed its tools into your tool-use loop:

from mcp import ClientSession
from mcp.client.stdio import stdio_client
from pipelines.odyssey import shim_stdio_params

params = shim_stdio_params(envelope=envelope)  # unscoped: all tools
async with stdio_client(params) as (read, write):
    async with ClientSession(read, write) as session:
        await session.initialize()
        tools = await session.list_tools()
        # pass tools to messages.create; on each tool_use -> session.call_tool(...)

Scoping the shim: when (not) to pass a server key

Default to unscoped. Every shim factory's server key is optional; omit it and the shim serves all of the agent's registered tools. For the common single-MCP-server agent that's exactly what you want — no key, no flag, nothing to keep in sync.

Pass a key only for a multi-server agent where you want one shim object per server (e.g. a separate MCPServerStdio for notion and one for github). The shim then filters its tools/list to that one endpoint's tools. The key matches the endpoint's friendly name or its id — both are recorded on each tool's passthrough_binding at registration — so you scope with the human-readable name ("notion") and never need a UUID:

You passThe agent sees
no key (unscoped, the default)all of the agent's registered tools
a key matching an endpoint name (or id)exactly that endpoint's tools
a key matching nothingan empty tool list (the agent thinks it has no tools)

When you do scope, drive the key from config rather than a hardcoded literal:

import os
from pipelines.odyssey.adapters.langchain import shim_mcp_client

# A multi-server agent picks which endpoints to expose, by friendly name.
servers = os.environ.get("MCP_SERVERS", "notion,github").split(",")
client = shim_mcp_client(servers, envelope=envelope)

The friendly name survives registration and is re-derived from the endpoint on every save, so a scoped shim works for registered agents too — name scoping no longer silently returns zero tools. (Agents registered before this change are backfilled automatically.)

3. The per-dispatch lifecycle (external HTTP agents)

A single agent process serves many runs back-to-back. Each run carries its own PIPELINES_RUN_TOKEN — and the previous run's token becomes stale (the proxy rejects it with 401 + run_token_*) the moment the dispatch finishes.

The agent SDK's dispatch_env_context mutates os.environ for the duration of a single dispatch only, then restores the prior state on exit. If you use register_dispatch_route(handler, manage_env=True) (the default), the wrap is applied for you and any subprocess you spawn inside the handler inherits the correct token.

from pipelines.odyssey import register_dispatch_route

@register_dispatch_route(app)  # manage_env=True is the default
async def handler(envelope):
    # Inside here, os.environ["PIPELINES_RUN_TOKEN"] is envelope.run_token.
    # Any subprocess.Popen(...) inherits it.
    ...
# After the handler returns, the prior environ is restored.

If you spin up your own dispatch routing, drop into the context manager directly:

from pipelines.odyssey import dispatch_env_context

async def handler(envelope):
    with dispatch_env_context(envelope):
        # Run your agent here.
        ...

Concurrency-safe shim spawns (SDK ≥ 0.1.5). When you spawn the shim via the SDK helpers from inside a handler, the per-run env is now baked from the active dispatch envelope on the ContextVar and not inherited from process-global os.environ. The ContextVar is isolated per asyncio-task (and the asyncio.to_thread workers that register_dispatch_route uses for sync handlers, which copy the context), so each shim gets the token of the run that actually spawned it even when concurrency_cap > 1 puts several dispatches in one process.

Non-SDK spawns still rely on os.environ. dispatch_env_context mutates process-global os.environ, so a subprocess you spawn yourself (without make_subprocess_env(envelope)) can still inherit another overlapping run's token. The same applies if you spawn the shim from a thread the SDK didn't start (e.g. a raw ThreadPoolExecutor.submit()) since ContextVars don't propagate to those threads and current() falls back to os.environ. For those, either pass env=make_subprocess_env(envelope) explicitly per Popen, keep the agent single-flight (concurrency_cap = 1), or use process-per-dispatch isolation.

4. Live mode: passthrough to the real server

Simulation is the default, but you can flip any tool to passthrough so the call hits your real MCP server instead of a simulated response. You set this on the platform — per tool when registering, overridable per run — not in agent code. The same agent binary serves both modes with no edits.

Why no code change is needed: your agent always calls the shim, and the shim always POSTs every tools/call to the per-run proxy ({proxy}/tools/{name}). The proxy decides what happens:

  1. It resolves the tool's effective mode: tool_mode_overrides[tool] (per-run) → the tool's default_execution_mode on tools_schemasandbox (simulate) as the fallback.
  2. If that mode is passthrough, the proxy reads the tool's passthrough_binding (the endpoint_name/endpoint_id recorded when you imported the tool), calls that registered endpoint's live server using the endpoint's stored credentials — all server-side — and returns the real response with source: "passthrough" (or source: "transport_error" if the live hop fails, which the shim surfaces as an MCP isError).

So the answer to the common question — "if I flip this exact agent to passthrough, does it actually call my real server?" — is yes, provided three things hold:

  • The tools were imported from your registered endpoint, so each carries a passthrough_binding. A hand-written tool schema with no binding can't pass through — the proxy logs a warning and falls back to simulation.
  • The shim lists the tool at all — i.e. it's unscoped (the default) or scoped to that endpoint's name/id (see Scoping the shim in §2). Routing itself always uses the binding's endpoint_id, independent of any scope.
  • Live credentials live on the registered endpoint, not your agent. Any NOTION_MCP_TOKEN-style secret in your script is read only when you run the agent outside Pipelines against the live server directly; Odyssey and passthrough never touch it.

5. Which transports are supported

Separate the two legs — the answer differs:

Agent ↔ shim (at run time): always stdio. Your framework spawns pipelines-mcp-shim as a local stdio MCP server. During a simulated run the shim never dials your real server — it forwards to the proxy over HTTP. This leg is fixed and independent of your real server's transport.

Platform/CLI ↔ your real MCP server (at import & passthrough time):

TransportSupportedWhere
Streamable HTTP (JSON-RPC 2.0 over HTTP POST, optional SSE upgrade)✅ primarypipelines odyssey mcp introspect --http <url>, dashboard Import from MCP URL, and passthrough
Legacy SSE (/sse endpoints)✅ fallbackStreamable-HTTP URL candidates are derived from an SSE URL automatically
stdio (local subprocess, e.g. npx …)✅ import onlypipelines odyssey mcp introspect --stdio "npx @example/server" → Import JSON. The hosted importer and passthrough can't reach a local process by URL
JSON-RPC✅ alwaysnot a transport — it's MCP's wire format, carried over both stdio and HTTP

Most hosted MCP servers (Notion, Hugging Face, GitHub) are Streamable HTTP, so they import via the URL path and support passthrough with no extra steps.

6. Troubleshooting

"Got fewer tools than expected"

The MCP server is returning a subset. Check the yellow banner from Import from MCP — if the server is Hugging Face or another anonymous-vs-authed service, connect it on the platform (with credentials / OAuth) and discover by id with pipelines odyssey mcp introspect --from-endpoint <name> --refresh, then re-import.

StaleRunTokenError mid-dispatch

The agent SDK raises StaleRunTokenError (a subclass of ProxyCallError) when the proxy rejects a call with 401 + run_token_expired | run_token_invalid | run_token_rejected. This always means the token belongs to a previous run — something is calling the proxy outside the current dispatch's env context. Common causes:

  • A subprocess was spawned before dispatch_env_context opened and is now reusing the parent's stale env. Fix: spawn the subprocess inside the handler, or rebuild its env with make_subprocess_env(envelope) per dispatch.
  • A background worker thread cached the token at startup. Fix: read os.environ["PIPELINES_RUN_TOKEN"] lazily per call, or pass the token explicitly.

Use is_stale_run_token(exc) to branch cleanly:

from pipelines.odyssey import is_stale_run_token, ProxyCallError

try:
    await call_tool(...)
except ProxyCallError as exc:
    if is_stale_run_token(exc):
        # The run has already concluded — log and move on.
        ...
    else:
        raise

Env vars not visible to the subprocess

If your subprocess doesn't see PIPELINES_RUN_TOKEN, you're probably calling subprocess.Popen(..., env={...}) with an env dict that doesn't include it. Use make_subprocess_env(envelope) instead — it returns a complete dict containing the four PIPELINES_* vars plus any extra you pass through.

References

  • SDK: pipelines.odysseydispatch_env_context, make_subprocess_env, StaleRunTokenError, is_stale_run_token.
  • CLI: pipelines odyssey mcp introspect ships in pipelines-sdk ≥ 0.1.0.
  • Backend: POST /api/agents/preview-tools-from-mcp (used by the dashboard Import from MCP button).
  • Tool Endpoint MCP (the inverse direction — Pipelines as the MCP client): see MCP Server Integration.