Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/tutorials/connecting-agent-frameworks.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Connecting Agent Frameworks

Already using a client configured with an `mcpServers` block? Start with
[Try cMCP from an existing MCP client](existing-mcp-clients.md); no agent code
is required.

Wire a real agent: LangChain, LlamaIndex, or a plain HTTP client: to the cMCP gateway so every tool call passes through policy enforcement.

## What you'll learn
Expand Down
34 changes: 34 additions & 0 deletions docs/tutorials/existing-mcp-clients.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Try cMCP from an existing MCP client

Start the cMCP gateway, then configure a client that supports the standard
`mcpServers` stdio shape to launch the included bridge:

```json
{
"mcpServers": {
"governed-tools": {
"command": "cmcp",
"args": ["client-bridge", "--gateway-url", "https://gateway.example/mcp"],
"env": {"CMCP_BEARER_TOKEN": "replace-with-the-gateway-token"}
}
}
}
```

Restart the client after changing its configuration. Its `tools/list` and
`tools/call` messages now pass through cMCP and appear in the gateway audit
chain; upstream addresses remain solely in the attested cMCP catalog.

## What this proves—and what it does not

The bridge is an evaluation aid. It runs on the user's machine, outside the
TEE and outside cMCP's measurement. A user who controls the client
configuration can remove it, so this does **not** prove cMCP cannot be
bypassed. Production enforcement requires controls that make the governed
gateway the only reachable path to upstream servers.

The bearer token is read from `CMCP_BEARER_TOKEN`; do not put it in command
arguments, where process-list tooling may expose it. Diagnostics go to stderr
so stdout remains strict newline-delimited JSON-RPC.

Design and trust boundary: [#510](https://github.com/agentrust-io/cmcp/issues/510).
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ otel = [
"opentelemetry-exporter-otlp-proto-http>=1.25",
]
dev = [
"mcp>=1.28.1,<3.0",
"pytest>=8.0",
"pytest-asyncio>=0.23",
"pytest-cov>=5.0",
Expand Down
15 changes: 15 additions & 0 deletions src/cmcp_runtime/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,21 @@ def start(config: str, enforcement: str | None) -> None:
uvicorn.run(server.app, host=host, port=port)


@main.command("client-bridge")
@click.option("--gateway-url", required=True,
help="Running cMCP MCP endpoint, e.g. https://gateway.example/mcp.")
@click.option("--token-env", default="CMCP_BEARER_TOKEN", show_default=True,
help="Environment variable containing the bearer token.")
def client_bridge(gateway_url: str, token_env: str) -> None:
"""Expose a running cMCP gateway as a local MCP stdio server."""
from cmcp_runtime.mcp.client_bridge import run_bridge

try:
run_bridge(gateway_url, token_env)
except RuntimeError as exc:
raise click.ClickException(str(exc)) from exc


@main.command()
@click.argument("claim_file", type=click.Path(exists=True))
@click.option("--policy-hash", default=None,
Expand Down
58 changes: 58 additions & 0 deletions src/cmcp_runtime/mcp/client_bridge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""stdio-to-HTTP bridge for configuring existing MCP clients behind cMCP."""

from __future__ import annotations

import json
import os
import sys
from typing import Any, TextIO

import httpx


def _error(request_id: Any, message: str) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": request_id,
"error": {"code": -32000, "message": message}}


def bridge_stream(
source: TextIO,
sink: TextIO,
*,
gateway_url: str,
bearer_token: str,
client: httpx.Client,
) -> None:
"""Forward newline-delimited JSON-RPC without diagnostics on stdout."""
headers = {"Authorization": f"Bearer {bearer_token}"}
for raw_line in source:
request_id: Any = None
try:
message = json.loads(raw_line)
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
raise ValueError("input is not a JSON-RPC 2.0 object")
request_id = message.get("id")
response = client.post(gateway_url, json=message, headers=headers)
response.raise_for_status()
body = response.json()
if not isinstance(body, dict) or body.get("jsonrpc") != "2.0":
raise ValueError("gateway response is not a JSON-RPC 2.0 object")
except (json.JSONDecodeError, ValueError) as exc:
body = _error(request_id, f"cMCP bridge protocol error: {exc}")
except httpx.HTTPStatusError as exc:
body = _error(request_id, f"cMCP gateway returned HTTP {exc.response.status_code}")
except httpx.HTTPError as exc:
sys.stderr.write(f"cMCP bridge transport error: {exc}\n")
body = _error(request_id, "cMCP gateway unavailable")
sink.write(json.dumps(body, separators=(",", ":")) + "\n")
sink.flush()


def run_bridge(gateway_url: str, token_env: str) -> None:
"""Run with a bearer token read only from the named environment variable."""
token = os.environ.get(token_env)
if not token:
raise RuntimeError(f"required bearer token environment variable {token_env} is unset")
with httpx.Client(timeout=httpx.Timeout(30.0)) as client:
bridge_stream(sys.stdin, sys.stdout, gateway_url=gateway_url,
bearer_token=token, client=client)
70 changes: 70 additions & 0 deletions tests/integration/test_official_client_bridge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Official MCP client -> stdio bridge -> real cMCP gateway proof (#491)."""

from __future__ import annotations

import os
import socket
import sys
import threading
import time

import anyio
import pytest
import uvicorn
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from tests.unit.test_mcp_proxy import _make_proxy

from cmcp_runtime.mcp.server import MCPServer


def _free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])


@pytest.mark.asyncio
async def test_official_client_call_reaches_real_gateway_audit_chain():
proxy, _, chain = _make_proxy()
app = MCPServer(proxy=proxy, bearer_token="bridge-test-token").app
port = _free_port()
server = uvicorn.Server(
uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
deadline = time.monotonic() + 10
while not server.started and time.monotonic() < deadline:
await anyio.sleep(0.01)
assert server.started

params = StdioServerParameters(
command=sys.executable,
args=[
"-c",
"from cmcp_runtime.cli import main; main()",
"client-bridge",
"--gateway-url",
f"http://127.0.0.1:{port}/mcp",
],
cwd=os.getcwd(),
env={**os.environ, "CMCP_BEARER_TOKEN": "bridge-test-token"},
)
try:
async with (
stdio_client(params) as (read_stream, write_stream),
ClientSession(read_stream, write_stream) as session,
):
await session.initialize()
tools = await session.list_tools()
assert any(tool.name == "test.tool" for tool in tools.tools)
result = await session.call_tool("test.tool", {})
assert result.isError is not True
finally:
server.should_exit = True
thread.join(timeout=10)

tool_entries = [entry for entry in chain.entries if entry.entry_type == "tool_call"]
assert tool_entries
assert tool_entries[-1].tool_name == "test.tool"
73 changes: 73 additions & 0 deletions tests/unit/test_client_bridge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Client-side stdio bridge tests for #491 / design #510."""

from __future__ import annotations

import io
import json

import httpx
from click.testing import CliRunner

from cmcp_runtime.cli import main
from cmcp_runtime.mcp.client_bridge import bridge_stream


def test_bridge_forwards_jsonrpc_and_bearer_token():
seen: dict = {}

def handler(request: httpx.Request) -> httpx.Response:
seen["authorization"] = request.headers["authorization"]
seen["body"] = json.loads(request.content)
return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1,
"result": {"tools": []}})

