Skip to content

v2: add guard methods#1842

Open
KKonstantinov wants to merge 1 commit intomainfrom
fix/type-guard-methods
Open

v2: add guard methods#1842
KKonstantinov wants to merge 1 commit intomainfrom
fix/type-guard-methods

Conversation

@KKonstantinov
Copy link
Copy Markdown
Contributor

Add isJSONRPCResponse and isCallToolResult type guard methods to the public API.

Motivation and Context

Two gaps in the v2 public API were identified during migration testing:

  1. isJSONRPCResponse — v1 had a deprecated isJSONRPCResponse that was a misnamed alias for result-only checking. v2 dropped it but didn't replace it. Users who need to check whether a value is any JSON-RPC response (result or error) had to combine two guards: isJSONRPCResultResponse(v) || isJSONRPCErrorResponse(v). The new isJSONRPCResponse guard validates against the union JSONRPCResponseSchema directly, with corrected semantics.

  2. isCallToolResult — In v1, users could import CallToolResultSchema and call .safeParse() to validate tool call results at runtime. In v2 the Zod schemas are no longer part of the public API, leaving no way to perform this validation. The new isCallToolResult guard fills this gap.

How Has This Been Tested?

New test file packages/core/src/types/guards.test.ts with tests covering:

  • Valid result and error responses for isJSONRPCResponse
  • Rejection of requests, notifications, and arbitrary values
  • Type narrowing behavior
  • Equivalence with isJSONRPCResultResponse || isJSONRPCErrorResponse across a range of inputs
  • Valid CallToolResult shapes (minimal, with content, with isError, with structuredContent)
  • Rejection of non-objects and invalid content items

All 488 tests pass, typecheck and lint are clean.

Breaking Changes

None. This is a purely additive change.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Files changed:

  • packages/core/src/types/guards.ts — Added isJSONRPCResponse and isCallToolResult type guards with JSDoc
  • packages/core/src/exports/public/index.ts — Exported both new guards
  • packages/core/src/types/guards.test.ts — New test file
  • docs/migration.md — Added note clarifying v1's deprecated isJSONRPCResponse vs v2's new one; added isCallToolResult migration example
  • docs/migration-SKILL.md — Updated rename table, corrected claim about schema exports, added isCallToolResult mapping table

@KKonstantinov KKonstantinov requested a review from a team as a code owner April 1, 2026 18:24
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 1, 2026

⚠️ No Changeset found

Latest commit: c10f4c0

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 1, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1842

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1842

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1842

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1842

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1842

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1842

commit: c10f4c0

Comment on lines +75 to 81
* @param value - The value to check.
*
* @returns True if the value is a valid {@linkcode CallToolResult}, false otherwise.
*/
export const isCallToolResult = (value: unknown): value is CallToolResult => CallToolResultSchema.safeParse(value).success;

/**
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The isCallToolResult guard has unsound type narrowing: it returns true for {} because CallToolResultSchema.content uses .default([]), so Zod fills in content during parsing but the guard narrows the original input value not the parsed output. After the guard passes, TypeScript believes value.content is ContentBlock[] while at runtime it is undefined, causing TypeError on any .map() or .length access with no TypeScript warning. Fix by using z.input for the narrowed type, or making content optional in the type predicate signature.

Extended reasoning...

What the bug is and how it manifests

isCallToolResult is implemented as CallToolResultSchema.safeParse(value).success. The CallToolResult TypeScript type is Flatten<z.infer>, which uses Zod output type inference. Because CallToolResultSchema.content is defined as z.array(ContentBlockSchema).default([]), the output type has content: ContentBlock[] as non-optional, since the default guarantees it is always present in the parsed result. The guard therefore declares value is CallToolResult, asserting content is ContentBlock[].

The specific code path that triggers it

When value = {} is passed: (1) CallToolResultSchema.safeParse({}) returns { success: true, data: { content: [] } } -- the default is applied to the parsed output object, not to the original value. (2) The guard returns true. (3) TypeScript narrows value to CallToolResult, reporting value.content as ContentBlock[]. (4) At runtime, value is still {}, so value.content is undefined.

Why existing code does not prevent it

The test file documents this behavior with it('returns true for a minimal valid result', () => { expect(isCallToolResult({})).toBe(true); }). The test passes, but it does not verify what TypeScript infers about value.content after the guard. The other guards in this file (isJSONRPCResultResponse, isJSONRPCErrorResponse) do not share this problem because their schemas do not have fields with .default() on non-optional properties -- their required fields must be present in the input.

Impact

Any code that accesses value.content after the guard without a null-check will throw a TypeError at runtime despite TypeScript reporting no type error. This is a silent regression that violates TypeScript type safety. Example: if (isCallToolResult(result)) { result.content.forEach(block => console.log(block)); } throws TypeError: Cannot read properties of undefined.

Step-by-step proof

  1. const value: unknown = {}. 2. isCallToolResult(value) returns true (Zod applies content:[] default in parsed output). 3. TypeScript narrows value to CallToolResult, so value.content is typed as ContentBlock[]. 4. value.content.length -- TypeScript: no error. Runtime: TypeError: Cannot read properties of undefined (reading 'length').

How to fix

Option 1 (preferred): Change the type predicate to use the Zod INPUT type so content is optional in the narrowed type: export const isCallToolResult = (value: unknown): value is z.input => CallToolResultSchema.safeParse(value).success. Option 2: Return the safeParsed data output to callers instead of narrowing the original value. Option 3: Annotate the narrowed type with content?: ContentBlock[] to reflect that the original input may not have the field.

Comment on lines +449 to 458
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
if (CallToolResultSchema.safeParse(value).success) { /* ... */ }

