agentruntime-mcp 0.0.1__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. agentruntime_mcp-0.2.0/PKG-INFO +128 -0
  2. agentruntime_mcp-0.2.0/README.md +109 -0
  3. {agentruntime_mcp-0.0.1 → agentruntime_mcp-0.2.0}/pyproject.toml +11 -3
  4. {agentruntime_mcp-0.0.1 → agentruntime_mcp-0.2.0}/setup.cfg +4 -4
  5. {agentruntime_mcp-0.0.1 → agentruntime_mcp-0.2.0}/src/agentruntime/__init__.py +2 -2
  6. agentruntime_mcp-0.2.0/src/agentruntime/mcp/__init__.py +30 -0
  7. agentruntime_mcp-0.2.0/src/agentruntime/mcp/adapter_registry.py +87 -0
  8. agentruntime_mcp-0.2.0/src/agentruntime/mcp/auth_http.py +43 -0
  9. agentruntime_mcp-0.2.0/src/agentruntime/mcp/bridge.py +164 -0
  10. agentruntime_mcp-0.2.0/src/agentruntime/mcp/bridge_auth.py +109 -0
  11. agentruntime_mcp-0.2.0/src/agentruntime/mcp/config_schema.py +86 -0
  12. agentruntime_mcp-0.2.0/src/agentruntime/mcp/context.py +76 -0
  13. agentruntime_mcp-0.2.0/src/agentruntime/mcp/control.py +124 -0
  14. {agentruntime_mcp-0.0.1 → agentruntime_mcp-0.2.0}/src/agentruntime/mcp/decorators.py +106 -74
  15. agentruntime_mcp-0.2.0/src/agentruntime/mcp/env_merge.py +85 -0
  16. agentruntime_mcp-0.2.0/src/agentruntime/mcp/errors.py +32 -0
  17. agentruntime_mcp-0.2.0/src/agentruntime/mcp/middleware.py +697 -0
  18. agentruntime_mcp-0.2.0/src/agentruntime/mcp/proxy.py +155 -0
  19. agentruntime_mcp-0.2.0/src/agentruntime/mcp/runtime.py +178 -0
  20. {agentruntime_mcp-0.0.1 → agentruntime_mcp-0.2.0}/src/agentruntime/mcp/schemas.py +170 -170
  21. agentruntime_mcp-0.2.0/src/agentruntime/mcp/webhook.py +52 -0
  22. agentruntime_mcp-0.2.0/src/agentruntime_mcp.egg-info/PKG-INFO +128 -0
  23. agentruntime_mcp-0.2.0/src/agentruntime_mcp.egg-info/SOURCES.txt +28 -0
  24. {agentruntime_mcp-0.0.1 → agentruntime_mcp-0.2.0}/src/agentruntime_mcp.egg-info/requires.txt +4 -1
  25. agentruntime_mcp-0.2.0/tests/test_bridge_auth.py +63 -0
  26. agentruntime_mcp-0.2.0/tests/test_context.py +22 -0
  27. agentruntime_mcp-0.2.0/tests/test_runtime.py +64 -0
  28. agentruntime_mcp-0.2.0/tests/test_runtime_router.py +32 -0
  29. agentruntime_mcp-0.0.1/PKG-INFO +0 -62
  30. agentruntime_mcp-0.0.1/README.md +0 -45
  31. agentruntime_mcp-0.0.1/src/agentruntime/mcp/__init__.py +0 -4
  32. agentruntime_mcp-0.0.1/src/agentruntime/mcp/generators/openapi_to_mcp.py +0 -48
  33. agentruntime_mcp-0.0.1/src/agentruntime/mcp/middleware.py +0 -207
  34. agentruntime_mcp-0.0.1/src/agentruntime/mcp/proxy.py +0 -76
  35. agentruntime_mcp-0.0.1/src/agentruntime/mcp/runtime.py +0 -47
  36. agentruntime_mcp-0.0.1/src/agentruntime_mcp.egg-info/PKG-INFO +0 -62
  37. agentruntime_mcp-0.0.1/src/agentruntime_mcp.egg-info/SOURCES.txt +0 -15
  38. {agentruntime_mcp-0.0.1 → agentruntime_mcp-0.2.0}/src/agentruntime_mcp.egg-info/dependency_links.txt +0 -0
  39. {agentruntime_mcp-0.0.1 → agentruntime_mcp-0.2.0}/src/agentruntime_mcp.egg-info/top_level.txt +0 -0
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentruntime-mcp
3
+ Version: 0.2.0
4
+ Summary: AgentRuntime MCP SDK (decorators, runtime, middleware, schemas, proxy)
5
+ Author: AgentRuntime
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: fastmcp>=2.10.0
9
+ Requires-Dist: pydantic>=2
10
+ Requires-Dist: starlette>=0.37
11
+ Requires-Dist: uvicorn[standard]>=0.30
12
+ Requires-Dist: opentelemetry-api>=1.25
13
+ Requires-Dist: opentelemetry-sdk>=1.25
14
+ Requires-Dist: opentelemetry-exporter-otlp>=1.25
15
+ Requires-Dist: PyYAML>=6
16
+ Requires-Dist: httpx>=0.25
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=7.0; extra == "test"
19
+
20
+ # AgentRuntime MCP SDK (Python)
21
+
22
+ Opinionated SDK for MCP connectors using **FastMCP** with the same **Control** integration as [`agentruntime-mcp-go`](https://github.com/agentruntime-io/agentruntime-mcp-go): optional `POST /mcp/config`, env merge, and request metadata (run token, instance id).
23
+
24
+ There is **no mandatory legacy standalone token/HMAC middleware chain** in front of every request. Probe-style auth is the **Bearer run token** (or `X-MCP-Token`) and headers AgentRuntime sends; optional `auth.mode` in `config.yaml` is reserved for future templates and does not switch Control gating off.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install agentruntime-mcp
30
+ ```
31
+
32
+ ## Runtimes
33
+
34
+ | Entry | When to use | HTTP |
35
+ |--------|-------------|------|
36
+ | **`run(config_path)`** | Decorator-only `tool()` modules; tools registered from `config.yaml` / `build_schemas` | `/mcp` |
37
+ | **`run_with_registry(config_path, *names)`** | Plugins registered with **`register_adapter`**; one process, one MCP server | `/mcp` |
38
+ | **`run_with_router(config_path)`** | Same registry, **monolith routing** (Go **`RunWithRouter`** / TS **`runWithRouter`** parity) | `/{adapter}/mcp` plus optional **webhook** routes on the **same** ASGI app |
39
+
40
+ Use **`register_adapter`** for anything that should share a process with other adapters. Keep plain **`run()`** for single-connector demos that only use decorators and YAML tools.
41
+
42
+ **Webhooks:** Implement **`register_webhook(self, mux: ServeMux)`** on the adapter. Paths are registered on the **root** Starlette app **before** adapter mounts (same dispatch order as Go/TS). Operators expose vendor callbacks as `https://<host>:<port><path>` (same host/port as MCP).
43
+
44
+ ## Minimal example
45
+
46
+ ```python
47
+ from pydantic import BaseModel, Field
48
+ from agentruntime.mcp.decorators import tool
49
+ from agentruntime.mcp.runtime import run
50
+ from fastmcp import Context
51
+
52
+ class In(BaseModel):
53
+ a: float = Field(...)
54
+ b: float = Field(...)
55
+
56
+ class Out(BaseModel):
57
+ result: float
58
+ expression: str
59
+
60
+ @tool(name="add", input_model=In, output_model=Out)
61
+ def add(a: float, b: float, ctx: Context) -> Out:
62
+ region = ctx.config.region # resolved config when schema + Control are used
63
+ return Out(result=a + b, expression=f"{a} + {b}")
64
+
65
+ if __name__ == "__main__":
66
+ run("config.yaml")
67
+ ```
68
+
69
+ ## Config (`config.yaml`)
70
+
71
+ - **`server`:** `name`, `host`, `port`, `stateless_http`
72
+ - **`tracing`:** enable OpenTelemetry when dependencies are present
73
+ - **`config`:** declarative schema for Control / `GET /mcp/config/schema`
74
+
75
+ Env overrides: **`HOST`**, **`PORT`** (see [`docs/mcp/mcp_env.md`](../../docs/mcp/mcp_env.md)).
76
+
77
+ Example:
78
+
79
+ ```yaml
80
+ server:
81
+ name: "MyConnector"
82
+ host: "127.0.0.1"
83
+ port: 8012
84
+ stateless_http: true
85
+
86
+ tracing:
87
+ enabled: false
88
+
89
+ config:
90
+ apiKey:
91
+ type: string
92
+ displayName: API Key
93
+ required: true
94
+ ```
95
+
96
+ ## Control config resolution
97
+
98
+ When the adapter registers at least one config key:
99
+
100
+ - Set **`MCP_CONTROL_SERVER_URL`** to your control-service base URL.
101
+ - The SDK may call **`POST /mcp/config`** with the run token from the request and your schema.
102
+ - Set **`MCP_CONFIG_FETCH_REQUIRED=false`** for local work without Control (when you still declare schema keys).
103
+
104
+ See [`docs/mcp/mcp_env.md`](../../docs/mcp/mcp_env.md) for the full variable list.
105
+
106
+ ## Proxy (library)
107
+
108
+ ```python
109
+ from agentruntime.mcp.proxy import run_proxy
110
+ run_proxy(
111
+ target_url="http://127.0.0.1:8000/mcp",
112
+ overlay_file="tools.yaml",
113
+ host="127.0.0.1",
114
+ port=8010,
115
+ )
116
+ ```
117
+
118
+ ## TypeScript sibling SDK
119
+
120
+ For Node deployments, use the monorepo package [`packages/agentruntime-mcp-ts`](../agentruntime-mcp-ts/README.md) (`@agentruntime-labs/agentruntime-mcp`): official **`@modelcontextprotocol/server`**, Zod 4, **`runWithRouter`**.
121
+
122
+ ## Examples in this repo
123
+
124
+ - [`connectors/py-connectors/resend-connector`](../../connectors/py-connectors/resend-connector/) — Python Resend connector (illustrative layout)
125
+
126
+ ## Releasing
127
+
128
+ See [`../RELEASE.md`](../RELEASE.md) (Python section): lightweight Git tags (`v0.1.0`, `v0.1.1`), bump `version` in `pyproject.toml`, do not commit `__pycache__` / `*.egg-info/`.
@@ -0,0 +1,109 @@
1
+ # AgentRuntime MCP SDK (Python)
2
+
3
+ Opinionated SDK for MCP connectors using **FastMCP** with the same **Control** integration as [`agentruntime-mcp-go`](https://github.com/agentruntime-io/agentruntime-mcp-go): optional `POST /mcp/config`, env merge, and request metadata (run token, instance id).
4
+
5
+ There is **no mandatory legacy standalone token/HMAC middleware chain** in front of every request. Probe-style auth is the **Bearer run token** (or `X-MCP-Token`) and headers AgentRuntime sends; optional `auth.mode` in `config.yaml` is reserved for future templates and does not switch Control gating off.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install agentruntime-mcp
11
+ ```
12
+
13
+ ## Runtimes
14
+
15
+ | Entry | When to use | HTTP |
16
+ |--------|-------------|------|
17
+ | **`run(config_path)`** | Decorator-only `tool()` modules; tools registered from `config.yaml` / `build_schemas` | `/mcp` |
18
+ | **`run_with_registry(config_path, *names)`** | Plugins registered with **`register_adapter`**; one process, one MCP server | `/mcp` |
19
+ | **`run_with_router(config_path)`** | Same registry, **monolith routing** (Go **`RunWithRouter`** / TS **`runWithRouter`** parity) | `/{adapter}/mcp` plus optional **webhook** routes on the **same** ASGI app |
20
+
21
+ Use **`register_adapter`** for anything that should share a process with other adapters. Keep plain **`run()`** for single-connector demos that only use decorators and YAML tools.
22
+
23
+ **Webhooks:** Implement **`register_webhook(self, mux: ServeMux)`** on the adapter. Paths are registered on the **root** Starlette app **before** adapter mounts (same dispatch order as Go/TS). Operators expose vendor callbacks as `https://<host>:<port><path>` (same host/port as MCP).
24
+
25
+ ## Minimal example
26
+
27
+ ```python
28
+ from pydantic import BaseModel, Field
29
+ from agentruntime.mcp.decorators import tool
30
+ from agentruntime.mcp.runtime import run
31
+ from fastmcp import Context
32
+
33
+ class In(BaseModel):
34
+ a: float = Field(...)
35
+ b: float = Field(...)
36
+
37
+ class Out(BaseModel):
38
+ result: float
39
+ expression: str
40
+
41
+ @tool(name="add", input_model=In, output_model=Out)
42
+ def add(a: float, b: float, ctx: Context) -> Out:
43
+ region = ctx.config.region # resolved config when schema + Control are used
44
+ return Out(result=a + b, expression=f"{a} + {b}")
45
+
46
+ if __name__ == "__main__":
47
+ run("config.yaml")
48
+ ```
49
+
50
+ ## Config (`config.yaml`)
51
+
52
+ - **`server`:** `name`, `host`, `port`, `stateless_http`
53
+ - **`tracing`:** enable OpenTelemetry when dependencies are present
54
+ - **`config`:** declarative schema for Control / `GET /mcp/config/schema`
55
+
56
+ Env overrides: **`HOST`**, **`PORT`** (see [`docs/mcp/mcp_env.md`](../../docs/mcp/mcp_env.md)).
57
+
58
+ Example:
59
+
60
+ ```yaml
61
+ server:
62
+ name: "MyConnector"
63
+ host: "127.0.0.1"
64
+ port: 8012
65
+ stateless_http: true
66
+
67
+ tracing:
68
+ enabled: false
69
+
70
+ config:
71
+ apiKey:
72
+ type: string
73
+ displayName: API Key
74
+ required: true
75
+ ```
76
+
77
+ ## Control config resolution
78
+
79
+ When the adapter registers at least one config key:
80
+
81
+ - Set **`MCP_CONTROL_SERVER_URL`** to your control-service base URL.
82
+ - The SDK may call **`POST /mcp/config`** with the run token from the request and your schema.
83
+ - Set **`MCP_CONFIG_FETCH_REQUIRED=false`** for local work without Control (when you still declare schema keys).
84
+
85
+ See [`docs/mcp/mcp_env.md`](../../docs/mcp/mcp_env.md) for the full variable list.
86
+
87
+ ## Proxy (library)
88
+
89
+ ```python
90
+ from agentruntime.mcp.proxy import run_proxy
91
+ run_proxy(
92
+ target_url="http://127.0.0.1:8000/mcp",
93
+ overlay_file="tools.yaml",
94
+ host="127.0.0.1",
95
+ port=8010,
96
+ )
97
+ ```
98
+
99
+ ## TypeScript sibling SDK
100
+
101
+ For Node deployments, use the monorepo package [`packages/agentruntime-mcp-ts`](../agentruntime-mcp-ts/README.md) (`@agentruntime-labs/agentruntime-mcp`): official **`@modelcontextprotocol/server`**, Zod 4, **`runWithRouter`**.
102
+
103
+ ## Examples in this repo
104
+
105
+ - [`connectors/py-connectors/resend-connector`](../../connectors/py-connectors/resend-connector/) — Python Resend connector (illustrative layout)
106
+
107
+ ## Releasing
108
+
109
+ See [`../RELEASE.md`](../RELEASE.md) (Python section): lightweight Git tags (`v0.1.0`, `v0.1.1`), bump `version` in `pyproject.toml`, do not commit `__pycache__` / `*.egg-info/`.
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentruntime-mcp"
7
- version = "0.0.1"
8
- description = "AgentRuntime MCP SDK (decorators, runtime, middleware, schemas, proxy, generators)"
7
+ version = "0.2.0"
8
+ description = "AgentRuntime MCP SDK (decorators, runtime, middleware, schemas, proxy)"
9
9
  requires-python = ">=3.10"
10
10
  readme = "README.md"
11
11
  authors = [{ name = "AgentRuntime" }]
12
12
  dependencies = [
13
- "fastmcp",
13
+ "fastmcp>=2.10.0",
14
14
  "pydantic>=2",
15
15
  "starlette>=0.37",
16
16
  "uvicorn[standard]>=0.30",
@@ -21,7 +21,15 @@ dependencies = [
21
21
  "httpx>=0.25",
22
22
  ]
23
23
 
24
+ [project.optional-dependencies]
25
+ test = ["pytest>=7.0"]
26
+
24
27
  [tool.setuptools]
25
28
  package-dir = {"" = "src"}
26
29
 
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
27
35
 
@@ -1,4 +1,4 @@
1
- [egg_info]
2
- tag_build =
3
- tag_date = 0
4
-
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -1,2 +1,2 @@
1
- # Namespace package for AgentRuntime
2
-
1
+ # Namespace package for AgentRuntime
2
+
@@ -0,0 +1,30 @@
1
+ from .decorators import tool, mount_tools # noqa: F401
2
+ from .runtime import run, make_server, load_config, run_with_registry, run_with_router # noqa: F401
3
+ from .schemas import emit_json_shape, emit_flat_shape, build_schemas # noqa: F401
4
+ from .context import get_config, ConfigView # noqa: F401
5
+ from .adapter_registry import register_adapter, list_adapter_names, instantiate_adapters # noqa: F401
6
+ from .adapter_registry import Adapter, WebhookAdapter, ServeMux # noqa: F401
7
+ from .config_schema import ( # noqa: F401
8
+ CONFIG_TYPE_STRING,
9
+ CONFIG_TYPE_NUMBER,
10
+ CONFIG_TYPE_BOOL,
11
+ field,
12
+ string_field,
13
+ opt_required,
14
+ opt_default,
15
+ new_schema_writer,
16
+ write_schema,
17
+ config_schema_has_keys,
18
+ )
19
+ from .errors import ErrAdapterNotFound, ControlError, human_message_from_control_api_body # noqa: F401
20
+ from .bridge import BRIDGE_MOUNT_PATH # noqa: F401
21
+ from .bridge_auth import apply_auth_mapping, apply_header_mappings, apply_bridge_headers # noqa: F401
22
+ from .control import ( # noqa: F401
23
+ HEADER_MCP_INSTANCE_ID,
24
+ HEADER_MCP_SERVER_ID,
25
+ ControlPayload,
26
+ fetch_control_payload,
27
+ build_runtime_context_from_request,
28
+ )
29
+ from .webhook import sign_mode_b, deliver_mode_b, ModeBRequest # noqa: F401
30
+
@@ -0,0 +1,87 @@
1
+ """Adapter registry mirroring agentruntime-mcp-go adapter.go."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import (
7
+ TYPE_CHECKING,
8
+ Any,
9
+ Callable,
10
+ Dict,
11
+ Iterable,
12
+ List,
13
+ Protocol,
14
+ Tuple,
15
+ runtime_checkable,
16
+ )
17
+
18
+ from .errors import ErrAdapterNotFound
19
+
20
+ if TYPE_CHECKING:
21
+ from fastmcp import FastMCP
22
+
23
+ from .config_schema import SchemaWriter
24
+
25
+
26
+ @runtime_checkable
27
+ class Adapter(Protocol):
28
+ def register(self, mcp: "FastMCP", schema: "SchemaWriter") -> None: ...
29
+
30
+
31
+ @runtime_checkable
32
+ class WebhookAdapter(Protocol):
33
+ def register_webhook(self, mux: "ServeMux") -> None: ...
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class AdapterConstructorInput:
38
+ """Reserved for future use (parity with Go AdapterConstructorInput)."""
39
+
40
+
41
+ AdapterConstructor = Callable[[AdapterConstructorInput], Adapter]
42
+
43
+
44
+ class ServeMux:
45
+ """HTTP route table (exact path → handler). Order preserved (first match wins when iterating).
46
+
47
+ Handlers should be Starlette-style ``async def(request: Request) -> Response``.
48
+ """
49
+
50
+ def __init__(self) -> None:
51
+ self._routes: List[Tuple[str, Any]] = []
52
+
53
+ def handle(self, path: str, handler: Any) -> None:
54
+ self._routes.append((normalize_path(path), handler))
55
+
56
+ def __iter__(self) -> Iterable[Tuple[str, Any]]:
57
+ return iter(self._routes)
58
+
59
+
60
+ def normalize_path(path: str) -> str:
61
+ if not path or path == "/":
62
+ return "/"
63
+ return path[:-1] if path.endswith("/") else path
64
+
65
+
66
+ _registry: Dict[str, AdapterConstructor] = {}
67
+
68
+
69
+ def register_adapter(name: str, ctor: AdapterConstructor) -> None:
70
+ _registry[name] = ctor
71
+
72
+
73
+ def list_adapter_names() -> List[str]:
74
+ return sorted(_registry.keys())
75
+
76
+
77
+ def instantiate_adapters(names: List[str]) -> List[Adapter]:
78
+ inp = AdapterConstructorInput()
79
+ if not names:
80
+ return [ctor(inp) for ctor in _registry.values()]
81
+ out: List[Adapter] = []
82
+ for n in names:
83
+ ctor = _registry.get(n)
84
+ if ctor is None:
85
+ raise ErrAdapterNotFound(n)
86
+ out.append(ctor(inp))
87
+ return out
@@ -0,0 +1,43 @@
1
+ """HTTP helpers mirroring agentruntime-mcp-go auth.go."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping, Optional
6
+ import urllib.parse
7
+
8
+ NO_CONFIG_METHODS = frozenset({"tools/list", "initialize", "notifications/initialized"})
9
+
10
+
11
+ def extract_token_from_headers(headers: Mapping[str, Any]) -> str:
12
+ auth = headers.get("authorization") or headers.get("Authorization")
13
+ if isinstance(auth, str) and auth.lower().startswith("bearer "):
14
+ return auth[7:].strip()
15
+ xt = headers.get("x-mcp-token") or headers.get("X-MCP-Token")
16
+ if isinstance(xt, str) and xt.strip():
17
+ return xt.strip()
18
+ return ""
19
+
20
+
21
+ def normalize_url_path(url_path: str) -> str:
22
+ p = urllib.parse.urlsplit(url_path).path or ""
23
+ if len(p) > 1 and p.endswith("/"):
24
+ p = p[:-1]
25
+ return p or "/"
26
+
27
+
28
+ def is_schema_endpoint_for_mount(method: str, url_path: str, mount_path: str) -> bool:
29
+ if method.upper() != "GET":
30
+ return False
31
+ path = normalize_url_path(url_path)
32
+ mp = mount_path.rstrip("/") or "/"
33
+ schema_path = normalize_url_path(mp + "/config/schema")
34
+ return path == schema_path
35
+
36
+
37
+ def needs_resolved_config_from_body(data: Optional[Mapping[str, Any]]) -> bool:
38
+ if not data or not isinstance(data, Mapping):
39
+ return True
40
+ m = data.get("method")
41
+ if not isinstance(m, str):
42
+ return True
43
+ return m not in NO_CONFIG_METHODS
@@ -0,0 +1,164 @@
1
+ """Generic MCP bridge route — mirror agentruntime-mcp-go bridge.go."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any, Dict, Mapping, Optional
8
+
9
+ import httpx
10
+ from starlette.requests import Request
11
+ from starlette.responses import JSONResponse, PlainTextResponse, Response
12
+
13
+ from .auth_http import extract_token_from_headers, is_schema_endpoint_for_mount
14
+ from .bridge_auth import apply_bridge_headers
15
+ from .control import build_runtime_context_from_request, fetch_control_payload
16
+ from .errors import ControlError, human_message_from_control_api_body
17
+
18
+ BRIDGE_MOUNT_PATH = "/bridge/mcp"
19
+ _LOG = logging.getLogger(__name__)
20
+
21
+
22
+ def _bridge_control_err(err: BaseException) -> str:
23
+ if isinstance(err, ControlError):
24
+ hm = human_message_from_control_api_body(err.body)
25
+ if hm:
26
+ return hm
27
+ return str(err)
28
+
29
+
30
+ def _bridge_upstream_from_payload(bridge: Optional[Mapping[str, Any]]) -> str:
31
+ if not bridge:
32
+ raise ValueError(
33
+ "server is not configured for bridge mode (missing metadata.bridge upstream_url)"
34
+ )
35
+ upstream = str(bridge.get("upstream_url", "")).strip()
36
+ if not upstream:
37
+ raise ValueError("bridge upstream_url is not set on mcp_servers.metadata")
38
+ return upstream
39
+
40
+
41
+ def _tool_name_from_params(data: Mapping[str, Any]) -> Optional[str]:
42
+ params = data.get("params")
43
+ if not isinstance(params, dict):
44
+ return None
45
+ name = str(params.get("name", "")).strip()
46
+ return name or None
47
+
48
+
49
+ def _json_rpc_response(
50
+ req_id: Any,
51
+ result: Optional[Dict[str, Any]] = None,
52
+ rpc_err: Optional[Dict[str, Any]] = None,
53
+ ) -> JSONResponse:
54
+ body: Dict[str, Any] = {"jsonrpc": "2.0", "id": req_id}
55
+ if rpc_err is not None:
56
+ body["error"] = rpc_err
57
+ else:
58
+ body["result"] = result or {}
59
+ return JSONResponse(body)
60
+
61
+
62
+ def _json_rpc_err(code: int, message: str) -> Dict[str, Any]:
63
+ return {"code": code, "message": message}
64
+
65
+
66
+ async def _bridge_post(
67
+ target_url: str,
68
+ payload: Mapping[str, Any],
69
+ extra_headers: Mapping[str, str],
70
+ ) -> Dict[str, Any]:
71
+ if not target_url.strip():
72
+ raise ValueError("proxy target not configured")
73
+ headers = {
74
+ "Accept": "application/json, text/event-stream",
75
+ "Content-Type": "application/json",
76
+ **dict(extra_headers),
77
+ }
78
+ async with httpx.AsyncClient() as client:
79
+ resp = await client.post(target_url, json=dict(payload), headers=headers)
80
+ if resp.status_code != 200:
81
+ raise ValueError(f"proxy target not configured: target returned {resp.status_code}: {resp.text}")
82
+ try:
83
+ parsed = resp.json()
84
+ except Exception as exc:
85
+ raise ValueError(f"proxy target not configured: {exc}") from exc
86
+ if not isinstance(parsed, dict):
87
+ raise ValueError("proxy target not configured: invalid JSON object")
88
+ return parsed
89
+
90
+
91
+ async def bridge_http_endpoint(request: Request) -> Response:
92
+ mount = BRIDGE_MOUNT_PATH
93
+ path = request.url.path
94
+
95
+ if is_schema_endpoint_for_mount(request.method, path, mount):
96
+ token = extract_token_from_headers(request.headers)
97
+ ctx = build_runtime_context_from_request(request)
98
+ try:
99
+ payload = fetch_control_payload(token, {}, ctx)
100
+ return JSONResponse(payload.config_schema or {})
101
+ except Exception as err:
102
+ return PlainTextResponse(_bridge_control_err(err), status_code=502)
103
+
104
+ if request.method.upper() != "POST":
105
+ return PlainTextResponse("Method Not Allowed", status_code=405)
106
+
107
+ try:
108
+ data = await request.json()
109
+ except Exception:
110
+ return _json_rpc_response(None, rpc_err=_json_rpc_err(-32700, "Parse error"))
111
+ if not isinstance(data, dict):
112
+ return _json_rpc_response(None, rpc_err=_json_rpc_err(-32700, "Parse error"))
113
+
114
+ req_id = data.get("id")
115
+ method = data.get("method") if isinstance(data.get("method"), str) else ""
116
+
117
+ ctx = build_runtime_context_from_request(request)
118
+ if method == "tools/call":
119
+ tn = _tool_name_from_params(data)
120
+ if tn:
121
+ ctx["tool_name"] = tn
122
+
123
+ token = extract_token_from_headers(request.headers)
124
+ try:
125
+ control_payload = fetch_control_payload(token, {}, ctx)
126
+ except Exception as err:
127
+ return _json_rpc_response(req_id, rpc_err=_json_rpc_err(-32603, _bridge_control_err(err)))
128
+
129
+ try:
130
+ upstream = _bridge_upstream_from_payload(control_payload.bridge)
131
+ except Exception as err:
132
+ return _json_rpc_response(req_id, rpc_err=_json_rpc_err(-32603, str(err)))
133
+
134
+ try:
135
+ auth_hdr = apply_bridge_headers(control_payload.config, control_payload.bridge)
136
+ except Exception as err:
137
+ return _json_rpc_response(req_id, rpc_err=_json_rpc_err(-32603, f"upstream auth: {err}"))
138
+
139
+ try:
140
+ proxied = await _bridge_post(upstream, data, auth_hdr)
141
+ except Exception as err:
142
+ return _json_rpc_response(req_id, rpc_err=_json_rpc_err(-32603, str(err)))
143
+
144
+ if method in ("tools/list", "tools/call"):
145
+ result = proxied.get("result")
146
+ if not isinstance(result, dict):
147
+ result = {}
148
+ return _json_rpc_response(req_id, result=result)
149
+ return JSONResponse(proxied)
150
+
151
+
152
+ def bridge_routes() -> list[Any]:
153
+ """Starlette routes for the generic bridge mount."""
154
+ from starlette.routing import Route
155
+
156
+ return [
157
+ Route(BRIDGE_MOUNT_PATH, endpoint=bridge_http_endpoint, methods=["GET", "POST"]),
158
+ Route(f"{BRIDGE_MOUNT_PATH}/", endpoint=bridge_http_endpoint, methods=["GET", "POST"]),
159
+ Route(
160
+ f"{BRIDGE_MOUNT_PATH}/config/schema",
161
+ endpoint=bridge_http_endpoint,
162
+ methods=["GET"],
163
+ ),
164
+ ]