Mounting into an existing MCP server¶
8.1 register_into¶
layer.register_into(server, *, prefix=None, include=None, principal_resolver=None,
list_changed=True) -> binding
# equivalent: cloudg.mcp.adapters.register_into(layer, server, ...)
# cloudg.mcp.register_into(layer, server, ...)
| Argument | Meaning |
|---|---|
server |
An mcp.server.mcpserver.MCPServer (mcp 2.x), an mcp.server.fastmcp.FastMCP (mcp 1.x), an mcp.server.lowlevel.Server (1.x or 2.x), a fastmcp.FastMCP (2.x, 3.x, 4.x), or the layer's own NativeMCPServer (returned unchanged) |
prefix |
Sets the layer's prefix through layer.set_prefix(), which raises ValueError for anything but up to 64 of A-Za-z0-9_.-; tool and prompt names become prefix + name. The prefix belongs to the layer, so mounting one layer twice with different prefixes logs a warning and renames it everywhere: use one layer per prefix. Resource URIs are never prefixed (they already start with cloudg://) |
include |
Subset of {"tools", "resources", "templates", "prompts", "completions", "logging", "subscriptions"}; default all. Anything else raises ValueError |
principal_resolver |
fn(RequestInfo) -> Principal or None; None falls back to the default resolver (8.8) |
list_changed |
Advertise listChanged capabilities on SDK servers so clients act on change notifications |
The server kind is detected by cloudg.mcp.adapters.detect_server_kind(server), which
returns native, fastmcp, sdk-highlevel or lowlevel, falls back to duck typing for
subclasses and wrappers, and raises TypeError for anything it does not recognize.
What every adapter guarantees:
- Tools are listed from the layer's own wire dicts, so
inputSchema,outputSchema,annotations,titleand_metareach the client exactly as cloudg built them. The host framework never re-derives a schema from a Python signature. - The host's own tools, resources and prompts keep working. List results are the host's entries followed by cloudg's; a name or URI the host already defines keeps the host's version and logs a warning.
- Calls for names and URIs cloudg owns go to the layer; everything else goes to the host's original handler.
structuredContent,isError, resource links and_metapass through.- Progress and log notifications reach the client; change notifications (8.10) are forwarded.
- Every request goes through
layer.call_tool / read_resource / get_prompt / complete, so policy, transforms and middleware apply.
Mount after the host has registered its own handlers. The low-level adapters wrap whatever
handler is installed when register_into runs; a host handler registered later replaces
cloudg's wrapper. On mcp 1.30, calling register_into before the host's @server.list_tools()
decorator left tools/list returning only the host's one tool.
8.2 mcp 2.x MCPServer¶
import asyncio
from pathlib import Path
from mcp import Client
from mcp.server.mcpserver import MCPServer
from cloudg.mcp import CloudGMCPLayer, Workspace
ROOT = Path("docest")
server = MCPServer("platform-tools")
@server.tool()
def ticket_count(team: str) -> int:
"""How many open tickets a team has (the host's own tool)."""
return 7
layer = CloudGMCPLayer(policy="standard", workspace=Workspace(allowed_roots=[ROOT]))
layer.workspace.load(ROOT / "inventory" / "inventory-map.json", "prod")
binding = layer.register_into(server, prefix="cloudg_")
print("binding:", type(binding).__name__, "sdk_major =", binding.sdk_major)
async def main() -> None:
for mode in ("legacy", "auto"):
async with Client(server, mode=mode) as client:
tools = (await client.list_tools()).tools
names = [t.name for t in tools]
print(mode, client.protocol_version, len(names), names[:3], "...", names[-2:])
res = await client.call_tool("cloudg_count_assets", {"group_by": "provider"})
print(" structured:", res.structured_content["groups"])
res = await client.call_tool("ticket_count", {"team": "sec"})
print(" host tool:", res.content[0].text)
asyncio.run(main())
Output (mcp 2.3.0):
binding: LowLevelBinding sdk_major = 2
legacy 2025-11-25 74 ['ticket_count', 'cloudg_workspace_status', 'cloudg_list_datasets'] ... ['cloudg_list_detectors', 'cloudg_privacy_audit_log']
structured: {'AWS': 36, 'AZURE': 5, 'GCP': 3}
host tool: 7
auto 2026-07-28 74 ['ticket_count', 'cloudg_workspace_status', 'cloudg_list_datasets'] ... ['cloudg_list_detectors', 'cloudg_privacy_audit_log']
structured: {'AWS': 36, 'AZURE': 5, 'GCP': 3}
host tool: 7
To serve it, use the host's own runner (server.run(), server.streamable_http_app(), and
so on). On MCPServer, a tool name cloudg does not own falls through to the host, which
answers an unknown name with an isError result instead of a JSON-RPC error.
8.3 mcp 1.x FastMCP¶
import asyncio
from pathlib import Path
from mcp.server.fastmcp import FastMCP
from mcp.shared.memory import create_connected_server_and_client_session
from cloudg.mcp import CloudGMCPLayer, Workspace
ROOT = Path("docest")
server = FastMCP("platform-tools")
@server.tool()
def ticket_count(team: str) -> int:
"""How many open tickets a team has (the host's own tool)."""
return 7
layer = CloudGMCPLayer(policy="standard", workspace=Workspace(allowed_roots=[ROOT]))
layer.workspace.load(ROOT / "inventory", "prod")
binding = layer.register_into(server, prefix="cloudg_")
print("binding:", type(binding).__name__, "sdk_major =", binding.sdk_major)
async def main() -> None:
async with create_connected_server_and_client_session(server) as session:
tools = (await session.list_tools()).tools
print(len(tools), [t.name for t in tools][:3])
res = await session.call_tool("cloudg_count_assets", {"group_by": "provider"})
print("structured:", res.structuredContent["groups"])
asyncio.run(main())
Output (mcp 1.30.0; the SDK's own INFO log lines removed):
binding: LowLevelBinding sdk_major = 1
74 ['ticket_count', 'cloudg_workspace_status', 'cloudg_list_datasets']
structured: {'AWS': 36, 'AZURE': 5, 'GCP': 3}
8.4 The low-level Server¶
mcp 2.x, a server that already answers tools/list and tools/call itself:
import asyncio
import json
from pathlib import Path
import mcp.types as types
from mcp import Client
from mcp.server.lowlevel import Server
from cloudg.mcp import CloudGMCPLayer, Workspace
ROOT = Path("docest")
HOST_TOOL = types.Tool(name="ping_host", description="Host liveness.",
input_schema={"type": "object", "properties": {}})
async def list_tools(ctx, params):
return types.ListToolsResult(tools=[HOST_TOOL])
async def call_tool(ctx, params):
return types.CallToolResult(content=[types.TextContent(type="text", text="pong")])
server = Server("platform", on_list_tools=list_tools, on_call_tool=call_tool)
layer = CloudGMCPLayer(policy="standard", workspace=Workspace(allowed_roots=[ROOT]),
include_categories=["workspace", "inventory"])
layer.workspace.load(ROOT / "inventory", "prod")
layer.register_into(server, prefix="cloudg_")
async def main() -> None:
async with Client(server, mode="auto") as client:
names = [t.name for t in (await client.list_tools()).tools]
print(len(names), names[:4])
print((await client.call_tool("ping_host", {})).content[0].text)
res = await client.call_tool("cloudg_list_regions", {})
print(json.dumps(res.structured_content)[:160])
asyncio.run(main())
20 ['ping_host', 'cloudg_workspace_status', 'cloudg_list_datasets', 'cloudg_load_dataset']
pong
{"dataset": "prod", "total": 5, "items": [{"region": "us-east-1", "providers": ["AWS"], "assets": 24, "accounts": 1}, {"region": "global", "providers": ["AWS"],
include_categories cut the catalog to 19 tools; with the host's tool that makes 20.
mcp 1.x, with the decorator API:
import mcp.types as types
from mcp.server.lowlevel import Server
server = Server("platform")
@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [types.Tool(name="ping_host", description="Host liveness.",
inputSchema={"type": "object", "properties": {}})]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
return [types.TextContent(type="text", text="pong")]
layer = CloudGMCPLayer(policy="standard", workspace=Workspace(allowed_roots=[ROOT]))
layer.workspace.load(ROOT / "inventory", "prod")
layer.register_into(server, prefix="cloudg_") # after the decorators
With mcp 1.30 the in-memory session listed 74 tools, ping_host answered pong, and
cloudg_count_assets returned {'AWS': 36, 'AZURE': 5, 'GCP': 3}.
A low-level server with no handlers of its own simply becomes a cloudg server. That is what
build_server(layer, "lowlevel") does (8.9).
8.5 Standalone fastmcp¶
import asyncio
from pathlib import Path
import fastmcp
from cloudg.mcp import CloudGMCPLayer, Workspace
ROOT = Path("docest")
server = fastmcp.FastMCP("platform-tools")
@server.tool
def ticket_count(team: str) -> int:
"""How many open tickets a team has (the host's own tool)."""
return 7
layer = CloudGMCPLayer(policy="standard", workspace=Workspace(allowed_roots=[ROOT]))
layer.workspace.load(ROOT / "inventory", "prod")
binding = layer.register_into(server, prefix="cloudg_")
async def main() -> None:
async with fastmcp.Client(server) as client:
tools = await client.list_tools()
print(len(tools), [t.name for t in tools][:3])
res = await client.call_tool_mcp("cloudg_count_assets", {"group_by": "provider"})
print("structured:", res.structuredContent["groups"])
text = await client.read_resource("cloudg://datasets/prod/summary")
print("resource bytes:", len(text[0].text))
asyncio.run(main())
Output, identical on fastmcp 4.0.11, 3.4.8 and 2.14.7 apart from the version line:
fastmcp 4.0.11 mcp 2.3.0 binding: FastMCPBinding
74 ['ticket_count', 'cloudg_workspace_status', 'cloudg_list_datasets']
structured: {'AWS': 36, 'AZURE': 5, 'GCP': 3}
resource bytes: 1783
fastmcp 4 also warns that structuredContent is deprecated in favor of structured_content
on its client result; use whichever your fastmcp version supports.
cloudg registers fastmcp component objects, adds a fastmcp middleware that filters list
results through the policy for the calling principal, and binds completions and resource
subscriptions on fastmcp's inner SDK server, since fastmcp components cannot express those.
fastmcp features such as its own middleware, auth providers, tags and the in-memory client
keep working. According to fastmcp 4's upgrade guide, mount(namespace=...) renames tools
and rewrites resource URIs, so resource links inside cloudg results would no longer match
(not tested here). Use prefix= rather than mounting a cloudg-only fastmcp server under a
namespace.
8.6 Inside an existing Starlette or ASGI app¶
The SDK's streamable_http_app() returns a Starlette app. Two details matter when you mount
it: Starlette's Mount does not run a sub-app's lifespan, so start the SDK session manager in
the parent app's lifespan; and mounting under /mcp with the SDK's default inner path makes
POST /mcp answer with a redirect, so mount at / and set streamable_http_path instead.
from contextlib import asynccontextmanager
from pathlib import Path
from mcp.server.transport_security import TransportSecuritySettings
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
from cloudg.mcp import CloudGMCPLayer, Principal, Workspace
from cloudg.mcp.adapters import build_server
from cloudg.mcp.native.auth import TokenAuth
from cloudg.mcp.server import TokenAuthASGIMiddleware
ROOT = Path("docest")
layer = CloudGMCPLayer(policy="standard", workspace=Workspace(allowed_roots=[ROOT]))
layer.workspace.load(ROOT / "inventory", "prod")
def resolver(info):
# TokenAuthASGIMiddleware already put the principal on the request scope;
# add the tenant header as an attribute for policies and audit.
if info.principal is not None:
info.principal.attributes["tenant"] = info.header("x-tenant", "unknown")
return info.principal
return None # fall back to the default resolver (anonymous on HTTP)
mcp_server = build_server(layer, "lowlevel", principal_resolver=resolver)
mcp_app = mcp_server.streamable_http_app(
streamable_http_path="/mcp",
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=["127.0.0.1:*", "localhost:*"],
allowed_origins=["http://localhost:*"],
),
)
async def health(request):
return JSONResponse({"ok": True})
@asynccontextmanager
async def lifespan(app):
# Mount() does not run a sub-app's lifespan: start the session manager here
async with mcp_server.session_manager.run():
yield
auth = TokenAuth({"t-analyst": Principal(id="ana", roles={"analyst"})})
app = Starlette(
routes=[Route("/health", health), Mount("/", app=TokenAuthASGIMiddleware(mcp_app, auth))],
lifespan=lifespan,
)
Run with uvicorn asgi_app:app --port 8797. Results with mcp 2.3.0:
$ curl -s http://127.0.0.1:8797/health
{"ok":true}
$ curl -si -X POST http://127.0.0.1:8797/mcp -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{...}}}'
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="cloudg-mcp"
{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Unauthorized"}}
$ curl -s -X POST http://127.0.0.1:8797/mcp -H 'Authorization: Bearer t-analyst' -H 'X-Tenant: acme' \
-H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/call' -H 'Mcp-Name: count_assets' ... \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"count_assets","arguments":{"group_by":"provider"},"_meta":{...}}}'
{"jsonrpc":"2.0","id":2,"result":{"_meta":{"cloudg/transforms":{...,"provenance":{...,"principal_roles":["analyst"]}}},"cloudg/duration_ms":1,"io.modelcontextprotocol/serverInfo":{"name":"cloudg","version":"0.6.0"}},"content":[...
TokenAuthASGIMiddleware(app, auth, guard=None) first runs guard (an OriginHostGuard,
section 7) when one is given, answering 421 or 403. It then checks the bearer header on HTTP
scopes, answers 401 when auth.required and the token is wrong, and otherwise stores the
principal in scope["cloudg.principal"] and scope["state"]["cloudg.principal"]. The
adapters' default resolution finds it there. Lifespan and non-HTTP scopes pass through
untouched, and with neither auth nor guard the middleware does nothing. The example above
keeps the SDK's own TransportSecuritySettings; to get cloudg's rules instead, pass
guard=OriginHostGuard("127.0.0.1", allowed_origins=None, allowed_hosts=None) (from
cloudg.mcp.native.http) and TransportSecuritySettings(enable_dns_rebinding_protection=False),
which is what cloudg mcp serve --flavor sdk does. The example was re-run after that change
and behaved as shown.
If your app already authenticates users, skip TokenAuth and put your own Principal into
scope["cloudg.principal"] in your middleware, or write a resolver that reads your session.
For the low-level server, mcp_server.streamable_http_app(...) also accepts the SDK's
auth=AuthSettings(...) and token_verifier=... for OAuth; the default resolver then turns
the validated AccessToken into a principal (8.8). I did not test an OAuth setup for this
guide.
8.7 Prefixes and name collisions¶
prefix exists so that cloudg's 74 tools cannot collide with the host's. With
prefix="cloudg_":
tools/listshowscloudg_find_assets; prompts get the same prefix.- The adapters only claim names that start with the prefix and whose remainder is a cloudg
tool.
find_assetswithout the prefix goes to the host. - Resource URIs are not prefixed.
- With a prefix set, only prefixed names resolve, in-process too:
layer.call_tool("progress_async")on a layer withprefix="cloudg_"raisedNotFoundError -32602 "Unknown tool: progress_async", while"cloudg_progress_async"worked. - The constructor validates the prefix: at most 64 characters from
A-Za-z0-9_.-.CloudGMCPLayer(prefix="cloudg:")raisesValueError: Invalid prefix 'cloudg:': use up to 64 of A-Z a-z 0-9 _ . -. Exposed names must also match^[A-Za-z0-9_.-]{1,128}$.
register_into(prefix=...) goes through layer.set_prefix(), the same check as the
constructor, so register_into(layer, server, prefix="bad:") raises ValueError at once.
The prefix belongs to the shared layer object, so mounting one layer into two servers with
different prefixes leaves both using the last one (a warning is logged). Build one layer per
prefix.
8.8 Principal resolvers¶
The adapters fill a cloudg.mcp.native.auth.RequestInfo for every request:
| Field | Content |
|---|---|
transport |
stdio, http, sse or memory |
headers |
HTTP headers with lower-cased names; empty on stdio |
session_id |
Mcp-Session-Id when present |
client_info |
The client's clientInfo dict when the framework exposes it |
access_token |
The SDK's AccessToken when the host validated an OAuth bearer token |
principal |
A principal stored by an auth middleware (scope["cloudg.principal"]) |
raw |
The framework's own request context object |
info.header(name, default=None) reads a header case-insensitively, and info.bearer_token
returns the token from Authorization: Bearer ... or None.
A resolver is fn(info) -> Principal | None. Returning None, or raising (which is logged),
falls back to default_principal_resolver:
info.principal, if an auth layer set one.- An OAuth access token: id from
client_id(oroauth-client), roles{"default", *scopes}, attributes{"auth": "oauth", "scopes": [...]}. - The local user (
Principal.local()) forstdioandmemorytransports. Principal(id="anonymous", roles={"default"})for anything else.
Map OAuth scopes to the role names your policy uses, or keep the scope names as roles. A resolver that trusts a gateway's identity header:
from cloudg.mcp import Principal
def gateway_resolver(info):
# Only safe when nothing but the gateway can reach this server.
user = info.header("x-authenticated-user")
groups = info.header("x-authenticated-groups", "")
if not user:
return None
return Principal(id=user, roles={g.strip() for g in groups.split(",") if g.strip()}
or {"default"}, attributes={"auth": "gateway"})
layer.register_into(server, principal_resolver=gateway_resolver)
Never derive roles from clientInfo; any client can send any value there.
8.9 build_server¶
from cloudg.mcp.adapters import build_server
build_server(layer, flavor="auto", *, name=None, principal_resolver=None, include=None,
**native_kwargs)
| Flavor | Returns |
|---|---|
auto |
lowlevel when mcp imports, else native |
sdk, lowlevel |
mcp.server.lowlevel.Server(name or layer.name, version=layer.version, instructions=layer.instructions) with cloudg registered |
mcpserver |
MCPServer (mcp 2.x) or FastMCP (mcp 1.x) with cloudg registered |
fastmcp |
fastmcp.FastMCP(name or layer.name, instructions=...) with cloudg registered |
native |
NativeMCPServer(layer, principal_resolver=..., **native_kwargs) |
The SDK and fastmcp objects carry the binding at server._cloudg_binding. The shorter
cloudg.mcp.build_server(layer, flavor="auto") takes only the flavor.
Running a built server yourself, for example with the SDK's stdio helpers on mcp 2.x:
import anyio
from mcp.server.stdio import stdio_server
server = build_server(layer, "lowlevel")
async def main():
async with stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())
anyio.run(main)
This is what cloudg mcp serve --flavor sdk does on stdio. For everything else, prefer
layer.serve(...) (10.3).
8.10 Change notifications¶
The workspace reports changes to the layer: loading a dataset fires ("resources", None)
and ("resource", uri) for cloudg://workspace, cloudg://datasets and the dataset's
summary; mutations such as suppressing findings fire ("resource", uri) for those URIs.
Tools may also call ctx.layer.notify_change("tools") or ("prompts"). The adapters turn
these into protocol notifications:
| Event | Handshake era | 2026-07-28 era |
|---|---|---|
tools, prompts, resources |
notifications/*/list_changed to every session the adapter has seen |
event on the subscriptions/listen streams that asked for it |
resource with a URI |
notifications/resources/updated, only to sessions that called resources/subscribe for that URI |
event on listen streams whose resourceSubscriptions include the URI |
On mcp 2.x the adapter publishes 2026 events on the server's subscription bus, installing a
subscriptions/listen handler when the server has none. Notifications raised from a worker
thread (a sync handler) are marshalled onto the event loop first.