mcpotel

package
v3.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 10, 2026 License: MIT Imports: 21 Imported by: 0

Documentation

Overview

Package mcpotel instruments MCP request handling with OpenTelemetry.

It imports only the OpenTelemetry API, never the SDK. Which providers exist, where they export and whether they exist at all is decided once in internal/telemetry; this package asks the global providers for a tracer and a meter and gets working no-ops when nothing is installed. That is why there is no "telemetry enabled" flag anywhere here, and why there must never be one: the API is specified to work without an SDK, so a flag would only add a branch that can disagree with reality.

Index

Constants

View Source
const (
	// TransportPipe and TransportTCP are the convention's own vocabulary for
	// network.transport, not names of our choosing: its "Recording MCP
	// transport" table gives "pipe" for stdio and "tcp" for streamable HTTP.
	TransportPipe = "pipe"
	TransportTCP  = "tcp"

	// AttrResourceURI and AttrResourceRef name the resource a request asked
	// for. Which of the two is set is the identity policy's decision, made in
	// internal/telemetry; this package only has to know that neither may reach
	// a metric, because both are one value per resource a client touches.
	AttrResourceURI = attribute.Key("mcp.resource.uri")
	AttrResourceRef = attribute.Key("gitlab_mcp.resource.ref")

	// AttrMCPMethodName is Required: every span and every measurement carries it.
	AttrMCPMethodName = attribute.Key("mcp.method.name")

	// AttrMCPProtocolVersion is Recommended: the negotiated revision string.
	AttrMCPProtocolVersion = attribute.Key("mcp.protocol.version")

	// AttrGenAIToolName is Conditionally Required when the operation relates to
	// a specific tool. Note that the convention reuses the gen_ai namespace
	// here; there is no mcp.tool.name, which is a thing to check rather than
	// assume, because the obvious guess is wrong.
	AttrGenAIToolName = attribute.Key("gen_ai.tool.name")

	// AttrMCPSessionID is Recommended, and its note is a condition rather than
	// a preference: "When the MCP request or notification is part of a
	// session." The default HTTP mode is stateless and has no session id, so
	// the condition is simply not met there and the attribute is omitted rather
	// than filled with a per-request invention. It is deliberately absent from
	// the metric, which the convention's own instrument table omits it from.
	//
	// The serverpool key is never a substitute: it is derived from the token,
	// and putting it here would place a credential fingerprint on every span.
	AttrMCPSessionID = attribute.Key("mcp.session.id")

	// AttrGenAIPromptName is the same shape for prompts/get.
	AttrGenAIPromptName = attribute.Key("gen_ai.prompt.name")

	// AttrGenAIOperationName is Recommended, and its note is a SHOULD NOT as
	// well as a SHOULD: set to execute_tool when the operation describes a tool
	// call, and not set otherwise.
	AttrGenAIOperationName = attribute.Key("gen_ai.operation.name")

	// AttrErrorType is Stable, and the only Stable key in this list. It is
	// Conditionally Required "if and only if the operation fails", which is why
	// nothing here sets it on a success path.
	AttrErrorType = attribute.Key("error.type")

	// AttrRPCResponseStatusCode is Release Candidate. It records the JSON-RPC
	// error code whenever the response carries one, including for the five
	// codes that do not count as errors: the code is a fact about the response,
	// while error.type is a classification of a failure.
	AttrRPCResponseStatusCode = attribute.Key("rpc.response.status_code")

	// AttrNetworkTransport is Stable. The convention's note is explicit for
	// this protocol: tcp when the transport is HTTP, pipe when it is stdio.
	AttrNetworkTransport = attribute.Key("network.transport")
)

The attribute keys this package emits, written out rather than imported.

The MCP semantic convention does not ship as a Go package. It lives in the open-telemetry/semantic-conventions-genai repository, not on opentelemetry.io, whose /docs/specs/semconv/mcp/ returns 404, and that repository has no tags and no releases. The frozen semconv/v1.41.0 module does contain some of these constants, but it is a snapshot of a convention that has since moved and been removed from Go semconv, so importing it would give a false sense of currency and would put two semconv versions in one build, which invites a schema URL conflict.

The cost of writing them out is that a rename upstream produces no compile error. The mitigation is that they are all here, in one block, so a rename is a one-file change, and the convention is re-read before each release.