// v2: use the type guard
import { isCallToolResult } from '@modelcontextprotocol/server';
if (isCallToolResult(value)) { /* ... */ }
```

### Client list methods return empty results for missing capabilities

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The isCallToolResult migration example in docs/migration.md (line 453) imports from @modelcontextprotocol/server, but the surrounding context is migrating client.callTool() usage -- a purely client-side concern. Since isCallToolResult is re-exported by both packages, a client-only user following this example would add an unnecessary server dependency. The import should use @modelcontextprotocol/client to match the client-focused context.

Extended reasoning...

What the bug is and how it manifests

In docs/migration.md, the code example for the isCallToolResult migration (lines 449-458) shows import { isCallToolResult } from '@modelcontextprotocol/server'. This example appears directly within the section documenting migration of client.callTool() -- a client-side API. The import recommends the server package to client-only users.

The specific code path that triggers it

The problematic example sits in the section titled 'Protocol.request(), ctx.mcpReq.send(), and Client.callTool() no longer take a schema parameter'. The preceding examples all show client.callTool() usage. The isCallToolResult guard is presented as the replacement for CallToolResultSchema.safeParse() when validating results returned by client.callTool() -- definitively a client-side operation.

Why existing code does not prevent it

isCallToolResult lives in @modelcontextprotocol/core/public/index.ts and is re-exported by BOTH packages/client/src/index.ts and packages/server/src/index.ts via export * from '@modelcontextprotocol/core/public'. Neither package exclusively owns the symbol, so there is no compile-time or lint-time signal that the wrong package is being cited in the docs.

What the impact would be

A developer who only has @modelcontextprotocol/client installed (the common case for tool-call consumers) will either (a) add an unnecessary @modelcontextprotocol/server dependency that brings in transitive server-side code, or (b) be confused when they cannot find the import in their existing dependencies. The docs/migration-SKILL.md companion document does not include a concrete import path for isCallToolResult, so migration.md is the primary source a migrating user will rely on.

How to fix it

Change line 453 from import { isCallToolResult } from '@modelcontextprotocol/server' to import { isCallToolResult } from '@modelcontextprotocol/client'. This aligns with the client-side context of the surrounding client.callTool() examples and with section 3 of the same document, which already advises importing from whichever package you depend on.

Step-by-step proof

  1. Developer has @modelcontextprotocol/client installed; they do NOT have @modelcontextprotocol/server.
  2. They migrate their client.callTool() code and reach the isCallToolResult example in migration.md.
  3. They copy import { isCallToolResult } from '@modelcontextprotocol/server' verbatim.
  4. Their bundler/TypeScript compiler reports an unresolved module error for @modelcontextprotocol/server.
  5. To silence the error, they run npm install @modelcontextprotocol/server -- an entirely unnecessary dependency for a client-only project.
  6. Alternatively, they waste time searching for why @modelcontextprotocol/client does not export isCallToolResult (it does -- the doc just points them to the wrong package).

Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isJSONRPCResponse looks right.

isCallToolResult I think is missing something: CallToolResultSchema.content has .default([]) (schemas.ts:1374), so isCallToolResult({}) returns true but the original {} still has no .contentv.content.map(...) throws after the guard passes. Test at line 82 locks this in. The spec has content required.

Two ways to fix:

Option 1 — guard-level check (probably the right scope here):

export function isCallToolResult(v: unknown): v is CallToolResult {
    if (typeof v !== 'object' || v === null || !('content' in v)) return false;
    return CallToolResultSchema.safeParse(v).success;
}

Plus flip the test: expect(isCallToolResult({})).toBe(false).

Option 2 — drop .default([]) from the schema:

// schemas.ts:1374
content: z.array(ContentBlockSchema),  // was: .default([])

Aligns Zod/TS/spec, fixes the guard for free. The .default([]) predates v2 (carried over from v1's types.ts when #1680 reorganized exports), so it's not a recent design decision we'd be unwinding. Would force tool authors returning only structuredContent to write content: [] explicitly. Maybe a follow-up if we decide that's fine; Option 1 is the safer scope for this PR.

Nit: migration.md example imports from /server in a client context, probably want /client.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants