Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,12 @@ formatter/linter (no black/isort/flake8). Commit `uv.lock`.

- **Branch from `main`.** Branch names: `<user>/<TICKET>-short-desc`
(e.g. `pablo/gla2-19-observe-decorator`). Linear team key is `GLA2`.
- **Conventional Commits are mandatory** — they drive releases:
`feat:` (minor), `fix:` (patch), `feat!:` / `BREAKING CHANGE:` (major), plus
`chore:`/`docs:`/`test:`/`refactor:`/`ci:`. Non-conforming commits are ignored
for versioning.
- **PRs are squash-merged; the PR title becomes the commit on `main`** and is
what release-please reads (the squash body is blank). PR titles must therefore
be Conventional Commits: `feat:` (minor), `fix:` (patch), `feat!:` (breaking —
must be marked in the **title**, since `BREAKING CHANGE:` footers in branch
commits don't survive the squash), plus `chore:`/`docs:`/`test:`/`refactor:`/
`ci:`. Non-conforming titles are ignored for versioning.
- **No AI attribution** in commits or PRs (no `Co-Authored-By: Claude`, no
"Generated with…" trailers).
- Open a PR; CI (lint/format/mypy + tests on Python 3.10–3.13) must pass to merge.
- Open a PR; CI (lint/format/mypy + tests on Python 3.10–3.14 + extras smoke) must pass to merge.
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,15 @@ glassflow.init(instruments=[]) # disable auto-instrumentation
```

Supported instruments: `openai`, `anthropic`, `langchain`, `llama-index`,
`litellm`. Content captured by third-party instrumentors is covered by the same
`litellm`, and `mcp`. Content captured by instrumentors is covered by the same
`mask` / `capture_content` controls as our own spans.

The `mcp` instrument is built in: if the [`mcp`](https://pypi.org/project/mcp/)
package is installed, every `ClientSession.call_tool()` your agent makes becomes
a first-class TOOL span (`execute_tool <name>`) with `gen_ai.tool.name`, the
arguments and result as `input.value`/`output.value`, latency, and error status
(including tools that return `isError` results).

Instrumentors patch libraries process-wide, so a scoped client
(`init(set_global=False)`) only enables them when `instruments=[...]` is passed
explicitly. Calling `init()` again while a client is active logs a warning and
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,14 @@ anthropic = ["openinference-instrumentation-anthropic>=1.0.6"]
langchain = ["openinference-instrumentation-langchain>=0.1.67"]
llama-index = ["openinference-instrumentation-llama-index>=4.4.3"]
litellm = ["openinference-instrumentation-litellm>=0.1.34"]
mcp = ["mcp>=1.0.0"]
instruments = [
"openinference-instrumentation-openai>=0.1.52",
"openinference-instrumentation-anthropic>=1.0.6",
"openinference-instrumentation-langchain>=0.1.67",
"openinference-instrumentation-llama-index>=4.4.3",
"openinference-instrumentation-litellm>=0.1.34",
"mcp>=1.0.0",
]

[project.urls]
Expand All @@ -75,6 +77,7 @@ dev = [
# integration tests for bundled auto-instrumentation
"openai>=1.0.0",
"openinference-instrumentation-openai",
"mcp>=1.28.1",
]

[tool.ruff]
Expand Down
2 changes: 2 additions & 0 deletions src/glassflow/instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ class InstrumentorSpec:
"llama-index", "openinference.instrumentation.llama_index", "LlamaIndexInstrumentor"
),
InstrumentorSpec("litellm", "openinference.instrumentation.litellm", "LiteLLMInstrumentor"),
# ours — first-class MCP tool-call spans (see instrumentation_mcp.py)
InstrumentorSpec("mcp", "glassflow.instrumentation_mcp", "MCPInstrumentor"),
)


Expand Down
111 changes: 111 additions & 0 deletions src/glassflow/instrumentation_mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""First-class spans for MCP tool calls.

Wraps ``mcp.ClientSession.call_tool`` so every tool invocation an agent makes
over MCP becomes a TOOL-kind span: tool name, arguments, result, latency, and
error status. Generic instrumentation SDKs cover MCP unevenly (the OpenInference
MCP package only propagates context; it creates no spans), so we instrument it
ourselves. Registered in :mod:`glassflow.instrumentation` under ``"mcp"`` — the
top-level import of ``mcp`` below makes an environment without the package look
"not installed" to the registry, exactly like a missing third-party instrumentor.

Tool arguments/results are recorded as ``input.value`` / ``output.value``, so
they are covered by the same ``mask`` / ``capture_content`` controls as all
other content.
"""

from __future__ import annotations

import functools
from typing import Any

from mcp import ClientSession # ImportError => registry treats "mcp" as not installed
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode

from . import __version__
from ._serde import serialize
from .semconv import (
GEN_AI_TOOL_NAME,
INPUT_VALUE,
OUTPUT_VALUE,
TRACER_NAME,
SpanKind,
set_span_kind,
)


def _serialize_result(result: Any) -> str:
"""Best-effort serialization of a CallToolResult."""
structured = getattr(result, "structuredContent", None)
if structured is not None:
return serialize(structured)
content = getattr(result, "content", None)
if content is not None:
texts = [block.text for block in content if getattr(block, "text", None) is not None]
if texts:
return texts[0] if len(texts) == 1 else serialize(texts)
return serialize(result)


class MCPInstrumentor:
"""Duck-types the OTel instrumentor interface (instrument/uninstrument)."""

_instrumented = False
_original_call_tool: Any = None
_tracer: trace.Tracer | None = None

@property
def is_instrumented_by_opentelemetry(self) -> bool:
return type(self)._instrumented

def instrument(self, *, tracer_provider: Any = None, **kwargs: Any) -> None:
cls = type(self)
if cls._instrumented:
return
provider = tracer_provider if tracer_provider is not None else trace.get_tracer_provider()
cls._tracer = provider.get_tracer(TRACER_NAME, __version__)
original = ClientSession.call_tool
cls._original_call_tool = original

@functools.wraps(original)
async def instrumented_call_tool(
session: ClientSession,
name: str,
arguments: dict[str, Any] | None = None,
*args: Any,
**kw: Any,
) -> Any:
tracer = cls._tracer
if tracer is None: # uninstrumented mid-flight; fall through
return await original(session, name, arguments, *args, **kw)
with tracer.start_as_current_span(
f"execute_tool {name}",
record_exception=False,
set_status_on_exception=False,
) as span:
set_span_kind(span, SpanKind.TOOL)
span.set_attribute(GEN_AI_TOOL_NAME, name)
if arguments is not None:
span.set_attribute(INPUT_VALUE, serialize(arguments))
try:
result = await original(session, name, arguments, *args, **kw)
except Exception as exc:
span.record_exception(exc)
span.set_status(Status(StatusCode.ERROR, str(exc)))
raise
span.set_attribute(OUTPUT_VALUE, _serialize_result(result))
if getattr(result, "isError", False):
span.set_status(Status(StatusCode.ERROR, "tool returned an error result"))
return result

setattr(ClientSession, "call_tool", instrumented_call_tool) # noqa: B010
cls._instrumented = True

def uninstrument(self) -> None:
cls = type(self)
if not cls._instrumented:
return
ClientSession.call_tool = cls._original_call_tool # type: ignore[method-assign]
cls._original_call_tool = None
cls._tracer = None
cls._instrumented = False
1 change: 1 addition & 0 deletions src/glassflow/semconv.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
GEN_AI_INPUT_MESSAGES = "gen_ai.input.messages"
GEN_AI_OUTPUT_MESSAGES = "gen_ai.output.messages"
GEN_AI_RESPONSE_FINISH_REASONS = "gen_ai.response.finish_reasons"
GEN_AI_TOOL_NAME = "gen_ai.tool.name"
GEN_AI_REQUEST_PREFIX = "gen_ai.request."

# Attribute keys carrying user content — masked/stripped at export (see masking.py).
Expand Down
135 changes: 135 additions & 0 deletions tests/test_mcp_instrumentation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""First-class MCP tool-call spans (client side: ClientSession.call_tool)."""

from __future__ import annotations

import asyncio
import json
from typing import Any

import pytest
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

pytest.importorskip("mcp")

from mcp.server.fastmcp import FastMCP # noqa: E402
from mcp.shared.memory import create_connected_server_and_client_session # noqa: E402

from glassflow import init # noqa: E402
from glassflow.instrumentation import REGISTRY # noqa: E402
from glassflow.instrumentation_mcp import MCPInstrumentor # noqa: E402


def _make_server() -> FastMCP:
server = FastMCP("test-server")

@server.tool()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b

@server.tool()
def boom() -> str:
"""Always fails."""
raise ValueError("tool failed")

return server


@pytest.fixture(autouse=True)
def _fresh_mcp_instrumentor() -> Any:
instrumentor = MCPInstrumentor()
if instrumentor.is_instrumented_by_opentelemetry:
instrumentor.uninstrument()
yield
if instrumentor.is_instrumented_by_opentelemetry:
instrumentor.uninstrument()


def _run_tool_call(
tool: str,
arguments: dict[str, Any] | None,
**init_kwargs: Any,
) -> tuple[list[ReadableSpan], Any]:
inner = InMemorySpanExporter()
client = init(span_exporter=inner, set_global=False, instruments=["mcp"], **init_kwargs)

async def scenario() -> Any:
server = _make_server()
async with create_connected_server_and_client_session(server._mcp_server) as session:
return await session.call_tool(tool, arguments)

result = asyncio.run(scenario())
client.flush()
return list(inner.get_finished_spans()), result


def test_mcp_is_a_registry_instrument() -> None:
assert "mcp" in {spec.name for spec in REGISTRY}


def test_call_tool_creates_tool_span() -> None:
spans, _result = _run_tool_call("add", {"a": 2, "b": 3})
tool_spans = [s for s in spans if s.name == "execute_tool add"]
assert tool_spans, f"no tool span; got {[s.name for s in spans]}"
attrs = tool_spans[0].attributes
assert attrs is not None
assert attrs["openinference.span.kind"] == "TOOL"
assert attrs["gen_ai.operation.name"] == "execute_tool"
assert attrs["gen_ai.tool.name"] == "add"
assert json.loads(attrs["input.value"]) == {"a": 2, "b": 3}
assert "5" in attrs["output.value"]


def test_tool_error_result_marks_span_error() -> None:
spans, result = _run_tool_call("boom", None)
assert result.isError # FastMCP converts tool exceptions into error results
(tool_span,) = [s for s in spans if s.name == "execute_tool boom"]
assert not tool_span.status.is_ok


def test_tool_span_nests_under_current_span() -> None:
inner = InMemorySpanExporter()
client = init(span_exporter=inner, set_global=False, instruments=["mcp"])

async def scenario() -> None:
server = _make_server()
with client.get_tracer().start_as_current_span("agent-step"):
async with create_connected_server_and_client_session(server._mcp_server) as session:
await session.call_tool("add", {"a": 1, "b": 1})

asyncio.run(scenario())
client.flush()
spans = {s.name: s for s in inner.get_finished_spans()}
tool_span = spans["execute_tool add"]
assert tool_span.parent is not None
assert tool_span.parent.span_id == spans["agent-step"].context.span_id


def test_capture_content_false_strips_tool_io_but_keeps_tool_name() -> None:
spans, _result = _run_tool_call("add", {"a": 2, "b": 3}, capture_content=False)
(tool_span,) = [s for s in spans if s.name == "execute_tool add"]
attrs = tool_span.attributes
assert attrs is not None
assert "input.value" not in attrs
assert "output.value" not in attrs
assert attrs["gen_ai.tool.name"] == "add"


def test_uninstrument_restores_call_tool() -> None:
spans, _result = _run_tool_call("add", {"a": 1, "b": 2})
assert any(s.name == "execute_tool add" for s in spans)

MCPInstrumentor().uninstrument()

inner = InMemorySpanExporter()
client = init(span_exporter=inner, set_global=False) # scoped, no instruments

async def scenario() -> None:
server = _make_server()
async with create_connected_server_and_client_session(server._mcp_server) as session:
await session.call_tool("add", {"a": 1, "b": 2})

asyncio.run(scenario())
client.flush()
assert not any(s.name.startswith("execute_tool") for s in inner.get_finished_spans())
Loading