
DSPy is a project of Stanford NLP; names used for identification only, used here for identification and integration guidance only. No endorsement, partnership, or affiliation is implied.
dspy.ReAct module runs a full
reasoning-and-acting loop over a list of tools, and dspy.Tool.from_mcp_tool
bridges any MCP server into that loop as native DSPy tools.
- What DSPy ships — typed signatures and
modules, a provider-agnostic
dspy.LMlayer (OpenAI, Anthropic, local, … via LiteLLM), adspy.ReActagent that plans → calls a tool → observes → loops,dspy.Toolas the standard tool interface, anddspy.Tool.from_mcp_toolto turn a remote MCP server into a discoverable toolset. - What it doesn’t ship — a way for those agents to act as a real account: sign up for a SaaS tool, hold a funded card, or spend within a budget you control, per end-user.
How the pairing works
- Naive ships a hosted MCP server and mints per-user sessions — short-lived, revocable SSE endpoints whose tool list is the fused native + third-party toolset, already filtered by that user’s Account Kit.
- You open the SSE session with the MCP Python SDK (
sse_client→ClientSession), list its tools, and convert each one withdspy.Tool.from_mcp_tool(session, tool)— the scoped bearer rides in the request headers, never in the URL. dspy.ReActruns the loop (the LM reasons → picks a Naive tool → observes the result → repeats); every call runs as that one user, gated on Naive’s servers. Sensitive actions resolve to apending_approvalpayload.
Tested against:
dspy 3.2.1 (dspy.LM, dspy.ReAct, dspy.Tool.from_mcp_tool,
dspy.Signature), the MCP Python SDK 1.28.1 (mcp.client.sse.sse_client +
mcp.ClientSession — live-verified: passing Naive’s session headers discovers and invokes Naive’s
tools; a wrong bearer fails MCP session initialization), and Naive API v2 (hosted MCP server
over SSE + per-user sessions), on Python ≥ 3.10 (DSPy supports 3.10–3.14).Version assumptions: DSPy delegates the MCP transport to the official MCP SDK, so DSPy never sees a
raw URL or bearer — you build the sse_client(url, headers=...) session and DSPy wraps its tools.
MCP tools are async, so run the agent with await agent.acall(...) and keep the
ClientSession open for the whole run (see the code below). Naive’s session URL contains
/mcp/sse/…, so use sse_client (the MCP SDK also ships streamablehttp_client for
Streamable-HTTP servers). There is no Python Naive SDK yet, so the control plane (Account Kit, user,
session) is shown over the REST API; you can equally provision from the
dashboard, the CLI, or the Node SDK. A Naive session is
short-lived (default 15 min, max 24h) — see rotation below. Pin
your versions and set the model to a provider you have access to.Prerequisites
- A Naive API key (
nv_sk_...) — get one from the dashboard. - An
OPENAI_API_KEY(or any other DSPy/LiteLLM provider — Anthropic, local models) for the model that runs the agent. - Python ≥ 3.10.
Minimal viable integration
The shortest path to a DSPy agent that can actually transact: define a policy and provision a user (Naive control plane, once), then mint a per-user MCP session, convert its tools withdspy.Tool.from_mcp_tool, and hand them to a dspy.ReAct agent.
Define the policy, then provision a user
An Account Kit is the spend/capability policy. Here a tenant user
gets a card (capped at $500, approval required), the vault, and an allowlist of apps. Everything the
agent does is bounded by this kit — server-side. These are one-time control-plane calls:
Mint a per-user MCP session
At runtime, mint a session for the user. It returns the scoped SSE
endpoint and a bearer that lives in the headers — never in the URL:
Convert Naive's tools and build the ReAct agent
Open the session’s scoped SSE endpoint with the MCP SDK, passing Naive’s session headers straight
through. List the tools, convert each with The agent discovers GitHub (
dspy.Tool.from_mcp_tool, and hand them to dspy.ReAct.
Because the tools hold a live MCP session, run the agent inside the session context with
await agent.acall(...):naive_connections_connect), returns a connect link for Alice to
authorize, and attempts to issue the card (naive_cards_create) — a real card on Alice’s
account, capped by her kit. The reasoning loop and the optimizers are DSPy’s; the real-world actions
are Naive’s.dspy.ReAct loop that would otherwise just reason about spending money
now issues a policy-bounded card on a specific user’s account.
MCP tools are async-only (the underlying
ClientSession runs on anyio task groups), so use
agent.acall(...) rather than the synchronous agent(...). Keep the sse_client /
ClientSession context open for the whole run — the tools call back into it on every step.Extension: human-in-the-loop spend
Because the kit setcards.requiresApproval: true, the agent cannot silently spend. You get two
complementary layers — pair them for defense in depth:
- In the loop (optional early interrupt) — since a
dspy.Toolis just a wrapper around a callable, wrap a sensitive tool’sfuncin a confirmation gate so a human can approve or reject the call before it leaves your process. Treat this as a UX convenience. - On the server (the real boundary) — Naive freezes the action and returns a pending approval
(HTTP
202) instead of a live card. This holds no matter what runtime calls it, and can’t be bypassed from the prompt or the agent config.
DSPy’s callbacks (
on_tool_start / on_tool_end) are
observational — they can log a tool call but not block it. To reject a call in-loop, wrap the
tool’s func as above. Either way, the real enforcement is Naive’s server-side approval gate below,
which can’t be bypassed from the agent config, the tool list, or the wrapper.pending → executed / failed / denied) and the deny endpoint.
Approvals are only enforced for agent (API-key / MCP) calls on real tenant users. A human acting
in your dashboard, and agent calls on the operator’s own default user, bypass the gate — so
end-user agents stay governed while your own automation isn’t slowed down.
Alternative: one agent per tenant
Serving every tenant is one function: mint a fresh, per-user session and build the tools +dspy.ReAct bound to it inside the session context. A plain comprehension narrows a user’s toolset
to exactly the primitives you want that agent to touch — a second layer on top of the Account Kit:
DELETE /v1/users/{user_id}/sessions/{id}.
Optimizing the agent? DSPy’s optimizers (
dspy.GEPA, dspy.MIPROv2, …) tune your program’s
prompts against a metric — they don’t change the tool boundary. Compile against a Naive session
scoped to a test user (or the operator’s default user, which bypasses approvals), and the compiled
program stays bound to whatever per-user session you hand it at runtime.What stays enforced
No matter how the agent is wired, the policy is enforced where it matters — on Naive’s servers, not in your prompt or your agent config:- Identity — every action runs as a specific tenant user, fully isolated from your other users.
- Capability bounds — the Account Kit decides which primitives
and which apps the agent can touch (
allowlist/blocklist/ per-tool). - Scoped spend — virtual cards are capped per card and per user; the model can’t raise its own limit.
- Human-in-the-loop — sensitive actions (cards, domains, KYC, formation, connecting an app) freeze as approvals until a human says yes.
- Scoped, expiring access — the agent holds a per-user session bearer, not your API key; it
expires (default 15 min) and you can
DELETE /v1/users/:user_id/sessions/:idto revoke it early.
Next steps
- MCP server — the hosted SSE server and its full tool list
- Sessions — per-user MCP sessions, TTL, and revocation
- Account Kits — author spend/capability policy
- Approvals — the human-in-the-loop lifecycle
- Pydantic AI · LlamaIndex · Agno · smolagents · · Letta — the same MCP-session pairing for other Python stacks