MCP Adapter¶
stargraph.adapters.mcp is the Model Context Protocol stdio adapter (FR-25,
design §3.3.2). It translates an MCP server's tool catalogue into Stargraph
ToolSpec records and gates every call_tool
invocation through three controls before the response reaches the LM
context.
Source: src/stargraph/adapters/mcp.py.
bind¶
server is one of:
- A session-shaped object exposing
initialize/list_tools/call_tool(the integration tests' in-memory stub takes this branch). Used directly, no transport open/close. - Anything else, treated as
mcp.StdioServerParametersand opened viamcp.client.stdio.stdio_clientper design §3.3.2.
bind records the Capabilities instance keyed by id(session) in the
module-level _SESSION_CAPS dict so call_tool can consult the gate
without the caller threading the same instance through twice.
The stdio branch lazy-imports mcp so the module remains importable in
environments where the optional dependency is absent.
Translation¶
Each MCP Tool becomes a ToolSpec with:
| MCP wire field | Stargraph field |
|---|---|
name |
name |
description |
description |
inputSchema |
input_schema |
outputSchema |
output_schema |
| (n/a) | namespace = "mcp" |
| (n/a) | version = "1" |
| (n/a) | side_effects = SideEffects.external |
| (n/a) | replay_policy = ReplayPolicy.must_stub |
| (n/a) | permissions = _required_permissions(name) |
MCP tools are untrusted by design (§3.11 threat model), so the engine
treats them as worst-case for replay routing unless the caller overrides
post-bind. _required_permissions consults a tiny static map; v1.1 will
expose this via config.
call_tool¶
async def call_tool(
session: _MCPSessionLike,
tool: ToolSpec,
arguments: dict[str, Any],
*,
capabilities: Capabilities | None = None,
) -> dict[str, Any]: ...
The five-step gauntlet (order is load-bearing):
- Capability gate. The
Capabilitiesrecorded atbindtime (or the explicitcapabilities=override) is consulted before the session is touched. Refusal raisesCapabilityError; the underlyingcall_toolis never reached. - Input validation.
argumentsis validated againsttool.input_schemawithjsonschema.Draft202012Validator. Failures raiseIRValidationErrorwithviolation="mcp-input-schema". - Invoke the underlying session's
call_tool(name, arguments). - Output validation. The structured payload is extracted via
_extract_structuredand validated againsttool.output_schema. Failures raiseIRValidationErrorwithviolation="mcp-output-schema". - Sanitization. The validated payload is recursively sanitized before return.
Warning
All five steps are mandatory. Tests assert that step 1 short-circuits
before the session is touched (no call_tool recorded on refusal) and
that step 4 catches a bad response before the LM sees it.
Schema validation¶
Validation uses jsonschema.Draft202012Validator. Errors are sorted by
JSON Pointer path so the first reported error is the leftmost violation in
the document tree. The error message is wrapped in IRValidationError
with the offending JSON Pointer attached as schema_path.
Capability gate¶
Capabilities.check(tool) returns False when the tool's required
permissions are not granted to the session. The adapter raises:
raise CapabilityError(
f"tool {tool.name!r} requires permissions not granted",
capability=",".join(tool.permissions),
tool_id=tool.name,
deployment="mcp",
)
Output sanitization¶
Three transforms run, in this order, on every string leaf in the response JSON:
- Strip ASCII control chars (C0
0x00-0x1fminus TAB/LF/CR, DEL0x7f, and C10x80-0x9f). - HTML-escape (
html.escape(value, quote=True)). - Remove system-marker tokens via the regex
__system__|<\|im_start\|>|<\|im_end\|>(case-insensitive).
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
_SYSTEM_MARKER_RE = re.compile(r"__system__|<\|im_start\|>|<\|im_end\|>", re.IGNORECASE)
Order matters: control-char strip runs before HTML-escape so the escape pass cannot reintroduce caret/ampersand sequences that look like control sequences. Marker removal runs last so the literal marker characters cannot survive the escape pass and re-coalesce.
The recursion preserves shape: a sanitized object is still an object, an
array is still an array. A non-dict top-level response raises
IRValidationError with violation="mcp-non-object-output" because the
FR-25 contract requires a JSON object.
Errors¶
| Error | Cause | Notable kwargs |
|---|---|---|
IRValidationError |
Bad MCP Tool shape (missing name, non-dict schemas), bad input/output payload, non-object response. |
tool_id, violation, schema_path |
CapabilityError |
The session's Capabilities denies the tool's required permissions. |
capability, tool_id, deployment="mcp" |
Example¶
from stargraph.adapters import mcp
from stargraph.security import Capabilities
caps = Capabilities(...)
specs = await mcp.bind(server_params, capabilities=caps)
read_secret = next(s for s in specs if s.name == "read-secret")
result = await mcp.call_tool(
session,
read_secret,
{"path": "/secrets/api-key"},
)
See also¶
- Adapters index
- Tools reference — the
ToolSpecshapebindemits. - DSPy adapter — the other v1 adapter seam.