How to Add an MCP Server¶
Goal¶
Bind a Model Context Protocol (MCP) server's tool catalogue as Stargraph
ToolSpec records and have every call_tool invocation
gated through three controls: schema validation, capability check, and
output sanitization.
Prerequisites¶
- Stargraph installed (
pip install stargraph>=0.2) —mcp>=1.0is a core dependency. - An MCP server you can launch over stdio (e.g.
mcp-server-filesystem,mcp-server-postgres). - Familiarity with adapters/mcp reference.
Steps¶
1. Define the capabilities you grant¶
Stargraph's MCP adapter is default-deny at the per-tool level. Build a
Capabilities instance with explicit
CapabilityClaim entries that match the tool's
declared permissions:
# my_app/_mcp_wire.py
from stargraph.security import Capabilities, CapabilityClaim
CAPS = Capabilities(
claims=frozenset({
CapabilityClaim(namespace="fs.read", scope="/secrets/*"),
CapabilityClaim(namespace="db.kb_facts", scope="read"),
}),
)
The MCP adapter ships a tiny static permission map
(stargraph.adapters.mcp._TOOL_PERMISSIONS); per-deployment permission
declarations are deferred to v1.1 (config plumbing TBD).
2. Bind the server¶
Two paths into bind: a real stdio session via
mcp.StdioServerParameters, or any session-shaped object exposing
initialize/list_tools/call_tool (the in-memory test stub).
# my_app/_mcp_wire.py (continued)
from mcp import StdioServerParameters
from stargraph.adapters import mcp as mcp_adapter
async def bind_filesystem_mcp() -> list:
params = StdioServerParameters(
command="mcp-server-filesystem",
args=["--root", "/tmp/sandbox"],
)
tools = await mcp_adapter.bind(params, capabilities=CAPS)
return tools # list[ToolSpec]
bind opens the stdio transport, calls initialize and list_tools,
and translates each MCP Tool into a ToolSpec with namespace="mcp",
version="1", side_effects=external, replay_policy=must_stub.
Verify: python -m asyncio my_app._mcp_wire prints the bound tools.
3. Call tools through the gated path¶
from stargraph.adapters.mcp import call_tool
async def use_secret_reader(session, tool_specs):
read_secret = next(t for t in tool_specs if t.name == "read-secret")
payload = await call_tool(
session,
read_secret,
arguments={"path": "/secrets/api-key"},
)
return payload # sanitized dict
The order of checks inside call_tool (load-bearing):
- Capability gate — refusal raises
CapabilityError; the underlyingsession.call_toolis never invoked. - Input validation against
tool.input_schema(jsonschema Draft 2020-12) — failure raisesIRValidationError. - Invoke the underlying MCP session.
- Output validation against
tool.output_schema. - Sanitization — HTML-escape, control-char strip,
__system__marker removal applied to every string leaf in the response payload.
4. Fold MCP tools into your IR¶
Each returned ToolSpec is registry-shaped and routes through the same
runtime executor as @tool-decorated callables:
Where McpToolNode.execute calls mcp_adapter.call_tool(...) against
your bound session. (The v1 adapter is library-shaped, not a
node-factory — wrap it in your own NodeBase subclass to attach to a
graph.)
Wire it up¶
Two paths, depending on whether you want the adapter discovered automatically or wired imperatively.
Pluggable: register under stargraph.mcp_adapters¶
Ship your adapter as a plugin. The Stargraph loader scans the
stargraph.mcp_adapters entry-point group at startup; your hookimpl
returns one or more MCPAdapterSpec records, and serve / engine
wiring drives bind() against each at the appropriate lifespan
point.
# pyproject.toml
[project.entry-points."stargraph.mcp_adapters"]
filesystem = "my_plugin.mcp_adapters:filesystem_module"
# my_plugin/mcp_adapters.py
import pluggy
from mcp import StdioServerParameters
from stargraph.plugin._markers import PROJECT
from stargraph.plugin.types import MCPAdapterSpec
hookimpl = pluggy.HookimplMarker(PROJECT)
@hookimpl
def register_mcp_adapters() -> list[MCPAdapterSpec]:
return [
MCPAdapterSpec(
name="filesystem",
server=StdioServerParameters(
command="mcp-server-filesystem",
args=["--root", "/tmp/sandbox"],
),
required_capabilities=["fs.read:/tmp/sandbox/*"],
),
]
Aggregate all registered adapters with
stargraph.adapters.mcp.collect_mcp_adapters(pm) — that's the helper
serve / engine wiring drives at lifespan time.
Imperative: hand-rolled wiring¶
If you don't need plugin discovery, expose your binding helper from
your distribution however you like and call bind() from your own
lifespan code. The pluggable path is a thin convenience over this.
Verify¶
python - <<'PY'
import asyncio
from my_app._mcp_wire import bind_filesystem_mcp
async def main():
tools = await bind_filesystem_mcp()
for t in tools:
print(t.namespace, t.name, t.permissions)
asyncio.run(main())
PY
Expect a list of MCP tools with namespace="mcp" and any permissions
you wired through _TOOL_PERMISSIONS.
Troubleshooting¶
Common failure modes
CapabilityError: tool 'X' requires permissions not granted— theCapabilitiesinstance is missing a claim that satisfies the tool'spermissionslist. Add aCapabilityClaimwith a matching namespace + scope glob.IRValidationError: MCP tool 'X' input schema validation failed— theargumentsdict you passed doesn't match the tool'sinputSchema. Inspecttool.input_schemaand re-shape your call.IRValidationError: MCP tool 'X' returned non-object output— the upstream tool returned a non-JSON-object payload. Adapter contract requiresdict-shaped responses.IRValidationError: MCP tool 'X' has non-dict schema(s)— the MCP server returned aToolwhoseinputSchema/outputSchemaisn't a JSON object. File a bug against the upstream server.
See also¶
- Adapters: MCP
stargraph.adapters.mcpsource.stargraph.security.Capabilities.- Build a tool plugin — for native (non-MCP) tools.