source = io.StringIO('{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n')
sink = io.StringIO()
with httpx.Client(transport=httpx.MockTransport(handler)) as client:
bridge_stream(source, sink, gateway_url="https://gateway.example/mcp",
bearer_token="secret", client=client)
assert seen["authorization"] == "Bearer secret"
assert seen["body"]["method"] == "tools/list"
assert json.loads(sink.getvalue())["result"] == {"tools": []}


def test_malformed_input_returns_one_error_line_without_forwarding():
calls = 0

def handler(request: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
return httpx.Response(500)

sink = io.StringIO()
with httpx.Client(transport=httpx.MockTransport(handler)) as client:
bridge_stream(io.StringIO("not-json\n"), sink,
gateway_url="https://gateway.example/mcp",
bearer_token="secret", client=client)
assert json.loads(sink.getvalue())["error"]["code"] == -32000
assert calls == 0
assert sink.getvalue().count("\n") == 1


def test_http_error_does_not_echo_gateway_body_or_token():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(401, text="secret internal detail", request=request)

sink = io.StringIO()
with httpx.Client(transport=httpx.MockTransport(handler)) as client:
bridge_stream(io.StringIO('{"jsonrpc":"2.0","id":7,"method":"tools/list"}\n'),
sink, gateway_url="https://gateway.example/mcp",
bearer_token="do-not-leak", client=client)
output = sink.getvalue()
assert "HTTP 401" in output
assert "secret internal detail" not in output
assert "do-not-leak" not in output


def test_cli_requires_token_environment_variable():
result = CliRunner().invoke(
main, ["client-bridge", "--gateway-url", "https://gateway.example/mcp"],
env={"CMCP_BEARER_TOKEN": ""},
)
assert result.exit_code != 0
assert "CMCP_BEARER_TOKEN is unset" in result.output