Skip to content

SDK reference

Everything exported from mcpobs. Generated from the source, so it cannot drift from what the package actually does.

mcpobs — the optional onboarding helper for MCP Observability.

Everything here is additive and replaceable. A customer who prefers to configure vanilla OpenTelemetry and send OTLP directly loses only the precise failure category -- their failures report the coarse tool_error instead -- and nothing else breaks.

(No internal spec citation here on purpose: this docstring is published as the customer-facing SDK reference, where a reference the reader cannot resolve is worse than no reference.)

from mcp.server import MCPServer
from mcpobs import instrument

mcp = MCPServer("my-server", version="1.0.0")
instrument(mcp)                     # that is the whole integration

FailureKind

TOOL_ERROR class-attribute instance-attribute

TOOL_ERROR = 'tool_error'

The tool ran and reported failure. The server behaved correctly.

SERVER_EXCEPTION class-attribute instance-attribute

SERVER_EXCEPTION = 'server_exception'

The handler raised. Our bug, or the tool's.

UNKNOWN_TOOL class-attribute instance-attribute

UNKNOWN_TOOL = 'unknown_tool'

The client called a tool that does not exist. Often a client-side bug.

INVALID_ARGUMENTS class-attribute instance-attribute

INVALID_ARGUMENTS = 'invalid_arguments'

Arguments failed schema validation. Usually a model or client problem.

UNCLASSIFIED class-attribute instance-attribute

UNCLASSIFIED = 'unclassified'

Matched nothing. A rising count means the SDK moved -- alert on it.

FailureClassifier

Maps an errored CallToolResult to a failure kind.

Pure and side-effect free so it can be unit tested without a server.

classify

classify(text)

Return a FailureKind for the first text block of an errored result.

ORDER MATTERS. A validation failure is wrapped by the SDK and therefore ALSO carries the "Error executing tool " prefix:

Error executing tool echo_fast: 1 validation error for ...

so the validation check must come first, or every schema violation is misreported as a server exception.

classify_result

classify_result(result)

Classify a tool result. Safe against unexpected shapes.

first_text staticmethod

first_text(result)

First text block of a result, or '' if there is none.

Handles BOTH shapes. By the time middleware sees a result it is the sealed wire form -- a plain dict with camelCase keys:

{"content": [{"type": "text", "text": "..."}],
 "isError": True, "resultType": "complete", "_meta": {...}}

The CallToolResult model only appears if this is called earlier in the chain or from a test. Supporting both costs four lines and avoids a classifier that silently returns "" in production.

Deliberately tolerant: this runs inside a customer's request path and must never raise, whatever shape the result turns out to be.

is_error staticmethod

is_error(result)

True if the result reports failure, in either shape.

error_detail

error_detail(result)

Truncated error text for a FAILING result. Never called otherwise.

The caller checks is_error first; this method does not, deliberately, so that the "errors only" rule lives at one obvious call site rather than being implied by a helper's internals.

client_info staticmethod

client_info(params)

(name, version) the client reported, or ("", "").

resource_uri staticmethod

resource_uri(params)

