Skip to content
Built by Postindustria. We help teams build agentic production systems.

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 conceptWhat it isneograph surface
Tools (bind)Capabilities the model decides to calltools= on agent/act nodes, filled per node from mcp_tool_factories()
Tools (compose)Primitives your code calls in sequencemcp_session() — one connection, N calls, typed/structured results
ResourcesData the framework fetches deterministicallyFromResource DI marker, the resource manifest, resource_reader
PromptsTemplates the server curatesmcp_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.

The battery ships as a separate import package inside the same distribution, so neograph core stays MCP-free:

Terminal window
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.”

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 knowwhich servers you havethe exact tool name
Names come fromthe server (discovery)your declaration
Connectsat build — discovery is a connectnever at build; first tool call
Returnsdict[str, factory] — the whole catalogone factory
Wrong config failsfast, at build timeloud, 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_provider reads the operator from config["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 an httpx.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) or output_models={tool: Model} (plural) and neograph rehydrates the tool’s structuredContent into your model, treating it — not the spec-mandated backwards-compat text block — as the source of truth. ToolInteraction.typed_result is the model, and the next ReAct turn’s ToolMessage is 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.

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.

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.

  • 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/json validates into your model; anything else needs a str parameter or an explicit parse= 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.