Skip to content

Telemetry: argument-schema rejections emit nothing, so bad tool calls are invisible #191

Description

@vzegnameta

Telemetry: argument-schema rejections emit nothing, so bad tool calls are invisible

The ask

Please record a tool call that fails input-schema validation. Today it produces no
span, no metric, and no log record, so it is not counted as a failure and not counted as
a call at all.

The category already exists in the vocabulary you shipped in v1.7.4 (invalid_argument).
This is only about the events reaching it.

Current behavior (v1.7.4)

docs/observability.md says, twice:

Instrumentation is automatic — every tool call emits a span, metrics, and a log record.

Each tool call produces a span, three metric updates, and one log record.

That holds for every call that reaches a handler. It does not hold for a call the MCP SDK
rejects first.

Every tool declares a z.strictObject input schema (src/server.ts:113 and the
equivalent in each registerTool block). The SDK validates against it and returns an
error result itself, without invoking the registered handler. withTelemetry
(src/telemetry/local.ts:149) wraps the handler, so on a rejected argument nothing in
src/telemetry/ runs:

  • no tool/<name> span
  • no tool.calls increment, so the denominator is wrong too
  • no tool.errors increment
  • no log record
  • no local JSONL ToolEvent

The repository's own test suite documents that this path exists and is reachable.
src/server.test.ts:104-110 defines:

const refusalFor = async (name: string, args: Record<string, unknown>): Promise<string> => {
  const result = (await client.callTool({ name, arguments: args })) as ToolResult;
  expect(result.isError, `${name} accepted ${JSON.stringify(args)}`).toBe(true);
  return result.content?.[0]?.text ?? "";
};

used by the case named "refuses a misspelled optional argument instead of running the
default"
. isError cannot have come from a handler: formatResult (src/server.ts:51-53)
is the only result builder in the file and it never sets isError. So the refusal is
produced outside the wrapped handler, which is exactly why telemetry does not see it.

Reproducing

With any OTLP endpoint configured, call a tool with a misspelled optional argument, for
example list_designs with searchPath instead of path (the case the test above
covers). The client receives isError: true. The backend receives nothing.

Why it matters

"The agent is calling this tool with the wrong arguments" is one of the most actionable
things per-tool telemetry can tell a server author, because the fix is usually a tool
description or a schema change rather than a code change. Right now that class of
failure reports a clean sheet.

It also skews the numbers that are reported. Since tool.calls never increments,
a tool whose arguments are frequently rejected looks like a tool with low usage and a
perfect success rate. Success rate is computed over the calls that got through, so the
worse the schema mismatch, the healthier the tool looks.

This is not specific to any one backend or deployment. Any server built on
registerTool plus a zod schema has the same blind spot.

Acceptance criteria

  • A call rejected by input-schema validation produces the same telemetry shape as any
    other failed call: span, tool.calls, tool.errors, and a log record
  • tool.outcome is error and error.type is invalid_argument
  • error.message carries the validation failure, so the offending argument is
    identifiable
  • tool.calls counts it, so success rate is computed over all attempts
  • docs/observability.md states which calls are instrumented, so the "every tool
    call" claim stays true

Deliberately left to you

  • Where to hook it. Wrapping at the server or transport level, a registerTool
    wrapper that validates before delegating, or moving validation inside the instrumented
    handler are all reasonable and have different trade-offs. I have no view on which.
  • Whether a rejected call belongs in tool.duration. There is a real argument that a
    validation failure has no meaningful duration and would distort the histogram.
  • Whether to distinguish it from a handler-raised invalid_argument, for instance
    with a separate error.class value. Grouping them is defensible; so is separating them.
  • Whether the local JSONL ToolEvent should also record it, or whether that file is
    deliberately handler-scoped.

Not asking for

To be explicit, since this follows two earlier telemetry issues: no new vocabulary, no
change to the eight categories from v1.7.4, and nothing shaped around a particular
backend. The v1.7.4 taxonomy is a good fit as it stands and we have adapted our side to
it rather than the other way round.

Happy to open a PR if that's easier than specifying it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions