agentruntime-mcp 0.1.2__tar.gz → 0.3.1__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.
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/PKG-INFO +14 -1
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/README.md +13 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/pyproject.toml +1 -1
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime/mcp/__init__.py +23 -1
- agentruntime_mcp-0.3.1/src/agentruntime/mcp/bridge.py +164 -0
- agentruntime_mcp-0.3.1/src/agentruntime/mcp/bridge_auth.py +109 -0
- agentruntime_mcp-0.3.1/src/agentruntime/mcp/control.py +124 -0
- agentruntime_mcp-0.3.1/src/agentruntime/mcp/request_bearer.py +12 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime/mcp/runtime.py +3 -1
- agentruntime_mcp-0.3.1/src/agentruntime/mcp/toolorg/__init__.py +29 -0
- agentruntime_mcp-0.3.1/src/agentruntime/mcp/toolorg/toolorg.py +225 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime_mcp.egg-info/PKG-INFO +14 -1
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime_mcp.egg-info/SOURCES.txt +9 -1
- agentruntime_mcp-0.3.1/tests/test_bridge_auth.py +63 -0
- agentruntime_mcp-0.3.1/tests/test_toolorg.py +54 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/setup.cfg +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime/__init__.py +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime/mcp/adapter_registry.py +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime/mcp/auth_http.py +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime/mcp/config_schema.py +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime/mcp/context.py +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime/mcp/decorators.py +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime/mcp/env_merge.py +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime/mcp/errors.py +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime/mcp/middleware.py +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime/mcp/proxy.py +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime/mcp/schemas.py +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime/mcp/webhook.py +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime_mcp.egg-info/dependency_links.txt +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime_mcp.egg-info/requires.txt +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime_mcp.egg-info/top_level.txt +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/tests/test_context.py +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/tests/test_runtime.py +0 -0
- {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/tests/test_runtime_router.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentruntime-mcp
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: AgentRuntime MCP SDK (decorators, runtime, middleware, schemas, proxy)
|
|
5
5
|
Author: AgentRuntime
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -115,6 +115,19 @@ run_proxy(
|
|
|
115
115
|
)
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
+
## Tool organization (`toolorg`)
|
|
119
|
+
|
|
120
|
+
Large connectors can build publish metadata for Control catalog grouping. Parity with Go `toolorg` — see [MCP_SDK_PARITY.md](../../docs/mcp/MCP_SDK_PARITY.md).
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from agentruntime.mcp.toolorg import publisher_metadata, Metadata
|
|
124
|
+
|
|
125
|
+
meta = publisher_metadata("clickup_get_task", Metadata())
|
|
126
|
+
# → display_name, suggested_group, suggested_tags for mcp_tools.metadata
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Per-request caller bearer (for downstream HTTP): `request_bearer_from_context()`.
|
|
130
|
+
|
|
118
131
|
## TypeScript sibling SDK
|
|
119
132
|
|
|
120
133
|
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`**.
|
|
@@ -96,6 +96,19 @@ run_proxy(
|
|
|
96
96
|
)
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
+
## Tool organization (`toolorg`)
|
|
100
|
+
|
|
101
|
+
Large connectors can build publish metadata for Control catalog grouping. Parity with Go `toolorg` — see [MCP_SDK_PARITY.md](../../docs/mcp/MCP_SDK_PARITY.md).
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from agentruntime.mcp.toolorg import publisher_metadata, Metadata
|
|
105
|
+
|
|
106
|
+
meta = publisher_metadata("clickup_get_task", Metadata())
|
|
107
|
+
# → display_name, suggested_group, suggested_tags for mcp_tools.metadata
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Per-request caller bearer (for downstream HTTP): `request_bearer_from_context()`.
|
|
111
|
+
|
|
99
112
|
## TypeScript sibling SDK
|
|
100
113
|
|
|
101
114
|
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`**.
|
|
@@ -17,5 +17,27 @@ from .config_schema import ( # noqa: F401
|
|
|
17
17
|
config_schema_has_keys,
|
|
18
18
|
)
|
|
19
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
|
+
)
|
|
20
29
|
from .webhook import sign_mode_b, deliver_mode_b, ModeBRequest # noqa: F401
|
|
21
|
-
|
|
30
|
+
from .request_bearer import request_bearer_from_context # noqa: F401
|
|
31
|
+
from .toolorg import ( # noqa: F401
|
|
32
|
+
Metadata,
|
|
33
|
+
EffectiveOrganization,
|
|
34
|
+
ToolGroup,
|
|
35
|
+
suggest_from_wire_name,
|
|
36
|
+
format_display_name,
|
|
37
|
+
publisher_metadata,
|
|
38
|
+
default_publisher_metadata,
|
|
39
|
+
group_label,
|
|
40
|
+
parse_metadata,
|
|
41
|
+
metadata_is_empty,
|
|
42
|
+
merge_effective,
|
|
43
|
+
)
|
|
@@ -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
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Bridge outbound auth/header mapping — mirror agentruntime-mcp-go bridge_auth.go."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Mapping, MutableMapping, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _string_from_map(m: Optional[Mapping[str, Any]], key: str) -> str:
|
|
9
|
+
if not m:
|
|
10
|
+
return ""
|
|
11
|
+
v = m.get(key)
|
|
12
|
+
if v is None:
|
|
13
|
+
return ""
|
|
14
|
+
return v if isinstance(v, str) else str(v)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _config_string(cfg: Optional[Mapping[str, Any]], key: str) -> str:
|
|
18
|
+
if not cfg or not key:
|
|
19
|
+
return ""
|
|
20
|
+
v = cfg.get(key)
|
|
21
|
+
if v is None:
|
|
22
|
+
return ""
|
|
23
|
+
return v.strip() if isinstance(v, str) else str(v).strip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def apply_auth_mapping(
|
|
27
|
+
cfg: Mapping[str, Any],
|
|
28
|
+
mapping: Optional[Mapping[str, Any]],
|
|
29
|
+
) -> Dict[str, str]:
|
|
30
|
+
"""auth_mapping: { type: none | bearer | header, from?, name? }."""
|
|
31
|
+
h: Dict[str, str] = {}
|
|
32
|
+
if not mapping:
|
|
33
|
+
return h
|
|
34
|
+
|
|
35
|
+
typ = _string_from_map(mapping, "type").lower().strip()
|
|
36
|
+
if not typ or typ == "none":
|
|
37
|
+
return h
|
|
38
|
+
|
|
39
|
+
from_key = _string_from_map(mapping, "from").strip()
|
|
40
|
+
if not from_key:
|
|
41
|
+
from_key = _string_from_map(mapping, "value_from").strip()
|
|
42
|
+
val = _config_string(cfg, from_key)
|
|
43
|
+
if not val and typ != "none":
|
|
44
|
+
raise ValueError(f'auth mapping requires config key "{from_key}"')
|
|
45
|
+
|
|
46
|
+
if typ == "bearer":
|
|
47
|
+
h["Authorization"] = f"Bearer {val}"
|
|
48
|
+
elif typ == "header":
|
|
49
|
+
name = _string_from_map(mapping, "name").strip()
|
|
50
|
+
if not name:
|
|
51
|
+
raise ValueError("auth mapping type header requires name")
|
|
52
|
+
h[name] = val
|
|
53
|
+
else:
|
|
54
|
+
raise ValueError(f'unsupported auth_mapping type "{typ}"')
|
|
55
|
+
return h
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def apply_header_mappings(
|
|
59
|
+
cfg: Mapping[str, Any],
|
|
60
|
+
mappings: List[Optional[Mapping[str, Any]]],
|
|
61
|
+
) -> Dict[str, str]:
|
|
62
|
+
"""Each mapping: { name, from } or { name, value_from }."""
|
|
63
|
+
h: Dict[str, str] = {}
|
|
64
|
+
for m in mappings:
|
|
65
|
+
if not m:
|
|
66
|
+
continue
|
|
67
|
+
name = _string_from_map(m, "name").strip()
|
|
68
|
+
from_key = _string_from_map(m, "from").strip()
|
|
69
|
+
if not from_key:
|
|
70
|
+
from_key = _string_from_map(m, "value_from").strip()
|
|
71
|
+
if not name:
|
|
72
|
+
raise ValueError("header mapping requires name")
|
|
73
|
+
if not from_key:
|
|
74
|
+
raise ValueError(f'header mapping for "{name}" requires from')
|
|
75
|
+
val = _config_string(cfg, from_key)
|
|
76
|
+
if not val:
|
|
77
|
+
raise ValueError(f'header mapping requires config key "{from_key}"')
|
|
78
|
+
h[name] = val
|
|
79
|
+
return h
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _coerce_header_mappings(raw: Any) -> List[Dict[str, Any]]:
|
|
83
|
+
if not isinstance(raw, list):
|
|
84
|
+
return []
|
|
85
|
+
out: List[Dict[str, Any]] = []
|
|
86
|
+
for item in raw:
|
|
87
|
+
if isinstance(item, dict):
|
|
88
|
+
out.append(item)
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def apply_bridge_headers(
|
|
93
|
+
cfg: Mapping[str, Any],
|
|
94
|
+
bridge: Optional[Mapping[str, Any]],
|
|
95
|
+
) -> Dict[str, str]:
|
|
96
|
+
"""Merge auth_mapping then header_mappings from bridge metadata."""
|
|
97
|
+
h: Dict[str, str] = {}
|
|
98
|
+
if not bridge:
|
|
99
|
+
return h
|
|
100
|
+
|
|
101
|
+
auth_map = bridge.get("auth_mapping")
|
|
102
|
+
if isinstance(auth_map, dict):
|
|
103
|
+
h.update(apply_auth_mapping(cfg, auth_map))
|
|
104
|
+
|
|
105
|
+
header_mappings = _coerce_header_mappings(bridge.get("header_mappings"))
|
|
106
|
+
if not header_mappings:
|
|
107
|
+
return h
|
|
108
|
+
h.update(apply_header_mappings(cfg, header_mappings))
|
|
109
|
+
return h
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Control POST /mcp/config client — mirror agentruntime-mcp-go control.go."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Dict, Mapping, Optional
|
|
9
|
+
from urllib import error as urlerror
|
|
10
|
+
from urllib import request as urlrequest
|
|
11
|
+
|
|
12
|
+
from .errors import ControlError
|
|
13
|
+
|
|
14
|
+
HEADER_MCP_INSTANCE_ID = "X-MCP-Instance-Id"
|
|
15
|
+
HEADER_MCP_SERVER_ID = "X-MCP-Server-Id"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ControlPayload:
|
|
20
|
+
config: Dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
config_schema: Dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
bridge: Optional[Dict[str, Any]] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def fetch_control_payload(
|
|
26
|
+
token: str,
|
|
27
|
+
config_schema: Mapping[str, Any],
|
|
28
|
+
runtime_context: Mapping[str, Any],
|
|
29
|
+
) -> ControlPayload:
|
|
30
|
+
base = os.environ.get("MCP_CONTROL_SERVER_URL", "").strip().rstrip("/")
|
|
31
|
+
if not base:
|
|
32
|
+
return ControlPayload()
|
|
33
|
+
|
|
34
|
+
timeout = 5.0
|
|
35
|
+
ts = os.environ.get("MCP_CONTROL_TIMEOUT_SEC", "").strip()
|
|
36
|
+
if ts:
|
|
37
|
+
try:
|
|
38
|
+
n = int(ts)
|
|
39
|
+
if n > 0:
|
|
40
|
+
timeout = float(n)
|
|
41
|
+
except ValueError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
payload = {
|
|
45
|
+
"configSchema": dict(config_schema),
|
|
46
|
+
"config_schema": dict(config_schema),
|
|
47
|
+
"schema": dict(config_schema),
|
|
48
|
+
"runtimeContext": dict(runtime_context),
|
|
49
|
+
"runtime_context": dict(runtime_context),
|
|
50
|
+
}
|
|
51
|
+
data = json.dumps(payload).encode("utf-8")
|
|
52
|
+
req = urlrequest.Request(
|
|
53
|
+
f"{base}/mcp/config",
|
|
54
|
+
data=data,
|
|
55
|
+
method="POST",
|
|
56
|
+
headers={
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
"Authorization": f"Bearer {token}",
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
try:
|
|
62
|
+
with urlrequest.urlopen(req, timeout=timeout) as resp:
|
|
63
|
+
raw = resp.read().decode("utf-8")
|
|
64
|
+
except urlerror.HTTPError as exc:
|
|
65
|
+
body = ""
|
|
66
|
+
try:
|
|
67
|
+
body = exc.read().decode("utf-8")
|
|
68
|
+
except Exception:
|
|
69
|
+
body = ""
|
|
70
|
+
raise ControlError(exc.code, body) from exc
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
raise ControlError(502, str(exc)) from exc
|
|
73
|
+
|
|
74
|
+
if not raw:
|
|
75
|
+
return ControlPayload()
|
|
76
|
+
try:
|
|
77
|
+
parsed = json.loads(raw)
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
raise ControlError(502, f"invalid response: {exc}") from exc
|
|
80
|
+
if not isinstance(parsed, dict):
|
|
81
|
+
raise ControlError(502, "invalid control config response")
|
|
82
|
+
|
|
83
|
+
out = ControlPayload()
|
|
84
|
+
cfg = parsed.get("config")
|
|
85
|
+
if isinstance(cfg, dict):
|
|
86
|
+
out.config = cfg
|
|
87
|
+
cs = parsed.get("config_schema")
|
|
88
|
+
if isinstance(cs, dict):
|
|
89
|
+
out.config_schema = cs
|
|
90
|
+
bridge = parsed.get("bridge")
|
|
91
|
+
if isinstance(bridge, dict):
|
|
92
|
+
out.bridge = bridge
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_runtime_context_from_request(req: Any) -> Dict[str, Any]:
|
|
97
|
+
"""Build runtime_context for bridge/control calls from a Starlette request."""
|
|
98
|
+
ctx: Dict[str, Any] = {}
|
|
99
|
+
if req is None:
|
|
100
|
+
ctx["tool_name"] = "__initialize"
|
|
101
|
+
return ctx
|
|
102
|
+
|
|
103
|
+
headers = getattr(req, "headers", None)
|
|
104
|
+
|
|
105
|
+
def _hdr(name: str) -> str:
|
|
106
|
+
if headers is None:
|
|
107
|
+
return ""
|
|
108
|
+
v = headers.get(name)
|
|
109
|
+
return v.strip() if isinstance(v, str) else ""
|
|
110
|
+
|
|
111
|
+
inst = _hdr(HEADER_MCP_INSTANCE_ID) or _hdr("x-mcp-instance-id")
|
|
112
|
+
if inst:
|
|
113
|
+
ctx["instance_id"] = inst
|
|
114
|
+
|
|
115
|
+
sid = _hdr(HEADER_MCP_SERVER_ID) or _hdr("x-mcp-server-id")
|
|
116
|
+
env_sid = os.environ.get("MCP_SERVER_ID", "").strip()
|
|
117
|
+
if env_sid:
|
|
118
|
+
sid = env_sid
|
|
119
|
+
if sid:
|
|
120
|
+
ctx["server_id"] = sid
|
|
121
|
+
|
|
122
|
+
tn = _hdr("X-Tool-Name") or _hdr("x-tool-name") or _hdr("X-MCP-Tool-Name") or _hdr("x-mcp-tool-name")
|
|
123
|
+
ctx["tool_name"] = tn or "__initialize"
|
|
124
|
+
return ctx
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Per-request bearer token context (parity with agentruntime-mcp-go request_bearer)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .context import get_auth_token
|
|
6
|
+
|
|
7
|
+
__all__ = ["request_bearer_from_context"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def request_bearer_from_context() -> str | None:
|
|
11
|
+
"""Return the incoming MCP caller bearer token for the active request, if set."""
|
|
12
|
+
return get_auth_token()
|
|
@@ -150,8 +150,10 @@ def run_with_router(config_path: str = "config.yaml") -> None:
|
|
|
150
150
|
if callable(reg):
|
|
151
151
|
reg(mux)
|
|
152
152
|
|
|
153
|
+
from .bridge import bridge_routes
|
|
154
|
+
|
|
153
155
|
sub_apps: List[Any] = []
|
|
154
|
-
routes: List[Any] =
|
|
156
|
+
routes: List[Any] = list(bridge_routes())
|
|
155
157
|
for path, handler in mux:
|
|
156
158
|
routes.append(Route(path, endpoint=handler, methods=list(_WEBHOOK_METHODS)))
|
|
157
159
|
for n in names:
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Tool organization helpers for MCP publish metadata (parity with agentruntime-mcp-go/toolorg)."""
|
|
2
|
+
|
|
3
|
+
from .toolorg import (
|
|
4
|
+
Metadata,
|
|
5
|
+
EffectiveOrganization,
|
|
6
|
+
ToolGroup,
|
|
7
|
+
suggest_from_wire_name,
|
|
8
|
+
format_display_name,
|
|
9
|
+
publisher_metadata,
|
|
10
|
+
default_publisher_metadata,
|
|
11
|
+
group_label,
|
|
12
|
+
parse_metadata,
|
|
13
|
+
metadata_is_empty,
|
|
14
|
+
merge_effective,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Metadata",
|
|
19
|
+
"EffectiveOrganization",
|
|
20
|
+
"ToolGroup",
|
|
21
|
+
"suggest_from_wire_name",
|
|
22
|
+
"format_display_name",
|
|
23
|
+
"publisher_metadata",
|
|
24
|
+
"default_publisher_metadata",
|
|
25
|
+
"group_label",
|
|
26
|
+
"parse_metadata",
|
|
27
|
+
"metadata_is_empty",
|
|
28
|
+
"merge_effective",
|
|
29
|
+
]
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Publish-time tool organization — mirror agentruntime-mcp-go/toolorg."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Mapping, Optional, Sequence
|
|
8
|
+
|
|
9
|
+
_DEFAULT_GROUP_LABELS: dict[str, str] = {
|
|
10
|
+
"hierarchy": "Hierarchy",
|
|
11
|
+
"tasks": "Tasks",
|
|
12
|
+
"search_members": "Search & members",
|
|
13
|
+
"comments": "Comments",
|
|
14
|
+
"time": "Time tracking",
|
|
15
|
+
"docs": "Docs",
|
|
16
|
+
"chat": "Chat",
|
|
17
|
+
"uncategorized": "Other",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Metadata:
|
|
23
|
+
display_name: str = ""
|
|
24
|
+
suggested_group: str = ""
|
|
25
|
+
suggested_tags: list[str] = field(default_factory=list)
|
|
26
|
+
suggested_rank_key: str = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class EffectiveOrganization:
|
|
31
|
+
group_id: str = ""
|
|
32
|
+
group_label: str = ""
|
|
33
|
+
rank_key: str = ""
|
|
34
|
+
tags: list[str] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ToolGroup:
|
|
39
|
+
id: str = ""
|
|
40
|
+
label: str = ""
|
|
41
|
+
rank_key: str = ""
|
|
42
|
+
source: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _normalize_tags(tags: Sequence[str]) -> list[str]:
|
|
46
|
+
seen: set[str] = set()
|
|
47
|
+
out: list[str] = []
|
|
48
|
+
for t in tags:
|
|
49
|
+
t = str(t).strip().lower()
|
|
50
|
+
if not t or t in seen:
|
|
51
|
+
continue
|
|
52
|
+
seen.add(t)
|
|
53
|
+
out.append(t)
|
|
54
|
+
out.sort()
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _infer_group_id(body: str) -> str:
|
|
59
|
+
if "chat" in body:
|
|
60
|
+
return "chat"
|
|
61
|
+
if "doc" in body:
|
|
62
|
+
return "docs"
|
|
63
|
+
if "comment" in body:
|
|
64
|
+
return "comments"
|
|
65
|
+
if "time" in body:
|
|
66
|
+
return "time"
|
|
67
|
+
if body == "search" or "member" in body or "assignee" in body or "filtered_team" in body:
|
|
68
|
+
return "search_members"
|
|
69
|
+
if (
|
|
70
|
+
"workspace" in body
|
|
71
|
+
or "space" in body
|
|
72
|
+
or "folder" in body
|
|
73
|
+
or "hierarchy" in body
|
|
74
|
+
or body == "get_list"
|
|
75
|
+
or body.startswith("get_lists")
|
|
76
|
+
or body.startswith("create_list")
|
|
77
|
+
or body.startswith("update_list")
|
|
78
|
+
or body.startswith("create_folder")
|
|
79
|
+
or body.startswith("update_folder")
|
|
80
|
+
):
|
|
81
|
+
return "hierarchy"
|
|
82
|
+
return "tasks"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_read_only_verb(body: str) -> bool:
|
|
86
|
+
for prefix in ("get_", "list_", "find_", "resolve_", "search"):
|
|
87
|
+
if body.startswith(prefix) or body == prefix.rstrip("_"):
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _infer_tags(body: str, group_id: str) -> list[str]:
|
|
93
|
+
tags = [group_id]
|
|
94
|
+
tags.append("read_only" if _is_read_only_verb(body) else "mutating")
|
|
95
|
+
tags.append("v3" if ("chat" in body or "doc" in body) else "v2")
|
|
96
|
+
return _normalize_tags(tags)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def suggest_from_wire_name(tool_name: str) -> tuple[str, list[str]]:
|
|
100
|
+
name = tool_name.strip().lower()
|
|
101
|
+
parts = name.split("_")
|
|
102
|
+
if len(parts) < 2:
|
|
103
|
+
return "uncategorized", ["mutating"]
|
|
104
|
+
body = "_".join(parts[1:])
|
|
105
|
+
group_id = _infer_group_id(body)
|
|
106
|
+
return group_id, _infer_tags(body, group_id)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def format_display_name(tool_name: str) -> str:
|
|
110
|
+
parts = tool_name.strip().split("_")
|
|
111
|
+
if len(parts) <= 1:
|
|
112
|
+
return tool_name
|
|
113
|
+
body = " ".join(parts[1:])
|
|
114
|
+
if not body:
|
|
115
|
+
return tool_name
|
|
116
|
+
return body[0].upper() + body[1:]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def publisher_metadata(tool_name: str, overrides: Optional[Metadata] = None) -> dict[str, Any]:
|
|
120
|
+
ov = overrides or Metadata()
|
|
121
|
+
out: dict[str, Any] = {}
|
|
122
|
+
display = ov.display_name.strip()
|
|
123
|
+
if not display:
|
|
124
|
+
display = format_display_name(tool_name)
|
|
125
|
+
if display:
|
|
126
|
+
out["display_name"] = display
|
|
127
|
+
group = ov.suggested_group.strip().lower()
|
|
128
|
+
if not group:
|
|
129
|
+
group, _ = suggest_from_wire_name(tool_name)
|
|
130
|
+
if group:
|
|
131
|
+
out["suggested_group"] = group
|
|
132
|
+
tags = list(ov.suggested_tags)
|
|
133
|
+
if not tags:
|
|
134
|
+
_, tags = suggest_from_wire_name(tool_name)
|
|
135
|
+
if tags:
|
|
136
|
+
out["suggested_tags"] = tags
|
|
137
|
+
rk = ov.suggested_rank_key.strip()
|
|
138
|
+
if rk:
|
|
139
|
+
out["suggested_rank_key"] = rk
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def default_publisher_metadata(tool_name: str) -> dict[str, Any]:
|
|
144
|
+
return publisher_metadata(tool_name, Metadata())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def group_label(group_id: str) -> str:
|
|
148
|
+
gid = group_id.strip().lower()
|
|
149
|
+
if gid in _DEFAULT_GROUP_LABELS:
|
|
150
|
+
return _DEFAULT_GROUP_LABELS[gid]
|
|
151
|
+
parts = gid.split("_")
|
|
152
|
+
titled = []
|
|
153
|
+
for p in parts:
|
|
154
|
+
if not p:
|
|
155
|
+
continue
|
|
156
|
+
titled.append(p[0].upper() + p[1:])
|
|
157
|
+
return " ".join(titled)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _normalize_group_id(group_id: str) -> str:
|
|
161
|
+
return group_id.strip().lower().replace(" ", "_")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def parse_metadata(raw: bytes | str) -> Metadata:
|
|
165
|
+
if not raw:
|
|
166
|
+
return Metadata()
|
|
167
|
+
if isinstance(raw, bytes):
|
|
168
|
+
text = raw.decode("utf-8", errors="replace")
|
|
169
|
+
else:
|
|
170
|
+
text = raw
|
|
171
|
+
try:
|
|
172
|
+
parsed = json.loads(text)
|
|
173
|
+
except json.JSONDecodeError:
|
|
174
|
+
return Metadata()
|
|
175
|
+
if not isinstance(parsed, dict):
|
|
176
|
+
return Metadata()
|
|
177
|
+
tags_raw = parsed.get("suggested_tags") or []
|
|
178
|
+
tags: list[str] = []
|
|
179
|
+
if isinstance(tags_raw, list):
|
|
180
|
+
tags = [str(t) for t in tags_raw]
|
|
181
|
+
return Metadata(
|
|
182
|
+
display_name=str(parsed.get("display_name") or ""),
|
|
183
|
+
suggested_group=_normalize_group_id(str(parsed.get("suggested_group") or "")),
|
|
184
|
+
suggested_tags=_normalize_tags(tags),
|
|
185
|
+
suggested_rank_key=str(parsed.get("suggested_rank_key") or ""),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def metadata_is_empty(meta: Mapping[str, Any]) -> bool:
|
|
190
|
+
if not meta:
|
|
191
|
+
return True
|
|
192
|
+
for k, v in meta.items():
|
|
193
|
+
if v is None:
|
|
194
|
+
continue
|
|
195
|
+
if k == "suggested_tags":
|
|
196
|
+
if isinstance(v, list) and len(v) > 0:
|
|
197
|
+
return False
|
|
198
|
+
continue
|
|
199
|
+
if isinstance(v, str) and v.strip():
|
|
200
|
+
return False
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def merge_effective(
|
|
205
|
+
published: Metadata,
|
|
206
|
+
overlay_group_id: Optional[str] = None,
|
|
207
|
+
overlay_rank_key: Optional[str] = None,
|
|
208
|
+
) -> EffectiveOrganization:
|
|
209
|
+
group_id = published.suggested_group.strip()
|
|
210
|
+
tags = list(published.suggested_tags)
|
|
211
|
+
if overlay_group_id is not None:
|
|
212
|
+
g = _normalize_group_id(overlay_group_id)
|
|
213
|
+
if g:
|
|
214
|
+
group_id = g
|
|
215
|
+
rank_key = published.suggested_rank_key.strip()
|
|
216
|
+
if overlay_rank_key is not None:
|
|
217
|
+
rk = overlay_rank_key.strip()
|
|
218
|
+
if rk:
|
|
219
|
+
rank_key = rk
|
|
220
|
+
return EffectiveOrganization(
|
|
221
|
+
group_id=group_id,
|
|
222
|
+
group_label=group_label(group_id),
|
|
223
|
+
rank_key=rank_key,
|
|
224
|
+
tags=tags,
|
|
225
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentruntime-mcp
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: AgentRuntime MCP SDK (decorators, runtime, middleware, schemas, proxy)
|
|
5
5
|
Author: AgentRuntime
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -115,6 +115,19 @@ run_proxy(
|
|
|
115
115
|
)
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
+
## Tool organization (`toolorg`)
|
|
119
|
+
|
|
120
|
+
Large connectors can build publish metadata for Control catalog grouping. Parity with Go `toolorg` — see [MCP_SDK_PARITY.md](../../docs/mcp/MCP_SDK_PARITY.md).
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from agentruntime.mcp.toolorg import publisher_metadata, Metadata
|
|
124
|
+
|
|
125
|
+
meta = publisher_metadata("clickup_get_task", Metadata())
|
|
126
|
+
# → display_name, suggested_group, suggested_tags for mcp_tools.metadata
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Per-request caller bearer (for downstream HTTP): `request_bearer_from_context()`.
|
|
130
|
+
|
|
118
131
|
## TypeScript sibling SDK
|
|
119
132
|
|
|
120
133
|
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`**.
|
|
@@ -4,21 +4,29 @@ src/agentruntime/__init__.py
|
|
|
4
4
|
src/agentruntime/mcp/__init__.py
|
|
5
5
|
src/agentruntime/mcp/adapter_registry.py
|
|
6
6
|
src/agentruntime/mcp/auth_http.py
|
|
7
|
+
src/agentruntime/mcp/bridge.py
|
|
8
|
+
src/agentruntime/mcp/bridge_auth.py
|
|
7
9
|
src/agentruntime/mcp/config_schema.py
|
|
8
10
|
src/agentruntime/mcp/context.py
|
|
11
|
+
src/agentruntime/mcp/control.py
|
|
9
12
|
src/agentruntime/mcp/decorators.py
|
|
10
13
|
src/agentruntime/mcp/env_merge.py
|
|
11
14
|
src/agentruntime/mcp/errors.py
|
|
12
15
|
src/agentruntime/mcp/middleware.py
|
|
13
16
|
src/agentruntime/mcp/proxy.py
|
|
17
|
+
src/agentruntime/mcp/request_bearer.py
|
|
14
18
|
src/agentruntime/mcp/runtime.py
|
|
15
19
|
src/agentruntime/mcp/schemas.py
|
|
16
20
|
src/agentruntime/mcp/webhook.py
|
|
21
|
+
src/agentruntime/mcp/toolorg/__init__.py
|
|
22
|
+
src/agentruntime/mcp/toolorg/toolorg.py
|
|
17
23
|
src/agentruntime_mcp.egg-info/PKG-INFO
|
|
18
24
|
src/agentruntime_mcp.egg-info/SOURCES.txt
|
|
19
25
|
src/agentruntime_mcp.egg-info/dependency_links.txt
|
|
20
26
|
src/agentruntime_mcp.egg-info/requires.txt
|
|
21
27
|
src/agentruntime_mcp.egg-info/top_level.txt
|
|
28
|
+
tests/test_bridge_auth.py
|
|
22
29
|
tests/test_context.py
|
|
23
30
|
tests/test_runtime.py
|
|
24
|
-
tests/test_runtime_router.py
|
|
31
|
+
tests/test_runtime_router.py
|
|
32
|
+
tests/test_toolorg.py
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from agentruntime.mcp.bridge_auth import (
|
|
6
|
+
apply_auth_mapping,
|
|
7
|
+
apply_bridge_headers,
|
|
8
|
+
apply_header_mappings,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_apply_auth_mapping_bearer() -> None:
|
|
13
|
+
h = apply_auth_mapping({"access_token": "tok123"}, {"type": "bearer", "from": "access_token"})
|
|
14
|
+
assert h["Authorization"] == "Bearer tok123"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_apply_auth_mapping_none() -> None:
|
|
18
|
+
assert apply_auth_mapping({}, {"type": "none"}) == {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_apply_header_mappings_grafana() -> None:
|
|
22
|
+
h = apply_header_mappings(
|
|
23
|
+
{
|
|
24
|
+
"grafana_url": "https://acme.grafana.net",
|
|
25
|
+
"service_account_token": "glsa_xxx",
|
|
26
|
+
},
|
|
27
|
+
[
|
|
28
|
+
{"name": "X-Grafana-URL", "from": "grafana_url"},
|
|
29
|
+
{"name": "X-Grafana-Service-Account-Token", "from": "service_account_token"},
|
|
30
|
+
],
|
|
31
|
+
)
|
|
32
|
+
assert h["X-Grafana-URL"] == "https://acme.grafana.net"
|
|
33
|
+
assert h["X-Grafana-Service-Account-Token"] == "glsa_xxx"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_apply_header_mappings_missing_key() -> None:
|
|
37
|
+
with pytest.raises(ValueError, match="grafana_url"):
|
|
38
|
+
apply_header_mappings({}, [{"name": "X-Grafana-URL", "from": "grafana_url"}])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_apply_bridge_headers_combined() -> None:
|
|
42
|
+
h = apply_bridge_headers(
|
|
43
|
+
{
|
|
44
|
+
"grafana_url": "https://acme.grafana.net",
|
|
45
|
+
"service_account_token": "glsa_xxx",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"auth_mapping": {"type": "none"},
|
|
49
|
+
"header_mappings": [
|
|
50
|
+
{"name": "X-Grafana-URL", "from": "grafana_url"},
|
|
51
|
+
{"name": "X-Grafana-Service-Account-Token", "from": "service_account_token"},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
assert h["X-Grafana-URL"] == "https://acme.grafana.net"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_apply_bridge_headers_legacy_auth_only() -> None:
|
|
59
|
+
h = apply_bridge_headers(
|
|
60
|
+
{"access_token": "tok"},
|
|
61
|
+
{"auth_mapping": {"type": "bearer", "from": "access_token"}},
|
|
62
|
+
)
|
|
63
|
+
assert h["Authorization"] == "Bearer tok"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Tests for toolorg parity with agentruntime-mcp-go/toolorg."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from agentruntime.mcp.toolorg import (
|
|
11
|
+
Metadata,
|
|
12
|
+
default_publisher_metadata,
|
|
13
|
+
merge_effective,
|
|
14
|
+
suggest_from_wire_name,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_cases() -> list[dict]:
|
|
19
|
+
path = (
|
|
20
|
+
Path(__file__).resolve().parent.parent
|
|
21
|
+
/ "src"
|
|
22
|
+
/ "agentruntime"
|
|
23
|
+
/ "mcp"
|
|
24
|
+
/ "toolorg"
|
|
25
|
+
/ "fixtures"
|
|
26
|
+
/ "suggest_cases.json"
|
|
27
|
+
)
|
|
28
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.parametrize("case", _load_cases(), ids=lambda c: c["tool_name"])
|
|
32
|
+
def test_suggest_from_wire_name_fixtures(case: dict) -> None:
|
|
33
|
+
group, tags = suggest_from_wire_name(case["tool_name"])
|
|
34
|
+
assert group == case["group"]
|
|
35
|
+
assert tags == case["tags"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.parametrize("case", _load_cases(), ids=lambda c: c["tool_name"])
|
|
39
|
+
def test_default_publisher_metadata_fixtures(case: dict) -> None:
|
|
40
|
+
meta = default_publisher_metadata(case["tool_name"])
|
|
41
|
+
assert meta["suggested_group"] == case["group"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_merge_effective_explicit_only() -> None:
|
|
45
|
+
eff = merge_effective(Metadata(), overlay_group_id="docs", overlay_rank_key="n")
|
|
46
|
+
assert eff.group_id == "docs"
|
|
47
|
+
assert eff.rank_key == "n"
|
|
48
|
+
|
|
49
|
+
eff = merge_effective(Metadata(suggested_group="tasks"))
|
|
50
|
+
assert eff.group_id == "tasks"
|
|
51
|
+
|
|
52
|
+
eff = merge_effective(Metadata())
|
|
53
|
+
assert eff.group_id == ""
|
|
54
|
+
assert eff.tags == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime_mcp.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime_mcp.egg-info/requires.txt
RENAMED
|
File without changes
|
{agentruntime_mcp-0.1.2 → agentruntime_mcp-0.3.1}/src/agentruntime_mcp.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|