MCP Integration
neograph is an MCP client, and its design stance fits in one sentence: neograph never owns an MCP session — the adapters own connection lifecycle; neograph owns typing, wiring, and replay-safety. Every MCP touchpoint is a consumer-supplied callable passed through config (a tool factory, a resource fetcher, a replayer), so routing, auth, and connection lifetime stay in your hands while the framework guarantees what it is actually good at: that the right node gets the right capability, under the right identity, with typed data on both sides.
MCP defines three primitives, and neograph maps each onto a surface you already know:
| MCP concept | What it is | neograph surface |
|---|---|---|
| Tools (bind) | Capabilities the model decides to call | tools= on agent/act nodes, filled per node from mcp_tool_factories() |
| Tools (compose) | Primitives your code calls in sequence | mcp_session() — one connection, N calls, typed/structured results |
| Resources | Data the framework fetches deterministically | FromResource DI marker, the resource manifest, resource_reader |
| Prompts | Templates the server curates | mcp_prompt_source() — a loader for DefaultPromptCompiler; the normal placeholder + lint rules apply |
The split matters: a tool call is a model decision inside a ReAct loop; a resource read is dependency injection at node entry. neograph keeps them separate so a deterministic fetch never rides on an LLM’s whim, and a model-driven read never bypasses budgets and gates. When your code (not the model) must issue several primitive calls and assemble the result — a composite — reach for mcp_session instead of binding: connect once, call by name, close.
Install
Section titled “Install”The battery ships as a separate import package inside the same distribution, so neograph core stays MCP-free:
pip install 'neograph[mcp]' # or: uv add 'neograph[mcp]'Importing neograph_mcp without the extra fails loud with the install hint. Core users pull zero MCP dependencies.
Tools: a dict of factories, sliced per node
Section titled “Tools: a dict of factories, sliced per node”mcp_tool_factories() connects to your servers once, enumerates their tools, and returns a dict — one async factory per tool:
from neograph_mcp import StdioServer, mcp_tool_factories
FACTORIES = mcp_tool_factories( {"crm": StdioServer(command=sys.executable, args=[SERVER])}, token_provider=lambda configurable: configurable.get("mcp_auth", "anon"),)The dict shape is the point. “One agent holds every tool” is the anti-pattern MCP clients drift into; here you slice the dict per node, so each node’s tools= (and its compile(tool_factories=...)) is a least-privilege boundary. A research agent whose slice lacks update_deal structurally cannot mutate — not “is prompted not to.”
Discovery or declaration: which builder?
Section titled “Discovery or declaration: which builder?”The battery has two entry points, and the difference is not one-vs-many — it is who supplies the tool names and when the network fires. The mnemonic: the plural asks the server, the singular tells the server.
mcp_tool_factories(servers) | mcp_tool_factory(key, spec, tool_name=...) | |
|---|---|---|
| You know | which servers you have | the exact tool name |
| Names come from | the server (discovery) | your declaration |
| Connects | at build — discovery is a connect | never at build; first tool call |
| Returns | dict[str, factory] — the whole catalog | one factory |
| Wrong config fails | fast, at build time | loud, at first invocation, listing the available tools |
Reach for the singular when the factory-build path must stay offline — at compile() time, in a deterministic test suite, or anywhere a live connect to the server is unwanted. Its second job is the gateway-federated rename: a gateway (e.g. IBM ContextForge) re-exposes a peer’s tool namespaced as <peer>-<tool>, while your node binds a fixed bare Tool(name). rename_to bridges the two:
from neograph_mcp import HttpServer, mcp_tool_factory
research = mcp_tool_factory( "gateway", HttpServer(url=GATEWAY_URL), tool_name="crm-perplexity_research", # the name the gateway exposes rename_to="perplexity_research", # the bare name the node's Tool() binds token_provider=lambda c: c["mcp_auth"], # same identity path as the plural)
compile(pipeline, tool_factories={"perplexity_research": research})Nothing connects here — not at the factory build, not at compile(). The MultiServerMCPClient is created inside the returned factory on the agent’s first tool call, and identity injection happens before the rename so it introspects the server’s real declared arguments. Everything below (per-run identity, gates, typed results) applies to both builders identically.
Three things ride along for free once MCP tools are node tools:
- Per-run identity. The
token_providerreads the operator fromconfig["configurable"]on every run, so two runs of the same compiled graph act as two different principals. Over stdio the token rides as a framework-injected tool argument (stdio has no headers); over streamable-http it rides as a bearer header minted per request by anhttpx.Auth. The token never enters state, the checkpoint, or the schema fingerprint. - Gated mutations.
gate_tools_when=pauses a checkpointed run before a mutating tool fires; approve runs it exactly once, deny feeds the agent a denial message and the tool never executes. An MCP mutation cannot fire before a human approves. - Typed results. Declare
output_model=(singular factory) oroutput_models={tool: Model}(plural) and neograph rehydrates the tool’sstructuredContentinto your model, treating it — not the spec-mandated backwards-compat text block — as the source of truth.ToolInteraction.typed_resultis the model, and the next ReAct turn’sToolMessageis its BAML rendering, not a repr of raw content blocks.
The battery is an overridable default: the seam is compile(tool_factories=...), and a factory is just an async (config, tool_config) -> tool callable that owns its client. See the MCP Client walkthrough for the full runnable story, including the hand-rolled escape hatch.
Resources: typed inputs, not blobs
Section titled “Resources: typed inputs, not blobs”A resource in neograph is a typed node input. The URI is wiring; the Pydantic model is the contract; the fetch happens at node entry, before your function runs:
@node(outputs=Assessment)async def assess( doc: Annotated[ContractDoc, FromResource("crm://deals/{deal_id}/contract")], claims: Claims,) -> Assessment: ...When an upstream agent discovers resources at runtime (a tool returns resource_links), neograph lifts them into a checkpointed manifest and a downstream node hydrates by domain kind — FromResource(ref="email-history") — with a layered expiry story: read, replay the producing tool call if it was idempotent, otherwise fail loud with ResourceExpiredError. When the model should decide what to read, resource_reader wraps a URI template + output model into a typed tool instead.
That is the whole surface at concept level — Resource Hydration covers the mechanics (templated URIs, the manifest, max_bytes, the idempotency gate), and the MCP Resources walkthrough runs it end-to-end against a real server, including a link that expires mid-pause and self-heals.
Async-only, checked statically
Section titled “Async-only, checked statically”MCP tools and resource hydration are async-only — drive these pipelines with arun()/astream(). This is not a caveat to memorize: lint() flags tool_requires_async_driver before you run, and a sync run() fails loud with a ConfigurationError pointing at arun() rather than half-executing. See Sync & Async Execution for the full boundary.
What neograph deliberately does not do
Section titled “What neograph deliberately does not do”- Hold sessions. No connection pool, no client registry, no reconnect logic in core. Your fetcher/factory owns the client; serverless and long-lived deployments make different choices and neograph should not pick for them.
- Guess at parsing. A resource typed
application/jsonvalidates into your model; anything else needs astrparameter or an explicitparse=callable. There is no silent LLM-parse inside dependency injection. - Trust the model with identity. The run’s token is framework-carried into the tool call, overriding any model-supplied value.
Documentation © 2025-2026 Constantine Mirin, mirin.pro. Licensed under CC BY-ND 4.0.