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.
- forgesight_mcp-0.1.0/.gitignore +38 -0
- forgesight_mcp-0.1.0/PKG-INFO +96 -0
- forgesight_mcp-0.1.0/README.md +70 -0
- forgesight_mcp-0.1.0/pyproject.toml +41 -0
- forgesight_mcp-0.1.0/src/forgesight_mcp/__init__.py +118 -0
- forgesight_mcp-0.1.0/src/forgesight_mcp/client.py +226 -0
- forgesight_mcp-0.1.0/src/forgesight_mcp/mapping.py +60 -0
- forgesight_mcp-0.1.0/src/forgesight_mcp/propagation.py +77 -0
- forgesight_mcp-0.1.0/src/forgesight_mcp/py.typed +0 -0
- forgesight_mcp-0.1.0/src/forgesight_mcp/server.py +152 -0
- forgesight_mcp-0.1.0/tests/test_mcp.py +518 -0
|
@@ -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)
|