View Source
const (
	// AttrActionID carries the canonical catalog action, such as issue.list.
	//
	// It exists because the dynamic surface, which is the default, exposes two
	// tools: gen_ai.tool.name is gitlab_execute_action for every call, so the
	// convention-owned attribute records nothing about what was actually done.
	// Without this the default deployment's traces cannot distinguish listing
	// issues from deleting a branch.
	AttrActionID = attribute.Key("gitlab_mcp.action")

	// AttrDomain is the catalog domain of a tool call, such as issue.
	//
	// Coarser than the action id and bounded by the catalog's domain count,
	// which is what makes it the dimension worth grouping by where the action
	// id is too many values. It is also what remains of a call whose action
	// does not resolve: a model that invents an action still named a real
	// domain, and recording that beats recording nothing.
	AttrDomain = attribute.Key("gitlab_mcp.domain")

	// AttrToolSurface records which catalog the deployment registered, because
	// the same request means different things across the three and a trace read
	// months later has no other way to tell.
	AttrToolSurface = attribute.Key("gitlab_mcp.tool_surface")

	// AttrRefusalReason carries a value from this server's closed set of
	// refusal reasons, for the failures that are ours rather than GitLab's.
	//
	// It sits alongside error.type rather than inside it, which is the shape
	// the error registry recommends for a domain-specific identifier, and it
	// keeps error.type predictable and low cardinality as that registry asks.
	AttrRefusalReason = attribute.Key("gitlab_mcp.refusal_reason")
)

Attribute keys this project invents, under one namespace.

The guidance against extending an OpenTelemetry namespace is a bare recommendation rather than an RFC 2119 keyword, and it sanctions "the attribute name by your application name, provided that the application name is reasonably unique". gitlab_mcp is that name. It is deliberately not mcp. or gen_ai., which upstream owns and may extend into, and not gitlab., which sits next to the vcs.* namespace and to a product name somebody may yet conventionalize.

Once shipped these cannot be renamed without breaking every dashboard built on them, so the set is kept small and each one earns its place.

View Source
const (
	// ErrorTypeToolError is the convention's own instruction for the case where
	// a JSON-RPC call succeeds and the failure is inside the result:
	// "When CallToolResult is returned with isError set to true, this attribute
	// SHOULD be set to tool_error."
	ErrorTypeToolError = "tool_error"

	// ErrorTypeOther is the registry's fallback, for a failure this server
	// cannot classify. Emitting it is better than inventing a value, and better
	// than omitting the attribute on a span whose status is Error.
	ErrorTypeOther = "_OTHER"
)

error.type values this server emits.

"Instrumentations SHOULD document the list of errors they report", so this is a closed set rather than a pattern, and it is published in the documentation as well as declared here.

View Source
const AttrPoolEvictionReason = attribute.Key("gitlab_mcp.credential_pool.eviction.reason")

AttrPoolEvictionReason says why the pool dropped an entry.

Under this server's own namespace rather than the convention's mcp.*, for the reason recorded on the attribute block in attributes.go: a credential pool is this deployment's concept and not the protocol's, so a name under mcp.* would claim part of a namespace the semantic convention owns and may later define differently. The instruments below are named the same way and for the same reason.

View Source
const OtherServerAddress = "_OTHER"

OtherServerAddress is what the metric carries for a host outside the declared set: the absence of a known name, not the name a caller sent.

Variables

View Source
var ErrNoPoolReader = errors.New("mcpotel: ObservePool needs a function that reads the pool")

ErrNoPoolReader is returned when ObservePool is given no way to read the pool. A registered callback that cannot read anything would export a permanent zero, which reads as a healthy empty pool rather than as a wiring mistake, so this refuses instead.

Functions

func IsNotification

func IsNotification(method string) bool

IsNotification reports whether a method name is a notification rather than a request.

Notifications have no response and therefore no error code, so the only failure they can record is a transport one. The prefix is the protocol's own convention and there is no other way to tell from a method name.

func Middleware

func Middleware(opts Options) mcp.Middleware

Middleware instruments every MCP request with a span and a duration measurement.

Shape

One span per MCP request, SERVER kind, parented from the trace context in params._meta rather than from the transport. The convention gives the reason: one MCP request can be served by several HTTP requests when a client retries, and one streamable HTTP request can carry more than one MCP request, so parenting to the transport would attach an operation to whichever round trip happened to carry it.

