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 today | Under Pipelines | |
|---|---|---|
| Transport | your live server (HTTP+OAuth, or stdio) | pipelines-mcp-shim over stdio |
Who answers tools/call | the live server | the per-run proxy (simulated, or passed through to the live server) |
| Credentials | your OAuth token, in the agent | none 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:
- Point the MCP session at the shim (the factories in §2 do this in one line).
- Wrap your entrypoint in a
POST /dispatchroute 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.jsonThe 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-mcpwith{transport, url}or the pasted JSON. The response feeds directly intotools_schemaon 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 SDK — MCPServerStdio:
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)LangChain — MultiServerMCPClient (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]Strands — MCPClient:
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 pass | The 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 nothing | an 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:
- It resolves the tool's effective mode:
tool_mode_overrides[tool](per-run) → the tool'sdefault_execution_modeontools_schema→sandbox(simulate) as the fallback. - If that mode is passthrough, the proxy reads the tool's
passthrough_binding(theendpoint_name/endpoint_idrecorded 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 withsource: "passthrough"(orsource: "transport_error"if the live hop fails, which the shim surfaces as an MCPisError).
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):
| Transport | Supported | Where |
|---|---|---|
| Streamable HTTP (JSON-RPC 2.0 over HTTP POST, optional SSE upgrade) | ✅ primary | pipelines odyssey mcp introspect --http <url>, dashboard Import from MCP URL, and passthrough |
Legacy SSE (/sse endpoints) | ✅ fallback | Streamable-HTTP URL candidates are derived from an SSE URL automatically |
stdio (local subprocess, e.g. npx …) | ✅ import only | pipelines odyssey mcp introspect --stdio "npx @example/server" → Import JSON. The hosted importer and passthrough can't reach a local process by URL |
| JSON-RPC | ✅ always | not 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_contextopened and is now reusing the parent's stale env. Fix: spawn the subprocess inside the handler, or rebuild its env withmake_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:
raiseEnv 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.odyssey—dispatch_env_context,make_subprocess_env,StaleRunTokenError,is_stale_run_token. - CLI:
pipelines odyssey mcp introspectships inpipelines-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.