toad-eye 2.6.0 → 2.7.0

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.
package/README.md CHANGED
@@ -46,13 +46,14 @@ Open [localhost:3100](http://localhost:3100) (Grafana, admin/admin) to see your
46
46
 
47
47
  ## What you get
48
48
 
49
- | Feature | Description |
50
- | ------------------------ | ------------------------------------------------------------------------------------------ |
51
- | **Auto-instrumentation** | OpenAI, Anthropic, Gemini, Vercel AI SDK — regular + streaming |
52
- | **8 Grafana dashboards** | Overview, Cost, Latency, Errors, Model Comparison, FinOps, Provider Health, Agent Workflow |
53
- | **Cost tracking** | Per-request USD cost, daily totals, projected monthly spend |
54
- | **Budget guards** | Daily/per-user/per-model spend limits — warn, block, or auto-downgrade |
55
- | **Privacy controls** | Built-in PII redaction (email, SSN, CC, phone), hashing, content masking |
49
+ | Feature | Description |
50
+ | ------------------------- | -------------------------------------------------------------------------------------- |
51
+ | **Auto-instrumentation** | OpenAI, Anthropic, Gemini, Vercel AI SDK — regular + streaming |
52
+ | **MCP server monitoring** | One-line middleware for MCP servers — trace every tool call, resource read, prompt |
53
+ | **9 Grafana dashboards** | Overview, Cost, Latency, Errors, Model Comparison, FinOps, Provider Health, Agent, MCP |
54
+ | **Cost tracking** | Per-request USD cost, daily totals, projected monthly spend |
55
+ | **Budget guards** | Daily/per-user/per-model spend limits — warn, block, or auto-downgrade |
56
+ | **Privacy controls** | Built-in PII redaction (email, SSN, CC, phone), hashing, content masking |
56
57
 
57
58
  <details>
58
59
  <summary>Advanced features</summary>
@@ -124,38 +125,42 @@ const result = await traceLLMCall(
124
125
 
125
126
  </details>
126
127
 
127
- <details>
128
- <summary>MCP Server observability</summary>
128
+ ## MCP Server observability
129
129
 
130
- Drop-in middleware for MCP servers — one line for full tracing of every tool call, resource read, and prompt request.
130
+ 8,600+ MCP servers. Zero built-in observability. toad-eye fixes that with one line:
131
131
 
132
132
  ```typescript
133
- import { McpServer } from "@modelcontextprotocol/sdk/server/index.js";
133
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
134
+ import { initObservability } from "toad-eye";
134
135
  import { toadEyeMiddleware } from "toad-eye/mcp";
135
136
 
137
+ initObservability({ serviceName: "my-mcp-server" });
138
+
136
139
  const server = new McpServer({ name: "my-server", version: "1.0.0" });
137
- toadEyeMiddleware(server);
140
+ toadEyeMiddleware(server); // every tool call, resource read, prompt — traced
138
141
 
139
- // All handlers are now instrumented — spans appear in Jaeger
140
142
  server.tool("calculate", { expression: z.string() }, async ({ expression }) => {
141
143
  return { content: [{ type: "text", text: String(eval(expression)) }] };
142
144
  });
143
145
  ```
144
146
 
145
- Spans follow OTel GenAI semconv: `execute_tool calculate`, `retrieval file:///docs`, `prompt greeting`.
147
+ Spans follow [OTel MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/): `tools/call calculate`, `resources/read file:///docs`, `prompts/get greeting`.
148
+
149
+ Every span includes: `mcp.method.name`, `mcp.session.id`, `mcp.protocol.version`, `network.transport`.
146
150
 
147
151
  Privacy by default — tool arguments and results are NOT recorded unless you opt in:
148
152
 
149
153
  ```typescript
150
154
  toadEyeMiddleware(server, {
151
155
  recordInputs: true,
156
+ recordOutputs: true,
152
157
  redactKeys: ["apiKey", "token"],
153
158
  });
154
159
  ```
155
160
 
156
- > Safe for stdio transport — OTel diagnostics are redirected to stderr.
161
+ Dedicated MCP dashboard in Grafana: tool call rate, duration p50/p95, error rate, resource reads, tool performance table.
157
162
 
158
- </details>
163
+ > Safe for stdio transport — OTel diagnostics are redirected to stderr.
159
164
 
160
165
  ## Budget guards
161
166
 
@@ -378,7 +383,7 @@ Self-hosted mode remains the default. Cloud mode activates automatically when `a
378
383
 
379
384
  ## Grafana dashboards
380
385
 
381
- 8 pre-built dashboards auto-provisioned on `npx toad-eye init`:
386
+ 9 pre-built dashboards auto-provisioned on `npx toad-eye init`:
382
387
 
383
388
  | Dashboard | What it shows |
384
389
  | ---------------------- | ---------------------------------------------------------------- |
@@ -390,6 +395,7 @@ Self-hosted mode remains the default. Cloud mode activates automatically when `a
390
395
  | **FinOps Attribution** | Cost by team/user/feature, projected spend, what-if table |
391
396
  | **Provider Health** | Provider status (healthy/degraded/down), uptime, error breakdown |
392
397
  | **Agent Workflow** | Steps per query, tool usage frequency, step type breakdown |
398
+ | **MCP Server** | Tool call rate, duration p50/p95, errors by tool, resource reads |
393
399
 
394
400
  ## Subpath imports
395
401
 
@@ -448,24 +454,24 @@ import { ToadEyeAISpanProcessor, withToadEye } from "toad-eye/vercel"; // Vercel
448
454
 
449
455
  ### Span attributes (OTel GenAI semconv)
450
456
 
451
- | Attribute | Description |
452
- | ---------------------------------- | ----------------------------------------------- |
453
- | `gen_ai.operation.name` | `chat`, `invoke_agent`, `execute_tool` |
454
- | `gen_ai.provider.name` | `anthropic`, `gemini`, `openai` |
455
- | `gen_ai.request.model` | Model identifier |
456
- | `gen_ai.usage.input_tokens` | Tokens in the prompt |
457
- | `gen_ai.usage.output_tokens` | Tokens in the completion |
458
- | `gen_ai.request.temperature` | Temperature parameter |
459
- | `gen_ai.agent.name` | Agent name (for agent spans) |
460
- | `gen_ai.agent.id` | Agent identifier |
461
- | `gen_ai.tool.name` | Tool name for execute_tool spans |
462
- | `gen_ai.tool.type` | `function`, `extension`, `retrieval`, `builtin` |
463
- | `gen_ai.toad_eye.cost` | Cost in USD |
464
- | `gen_ai.toad_eye.agent.step.type` | ReAct step: think/act/observe/answer/handoff |
465
- | `gen_ai.toad_eye.agent.handoff.to` | Target agent for handoff steps |
466
- | `gen_ai.toad_eye.agent.loop_count` | Agent loop iterations (observe→think) |
467
- | `gen_ai.toad_eye.guard.mode` | Guard mode: shadow/enforce |
468
- | `session.id` | Session identifier (if configured) |
457
+ | Attribute | Description |
458
+ | ---------------------------------- | ---------------------------------------------------------------------- |
459
+ | `gen_ai.operation.name` | `chat`, `invoke_agent`, `execute_tool`, `tools/call`, `resources/read` |
460
+ | `gen_ai.provider.name` | `anthropic`, `gemini`, `openai` |
461
+ | `gen_ai.request.model` | Model identifier |
462
+ | `gen_ai.usage.input_tokens` | Tokens in the prompt |
463
+ | `gen_ai.usage.output_tokens` | Tokens in the completion |
464
+ | `gen_ai.request.temperature` | Temperature parameter |
465
+ | `gen_ai.agent.name` | Agent name (for agent spans) |
466
+ | `gen_ai.agent.id` | Agent identifier |
467
+ | `gen_ai.tool.name` | Tool name for execute_tool spans |
468
+ | `gen_ai.tool.type` | `function`, `extension`, `retrieval`, `builtin` |
469
+ | `gen_ai.toad_eye.cost` | Cost in USD |
470
+ | `gen_ai.toad_eye.agent.step.type` | ReAct step: think/act/observe/answer/handoff |
471
+ | `gen_ai.toad_eye.agent.handoff.to` | Target agent for handoff steps |
472
+ | `gen_ai.toad_eye.agent.loop_count` | Agent loop iterations (observe→think) |
473
+ | `gen_ai.toad_eye.guard.mode` | Guard mode: shadow/enforce |
474
+ | `session.id` | Session identifier (if configured) |
469
475
 
470
476
  </details>
471
477
 
package/dist/cli.js CHANGED
@@ -97,19 +97,26 @@ function down() {
97
97
  export function findContainerByService(containers, serviceKey) {
98
98
  return containers.find((c) => c.Service === serviceKey);
99
99
  }
100
+ function isContainerHealthy(container) {
101
+ return container?.State === "running" && container.ExitCode === 0;
102
+ }
100
103
  function status() {
101
104
  const composeFile = requireInfra();
102
105
  try {
103
- const output = execFileSync("docker", ["compose", "-f", composeFile, "ps", "--format", "json"], { encoding: "utf-8" });
106
+ const output = execFileSync("docker", ["compose", "-f", composeFile, "ps", "--all", "--format", "json"], { encoding: "utf-8" });
104
107
  const lines = output.trim().split("\n").filter(Boolean);
105
108
  const containers = lines.map((line) => JSON.parse(line));
106
109
  console.log("\u{1f438} toad-eye stack:");
107
110
  console.log();
108
111
  for (const svc of SERVICES) {
109
- const container = findContainerByService(containers, svc.service);
110
- const state = container?.State === "running" ? "\u{1f7e2}" : "\u{1f534}";
112
+ const container = containers.find((c) => c.Service === svc.service);
113
+ const healthy = isContainerHealthy(container);
114
+ const icon = healthy ? "\u{1f7e2}" : "\u{1f534}";
115
+ const suffix = container && !healthy
116
+ ? ` (${container.State}${container.ExitCode ? `, exit code ${container.ExitCode}` : ""})`
117
+ : "";
111
118
  const loginInfo = "login" in svc ? ` (${svc.login})` : "";
112
- console.log(` ${state} ${svc.name.padEnd(16)} ${svc.url}${loginInfo}`);
119
+ console.log(` ${icon} ${svc.name.padEnd(16)} ${svc.url}${loginInfo}${suffix}`);
113
120
  }
114
121
  console.log();
115
122
  }
@@ -1,3 +1,4 @@
1
1
  export { toadEyeMiddleware, traceSampling } from "./middleware.js";
2
2
  export type { TraceSamplingOptions } from "./middleware.js";
3
3
  export type { ToadMcpOptions, McpSpanAttributes } from "./types.js";
4
+ export { MCP_METHODS } from "./spans.js";
package/dist/mcp/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export { toadEyeMiddleware, traceSampling } from "./middleware.js";
2
+ export { MCP_METHODS } from "./spans.js";
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Separate from core metrics (core/metrics.ts) — these only exist
5
5
  * when MCP middleware is active. Initialized on first middleware call.
6
+ *
7
+ * All metrics include `mcp.method.name` dimension per OTel MCP semconv.
6
8
  */
7
9
  export declare function recordMcpToolCall(toolName: string, durationMs: number, status: "success" | "error"): void;
8
10
  export declare function recordMcpToolError(toolName: string, errorType: string): void;
@@ -3,9 +3,12 @@
3
3
  *
4
4
  * Separate from core metrics (core/metrics.ts) — these only exist
5
5
  * when MCP middleware is active. Initialized on first middleware call.
6
+ *
7
+ * All metrics include `mcp.method.name` dimension per OTel MCP semconv.
6
8
  */
7
9
  import { metrics, } from "@opentelemetry/api";
8
10
  import { GEN_AI_METRICS } from "../types/metrics.js";
11
+ import { MCP_METHODS } from "./spans.js";
9
12
  const METER_NAME = "toad-eye-mcp";
10
13
  let toolDuration;
11
14
  let toolCalls;
@@ -37,16 +40,30 @@ function ensureInit() {
37
40
  }
38
41
  export function recordMcpToolCall(toolName, durationMs, status) {
39
42
  ensureInit();
40
- toolCalls.add(1, { "gen_ai.tool.name": toolName, status });
41
- toolDuration.record(durationMs, { "gen_ai.tool.name": toolName });
43
+ toolCalls.add(1, {
44
+ "gen_ai.tool.name": toolName,
45
+ "mcp.method.name": MCP_METHODS.TOOLS_CALL,
46
+ status,
47
+ });
48
+ toolDuration.record(durationMs, {
49
+ "gen_ai.tool.name": toolName,
50
+ "mcp.method.name": MCP_METHODS.TOOLS_CALL,
51
+ });
42
52
  }
43
53
  export function recordMcpToolError(toolName, errorType) {
44
54
  ensureInit();
45
- toolErrors.add(1, { "gen_ai.tool.name": toolName, "error.type": errorType });
55
+ toolErrors.add(1, {
56
+ "gen_ai.tool.name": toolName,
57
+ "mcp.method.name": MCP_METHODS.TOOLS_CALL,
58
+ "error.type": errorType,
59
+ });
46
60
  }
47
61
  export function recordMcpResourceRead(uri) {
48
62
  ensureInit();
49
- resourceReads.add(1, { "gen_ai.data_source.id": uri });
63
+ resourceReads.add(1, {
64
+ "gen_ai.data_source.id": uri,
65
+ "mcp.method.name": MCP_METHODS.RESOURCES_READ,
66
+ });
50
67
  }
51
68
  export function recordMcpSessionStart() {
52
69
  ensureInit();
@@ -45,4 +45,10 @@ export interface TraceSamplingOptions {
45
45
  readonly serverVersion?: string | undefined;
46
46
  /** Parent OTel context. */
47
47
  readonly parentContext?: import("@opentelemetry/api").Context | undefined;
48
+ /** MCP session ID. */
49
+ readonly sessionId?: string | undefined;
50
+ /** MCP protocol version. */
51
+ readonly protocolVersion?: string | undefined;
52
+ /** Network transport: "pipe" (stdio) or "tcp" (HTTP). */
53
+ readonly networkTransport?: string | undefined;
48
54
  }
@@ -9,10 +9,25 @@
9
9
  * and strict TypeScript. Wrapping the public API is the stable path.
10
10
  */
11
11
  import { context, diag, DiagConsoleLogger, DiagLogLevel, } from "@opentelemetry/api";
12
+ import { randomUUID } from "node:crypto";
12
13
  import { extractContextFromMeta } from "./context.js";
13
14
  import { startToolSpan, startResourceSpan, startPromptSpan, startSamplingSpan, endSpanSuccess, endSpanError, } from "./spans.js";
14
15
  import { recordMcpToolCall, recordMcpToolError, recordMcpResourceRead, recordMcpSessionStart, } from "./metrics.js";
15
16
  const DEFAULT_MAX_PAYLOAD_SIZE = 4096;
17
+ /** Detect MCP transport type from server internals. */
18
+ function detectTransport(server) {
19
+ // Check if server has a transport reference
20
+ const transport = server.transport ?? server._transport;
21
+ if (transport && typeof transport === "object") {
22
+ const name = transport.constructor?.name ?? "";
23
+ if (/stdio/i.test(name))
24
+ return "pipe";
25
+ if (/sse|http/i.test(name))
26
+ return "tcp";
27
+ }
28
+ // Default: stdio is most common for local dev
29
+ return "pipe";
30
+ }
16
31
  function truncate(value, maxBytes) {
17
32
  if (value.length <= maxBytes)
18
33
  return value;
@@ -75,7 +90,19 @@ server, options = {}) {
75
90
  const redactKeys = options.redactKeys ?? [];
76
91
  const maxPayload = options.maxPayloadSize ?? DEFAULT_MAX_PAYLOAD_SIZE;
77
92
  const propagate = options.propagateContext ?? true;
78
- const spanOpts = { serverName, serverVersion };
93
+ // Session ID: explicit > server-provided > generated
94
+ const sessionId = options.sessionId ?? server.sessionId ?? randomUUID().slice(0, 8);
95
+ // Protocol version from server capabilities or default
96
+ const protocolVersion = server.protocolVersion ?? server._protocolVersion ?? "2025-03-26";
97
+ // Detect transport type: pipe (stdio) vs tcp (HTTP)
98
+ const networkTransport = detectTransport(server);
99
+ const spanOpts = {
100
+ serverName,
101
+ serverVersion,
102
+ sessionId,
103
+ protocolVersion,
104
+ networkTransport,
105
+ };
79
106
  // --- Wrap .tool() ---
80
107
  const originalTool = server.tool.bind(server);
81
108
  server.tool = function wrappedTool(name, ...rest) {
@@ -227,6 +254,9 @@ export async function traceSampling(fn, options) {
227
254
  serverName: options.serverName ?? "mcp-server",
228
255
  serverVersion: options.serverVersion ?? "unknown",
229
256
  parentContext: options.parentContext,
257
+ sessionId: options.sessionId,
258
+ protocolVersion: options.protocolVersion,
259
+ networkTransport: options.networkTransport,
230
260
  });
231
261
  if (options.maxTokens !== undefined) {
232
262
  span.setAttribute("gen_ai.request.max_tokens", options.maxTokens);
@@ -1,18 +1,28 @@
1
1
  /**
2
2
  * OTel span creation for MCP operations.
3
3
  *
4
- * Maps MCP methods to OTel GenAI semantic conventions:
5
- * - tools/call → execute_tool {tool_name}
6
- * - resources/read → retrieval {uri}
7
- * - prompts/get → prompt {name}
8
- * - sampling/createMessage → chat {model}
4
+ * Follows OTel MCP semantic conventions (PR #2083):
5
+ * - tools/call → `tools/call {tool_name}`
6
+ * - resources/read → `resources/read {uri}`
7
+ * - prompts/get → `prompts/get {name}`
8
+ * - sampling/createMessage → `sampling/createMessage {model}`
9
9
  */
10
10
  import { type Context } from "@opentelemetry/api";
11
11
  declare const tracer: import("@opentelemetry/api").Tracer;
12
+ /** MCP method names per OTel semconv. */
13
+ export declare const MCP_METHODS: {
14
+ readonly TOOLS_CALL: "tools/call";
15
+ readonly RESOURCES_READ: "resources/read";
16
+ readonly PROMPTS_GET: "prompts/get";
17
+ readonly SAMPLING_CREATE_MESSAGE: "sampling/createMessage";
18
+ };
12
19
  export interface SpanOptions {
13
20
  readonly serverName: string;
14
21
  readonly serverVersion: string;
15
22
  readonly parentContext: Context;
23
+ readonly sessionId?: string | undefined;
24
+ readonly protocolVersion?: string | undefined;
25
+ readonly networkTransport?: string | undefined;
16
26
  }
17
27
  export declare function startToolSpan(toolName: string, options: SpanOptions): import("@opentelemetry/api").Span;
18
28
  export declare function startResourceSpan(uri: string, options: SpanOptions): import("@opentelemetry/api").Span;
@@ -21,6 +31,9 @@ export interface SamplingSpanOptions {
21
31
  readonly serverName: string;
22
32
  readonly serverVersion: string;
23
33
  readonly parentContext?: Context | undefined;
34
+ readonly sessionId?: string | undefined;
35
+ readonly protocolVersion?: string | undefined;
36
+ readonly networkTransport?: string | undefined;
24
37
  }
25
38
  export declare function startSamplingSpan(model: string, options: SamplingSpanOptions): import("@opentelemetry/api").Span;
26
39
  export declare function endSpanSuccess(span: ReturnType<typeof tracer.startSpan>): void;
package/dist/mcp/spans.js CHANGED
@@ -1,56 +1,71 @@
1
1
  /**
2
2
  * OTel span creation for MCP operations.
3
3
  *
4
- * Maps MCP methods to OTel GenAI semantic conventions:
5
- * - tools/call → execute_tool {tool_name}
6
- * - resources/read → retrieval {uri}
7
- * - prompts/get → prompt {name}
8
- * - sampling/createMessage → chat {model}
4
+ * Follows OTel MCP semantic conventions (PR #2083):
5
+ * - tools/call → `tools/call {tool_name}`
6
+ * - resources/read → `resources/read {uri}`
7
+ * - prompts/get → `prompts/get {name}`
8
+ * - sampling/createMessage → `sampling/createMessage {model}`
9
9
  */
10
10
  import { trace, SpanStatusCode, SpanKind, } from "@opentelemetry/api";
11
11
  const TRACER_NAME = "toad-eye-mcp";
12
12
  const tracer = trace.getTracer(TRACER_NAME);
13
+ /** MCP method names per OTel semconv. */
14
+ export const MCP_METHODS = {
15
+ TOOLS_CALL: "tools/call",
16
+ RESOURCES_READ: "resources/read",
17
+ PROMPTS_GET: "prompts/get",
18
+ SAMPLING_CREATE_MESSAGE: "sampling/createMessage",
19
+ };
20
+ /** Build common MCP attributes shared across all span types. */
21
+ function baseAttrs(method, options) {
22
+ const attrs = {
23
+ "gen_ai.operation.name": method,
24
+ "mcp.method.name": method,
25
+ "mcp.server.name": options.serverName,
26
+ "mcp.server.version": options.serverVersion,
27
+ };
28
+ if (options.sessionId)
29
+ attrs["mcp.session.id"] = options.sessionId;
30
+ if (options.protocolVersion)
31
+ attrs["mcp.protocol.version"] = options.protocolVersion;
32
+ if (options.networkTransport)
33
+ attrs["network.transport"] = options.networkTransport;
34
+ return attrs;
35
+ }
13
36
  export function startToolSpan(toolName, options) {
14
- return tracer.startSpan(`execute_tool ${toolName}`, {
37
+ return tracer.startSpan(`${MCP_METHODS.TOOLS_CALL} ${toolName}`, {
15
38
  kind: SpanKind.INTERNAL,
16
39
  attributes: {
17
- "gen_ai.operation.name": "execute_tool",
40
+ ...baseAttrs(MCP_METHODS.TOOLS_CALL, options),
18
41
  "gen_ai.tool.name": toolName,
19
- "mcp.server.name": options.serverName,
20
- "mcp.server.version": options.serverVersion,
21
42
  },
22
43
  }, options.parentContext);
23
44
  }
24
45
  export function startResourceSpan(uri, options) {
25
- return tracer.startSpan(`retrieval ${uri}`, {
46
+ return tracer.startSpan(`${MCP_METHODS.RESOURCES_READ} ${uri}`, {
26
47
  kind: SpanKind.INTERNAL,
27
48
  attributes: {
28
- "gen_ai.operation.name": "retrieval",
49
+ ...baseAttrs(MCP_METHODS.RESOURCES_READ, options),
29
50
  "gen_ai.data_source.id": uri,
30
- "mcp.server.name": options.serverName,
31
- "mcp.server.version": options.serverVersion,
32
51
  },
33
52
  }, options.parentContext);
34
53
  }
35
54
  export function startPromptSpan(promptName, options) {
36
- return tracer.startSpan(`prompt ${promptName}`, {
55
+ return tracer.startSpan(`${MCP_METHODS.PROMPTS_GET} ${promptName}`, {
37
56
  kind: SpanKind.INTERNAL,
38
57
  attributes: {
39
- "gen_ai.operation.name": "prompt",
58
+ ...baseAttrs(MCP_METHODS.PROMPTS_GET, options),
40
59
  "gen_ai.prompt.name": promptName,
41
- "mcp.server.name": options.serverName,
42
- "mcp.server.version": options.serverVersion,
43
60
  },
44
61
  }, options.parentContext);
45
62
  }
46
63
  export function startSamplingSpan(model, options) {
47
- return tracer.startSpan(`chat ${model}`, {
64
+ return tracer.startSpan(`${MCP_METHODS.SAMPLING_CREATE_MESSAGE} ${model}`, {
48
65
  kind: SpanKind.CLIENT,
49
66
  attributes: {
50
- "gen_ai.operation.name": "chat",
67
+ ...baseAttrs(MCP_METHODS.SAMPLING_CREATE_MESSAGE, options),
51
68
  "gen_ai.request.model": model,
52
- "mcp.server.name": options.serverName,
53
- "mcp.server.version": options.serverVersion,
54
69
  },
55
70
  }, options.parentContext);
56
71
  }
@@ -63,5 +78,10 @@ export function endSpanError(span, error) {
63
78
  const errorType = error instanceof Error ? error.constructor.name : "UnknownError";
64
79
  span.setStatus({ code: SpanStatusCode.ERROR, message });
65
80
  span.setAttribute("error.type", errorType);
81
+ // JSON-RPC error code if available (MCP SDK errors often carry a code)
82
+ const code = error?.code;
83
+ if (typeof code === "number") {
84
+ span.setAttribute("rpc.response.status_code", code);
85
+ }
66
86
  span.end();
67
87
  }
@@ -13,6 +13,8 @@ export interface ToadMcpOptions {
13
13
  readonly redactKeys?: readonly string[] | undefined;
14
14
  /** Max payload size in bytes before truncation. Default: 4096. */
15
15
  readonly maxPayloadSize?: number | undefined;
16
+ /** Explicit session ID. Auto-generated if not provided. */
17
+ readonly sessionId?: string | undefined;
16
18
  /** Extract W3C traceparent from _meta field. Default: true. */
17
19
  readonly propagateContext?: boolean | undefined;
18
20
  /** Hook called on each tool call span. Add custom attributes here. */
@@ -23,12 +25,15 @@ export interface ToadMcpOptions {
23
25
  /** OTel attributes set on MCP spans. */
24
26
  export interface McpSpanAttributes {
25
27
  readonly "gen_ai.operation.name": string;
28
+ readonly "mcp.method.name": string;
26
29
  readonly "gen_ai.tool.name"?: string;
27
30
  readonly "gen_ai.tool.call.id"?: string;
28
31
  readonly "gen_ai.data_source.id"?: string;
29
32
  readonly "gen_ai.prompt.name"?: string;
30
33
  readonly "mcp.server.name": string;
31
34
  readonly "mcp.server.version": string;
32
- readonly "mcp.transport"?: "stdio" | "sse" | "streamable-http";
33
35
  readonly "mcp.session.id"?: string;
36
+ readonly "mcp.protocol.version"?: string;
37
+ readonly "network.transport"?: "pipe" | "tcp";
38
+ readonly "rpc.response.status_code"?: number;
34
39
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toad-eye",
3
- "version": "2.6.0",
4
- "description": "Auto-instrumentation, cost tracking, budget guards, and observability for LLM services",
3
+ "version": "2.7.0",
4
+ "description": "Observability for MCP servers and LLM applications — traces, metrics, dashboards. One-line instrumentation for OpenAI, Anthropic, Gemini, and MCP. Self-hosted with Grafana + Prometheus + Jaeger.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -49,7 +49,7 @@
49
49
  "build": "tsc -p tsconfig.build.json",
50
50
  "test": "vitest run",
51
51
  "test:watch": "vitest",
52
- "prepublishOnly": "npm run build"
52
+ "prepublishOnly": "test -s README.md || { echo '❌ README.md is missing or empty'; exit 1; } && npm run build"
53
53
  },
54
54
  "repository": {
55
55
  "type": "git",
@@ -58,6 +58,11 @@
58
58
  "homepage": "https://github.com/vola-trebla/toad-eye#readme",
59
59
  "author": "albertalov",
60
60
  "keywords": [
61
+ "mcp",
62
+ "model-context-protocol",
63
+ "mcp-server",
64
+ "mcp-observability",
65
+ "mcp-monitoring",
61
66
  "opentelemetry",
62
67
  "llm",
63
68
  "instrumentation",
@@ -71,9 +76,13 @@
71
76
  "cost-tracking",
72
77
  "auto-instrumentation",
73
78
  "gen-ai",
79
+ "ai-agent",
80
+ "tool-calling",
74
81
  "budget-guards",
75
82
  "finops",
76
- "streaming"
83
+ "streaming",
84
+ "jaeger",
85
+ "prometheus"
77
86
  ],
78
87
  "license": "ISC",
79
88
  "dependencies": {
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "targets": [
25
25
  {
26
- "expr": "sum(rate(gen_ai_mcp_tool_calls_total[5m]))",
26
+ "expr": "sum(rate(gen_ai_mcp_tool_calls_total{gen_ai_tool_name=~\"$tool_name\"}[5m]))",
27
27
  "refId": "A"
28
28
  }
29
29
  ],
@@ -66,7 +66,7 @@
66
66
  },
67
67
  "targets": [
68
68
  {
69
- "expr": "sum(rate(gen_ai_mcp_tool_duration_sum[5m])) / sum(rate(gen_ai_mcp_tool_duration_count[5m])) or vector(0)",
69
+ "expr": "sum(rate(gen_ai_mcp_tool_duration_sum{gen_ai_tool_name=~\"$tool_name\"}[5m])) / sum(rate(gen_ai_mcp_tool_duration_count{gen_ai_tool_name=~\"$tool_name\"}[5m])) or vector(0)",
70
70
  "refId": "A"
71
71
  }
72
72
  ],
@@ -109,7 +109,7 @@
109
109
  },
110
110
  "targets": [
111
111
  {
112
- "expr": "sum(rate(gen_ai_mcp_tool_errors_total[5m])) / sum(rate(gen_ai_mcp_tool_calls_total[5m])) or vector(0)",
112
+ "expr": "sum(rate(gen_ai_mcp_tool_errors_total{gen_ai_tool_name=~\"$tool_name\"}[5m])) / sum(rate(gen_ai_mcp_tool_calls_total{gen_ai_tool_name=~\"$tool_name\"}[5m])) or vector(0)",
113
113
  "refId": "A"
114
114
  }
115
115
  ],
@@ -187,7 +187,7 @@
187
187
  },
188
188
  "targets": [
189
189
  {
190
- "expr": "sum by (gen_ai_tool_name) (rate(gen_ai_mcp_tool_calls_total[5m]))",
190
+ "expr": "sum by (gen_ai_tool_name) (rate(gen_ai_mcp_tool_calls_total{gen_ai_tool_name=~\"$tool_name\"}[5m]))",
191
191
  "legendFormat": "{{gen_ai_tool_name}}",
192
192
  "refId": "A"
193
193
  }
@@ -231,12 +231,12 @@
231
231
  },
232
232
  "targets": [
233
233
  {
234
- "expr": "histogram_quantile(0.50, sum by (gen_ai_tool_name, le) (rate(gen_ai_mcp_tool_duration_bucket[5m])))",
234
+ "expr": "histogram_quantile(0.50, sum by (gen_ai_tool_name, le) (rate(gen_ai_mcp_tool_duration_bucket{gen_ai_tool_name=~\"$tool_name\"}[5m])))",
235
235
  "legendFormat": "p50 {{gen_ai_tool_name}}",
236
236
  "refId": "A"
237
237
  },
238
238
  {
239
- "expr": "histogram_quantile(0.95, sum by (gen_ai_tool_name, le) (rate(gen_ai_mcp_tool_duration_bucket[5m])))",
239
+ "expr": "histogram_quantile(0.95, sum by (gen_ai_tool_name, le) (rate(gen_ai_mcp_tool_duration_bucket{gen_ai_tool_name=~\"$tool_name\"}[5m])))",
240
240
  "legendFormat": "p95 {{gen_ai_tool_name}}",
241
241
  "refId": "B"
242
242
  }
@@ -280,7 +280,7 @@
280
280
  },
281
281
  "targets": [
282
282
  {
283
- "expr": "sum by (gen_ai_tool_name, error_type) (rate(gen_ai_mcp_tool_errors_total[5m]))",
283
+ "expr": "sum by (gen_ai_tool_name, error_type) (rate(gen_ai_mcp_tool_errors_total{gen_ai_tool_name=~\"$tool_name\"}[5m]))",
284
284
  "legendFormat": "{{gen_ai_tool_name}} ({{error_type}})",
285
285
  "refId": "A"
286
286
  }
@@ -371,28 +371,28 @@
371
371
  },
372
372
  "targets": [
373
373
  {
374
- "expr": "sum by (gen_ai_tool_name) (rate(gen_ai_mcp_tool_calls_total[5m]))",
374
+ "expr": "sum by (gen_ai_tool_name) (rate(gen_ai_mcp_tool_calls_total{gen_ai_tool_name=~\"$tool_name\"}[5m]))",
375
375
  "legendFormat": "",
376
376
  "refId": "calls",
377
377
  "format": "table",
378
378
  "instant": true
379
379
  },
380
380
  {
381
- "expr": "sum by (gen_ai_tool_name) (rate(gen_ai_mcp_tool_duration_sum[5m])) / sum by (gen_ai_tool_name) (rate(gen_ai_mcp_tool_duration_count[5m]))",
381
+ "expr": "sum by (gen_ai_tool_name) (rate(gen_ai_mcp_tool_duration_sum{gen_ai_tool_name=~\"$tool_name\"}[5m])) / sum by (gen_ai_tool_name) (rate(gen_ai_mcp_tool_duration_count{gen_ai_tool_name=~\"$tool_name\"}[5m]))",
382
382
  "legendFormat": "",
383
383
  "refId": "avg_duration",
384
384
  "format": "table",
385
385
  "instant": true
386
386
  },
387
387
  {
388
- "expr": "histogram_quantile(0.95, sum by (gen_ai_tool_name, le) (rate(gen_ai_mcp_tool_duration_bucket[5m])))",
388
+ "expr": "histogram_quantile(0.95, sum by (gen_ai_tool_name, le) (rate(gen_ai_mcp_tool_duration_bucket{gen_ai_tool_name=~\"$tool_name\"}[5m])))",
389
389
  "legendFormat": "",
390
390
  "refId": "p95_duration",
391
391
  "format": "table",
392
392
  "instant": true
393
393
  },
394
394
  {
395
- "expr": "sum by (gen_ai_tool_name) (rate(gen_ai_mcp_tool_errors_total[5m])) / sum by (gen_ai_tool_name) (rate(gen_ai_mcp_tool_calls_total[5m]))",
395
+ "expr": "sum by (gen_ai_tool_name) (rate(gen_ai_mcp_tool_errors_total{gen_ai_tool_name=~\"$tool_name\"}[5m])) / sum by (gen_ai_tool_name) (rate(gen_ai_mcp_tool_calls_total{gen_ai_tool_name=~\"$tool_name\"}[5m]))",
396
396
  "legendFormat": "",
397
397
  "refId": "error_rate",
398
398
  "format": "table",
@@ -505,7 +505,46 @@
505
505
  "schemaVersion": 39,
506
506
  "tags": ["toad-eye", "mcp", "llm"],
507
507
  "templating": {
508
- "list": []
508
+ "list": [
509
+ {
510
+ "name": "mcp_method",
511
+ "label": "MCP Method",
512
+ "type": "query",
513
+ "datasource": {
514
+ "type": "prometheus",
515
+ "uid": "prometheus"
516
+ },
517
+ "query": "label_values(gen_ai_mcp_tool_calls_total, mcp_method_name)",
518
+ "refresh": 2,
519
+ "includeAll": true,
520
+ "allValue": ".*",
521
+ "current": {
522
+ "text": "All",
523
+ "value": "$__all"
524
+ },
525
+ "multi": true,
526
+ "sort": 1
527
+ },
528
+ {
529
+ "name": "tool_name",
530
+ "label": "Tool",
531
+ "type": "query",
532
+ "datasource": {
533
+ "type": "prometheus",
534
+ "uid": "prometheus"
535
+ },
536
+ "query": "label_values(gen_ai_mcp_tool_calls_total, gen_ai_tool_name)",
537
+ "refresh": 2,
538
+ "includeAll": true,
539
+ "allValue": ".*",
540
+ "current": {
541
+ "text": "All",
542
+ "value": "$__all"
543
+ },
544
+ "multi": true,
545
+ "sort": 1
546
+ }
547
+ ]
509
548
  },
510
549
  "time": {
511
550
  "from": "now-1h",
@@ -31,7 +31,7 @@ processors:
31
31
  sampling_percentage: 100
32
32
 
33
33
  exporters:
34
- otlp_http/jaeger:
34
+ otlphttp/jaeger:
35
35
  endpoint: http://jaeger:4318
36
36
  tls:
37
37
  insecure: true
@@ -44,7 +44,7 @@ service:
44
44
  traces:
45
45
  receivers: [otlp]
46
46
  processors: [batch]
47
- exporters: [otlp_http/jaeger]
47
+ exporters: [otlphttp/jaeger]
48
48
  metrics:
49
49
  receivers: [otlp]
50
50
  processors: [batch]