No enabled flag

There is none, deliberately. Without an installed SDK, otel.Tracer and otel.Meter return working no-ops that still propagate span context, so a flag would only add a branch that can disagree with whether telemetry is actually running. The cost of instrumenting unconditionally is the attribute construction below, which is a handful of constant-keyed strings.

func NewTransport

func NewTransport(base http.RoundTripper) http.RoundTripper

NewTransport wraps a RoundTripper so every GitLab API call becomes a child span of whatever MCP operation caused it.

Why not otelhttp

The contrib instrumentation is the obvious choice and it records url.full, which for this server means a span carrying the project path, the group path, and the contents of every search query. That is the same category of data this project already declined to record as tool arguments, and declining it there while shipping it here through a different door would be worse than not instrumenting at all: it would look like a considered privacy position while being none.

Redacting url.full afterwards is possible, with a SpanProcessor rewriting it at OnStart, and it is more machinery than the value justifies. So this records what an operator actually needs and nothing else.

What a trace shows without the path

It is a smaller loss than it sounds, because the parent span already names the operation: gitlab_mcp.action carries issue.list or branch.delete, which says which endpoint family was called far more legibly than a URL would. The child spans then answer the questions the parent cannot: how many round trips one tool call took, how long each took, which one failed, and whether a retry happened. Pagination showing up as eleven children is exactly the kind of thing that is invisible in a log and obvious in a trace.

Errors

A transport error is a failure. A 4xx or 5xx is NOT marked as a span error here, deliberately: "For HTTP status codes in the 4xx range span status ... SHOULD be set to Error in case of SpanKind.CLIENT", which would make every expected 404 from a not-found probe a red span. This server treats a 404 as an answer rather than a failure in its own handlers, and the span should agree with the handler rather than with a rule written for a generic client. The status code is always recorded, so a dashboard can classify however it likes.

func ObservePool

func ObservePool(read func() PoolCounts) (metric.Registration, error)

ObservePool publishes the credential pool's occupancy and its evictions, reading them through read on every collection.

Asynchronous instruments rather than counters incremented at the eviction site, because the pool already keeps these numbers and the increments happen under its write lock: a synchronous instrument there would put an exporter's code on a path that must not block. read is called on the SDK's collection goroutine, so it must be cheap and must not block either; a pool snapshot is a read lock and a handful of atomic loads.

Register it unconditionally. With telemetry off the global meter is a no-op whose callback is never invoked, so this costs one registration at startup and nothing afterwards.

The returned metric.Registration must be unregistered when the pool it reads is closed, or the callback keeps a dead pool reachable.

func RecordRefusal

func RecordRefusal(ctx context.Context, reason string)

RecordRefusal marks the current span with why this server declined a call.

Called from where the refusal is decided rather than from the middleware, because the middleware cannot know: a refusal travels as an error result, which is a successful JSON-RPC response carrying a failure meant for the model, and from outside the handler it is indistinguishable from a handler that ran and failed.

A no-op when there is no recording span, which is the case with telemetry off and in every unit test that installs no provider.

func SendingMiddleware

func SendingMiddleware(opts Options) mcp.Middleware

