Model Context Protocol bridge
August 31, 2026 ยท View on GitHub
Samsarix Core can expose its trusted local tools through the Model Context
Protocol (MCP) without adding a runtime dependency. The bridge implements the
explicit protocol revisions 2025-11-25 and 2025-06-18. These are compatibility
targets. Starting with 2.0.0a10, Core also offers opt-in 2026-07-28 ordinary-tool
support through MCPServer(..., enable_modern=True). The published a10 artifact
contains that option; a9 does not. Default servers keep the 2025 handshake unchanged;
newer clients can negotiate backward or use the explicit modern option below.
The supported server surface is intentionally narrow:
- lifecycle initialization and version negotiation;
ping;tools/listwith JSON Schema Draft 2020-12 input and output contracts;tools/callwith Samsarix validation, timeouts, concurrency and opt-in per-tool circuit/rate controls, and safe structured errors, including optional host-policy denial;- requested
notifications/progressupdates from cooperative asynchronous tools; - opt-in
logging/setLeveland content-freenotifications/messageoperational events; notifications/cancelledfor active tool calls and task-result waits;- opt-in experimental task-augmented tool calls with
tasks/get, blockingtasks/result, explicittasks/cancel, finite retention, and bounded capacity; - newline-delimited stdio with configurable message-size and active-request caps.
It does not implement MCP resources, prompts, sampling, tasks/list, durable
cross-process task persistence, HTTP transport, authentication, or authorization.
Those remain host-application concerns.
For real persistent effects, the a11 source archive's SQLite MCP reservation walkthrough uses the same public bridge with host-selected storage and explicit write opt-in. It is separate from the read-only inventory preview and works in both supported protocol eras. It enables neither experimental tasks nor operational logging.
Opt-in MCP 2026-07-28
From a 2.0.0a10 or newer checkout, install with python -m pip install -e ., then launch the same read-only inventory example with:
python examples/mcp_inventory_server.py --modern
For an application-owned runtime, construct MCPServer(runtime, enable_modern=True). One MCPServer instance represents one trusted stdio
connection/process and selects an era: a successful legacy initialize selects
the 2025 contract; a valid modern request selects the per-request contract. This
implementation does not mix eras on that instance. Use a fresh instance/process
to switch. Protocol version, capabilities and logging are still validated on
every modern request; discovery never grants capabilities to later requests.
An unversioned pre-initialization ping still receives the legacy empty result
without selecting either era. A modern-versioned ping remains a removed method.
The modern path implements:
server/discover, or a direct firsttools/list/tools/callwithout discovery;- required
params._metafieldsio.modelcontextprotocol/protocolVersionandio.modelcontextprotocol/clientCapabilities; missing or malformed required metadata returns-32602before tool execution; - unsupported-version error
-32022withdata.requestedanddata.supported; resultType: "complete"andio.modelcontextprotocol/serverInforesult metadata;- deterministic name-sorted tool discovery with
ttlMs: 0,cacheScope: "private": no freshness promise or permission to share a potentially private tool catalog; - the same validated calls, bounded runtime controls, output schema/text fallback, progress and cooperative cancellation as the legacy bridge;
- if the host already enables operational logging, per-request
io.modelcontextprotocol/logLevelfiltering. Missing logLevel means no protocol log, including errors; overlapping calls do not share log settings. This remains a deprecated protocol feature, not a reason to add new logging integrations.
Optional client identity/capabilities are untrusted metadata, not authentication, tenant identity, policy approval or permission to invoke a tool. Core never acts on arbitrary extension capabilities. It emits only complete ordinary tool results; it does not request roots, sampling or elicitation, so no client capability is required beyond the required capabilities object (which can be empty).
Modern ping, logging/setLevel, task methods, subscriptions, resources and prompts
return -32601. Legacy task support is not the redesigned 2026 task extension:
modern discovery advertises no tasks/extensions or execution.taskSupport.
Task-required tools are omitted from the modern catalog and rejected by name;
task-optional tools remain ordinary calls. Calls containing task, inputResponses
or requestState are rejected before execution; no unimplemented continuation is
silently treated as a fresh write. HTTP/authentication, multi-round-trip operations,
subscriptions and the task extension are not implemented. Existing object-wrapped
scalar/array outputs remain deliberate valid schemas, not native unwrapped output.
The source's official-client checker adds an explicit mode:
python -I scripts/verify_mcp_client.py /absolute/path/to/newly-built.whl --sdk-version 2.1.1 --modern
It requires the official SDK 2.1.1, a wheel containing the new option, successful modern discovery (not fallback), modern results/cache hints, per-request log privacy, Unicode, validation errors, empty results and progress. A separate modern session proves repeated cooperative cancellation and slot recovery. The existing SDK 2.x CI jobs run both legacy and modern commands; SDK 1.x continues proving the legacy path. SDK packages remain outside Core's dependencies. This is the narrow stdio tool surface, not certification of every optional MCP feature.
Primary sources checked 2026-08-31: revision changes, versioning, request metadata, discovery, tools, stdio, and request-scoped logging.
Run the example
Install the project, then start the example as the command for a trusted local MCP client:
python examples/mcp_inventory_server.py
The process reads MCP JSON-RPC messages from stdin and writes only protocol messages to stdout. Send application diagnostics to stderr. For stdio servers, provide credentials through the process environment rather than protocol arguments or command-line flags that may be logged by a launcher.
The complete server setup is in
examples/mcp_inventory_server.py.
Official Python client verification
In addition to the dependency-free raw-pipe checker, CI builds Core and runs the
documented inventory servers with the official Python MCP SDK pinned separately to
1.29.1 and 2.1.1, on Linux, Windows and macOS with Python 3.11. The SDK is not a
Core runtime or development dependency: it is installed only in the client check
environment. The server receives only the exact Core wheel in a fresh offline
environment, runs outside the checkout with Python isolated mode, and must not
have the MCP SDK installed.
Use a separate client virtual environment, install mcp==2.1.1, then run from
the Core checkout using that environment's Python:
python -m pip install "mcp==2.1.1"
python -I scripts/verify_mcp_client.py /absolute/path/to/samsarix_core-2.0.0a11-py3-none-any.whl --sdk-version 2.1.1
Repeat in another client environment with mcp==1.29.1 and --sdk-version 1.29.1;
keep the Core wheel path unchanged. Omit it only when dist/ contains exactly one
wheel. The checker rejects a mismatched SDK pin and reports the wheel SHA-256. Initial SDK
installation accesses the package index and installs that SDK's dependencies;
the subsequent Core installation is offline. SDK transitive dependencies are
resolved at installation time, not represented as a fully locked environment.
The journey uses official SDK transport/session methods and parsed models. It
checks initialization, ping, tool discovery and behavioral hints, Draft 2020-12
input/output schema validity, Unicode and escaped-newline calls, structured/text
agreement, safe validation errors, content-free client-filtered logging, correlated
progress, empty audit results, recovery after errors and client-context shutdown.
SDK 2.x uses its high-level client's default auto negotiation: it probes discovery
and falls back to the 2025-11-25 initialization handshake. Its session then runs
the shared tool journey; no modern protocol behavior is inferred from that fallback.
The current checkout also verifies the persistent SQLite workflow through those same SDK methods, independently of the raw-pipe encoder/parser. It initializes a temporary host-owned database, then opens four fresh client/server sessions: default-denied write, explicitly enabled write, enabled replay after restart, and denied saved replay after removing host opt-in. Each session checks the real catalog, valid input/output schemas, destructive/idempotent annotations, result/text agreement, empty lookup, and absence of unsolicited logs. Writable sessions distinguish business conflicts and full-ledger refusals from execution errors and reject boolean quantities.
After initialization and every session, a separate read-only SQLite connection checks
the exact stock and ledger, including SKU and request identity. Five items must become
three once, with exactly one ledger row. A passing conversation without a durable write
cannot pass this gate. Each of the three SDK/protocol modes runs its own database; modern
sessions require discovery without legacy fallback. The final report includes
sqlite: host_denial_write_restart_replay_disk_verified only after all checks pass.
This is test-owned temporary data, not desktop approval or third-party adoption.
The same command then opens a separate official ClientSession against a controlled
in-memory cancellation fixture, using the same installed Core interpreter. It waits
for actual start progress, cancels the call, and invokes a state tool that needs the
same sole execution slot. Two consecutive cycles must show zero active/completed
waiters, exact tool/runtime cancellation counts, zero runtime timeouts, and only the
state tool's own in-flight/pending call. A successful ping follows each cycle.
Thus cancelling a local Python task without stopping server work cannot pass.
Execution capacity can become available before the cancelled invocation finishes
its terminal accounting. The checker allows only that exact transient (tool already
stopped, no timeouts/completions, at most one remaining cancelled admission) and
requires the exact final counters within the same five-second recovery deadline,
with at most 100 observations. Counter leakage still fails; retries do not cancel
the server work or replace cancellation with a timeout.
The tested paths deliberately differ:
| SDK pin | Cancellation path proved |
|---|---|
1.29.1 | Explicit typed SDK notifications/cancelled, then cancellation of the local waiting task. A forwarding observer reads the actual outgoing SDK request ID; it never guesses an ID, treats a progress token as an ID, or accesses SDK private counters. |
2.1.1 | Cancellation of the local waiting task triggers the SDK's automatic cancellation notification; the checker does not inject one. |
In SDK 1.29.1, cancelling a local waiter or hitting its read timeout does not itself send a server cancellation notification. Hosts using that version must explicitly notify the server if they need cooperative remote cancellation. This is client behavior, not something Core can infer from a still-open stdio connection. The checker prints the tested cancellation mode with its wheel digest.
Each inventory session has a 45-second deadline; the cancellation session has a 20-second deadline with five-second start/recovery bounds. The outer checker has a 60-second deadline and forcibly terminates the checker if SDK cleanup stalls. Because the SDK may start its server in a separate process group, a stdlib-only test bootstrap also enforces a 55-second server lifetime independently. The watchdog is cancelled on normal exit; hard exit code 124 is a failed check, never graceful-shutdown evidence. This bootstrap is only for the trusted example and controlled fixture, not production tools. SQLite initialization has a ten-second deadline with kill/reap cleanup on failure. The added sessions remain inside the unchanged 60-second whole-checker bound. Setup commands also have finite timeouts. Negative-control unit tests reject wrong results, missing progress, private log fields/values, SDK pin drift, missing durable writes, misleading write hints, incorrect business/error results and checker failure, and exercise timeout cleanup, including independent server exit. No model credentials, signed-in desktop UI or external API calls are needed for the journey.
The default legacy gate does not validate experimental tasks, cancellation of synchronous or
non-cooperative functions, a signed-in tool-approval UI, HTTP/authentication, every
SDK version or newer MCP revisions. The explicit --modern mode above separately
covers the 2026 ordinary-tool path. Cancellation is not rollback of committed side
effects. The cancellation fixture performs no durable writes; Core's separate tests cover its
task and surviving-sync-worker contracts. SDK 2.x emits a logging deprecation
warning because the checker deliberately exercises the older negotiated revision.
Tasks in Core remain opt-in, revision-specific experimental behavior; the upstream
SDK removed its experimental task API in 2.x. Do not infer task compatibility from
ordinary tool-call success.
The persistent SDK journey ships in a11's source archive, not as a retroactive change to a10's immutable source archive. It passed against the published a10 and a11 runtimes; the a11 downloaded-wheel acceptance used the downloaded source archive's own scripts/examples. Older wheels need scripts/examples appropriate to their APIs.
References checked on 2026-08-31: official SDK releases
v1.29.1,
v2.1.1,
the 2.x negotiation implementation,
and the 2025-11-25 tool contract.
Cancellation references: the protocol requirements,
1.29.1 request handling,
and 2.1.1 request abandonment.
Declare behavior honestly
MCP clients use behavioral annotations to explain and gate tool calls. Samsarix
Core exposes them directly from @samsarix_tool:
from samsarix_core import samsarix_tool
@samsarix_tool(
title="Find an order",
read_only=True,
open_world=False,
)
def find_order(order_id: str) -> dict[str, str]:
"""Find one order in the application's local store."""
return {"order_id": order_id, "status": "processing"}
The metadata has conservative defaults:
| Python option | MCP field | Default |
|---|---|---|
read_only | readOnlyHint | False |
destructive | destructiveHint | False for read-only tools; otherwise True |
idempotent | idempotentHint | True for read-only tools; otherwise False |
open_world | openWorldHint | True |
Annotations are hints, not an authorization system. A client must not trust annotations from an untrusted server, and an application must still enforce its own identity, permissions, tenant boundaries, and human-approval policy.
Apply a server-side policy without replacing client approval
An MCPServer uses its runtime's optional policy for ordinary and task-augmented
calls. The policy runs after argument validation and before the tool, and a denial is a
safe isError: true result with Samsarix status denied. It receives a detached call
snapshot that Core never adds to protocol output or operational logs.
This is a programmatic host gate, not MCP authorization or a confirmation prompt. The MCP specification recommends that applications provide a human the ability to deny tool invocations, so clients should continue to show and approve sensitive actions: https://modelcontextprotocol.io/specification/2025-11-25/server/tools.
Bound calls to quota-constrained tools
ToolRuntime.register(..., rate_limit=ToolRateLimit(...)) applies the same process-local
token bucket to direct calls, ordinary MCP calls, and task-augmented calls. When no token
is available, an ordinary call returns isError: true, Samsarix status rate_limited,
safe code tool_rate_limited, and details.retry_after_ms. A task reaches failed and
tasks/result returns that same tool result. Core never reflects the call arguments in
the error.
The MCP tool security considerations require rate limiting, but this local bucket does not identify clients or coordinate multiple server processes. A network adapter still needs authenticated per-principal and distributed controls. See per-tool rate limits for configuration and token-accounting semantics.
Fail fast around an unhealthy dependency
ToolRuntime.register(..., circuit_breaker=ToolCircuitBreaker(...)) applies the same
process-local breaker to direct, ordinary MCP, and task-augmented calls. An open circuit
returns isError: true, Samsarix status circuit_open, safe code
tool_circuit_open, and a retry delay when the recovery interval has time remaining.
The failure is a tool execution result rather than a JSON-RPC protocol error. A task
reaches failed and retains that exact safe result; call arguments and the triggering
exception are not reflected. See per-tool circuit breakers.
Structured output
MCP requires an object at the root of outputSchema. Object-returning Samsarix
tools are exported directly. Scalar, array, tuple, union, and null outputs are
wrapped consistently:
{
"type": "object",
"properties": {
"result": { "type": "string" }
},
"required": ["result"],
"additionalProperties": false
}
The corresponding successful structuredContent is {"result": "..."}.
For compatibility, the same data is serialized into a text content block.
Failures set isError: true and contain the safe ToolError; exception messages
remain redacted unless the runtime was explicitly created with
expose_exceptions=True.
Progress
An async tool can publish bounded progress without adding a parameter to its public schema:
from samsarix_core import report_progress, samsarix_tool
async def index_one(record: str) -> None:
"""Replace this stub with application-owned async indexing."""
return None
@samsarix_tool
async def index_records(records: list[str]) -> int:
"""Index records and report completed work."""
for position, record in enumerate(records, start=1):
await index_one(record)
await report_progress(
position,
total=len(records),
message=f"Indexed {position} of {len(records)} records",
)
return len(records)
The client opts in by adding a unique string or numeric token to the call:
{
"jsonrpc": "2.0",
"id": "call-41",
"method": "tools/call",
"params": {
"name": "index_records",
"arguments": {"records": ["a", "b"]},
"_meta": {"progressToken": "progress-41"}
}
}
serve_stdio() writes each accepted notifications/progress message before the
terminal response. Progress values must be finite, non-negative, and strictly
increasing. ToolRuntime defaults to at most 1,000 accepted updates and 4,096
UTF-8 bytes per progress message for each invocation; tune
max_progress_updates and max_progress_message_bytes for the application.
Once the cap is reached, no handler exists, or the call has completed,
report_progress() returns False. Oversized transport notifications are
omitted instead of being replaced by a spurious JSON-RPC error.
A non-increasing progress value or an oversized progress message raises
ValueError and fails the tool invocation.
Progress is cooperative and currently available inside async tools. Messages are sent to the client and may be displayed or logged, so do not include credentials, document content, tenant identifiers, or other sensitive values. Core enforces the MCP stable progress utility for normal and task-augmented calls. Task-owned progress includes the required related-task metadata.
Experimental task execution
MCP 2025-11-25 defines experimental task augmentation for expensive work and
batch processing. Samsarix Core supports the bounded local subset as an explicit
opt-in. Declare support on the individual tool and enable it on the server:
@samsarix_tool(task_support="optional")
async def build_export(export_id: str) -> dict[str, str]:
"""Build one application-owned export."""
return {"export_id": export_id, "status": "ready"}
runtime.register(build_export)
server = MCPServer(
runtime,
enable_tasks=True,
max_retained_tasks=64,
default_task_ttl_ms=300_000,
max_task_ttl_ms=3_600_000,
task_poll_interval_ms=500,
)
task_support is "forbidden" by default. "optional" allows either an
ordinary call or a task-augmented call, while "required" requires task
augmentation whenever 2025-11-25 task capabilities were negotiated. The setting
does not change direct ToolRuntime calls, and 2025-06-18 MCP clients retain
ordinary synchronous-response behavior.
A client creates a task by adding a task object to tools/call:
{
"jsonrpc": "2.0",
"id": "create-export",
"method": "tools/call",
"params": {
"name": "build_export",
"arguments": {"export_id": "export-42"},
"task": {"ttl": 60000}
}
}
The immediate response contains a cryptographically random task ID and working
state, never the arguments or result. Poll tasks/get, retrieve the final
CallToolResult with tasks/result, or stop active work with tasks/cancel.
tasks/result waits until a terminal state but remains independently cancellable;
cancelling only that wait does not cancel the retained task. Every final result,
progress update, and operational event carries
io.modelcontextprotocol/related-task metadata.
Retention is in memory and scoped to one MCPServer session. Requested TTLs are
positive finite numbers representable as Python floats, then clamped to
max_task_ttl_ms. Expiry is checked when the store is accessed, including creation,
get, result, and cancellation; expired entries are removed and capacity reclaimed
then. A pending tasks/result wait is also bounded by its remaining TTL. Cleanup
is lazy: an idle server can retain expired result objects in memory until the next
store operation or server close. TTL is an access-validity limit, not a timed memory
erasure guarantee. Arguments must pass the runtime's byte, depth, node, cycle, and JSON
compatibility preflight before the server detaches them for background execution.
At max_retained_tasks, new task requests receive server-busy error -32000.
Task cancellation is cooperative: async work is cancelled, while a running
synchronous function can retain its runtime worker until it actually stops.
Invalid requested TTLs, including overflowing integers, receive invalid-parameters
error -32602 before task creation, leaving retention capacity available. Host
duration settings (default_task_ttl_ms, max_task_ttl_ms, task_poll_interval_ms)
must be positive integers representable as finite floats.
Core deliberately does not advertise tasks.list on unauthenticated stdio. MCP's
task security guidance warns that listing can expose task metadata when requestor
identity cannot be bound. Random IDs make guessing impractical, but any process
with a valid task ID on the same logical session can retrieve or cancel it. A
network host must bind task access to authenticated authorization context, add
per-principal quotas and rate limits, and should provide a separate persistent
task store before claiming durable service behavior. See the experimental
MCP Tasks specification.
Operational logging
Enable the stable MCP logging capability when a client needs runtime health events:
server = MCPServer(
runtime,
enable_logging=True,
default_log_level="warning",
)
The client can select any syslog-compatible MCP minimum level with
logging/setLevel: debug, info, notice, warning, error, critical,
alert, or emergency. Core emits at most one notifications/message event for
each non-cancelled tool call: successful results use info and failures use
error. The default warning threshold therefore suppresses successful-call
events until the client requests info or debug.
Each event contains only the already-public tool name, invocation ID, terminal
status, and duration. Core never copies arguments, outputs, exception text,
validation details, cancellation reasons, or progress messages into operational
logs. Delivery is best effort: a failing log notification sender does not replace
an already-computed tool result. serve_stdio() serializes an accepted log before
the terminal response and drops an oversized notification with a generic stderr
diagnostic.
Logging is disabled by default, is advertised only when enabled, and remains bounded to one event per call. A network host still needs connection-level rate limits and access control. This implements the stable MCP logging utility with a deliberately content-free data shape.
Request cancellation and admission
MCP clients can stop an active ordinary call or a blocking tasks/result wait
with a notification:
{
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": {
"requestId": "call-42",
"reason": "User stopped the operation"
}
}
Core cancels the matching request and sends no response for it, as required by
MCP. Cancelling a tasks/result wait leaves the retained task running; use
tasks/cancel to transition the task itself to cancelled. Unknown, completed,
missing, or malformed request IDs are ignored. Cancellation reasons are not
logged by Core. Host cancellation of the handle() coroutine still propagates
as asyncio.CancelledError; it is not mistaken for an MCP client notification.
serve_stdio() reads control messages while tool calls are active and serializes
all responses through one writer lock. It admits at most
max_in_flight_requests=64 tool-call or task-result coroutines by default. This
admission cap is
separate from ToolRuntime.max_concurrency: the former bounds waiting protocol
requests, while the latter bounds executing tools. Excess calls receive JSON-RPC
server error -32000 and are not executed. Normal input EOF drains calls that
were already admitted.
Async cancellation is cooperative. A synchronous Python function cannot be force-stopped; cancelling its MCP request stops the protocol wait while the runtime retains its real worker and concurrency slot until the function exits. This follows MCP's stable cancellation utility and the experimental task cancellation state transition.
Embed without stdio
Applications with an existing transport can call the protocol handler directly:
server = MCPServer(runtime)
response = await server.handle(
json_rpc_message,
notification_sender=send_json_rpc_notification,
)
handle() accepts one parsed JSON-RPC object and returns a response object or
None for notifications and MCP-cancelled calls. The optional async sender is
required for progress and operational-log delivery. An MCPServer instance is one
logical MCP connection/session; create separate instances for independently
negotiated clients. A custom concurrent transport must serialize its
outbound messages and deliver cancellation notifications while the corresponding
handle() call is still active. HTTP authentication, MCP session headers, origin
validation, request body limits, and rate limits must be implemented by the
hosting HTTP layer.
Call await server.aclose() to cancel retained background tasks and close the
runtime. Pass close_runtime=False only when the application owns the runtime's
later shutdown. serve_stdio() performs the default server close at transport
shutdown unless its own close_runtime=False option transfers that responsibility
to the host.
A notification-sender failure while delivering requested progress for an ordinary
call raises ProgressHandlerError with the original transport exception chained
as its cause; it is not converted into an isError tool result. A task has already
returned its creation response, so the same background delivery failure instead
makes that retained task fail with a generic safe result. Operational-log delivery
is separately best effort, so its sender failures are suppressed and the
already-computed tool result remains unchanged.
Operational boundaries
- Registered functions remain trusted in-process application code.
- Read-only and idempotent annotations do not make a function safe by themselves.
serve_stdio()caps individual requests and responses at 1 MiB by default.serve_stdio()admits at most 64 active tool-call or task-result requests by default; tune the cap withmax_in_flight_requests.- Runtime timeouts and concurrency controls continue to apply to MCP calls.
- Host-policy evaluation is bounded, included in invocation timeout/cancellation, and precedes any tool progress or side effect.
- Progress is opt-in, strictly increasing, update-capped, message-bounded, and automatically closed before a call's terminal response.
- Progress messages cross the protocol boundary; keep them free of sensitive data.
- Client cancellation emits no response, stops cooperative async tools, and does not imply a running sync function has stopped.
- A timed-out synchronous function retains its bounded worker slot until it stops.
- Experimental tasks are disabled by default, retained only in memory, and capped at 64 entries by default. Access expires within the configured TTL (one-hour maximum by default); idle in-memory cleanup is lazy, not a timed erasure guarantee.
tasks.listis not exposed without requestor identity; possession of a valid task ID permits get, result, and cancellation within the same server session.serve_stdio()closes without waiting indefinitely for surviving sync work. A host that requires shutdown quiescence should setclose_runtime=False, stop MCP admission, and callruntime.aclose(wait_for_sync=True, timeout=<deadline>)itself.- Tool arguments and results are not logged by the bridge.
- Keep user confirmation in the MCP host for write, destructive, and open-world calls.