scala-mcp

[md]trace4cats

mcp-trace4cats · JVM

A trace4cats-backed ToolMiddleware that wraps every tools/call in a span. Drop it onto a server builder, plug in whatever Trace[F] instance your app already uses, and tool calls show up in your tracing backend with built-in attributes plus anything else you care to extract from the request.

libraryDependencies += "net.andimiller.mcp" %% "mcp-trace4cats" % "0.12.0"

The module depends on trace4cats-core only — exporters, samplers, and the choice of Trace[F] instance stay yours.

Built-in span shape

For each call, the middleware emits a span shaped like:

field value
name mcp.tools.call ${request.name}
mcp.tool.name the tool's name (string)
mcp.tool.is_error response's isError flag (boolean)
status SpanStatus.Ok on success, SpanStatus.Internal(...) if response.isError

Wiring it up

The no-arg overload wraps tool calls with just the built-in attributes:

import cats.effect.IO
import net.andimiller.mcp.core.server.ServerBuilder
import net.andimiller.mcp.trace4cats.TraceToolMiddleware
import trace4cats.Trace

def wireUp(using Trace[IO]) =
  ServerBuilder[IO]("my-server", "1.0.0")
    .withToolMiddleware(TraceToolMiddleware[IO, Unit])
    .build

TraceToolMiddleware[IO, Unit] requires a Trace[IO] in scope. Use whichever trace4cats integration you prefer — trace4cats-iolocal, a Kleisli instance, or anything else that satisfies the typeclass. The second type parameter is the server's Ctx (Unit for the non-contextual ServerBuilder / McpHttp.basic; the per-session type for McpHttp.streaming).

Adding custom attributes

The extractor overload takes an effectful function from ToolCallContext[F, Ctx] to a Map[String, AttributeValue] of additional attributes. Returned attributes are recorded INSIDE the span and BEFORE the handler runs, so they're captured even if the handler errors. The context gives you the request, the session id (when available), the per-session Ctx, and the resolved tool — so extractors can reach ctx.request._meta, ctx.sessionId, ctx.ctx, or ctx.resolved.annotations:

import cats.effect.IO
import net.andimiller.mcp.core.server.{ServerBuilder, ToolCallContext}
import net.andimiller.mcp.trace4cats.TraceToolMiddleware
import trace4cats.AttributeValue
import trace4cats.Trace

def extractor(ctx: ToolCallContext[IO, Unit]): IO[Map[String, AttributeValue]] =
  IO.pure {
    val user = ctx.request.arguments.hcursor.get[String]("user").toOption
    user.map(u => Map("mcp.tool.user" -> AttributeValue.StringValue(u))).getOrElse(Map.empty)
  }

def wireUp(using Trace[IO]) =
  ServerBuilder[IO]("my-server", "1.0.0")
    .withToolMiddleware(TraceToolMiddleware[IO, Unit](extractor))
    .build

Built-in attributes the middleware adds for you:

Key When
mcp.tool.name always
mcp.session.id when ctx.sessionId is Some (streaming HTTP)
mcp.tool.is_error after the handler runs

Composition

Server-wide middleware (.withToolMiddleware) wraps per-tool middleware (.withMiddleware on Tool[F, Ctx]) which wraps the handler. First registered = outermost within each level. So stacking the tracing middleware server-wide alongside a per-tool rate-limiter gets you:

request →  TraceToolMiddleware  →  rateLimit (per-tool)  →  handler
response ←  TraceToolMiddleware  ←  rateLimit             ←  handler

The span fires around everything — the rate-limiter's latency is part of the span just like the handler's. See tool metadata for the broader middleware story.

Continuing an upstream trace

TraceToolMiddleware always opens a child of the ambient Trace[F] context — it doesn't read inbound trace context from the request. When your caller has its own trace running and passes a traceparent in request._meta, you want to continue that trace so the cross-system view stitches together. That's what EntrypointTraceToolMiddleware is for. It's a separate middleware in the same module.

The shape:

import cats.effect.IO
import net.andimiller.mcp.core.server.ServerBuilder
import net.andimiller.mcp.trace4cats.EntrypointTraceToolMiddleware
import net.andimiller.mcp.trace4cats.TraceMetaExtractor
import trace4cats.EntryPoint
import trace4cats.Span
import trace4cats.context.Local

def wireUp(ep: EntryPoint[IO], local: Local[IO, Span[IO]]) =
  ServerBuilder[IO]("my-server", "1.0.0")
    .withToolMiddleware(
      EntrypointTraceToolMiddleware[IO, Unit](ep, local, TraceMetaExtractor.w3c[IO, Unit])
    )
    .build

Building the Local[IO, Span[IO]] is a one-liner with the trace4cats-iolocal module:

import cats.effect.IOLocal
import trace4cats.iolocal.ioLocalProvide

IOLocal[Span[IO]](rootSpan).map(ioLocalProvide(_))

Three dependencies:

  1. EntryPoint[F] — your app-level boundary primitive (constructed once from your SpanSampler + SpanCompleter, exactly like every other trace4cats integration).
  2. Local[F, Span[F]] — usually built from an IOLocal[Span[IO]] via ioLocalProvide. Any downstream code using Trace[F] (e.g. ioLocalTrace(local)) will see the new span as the ambient context.
  3. headers: ToolCallContext[F, Ctx] => F[TraceHeaders] — pulls the propagation headers out of the request. The included TraceMetaExtractor.w3c[F, Ctx] pulls W3C traceparent and tracestate from ctx.request._meta. Return TraceHeaders.empty to force a fresh root.

Optionally a fourth: the same extractor: ToolCallContext[F, Ctx] => F[Map[String, AttributeValue]] shape as the simpler middleware, for custom attributes.

Which one to pick?

Scenario Use
Single-process server, internal tool calls, you just want spans recorded TraceToolMiddleware
Cross-service traces — caller has its own trace running and sends propagation headers via _meta EntrypointTraceToolMiddleware

The propagation middleware is strictly more capable but takes a few more dependencies at the wiring site. If you're not sure whether callers will propagate, you can start with EntrypointTraceToolMiddleware — absent any inbound context, it falls back to a fresh root span just like TraceToolMiddleware would.