SendingMiddleware instruments the requests and notifications this server initiates: elicitation/create, sampling/createMessage, roots/list, and the notifications/* family including resources/updated and the list_changed set.

Why a second middleware rather than one

The MCP convention splits client and server spans by INITIATOR, not by role. This server is the receiver for a tools/call and the initiator for an elicitation, so the same process produces both kinds, and they do not share rules:

  • Kind is CLIENT here and SERVER there.
  • Error classification is stricter here. "All JSON-RPC error codes SHOULD be considered errors" on the client side, while the server side exempts five caller-fault codes. The convention says this twice, in the client span note and the client metric note, so it is a decision rather than an editing slip: a code the caller is responsible for is not the receiver's failure, but when WE are the caller every refusal is ours to notice.

Folding the two into one function with a boolean would put those differences behind a flag, which is how one of them eventually gets applied to the wrong side.

Trace context is not injected outward

A span is recorded here and nothing is written into the outgoing message's _meta. Injecting would let the client join our trace, which is the textbook reason to propagate, and it would also hand every caller the identifiers of this server's internal spans. On stdio that is harmless, since the client and the server share a principal. On a shared HTTP endpoint it is the outward leak the W3C security section warns about, and it is the same judgement already made for baggage in [OutboundContext]. One rule, both directions.

func ServerMiddleware

func ServerMiddleware(next http.Handler) http.Handler

ServerMiddleware instruments the HTTP layer, outside authentication.

What it adds that the MCP span does not

The MCP span starts after the credential check, so it never exists for a request that was refused. That is deliberate, and it leaves an operator of a published endpoint unable to see the thing they most need to watch: how much traffic is being rejected, and how long the rejection takes. This span covers host validation, CORS, the credential check and the handler, so a refusal is visible as a status code without ever reaching the MCP instrumentation.

Why not otelhttp

The contrib middleware records url.full and derives server.address from the client-controlled Host header, and the convention attaches a warning to that second one: "Since this attribute is based on HTTP headers, opting in to it may allow an attacker to trigger cardinality limits, degrading the usefulness of the metric." It also reads client.address from X-Forwarded-For with no allow-list, which ignores this server's own --trusted-proxy-header setting and would put an attacker-chosen string on every span.

Each of those is fixable with an option or a SpanProcessor. Together they are more configuration than the thing being configured, and every one of them fails open: forget the option and the attribute ships.

No route, and why that is not the loss it looks like

http.route would need the pattern the mux matched, and net/http sets that on the request it passes downward rather than on the one this middleware holds. Recording url.path instead would be worse than nothing on a published endpoint: the path is whatever a scanner sends, so /wp-admin.php and ten thousand friends would each mint a series.

This server serves a handful of fixed paths, so method and status answer the questions an HTTP-level view is for: request rate, error rate, latency, and how much of it is being refused. What was called is on the MCP span.

func SetMetricServerAddresses

func SetMetricServerAddresses(hosts []string)

SetMetricServerAddresses declares which hosts the http.client metric may name. Unset, or set empty, every host is recorded as OtherServerAddress.

Types

type CallIdentifier

type CallIdentifier interface {
	// Identify maps a tools/call to its canonical catalog action.
	//
	// arguments is the raw tool argument value, exactly as the SDK decoded it,
	// because the shape differs per surface and only the implementation knows
	// which field to read.
	//
	// Returning false is normal rather than exceptional: a standalone tool such
	// as gitlab_discover_project or an interactive elicitation flow belongs to
	// no catalog action, and a call naming a tool that does not exist reaches
	// here before anything rejects it. Neither is worth an error, and both must
	// leave the attribute unset rather than carry a placeholder.
	Identify(toolName string, arguments any) (Identity, bool)
}

CallIdentifier answers what a tools/call actually invokes.

Why this is an interface rather than code in this package

The tool name is not the operation on two of the three surfaces, and on the third it is not derivable from anything:

  • dynamic (the default): the tool is gitlab_execute_action for every call, and the operation lives in the "action" argument as a canonical catalog id such as issue.list. Two tool names cover roughly a thousand operations, so a trace keyed on the tool name records nothing about what the server did.
  • meta: the tool is the bare domain, gitlab_issue, and the operation is the "action" argument, list. The canonical id is the pair, and neither half is enough.
  • individual: the tool is gitlab_issue_list and there is no action argument, but the name is DECLARED in each ActionSpec rather than derived. A large legacy set is verb-first (gitlab_list_issue_discussions) while new ones are domain-first, so no formula maps a tool name back to an action. Only the catalog knows.

Instrumentation that tried to work this out from the arguments would be a second copy of dispatch, drifting from the first the moment a surface changes. So this package asks, and the composition root answers from the catalog it already built. That also keeps the import direction clean: nothing here knows about the catalog, and the catalog knows nothing about telemetry.

Why the identity has to be known before the span starts

The span name and its attributes are fixed at creation. "Samplers can only consider information already present during span creation. Any changes done later, including updated span name, cannot change their decisions." So the call is identified first and the span started second, rather than starting a span and renaming it once dispatch has worked out what it is.

func IdentifierFunc

func IdentifierFunc(f func(toolName string, arguments any) (Identity, bool)) CallIdentifier

IdentifierFunc wraps a function as a CallIdentifier.

type Identity

type Identity struct {
	// ActionID is the canonical catalog id, such as issue.list. Empty when the
	// call is not a catalog action.
	ActionID string

	// Domain is the catalog domain, such as issue. It is carried separately
	// because it is the dimension worth grouping by when the action id itself
	// is too many values to put on a metric.
	Domain string
}

Identity is what a call turned out to be.

type Options

type Options struct {
	// Identifier resolves a tools/call to its catalog action. Nil is allowed
	// and means action attributes are omitted; see [CallIdentifier] for why
	// this is not something this package can work out for itself.
	Identifier CallIdentifier

	// Users turns an authenticated caller into attributes, subject to the
	// deployment's identity policy. Nil means nothing about who made a call is
	// ever recorded, which is also what the default policy does.
	Users UserAttributer

	// Resources turns a resource URI into attributes, subject to the
	// deployment's identity policy. Nil records nothing about which resource a
	// request named, which is also what the default policy does with the URI
	// itself: it records a keyed digest instead.
	Resources ResourceAttributer

	// Surface names the registered tool catalog (dynamic, meta, individual).
	// It goes on every span because the same request means different things
	// across the three, and a trace read months later has no other way to tell.
	Surface string

	// Transport is "pipe" for stdio and "tcp" for HTTP, which is what the
	// convention's note prescribes rather than a name of our choosing. Use
	// [TransportPipe] and [TransportTCP].
	Transport string

	// ProtocolVersions are the MCP revisions this server admits. Only a
	// version in this list is ever recorded, because the value arrives from the
	// caller and lands on a metric dimension; see protocolVersionFor. Empty
	// means the attribute is never recorded, which is the safe default for a
	// caller that has not thought about it.
	ProtocolVersions []string
}

Options configure the middleware.

type PoolCounts

type PoolCounts struct {
	// Entries is how many credentials the pool holds at this instant and
	// MaxSize is how many --max-http-clients allows. Both are published,
	// because the question an operator asks is "how close to the bound", and
	// answering it from a constant typed into a dashboard makes the answer
	// wrong the moment the flag moves.
	Entries int64
	MaxSize int64

	// The eviction counters, one per reason and disjoint by construction. Their
	// sum is every entry the pool has ever dropped except the ones taken at
	// shutdown, which are deliberately uncounted: nothing observes a metric
	// after the process ends.
	SizeEvictions     int64
	BusyEvictions     int64
	IdleEvictions     int64
	StaleEvictions    int64
	RejectedEvictions int64
	InvalidEvictions  int64
	RebuildEvictions  int64
}

PoolCounts is one reading of an HTTP deployment's credential pool, in the shape this package publishes it.

It is declared here rather than imported from internal/serverpool on purpose. This package instruments MCP request handling and knows nothing about how a deployment holds credentials; importing the pool would invert a dependency that currently runs one way only. The caller owns both and adapts one to the other in a closure, which is also what keeps stdio, where there is no pool, from linking any of this.

type ResourceAttributer

type ResourceAttributer interface {
	// ResourceAttributes returns what a span may record about one resource
	// URI, which is nothing when the URI is empty or the policy allows none.
	ResourceAttributes(uri string) []attribute.KeyValue
}

ResourceAttributer turns a resource URI into the attributes a span may carry for it, subject to the deployment's policy.

It is an interface for the same reason UserAttributer is: the rule lives in internal/telemetry, which pulls the OpenTelemetry SDK in, and this package imports the API alone. Nil means nothing about which resource was named is ever recorded, which is what a caller that has not thought about it should get.

type UserAttributer

type UserAttributer interface {
	// UserAttributes returns what may be recorded about the caller of this
	// request, which is frequently nothing.
	UserAttributes(ctx context.Context, req mcp.Request) []attribute.KeyValue
}

UserAttributer turns an authenticated caller into the attributes a deployment's identity policy permits, or into nothing.

Why this is an interface too

The policy itself, its three modes and its per-process HMAC salt, live in internal/telemetry beside the rest of the configuration, and the identity is resolved by internal/toolutil from a request or a context. Neither belongs here, and importing either would tie instrumentation to a package it has no business knowing. This is the seam between them.

Why an empty result is the common case

The default policy exports nothing about who made a call, and a deployment that never thought about the question keeps that default. So the ordinary answer is nil, and every caller must treat it as ordinary rather than as a failure to look something up.

func UserAttributerFunc

func UserAttributerFunc(f func(ctx context.Context, req mcp.Request) []attribute.KeyValue) UserAttributer

UserAttributerFunc wraps a function as a UserAttributer.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL