agentruntime-mcp 0.1.2__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 (30) hide show
  1. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/PKG-INFO +1 -1
  2. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/pyproject.toml +1 -1
  3. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime/mcp/__init__.py +9 -0
  4. agentruntime_mcp-0.2.0/src/agentruntime/mcp/bridge.py +164 -0
  5. agentruntime_mcp-0.2.0/src/agentruntime/mcp/bridge_auth.py +109 -0
  6. agentruntime_mcp-0.2.0/src/agentruntime/mcp/control.py +124 -0
  7. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime/mcp/runtime.py +3 -1
  8. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime_mcp.egg-info/PKG-INFO +1 -1
  9. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime_mcp.egg-info/SOURCES.txt +4 -0
  10. agentruntime_mcp-0.2.0/tests/test_bridge_auth.py +63 -0
  11. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/README.md +0 -0
  12. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/setup.cfg +0 -0
  13. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime/__init__.py +0 -0
  14. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime/mcp/adapter_registry.py +0 -0
  15. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime/mcp/auth_http.py +0 -0
  16. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime/mcp/config_schema.py +0 -0
  17. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime/mcp/context.py +0 -0
  18. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime/mcp/decorators.py +0 -0
  19. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime/mcp/env_merge.py +0 -0
  20. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime/mcp/errors.py +0 -0
  21. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime/mcp/middleware.py +0 -0
  22. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime/mcp/proxy.py +0 -0
  23. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime/mcp/schemas.py +0 -0
  24. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime/mcp/webhook.py +0 -0
  25. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime_mcp.egg-info/dependency_links.txt +0 -0
  26. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime_mcp.egg-info/requires.txt +0 -0
  27. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/src/agentruntime_mcp.egg-info/top_level.txt +0 -0
  28. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/tests/test_context.py +0 -0
  29. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/tests/test_runtime.py +0 -0
  30. {agentruntime_mcp-0.1.2 → agentruntime_mcp-0.2.0}/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.2
3
+ Version: 0.2.0
4
4
  Summary: AgentRuntime MCP SDK (decorators, runtime, middleware, schemas, proxy)
5
5
  Author: AgentRuntime
6
6
  Requires-Python: >=3.10
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentruntime-mcp"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  description = "AgentRuntime MCP SDK (decorators, runtime, middleware, schemas, proxy)"
9
9
  requires-python = ">=3.10"
10
10
  readme = "README.md"
@@ -17,5 +17,14 @@ 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
 
@@ -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
@@ -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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentruntime-mcp
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: AgentRuntime MCP SDK (decorators, runtime, middleware, schemas, proxy)
5
5
  Author: AgentRuntime
6
6
  Requires-Python: >=3.10
@@ -4,8 +4,11 @@ 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
@@ -19,6 +22,7 @@ src/agentruntime_mcp.egg-info/SOURCES.txt
19
22
  src/agentruntime_mcp.egg-info/dependency_links.txt
20
23
  src/agentruntime_mcp.egg-info/requires.txt
21
24
  src/agentruntime_mcp.egg-info/top_level.txt
25
+ tests/test_bridge_auth.py
22
26
  tests/test_context.py
23
27
  tests/test_runtime.py
24
28
  tests/test_runtime_router.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"