Writing a new adapter or transport¶
There are two ways in, depending on whether the target speaks MCP itself.
A framework that dispatches MCP methods to your code (another SDK, a gateway): follow
lowlevel.py. For each method it hands you, decide ownership, build a ToolContext with
layer.context(principal, request_id=..., progress_callback=..., log_callback=...,
session=...), call the layer method, convert to_wire() output to the framework's types,
and map MCPLayerError to the framework's error type (NotFoundError to -32602, or -32002
for resources on handshake-era connections). Wrap the host's existing handlers instead of
replacing them, and merge list results. Forward change events with a ChangeFanout.
export_definitions already gives you per-method handlers if the framework is simple enough.
A raw transport (a socket, a message queue, a websocket): reuse the native protocol engine
and write only the framing. One Session per connection; feed it decoded messages; send back
what handle returns; pass a send coroutine for server-initiated notifications. This TCP
transport is complete and was run:
"""A newline-delimited JSON-RPC transport over TCP for NativeMCPServer."""
import asyncio
import json
from cloudg.mcp import CloudGMCPLayer, Principal
from cloudg.mcp.native import NativeMCPServer
from cloudg.mcp.native.jsonrpc import PARSE_ERROR, dumps, error_response
async def serve_tcp(server: NativeMCPServer, host: str = "127.0.0.1", port: int = 0):
async def on_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
lock = asyncio.Lock()
async def send(message: dict) -> None: # server-initiated notifications
async with lock:
writer.write((dumps(message) + "\n").encode())
await writer.drain()
session = server.create_session(transport="tcp", send=send,
principal=Principal(id="tcp-client", roles={"default"}))
try:
while line := await reader.readline():
try:
msg = json.loads(line)
except ValueError:
await send(error_response(None, PARSE_ERROR, "Parse error"))
continue
response = await session.handle(msg)
if response is not None:
await send(response)
finally:
session.close()
writer.close()
return await asyncio.start_server(on_client, host, port)
Driving it with initialize, notifications/initialized and a tools/call of the test
registry's echo tool printed:
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true},"resources":{"subscribe":true,"listChanged":true},
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{\n \"text\": \"hihi\"\n}"}],"isError":false,"structuredContent":{"text":"hihi"},"_meta":{"
This version handles one request at a time per connection, so notifications/cancelled
cannot reach a running request. stdio.py shows how to run requests as tasks (everything but
initialize, notifications and responses) while keeping writes serialized.
A checklist for any adapter or transport:
- Resolve the principal from transport facts with
resolve_principal(resolver, info); never fromclientInfo. - Never write protocol data and logs to the same stream.
- Keep progress monotonic per request.
- Hop to the event loop before touching sessions from change listeners.
- Do not cache list results across principals; on 2026 results keep
cacheScope: "private". - Add a test module that runs the shared
_adapter_testkit.build_layer()through the new path: definitions identical tolayer.tools_wire(), a structured call, an error call, progress, a resource read, a completion, a prompt, a change notification.