forgesight-mcp 0.1.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.
@@ -0,0 +1,38 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ *.so
9
+
10
+ # venv / tooling
11
+ .venv/
12
+ venv/
13
+ .uv/
14
+ uv.lock
15
+
16
+ # test / type / lint caches
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ .coverage
21
+ .coverage.*
22
+ coverage.xml
23
+ htmlcov/
24
+
25
+ # secrets / local env (never commit)
26
+ .env
27
+ .env.*
28
+
29
+ # editor / OS
30
+ .DS_Store
31
+ .idea/
32
+ .vscode/
33
+
34
+ # local-only session working state (per the workspace pipeline)
35
+ .claude/state/
36
+
37
+ # local-only launch planning (not part of the published repo)
38
+ /launch/
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: forgesight-mcp
3
+ Version: 0.1.0
4
+ Summary: ForgeSight MCP instrumentation — client + server spans, mcp.* conventions, W3C propagation.
5
+ Project-URL: Homepage, https://github.com/Scaffoldic/forgesight
6
+ Project-URL: Repository, https://github.com/Scaffoldic/forgesight
7
+ Project-URL: Issues, https://github.com/Scaffoldic/forgesight/issues
8
+ Project-URL: Changelog, https://github.com/Scaffoldic/forgesight/blob/main/docs/releases/v0.1.md
9
+ Author: kjoshi
10
+ License-Expression: Apache-2.0
11
+ Keywords: ai-agents,forgesight,mcp,model-context-protocol,observability
12
+ Classifier: Development Status :: 2 - Pre-Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Information Technology
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Topic :: System :: Monitoring
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: forgesight-core
24
+ Requires-Dist: mcp>=1
25
+ Description-Content-Type: text/markdown
26
+
27
+ # forgesight-mcp
28
+
29
+ MCP instrumentation for [ForgeSight](https://github.com/Scaffoldic/forgesight). Turns every
30
+ [Model Context Protocol](https://modelcontextprotocol.io) `tools/call` / `tools/list` /
31
+ `prompts/get` / `resources/read` into a correctly-mapped span — with W3C trace propagation
32
+ across the transport, so a `pr-reviewer → github-mcp → internal-api-mcp` chain is **one
33
+ trace**.
34
+
35
+ ```bash
36
+ pip install forgesight-mcp
37
+ ```
38
+
39
+ ```python
40
+ # client side — an agent calling an MCP server
41
+ import forgesight
42
+ from forgesight_mcp import instrument_mcp_client
43
+ from mcp import ClientSession
44
+
45
+ forgesight.configure()
46
+
47
+ async with ClientSession(read, write) as session:
48
+ instrument_mcp_client(session) # one line; wraps the transport
49
+ await session.initialize()
50
+ result = await session.call_tool("get_diff", {"pr": 42}) # execute_tool get_diff
51
+ ```
52
+
53
+ ```python
54
+ # server side — a tool/resource server
55
+ import forgesight
56
+ from forgesight_mcp import instrument_mcp_server
57
+ from mcp.server import Server
58
+
59
+ forgesight.configure()
60
+ server = Server("github-mcp")
61
+ instrument_mcp_server(server) # extracts traceparent, opens child spans
62
+ ```
63
+
64
+ ## What you get
65
+
66
+ - **One span per request**, mapped per the OTel MCP conventions. A `tools/call` is the
67
+ *single* span carrying both `mcp.method.name = tools/call` **and**
68
+ `gen_ai.operation.name = execute_tool` / `gen_ai.tool.name` — never double-instrumented.
69
+ - **Uniform tool telemetry.** A tool reached via MCP and the same tool reached natively both
70
+ land under `gen_ai.tool.name`, so "failure rate of `get_diff`" is one query across both.
71
+ - **Traces stitch automatically.** The client injects `traceparent` into the request `_meta`;
72
+ the server extracts it and opens its span as a child of the caller's — same `trace_id`.
73
+ - **Metrics for free.** Each call feeds `mcp.client.operation.duration` and the derived
74
+ `agentforge.mcp.invocations_total` (server / method / tool / status).
75
+ - **Secure by default (P7).** `tools/call` arguments and results are captured **only** when
76
+ `capture_content` resolves true; the redaction interceptor still runs.
77
+
78
+ ## Idempotent + reversible
79
+
80
+ `instrument_mcp_client` / `instrument_mcp_server` are idempotent (re-instrumenting is a
81
+ no-op) and reversible (`uninstrument_mcp_client` / `uninstrument_mcp_server` restore the
82
+ originals). Auto-instrument new sessions/servers at `configure()` via the `install()` entry
83
+ point:
84
+
85
+ ```yaml
86
+ # forgesight.yaml
87
+ integrations:
88
+ mcp:
89
+ enabled: true
90
+ auto_instrument: true
91
+ capture_content: false # P7 default
92
+ ```
93
+
94
+ ## License
95
+
96
+ Apache-2.0
@@ -0,0 +1,70 @@
1
+ # forgesight-mcp
2
+
3
+ MCP instrumentation for [ForgeSight](https://github.com/Scaffoldic/forgesight). Turns every
4
+ [Model Context Protocol](https://modelcontextprotocol.io) `tools/call` / `tools/list` /
5
+ `prompts/get` / `resources/read` into a correctly-mapped span — with W3C trace propagation
6
+ across the transport, so a `pr-reviewer → github-mcp → internal-api-mcp` chain is **one
7
+ trace**.
8
+
9
+ ```bash
10
+ pip install forgesight-mcp
11
+ ```
12
+
13
+ ```python
14
+ # client side — an agent calling an MCP server
15
+ import forgesight
16
+ from forgesight_mcp import instrument_mcp_client
17
+ from mcp import ClientSession
18
+
19
+ forgesight.configure()
20
+
21
+ async with ClientSession(read, write) as session:
22
+ instrument_mcp_client(session) # one line; wraps the transport
23
+ await session.initialize()
24
+ result = await session.call_tool("get_diff", {"pr": 42}) # execute_tool get_diff
25
+ ```
26
+
27
+ ```python
28
+ # server side — a tool/resource server
29
+ import forgesight
30
+ from forgesight_mcp import instrument_mcp_server
31
+ from mcp.server import Server
32
+
33
+ forgesight.configure()
34
+ server = Server("github-mcp")
35
+ instrument_mcp_server(server) # extracts traceparent, opens child spans
36
+ ```
37
+
38
+ ## What you get
39
+
40
+ - **One span per request**, mapped per the OTel MCP conventions. A `tools/call` is the
41
+ *single* span carrying both `mcp.method.name = tools/call` **and**
42
+ `gen_ai.operation.name = execute_tool` / `gen_ai.tool.name` — never double-instrumented.
43
+ - **Uniform tool telemetry.** A tool reached via MCP and the same tool reached natively both
44
+ land under `gen_ai.tool.name`, so "failure rate of `get_diff`" is one query across both.
45
+ - **Traces stitch automatically.** The client injects `traceparent` into the request `_meta`;
46
+ the server extracts it and opens its span as a child of the caller's — same `trace_id`.
47
+ - **Metrics for free.** Each call feeds `mcp.client.operation.duration` and the derived
48
+ `agentforge.mcp.invocations_total` (server / method / tool / status).
49
+ - **Secure by default (P7).** `tools/call` arguments and results are captured **only** when
50
+ `capture_content` resolves true; the redaction interceptor still runs.
51
+
52
+ ## Idempotent + reversible
53
+
54
+ `instrument_mcp_client` / `instrument_mcp_server` are idempotent (re-instrumenting is a
55
+ no-op) and reversible (`uninstrument_mcp_client` / `uninstrument_mcp_server` restore the
56
+ originals). Auto-instrument new sessions/servers at `configure()` via the `install()` entry
57
+ point:
58
+
59
+ ```yaml
60
+ # forgesight.yaml
61
+ integrations:
62
+ mcp:
63
+ enabled: true
64
+ auto_instrument: true
65
+ capture_content: false # P7 default
66
+ ```
67
+
68
+ ## License
69
+
70
+ Apache-2.0
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "forgesight-mcp"
3
+ version = "0.1.0"
4
+ description = "ForgeSight MCP instrumentation — client + server spans, mcp.* conventions, W3C propagation."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "Apache-2.0"
8
+ authors = [{ name = "kjoshi" }]
9
+ keywords = ["observability", "mcp", "model-context-protocol", "ai-agents", "forgesight"]
10
+ classifiers = [
11
+ "Development Status :: 2 - Pre-Alpha",
12
+ "Intended Audience :: Developers",
13
+ "Intended Audience :: Information Technology",
14
+ "Topic :: System :: Monitoring",
15
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = ["forgesight-core", "mcp>=1"]
23
+
24
+ [project.entry-points."forgesight.integrations"]
25
+ mcp = "forgesight_mcp:install"
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/Scaffoldic/forgesight"
29
+ Repository = "https://github.com/Scaffoldic/forgesight"
30
+ Issues = "https://github.com/Scaffoldic/forgesight/issues"
31
+ Changelog = "https://github.com/Scaffoldic/forgesight/blob/main/docs/releases/v0.1.md"
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/forgesight_mcp"]
39
+
40
+ [tool.uv.sources]
41
+ forgesight-core = { workspace = true }
@@ -0,0 +1,118 @@
1
+ """ForgeSight MCP instrumentation — client + server spans, ``mcp.*`` conventions, W3C propagation.
2
+
3
+ Public surface (stable for 0.2): :func:`instrument_mcp_client`, :func:`instrument_mcp_server`,
4
+ :func:`uninstrument_mcp_client`, :func:`uninstrument_mcp_server`. :func:`install` is the
5
+ ``forgesight.integrations`` entry point that auto-instruments new sessions/servers when
6
+ ``auto_instrument`` is on.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from collections.abc import Sequence
13
+ from typing import Any
14
+
15
+ from .client import in_mcp_tool_call, instrument_mcp_client, uninstrument_mcp_client
16
+ from .mapping import KNOWN_METHODS
17
+ from .server import instrument_mcp_server, uninstrument_mcp_server
18
+
19
+ _log = logging.getLogger("forgesight.mcp")
20
+ __version__ = "0.1.0"
21
+
22
+ # class-patch registry for install()/uninstall(): key → (class, original __init__)
23
+ _PATCHED: dict[str, tuple[type, Any]] = {}
24
+ _CONTENT_LOGGED = False
25
+
26
+
27
+ def install(
28
+ config: dict[str, object] | None = None,
29
+ *,
30
+ _client_cls: type | None = None,
31
+ _server_cls: type | None = None,
32
+ ) -> bool:
33
+ """Auto-instrument MCP: patch new ``ClientSession`` / ``Server`` instances at creation.
34
+
35
+ The ``forgesight.integrations`` entry point. Honours the ``integrations.mcp`` config
36
+ block (``enabled`` / ``auto_instrument`` / ``methods`` / ``capture_content``). Returns
37
+ True if patching was applied. Idempotent.
38
+ """
39
+ cfg = dict(config or {})
40
+ if not cfg.get("enabled", True) or not cfg.get("auto_instrument", True):
41
+ return False
42
+ if _PATCHED:
43
+ return True # already installed
44
+ capture = cfg.get("capture_content")
45
+ capture_opt = bool(capture) if capture is not None else None
46
+ methods = cfg.get("methods")
47
+ methods_opt = (
48
+ list(methods) if isinstance(methods, Sequence) and not isinstance(methods, str) else None
49
+ )
50
+ if capture_opt:
51
+ _log_content_capture()
52
+
53
+ client_cls = _client_cls or _import("mcp", "ClientSession")
54
+ server_cls = _server_cls or _import("mcp.server", "Server")
55
+ if client_cls is not None:
56
+ _patch_init("client", client_cls, instrument_mcp_client, capture_opt, methods_opt)
57
+ if server_cls is not None:
58
+ _patch_init("server", server_cls, instrument_mcp_server, capture_opt, methods_opt)
59
+ return bool(_PATCHED)
60
+
61
+
62
+ def uninstall() -> None:
63
+ """Undo :func:`install` — restore the original class constructors."""
64
+ for cls, original in _PATCHED.values():
65
+ setattr(cls, "__init__", original) # noqa: B010 - direct __init__ assign trips mypy
66
+ _PATCHED.clear()
67
+
68
+
69
+ def _patch_init(
70
+ key: str,
71
+ cls: type,
72
+ instrument: Any,
73
+ capture: bool | None,
74
+ methods: list[str] | None,
75
+ ) -> None:
76
+ original_init = getattr(cls, "__init__") # noqa: B009 - keep mypy off __init__ specialcasing
77
+
78
+ def patched_init(self: Any, *args: Any, **kwargs: Any) -> None:
79
+ original_init(self, *args, **kwargs)
80
+ try:
81
+ instrument(self, capture_content=capture, methods=methods)
82
+ except Exception: # pragma: no cover - defensive; auto-instrument must never break init
83
+ _log.warning("forgesight-mcp: auto-instrument failed for %s", cls.__name__)
84
+
85
+ setattr(cls, "__init__", patched_init) # noqa: B010 - direct __init__ assign trips mypy
86
+ _PATCHED[key] = (cls, original_init)
87
+
88
+
89
+ def _import(module: str, attr: str) -> type | None:
90
+ import importlib
91
+
92
+ try:
93
+ return getattr(importlib.import_module(module), attr) # type: ignore[no-any-return]
94
+ except Exception: # pragma: no cover - mcp is a declared dep; absent only in odd installs
95
+ _log.warning(
96
+ "forgesight-mcp: could not import %s.%s; auto-instrument skipped", module, attr
97
+ )
98
+ return None
99
+
100
+
101
+ def _log_content_capture() -> None:
102
+ global _CONTENT_LOGGED
103
+ if not _CONTENT_LOGGED:
104
+ _log.info("forgesight-mcp: MCP content capture is ON (tools/call args + results)")
105
+ _CONTENT_LOGGED = True
106
+
107
+
108
+ __all__ = [
109
+ "KNOWN_METHODS",
110
+ "__version__",
111
+ "in_mcp_tool_call",
112
+ "install",
113
+ "instrument_mcp_client",
114
+ "instrument_mcp_server",
115
+ "uninstall",
116
+ "uninstrument_mcp_client",
117
+ "uninstrument_mcp_server",
118
+ ]
@@ -0,0 +1,226 @@
1
+ """MCP **client** instrumentation — a span per outgoing request, W3C inject, ``mcp.*`` attrs.
2
+
3
+ ``instrument_mcp_client`` replaces the public request methods on a ``ClientSession`` instance
4
+ with wrappers that open the right :class:`~forgesight_core.MCPScope` via the runtime, inject
5
+ ``traceparent`` into the request ``_meta`` (so the server continues the trace), and close the
6
+ span with status / duration. A ``tools/call`` becomes the single span carrying both the
7
+ ``mcp.*`` attributes and ``gen_ai.operation.name = execute_tool`` — never a second span.
8
+
9
+ Wrapping the *public* session API (not transport internals) keeps it resilient to MCP-SDK
10
+ churn (risk table §8). Idempotent; ``uninstrument_mcp_client`` restores the originals.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextvars
16
+ import logging
17
+ from collections.abc import Callable, Sequence
18
+ from typing import Any
19
+
20
+ from forgesight_core import MCPScope, get_runtime
21
+
22
+ from .mapping import (
23
+ MCP_PROMPT_NAME,
24
+ MCP_RESOURCE_URI,
25
+ PROMPTS_GET,
26
+ RESOURCES_READ,
27
+ TOOL_CALL_ARGUMENTS,
28
+ TOOL_CALL_RESULT,
29
+ TOOLS_CALL,
30
+ resolve_methods,
31
+ unknown_methods,
32
+ )
33
+ from .propagation import inject_traceparent
34
+
35
+ _log = logging.getLogger("forgesight.mcp")
36
+
37
+ _MARKER = "_forgesight_mcp"
38
+ # method string → the ClientSession attribute that issues it
39
+ _METHOD_ATTRS: dict[str, str] = {
40
+ TOOLS_CALL: "call_tool",
41
+ "tools/list": "list_tools",
42
+ PROMPTS_GET: "get_prompt",
43
+ "prompts/list": "list_prompts",
44
+ RESOURCES_READ: "read_resource",
45
+ "resources/list": "list_resources",
46
+ }
47
+
48
+ # Set while a tools/call span is in flight so a framework adapter (feat-019) can defer to
49
+ # the MCP span instead of opening a second execute_tool span (no double-instrument).
50
+ _IN_TOOLS_CALL: contextvars.ContextVar[bool] = contextvars.ContextVar(
51
+ "forgesight_mcp_in_tools_call", default=False
52
+ )
53
+
54
+
55
+ def in_mcp_tool_call() -> bool:
56
+ """True while an MCP ``tools/call`` span is open on this context (re-entrancy guard)."""
57
+ return _IN_TOOLS_CALL.get()
58
+
59
+
60
+ class tool_error(Exception):
61
+ """Marks a ``CallToolResult.isError`` so the span records ``error.type = tool_error``.
62
+
63
+ The runtime derives ``error.type`` from the exception's class name (feat-009), so the
64
+ name is load-bearing — it is the exact ``error.type`` the MCP semconv mandates for an
65
+ ``isError`` result (otel mapping §4.2).
66
+ """
67
+
68
+
69
+ def instrument_mcp_client(
70
+ session: Any,
71
+ *,
72
+ capture_content: bool | None = None,
73
+ methods: Sequence[str] | None = None,
74
+ server_name: str = "mcp",
75
+ ) -> Any:
76
+ """Wrap an MCP client session: span per request, W3C inject, ``mcp.*`` attrs.
77
+
78
+ Idempotent — instrumenting an already-instrumented session is a no-op. Returns the same
79
+ session for chaining.
80
+ """
81
+ if getattr(session, _MARKER, None) is not None:
82
+ return session
83
+ for name in unknown_methods(methods):
84
+ _log.warning("forgesight-mcp: ignoring unknown MCP method %r", name)
85
+ instrumentation = _ClientInstrumentation(
86
+ session, capture_content=capture_content, methods=methods, server_name=server_name
87
+ )
88
+ instrumentation.apply()
89
+ setattr(session, _MARKER, instrumentation)
90
+ return session
91
+
92
+
93
+ def uninstrument_mcp_client(session: Any) -> None:
94
+ """Restore a session's original methods. No-op if it was never instrumented."""
95
+ instrumentation = getattr(session, _MARKER, None)
96
+ if instrumentation is None:
97
+ return
98
+ instrumentation.restore()
99
+ delattr(session, _MARKER)
100
+
101
+
102
+ class _ClientInstrumentation:
103
+ def __init__(
104
+ self,
105
+ session: Any,
106
+ *,
107
+ capture_content: bool | None,
108
+ methods: Sequence[str] | None,
109
+ server_name: str,
110
+ ) -> None:
111
+ self._session = session
112
+ self._capture_opt = capture_content
113
+ self._methods = resolve_methods(methods)
114
+ self._server_name = server_name
115
+ self._originals: dict[str, Callable[..., Any]] = {}
116
+ self._protocol_version: str | None = None
117
+
118
+ # --- (un)patching ----------------------------------------------------
119
+ def apply(self) -> None:
120
+ self._wrap_initialize()
121
+ for method in self._methods:
122
+ attr = _METHOD_ATTRS[method]
123
+ original = getattr(self._session, attr, None)
124
+ if original is None or not callable(original):
125
+ continue
126
+ self._originals[attr] = original
127
+ setattr(self._session, attr, self._make_wrapper(method, attr, original))
128
+
129
+ def restore(self) -> None:
130
+ for attr, original in self._originals.items():
131
+ setattr(self._session, attr, original)
132
+ self._originals.clear()
133
+
134
+ # --- wrappers --------------------------------------------------------
135
+ def _wrap_initialize(self) -> None:
136
+ original = getattr(self._session, "initialize", None)
137
+ if original is None or not callable(original):
138
+ return
139
+ self._originals["initialize"] = original
140
+
141
+ async def initialize(*args: Any, **kwargs: Any) -> Any:
142
+ result = await original(*args, **kwargs)
143
+ version = getattr(result, "protocolVersion", None)
144
+ if version is not None:
145
+ self._protocol_version = str(version)
146
+ return result
147
+
148
+ self._session.initialize = initialize
149
+
150
+ def _make_wrapper(
151
+ self, method: str, attr: str, original: Callable[..., Any]
152
+ ) -> Callable[..., Any]:
153
+ is_tools_call = method == TOOLS_CALL
154
+
155
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
156
+ tool = _first_name(args, kwargs) if is_tools_call else None
157
+ scope = MCPScope(
158
+ get_runtime(), server=self._server_name, method=method, tool=tool, session_id=None
159
+ )
160
+ async with scope:
161
+ if self._protocol_version is not None:
162
+ scope._call.protocol_version = self._protocol_version
163
+ self._set_request_metadata(scope, method, args, kwargs)
164
+ capture = self._resolve_capture()
165
+ if is_tools_call:
166
+ kwargs = dict(kwargs)
167
+ kwargs["meta"] = inject_traceparent(
168
+ kwargs.get("meta"), trace_id=scope.trace_id, span_id=scope.span_id
169
+ )
170
+ if capture:
171
+ scope.set_metadata(**{TOOL_CALL_ARGUMENTS: _arguments(args, kwargs)})
172
+ token = _IN_TOOLS_CALL.set(True) if is_tools_call else None
173
+ try:
174
+ result = await original(*args, **kwargs)
175
+ finally:
176
+ if token is not None:
177
+ _IN_TOOLS_CALL.reset(token)
178
+ if is_tools_call:
179
+ self._handle_tool_result(scope, result, capture)
180
+ return result
181
+
182
+ return wrapper
183
+
184
+ @staticmethod
185
+ def _set_request_metadata(
186
+ scope: MCPScope, method: str, args: tuple[Any, ...], kwargs: dict[str, Any]
187
+ ) -> None:
188
+ if method == RESOURCES_READ:
189
+ uri = args[0] if args else kwargs.get("uri")
190
+ if uri is not None:
191
+ scope.set_metadata(**{MCP_RESOURCE_URI: str(uri)})
192
+ elif method == PROMPTS_GET:
193
+ name = _first_name(args, kwargs)
194
+ if name is not None:
195
+ scope.set_metadata(**{MCP_PROMPT_NAME: name})
196
+
197
+ def _handle_tool_result(self, scope: MCPScope, result: Any, capture: bool) -> None:
198
+ if getattr(result, "isError", False):
199
+ scope.record_error(tool_error("MCP tools/call returned isError"))
200
+ elif capture:
201
+ scope.set_metadata(**{TOOL_CALL_RESULT: _result_repr(result)})
202
+
203
+ def _resolve_capture(self) -> bool:
204
+ if self._capture_opt is not None:
205
+ return self._capture_opt
206
+ try:
207
+ return bool(get_runtime().config.capture_content)
208
+ except Exception: # pragma: no cover - runtime always configured in practice
209
+ return False
210
+
211
+
212
+ def _first_name(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str | None:
213
+ if args:
214
+ return str(args[0])
215
+ name = kwargs.get("name")
216
+ return str(name) if name is not None else None
217
+
218
+
219
+ def _arguments(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
220
+ value = args[1] if len(args) > 1 else kwargs.get("arguments")
221
+ return repr(value)
222
+
223
+
224
+ def _result_repr(result: Any) -> str:
225
+ content = getattr(result, "content", result)
226
+ return repr(content)
@@ -0,0 +1,60 @@
1
+ """MCP method ↔ span mapping (otel-semantic-conventions §4.2).
2
+
3
+ A ``tools/call`` is *both* an MCP method (``mcp.method.name = tools/call``) and a tool
4
+ execution (``gen_ai.operation.name = execute_tool``, ``gen_ai.tool.name = <tool>``) — the
5
+ SDK's ``MCPScope`` already emits that single span (no double-instrument). This module owns
6
+ the small, stable facts the instrumentation needs: which methods are known, and how to pull
7
+ the tool / resource / prompt name off each call.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Sequence
13
+
14
+ # Methods the client/server instrumentation spans by default.
15
+ TOOLS_CALL = "tools/call"
16
+ TOOLS_LIST = "tools/list"
17
+ PROMPTS_GET = "prompts/get"
18
+ PROMPTS_LIST = "prompts/list"
19
+ RESOURCES_READ = "resources/read"
20
+ RESOURCES_LIST = "resources/list"
21
+
22
+ KNOWN_METHODS: frozenset[str] = frozenset(
23
+ {TOOLS_CALL, TOOLS_LIST, PROMPTS_GET, PROMPTS_LIST, RESOURCES_READ, RESOURCES_LIST}
24
+ )
25
+
26
+ # Extra span metadata keys (mapped onto the record's attributes).
27
+ MCP_RESOURCE_URI = "mcp.resource.uri"
28
+ MCP_PROMPT_NAME = "mcp.prompt.name"
29
+ TOOL_CALL_ARGUMENTS = "gen_ai.tool.call.arguments"
30
+ TOOL_CALL_RESULT = "gen_ai.tool.call.result"
31
+
32
+ # Server-side: low-level MCP request type name → method string. Type *names* (not the
33
+ # imported classes) keep this resilient to mcp-SDK import churn.
34
+ REQUEST_TYPE_TO_METHOD: dict[str, str] = {
35
+ "CallToolRequest": TOOLS_CALL,
36
+ "ListToolsRequest": TOOLS_LIST,
37
+ "GetPromptRequest": PROMPTS_GET,
38
+ "ListPromptsRequest": PROMPTS_LIST,
39
+ "ReadResourceRequest": RESOURCES_READ,
40
+ "ListResourcesRequest": RESOURCES_LIST,
41
+ }
42
+
43
+
44
+ def resolve_methods(methods: Sequence[str] | None) -> frozenset[str]:
45
+ """Resolve a caller's ``methods`` option to a known-method set.
46
+
47
+ ``None`` ⇒ all known methods. Unknown names are dropped (forward-compat with new MCP
48
+ methods); the caller is responsible for logging the drop.
49
+ """
50
+ if methods is None:
51
+ return KNOWN_METHODS
52
+ requested = {str(m) for m in methods}
53
+ return frozenset(requested & KNOWN_METHODS)
54
+
55
+
56
+ def unknown_methods(methods: Sequence[str] | None) -> list[str]:
57
+ """Return requested method names that are not known (for a one-time WARN)."""
58
+ if methods is None:
59
+ return []
60
+ return sorted({str(m) for m in methods} - KNOWN_METHODS)