The uri a resources/* call addressed, or ''.

mrtr_state staticmethod

mrtr_state(value)

Short, stable hash of a requestState blob. Never the blob itself.

outgoing_state classmethod

outgoing_state(result)

Hash of the requestState this round EMITS (round N).

incoming_state classmethod

incoming_state(params)

Hash of the requestState this round RECEIVES (round N+1).

result_type staticmethod

result_type(result)

The 2026-07-28 resultType: "complete" or "input_required".

Present in the wire form, which means MRTR interim results ARE observable from middleware -- closing the gap D11 recorded, where the SDK's own span carries no resultType attribute.

PayloadCapture

Renders tool arguments and results into bounded, redacted previews.

request

request(method, request_id, params)

The JSON-RPC request, as (preview, original size).

THE ACTUAL WIRE MESSAGE, not just arguments. This is an MCP observability product: the useful artefact is the protocol message you can compare against the spec or paste into a bug report, and the first version threw away everything except the arguments.

What that discarded, all of it debugging-relevant: * _meta.io.modelcontextprotocol/clientInfo -- WHICH CLIENT called. The SDK sets no client attribute on the span, so this is the only place client identity appears at all, and V2 6.1 asks for exactly that ("which clients are calling which tools"). * _meta clientCapabilities -- what the client claimed to support, the first thing to check on a capability error. * _meta protocolVersion and traceparent. * name, so a prompt or resource call is self-describing.

response

response(request_id, result)

The JSON-RPC response, as (preview, original size).

Also the whole message, so resultType, isError and especially structuredContent -- the typed result, previously invisible -- survive.

Works for EVERY method. The first version looked for content blocks and therefore returned nothing for prompts/get (which returns messages) or resources/read (which returns contents).

render

render(value)

Redact + truncate an arbitrary value, as (preview, original size).

Public because mcpobs.http needs exactly the same treatment for HTTP bodies, and a second copy of the redaction rules is how the two drift apart until one of them leaks.

HttpBodyCapture

Hooks that record downstream HTTP detail onto the client span.

ObservedSubscriptionBus

Wraps a SubscriptionBus so every published event becomes a span.

A WRAPPER, NOT A PATCH, because the customer already hands the bus to MCPServer(subscriptions=...) -- so there is an explicit seam and no reason to monkey-patch a class. It also means a customer with their own bus implementation gets this for free:

bus = ObservedSubscriptionBus(InMemorySubscriptionBus())
mcp = MCPServer("srv", subscriptions=bus)

WHAT IT FIXES A subscriptions/listen span covers the whole stream and is exported when the stream ends, so events delivered on it were invisible -- throughput, delivery gaps and starvation all unanswerable. Each event is now its own span, exported immediately, so a stream that has stopped carrying events is visible as an absence of recent spans rather than as nothing at all.

instrument

instrument(
    server,
    capture_error_detail=True,
    capture_payloads=False,
    transport=None,
    session_endpoint=None,
    session_headers=None,
)

Attach failure classification to an MCPServer.

capture_error_detail (default True) also records the error text from FAILING tool results, truncated to 512 characters. Without it an operator cannot see why a call failed -- the SDK leaves status_message empty. It never reads successful results and never populates the payload columns. Pass False to send only the failure category.

capture_payloads (default False) additionally records tool arguments and results, truncated and redacted. It is OFF by default because it is every argument and every result of every call, not just failures -- see mcpobs/payload.py for what the redaction does and does not catch.

Appends to the server's middleware chain, so it runs inside the SDK's built-in OpenTelemetry middleware and can annotate the span the SDK already opened. Does not create spans and does not wrap the protocol.

transport names the transport this server runs on ("stdio", "streamable-http", "sse"). Leave it unset and it is detected from server.run(...), which is where the SDK itself names it. Pass it when your server never calls run() -- for example when you build the ASGI app and drive uvicorn yourself.

session_endpoint is your own service that mints short-lived tokens for a server running on an END USER's machine. Configure it HERE, in your server, rather than asking your users to add environment variables to their MCP client config -- they should not have to paste observability settings into Claude Desktop to use your product.

session_headers authenticates that call. Pass a CALLABLE when the credential refreshes, which it usually does:

instrument(
    mcp,
    session_endpoint="https://acme.com/mcpobs-session",
    session_headers=lambda: {"authorization": f"Bearer {current_token()}"},
)

A dict is read once at startup; a callable is read on every fetch. With a refreshing user token the difference is telemetry that works for an hour and then stops with no error anywhere.

Idempotent: calling it twice attaches one middleware.

instrument_downstream

instrument_downstream(exclude=())

Instrument every installed library. Returns {name: outcome}.

A REPORT, not None. The customer needs to be able to see what this touched -- a call that patches an unknown set of libraries and says nothing is not something anyone should be comfortable putting in a production server.

Never raises. Each instrumentor is attempted independently, so one package with a version conflict cannot stop the others, and none of them can take down the customer's startup. An observability library that prevents a server from booting has done more damage than the telemetry was worth.

instrument_httpx

instrument_httpx(capture=None)

Attach body capture to the OTel httpx instrumentation.

Deliberately separate from instrument(server): that instruments the customer's MCP server, this instruments their OUTBOUND HTTP client. They are different subjects, and conflating them would surprise someone who asked for only the first.

Returns False when httpx instrumentation is not installed, rather than raising -- it is an optional dependency and its absence is not an error.

instrument_asgi

instrument_asgi(app, **kwargs)

Wrap an ASGI app so every HTTP request becomes a server span.

Returns the wrapped app; the original is unchanged. Use it as:

app = mcp.streamable_http_app()
app = instrument_asgi(app)
uvicorn.run(app, host="0.0.0.0", port=8000)

This is what makes 401 and 403 visible: those responses never reach an MCP method, so the span the ASGI layer produces is the ONLY record that the request happened at all.

Returns the app unwrapped if the ASGI instrumentation is not installed. An observability helper must not stop a server from serving because an optional dependency is missing.

instrument_progress

instrument_progress()

Emit a span for every ctx.report_progress() call.

Patches the SDK's Context.report_progress, which is the only seam: the call goes straight to the session and produces no telemetry of its own. Idempotent, and returns False if the SDK is not importable.

The customer's call is made FIRST and its result returned unchanged. If the notification fails, that failure reaches them exactly as it would have -- instrumentation must not change the semantics of the thing it observes.

available

available()

Names of the instrumentations installed in this process.

Separate from instrument_downstream so a customer can ask what WOULD be turned on before turning it on. "It silently did nothing" and "it silently did more than I expected" are both worth being able to check.