Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4

# Node 24 is the Active LTS. The 2.0 MCP SDK packages require >=20, but
# Node 20 reached end of life in April 2026.
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
node-version: "24"

- name: Set up Python
uses: actions/setup-python@v5
Expand Down
30 changes: 23 additions & 7 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
# MCP Quickstart Smoke Tests

This directory contains smoke tests for the MCP quickstart examples. These tests verify that all example servers and clients can start and respond correctly, without calling external APIs.
This directory contains smoke tests for the MCP quickstart examples. These tests verify that all example servers and clients can start and respond correctly.

## Overview

The smoke tests verify:

- **Servers**: Each weather server (Python, TypeScript, Rust) can start and respond to MCP protocol requests
- **Clients**: Each MCP client (Python, TypeScript) can connect to a mock server and list tools
- **Servers**: Each weather server (Python, TypeScript, Rust, Go) can start, respond to MCP protocol requests, and honour the output schemas it advertises
- **Clients**: Each MCP client (Python, TypeScript, Go, Rust) can connect to a mock server and list tools

The Ruby examples are not covered: the `mcp` gem cannot negotiate protocol revision `2026-07-28`.
Comment on lines +9 to +12

## Structured output

Listing tools is not enough to catch a broken structured result, so each server test also **calls** every tool that declares an `outputSchema` and checks the answer:

- the result must carry `structuredContent`, and it must conform to the declared schema (the SDK validates this and throws on a mismatch);
- a tool with an array-rooted schema must return a **top-level JSON array**, and one with an object-rooted schema must return an object.

The array case is the one worth guarding. A server that advertises `{"type": "array"}` and then answers `{"result": [...]}` passes a tools/list-only test and fails this one.

Tool calls reach the live NWS API. When it is unreachable the tools return an error result, which the test reports as a skip rather than a failure — someone else's outage should not fail the build.

## Running Tests

Expand All @@ -23,17 +36,18 @@ The smoke tests verify:
- **uv** (Python package manager)
- **Rust** stable
- **Cargo** (for Rust builds)
- **Go** 1.25+

## How It Works

### Server Tests

Each server test:

1. Builds/prepares the server if needed
1. Builds/prepares the server if needed (a failed build prints its compiler output rather than swallowing it)
2. Uses `mcp-test-client.ts` to connect to the server via stdio
3. Sends MCP initialize and `tools/list` requests
4. Verifies the server responds with a valid tool list
3. Negotiates a protocol era with `mode: "auto"` — one `server/discover` probe, falling back to the `2025-11-25` `initialize` handshake
4. Lists tools, then calls each tool that declares an `outputSchema` and checks the structured result against it
5. Reports pass/fail

### Client Tests
Expand Down Expand Up @@ -68,7 +82,9 @@ node tests/helpers/build/mcp-test-client.js python weather.py

### mock-mcp-server.ts

A minimal MCP server that verifies clients call the `tools/list` method and returns an empty tool list. Used to test clients without requiring a real weather server. Exits with an error if the client doesn't call `tools/list`.
A minimal MCP server that verifies clients call the `tools/list` method. Used to test clients without requiring a real weather server. Exits with an error if the client doesn't call `tools/list`.

It advertises two tools whose output schemas cover both shapes a structured result can take: an object root, and an array root. The array-rooted one is deliberate — a client that compiles every declared `outputSchema` up front, as the Go and Rust quickstart clients do, will fail here if it assumes an output schema is always `{"type": "object"}`.

**Usage**:

Expand Down
106 changes: 89 additions & 17 deletions tests/helpers/mcp-test-client.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,112 @@
#!/usr/bin/env node
/**
* Minimal MCP Test Client for testing servers
* Connects to a server, initializes, and lists tools
*
* Connects to a server, initializes, lists tools, and then checks the tools
* actually honour what they advertise:
*
* - every tool that declares an `outputSchema` is called, and the result must
* carry `structuredContent` (the SDK validates it against the schema for us
* and throws on a mismatch);
* - a tool whose `outputSchema` is array-rooted must answer with a top-level
* JSON array, not an object wrapping one.
*
* The second check is the point of the exercise. Listing tools alone would not
* notice a server that advertises `{"type": "array"}` and then returns
* `{"result": [...]}`.
*/

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";

/** Arguments to call a tool with, chosen by matching its name. */
const TOOL_ARGUMENTS: { match: RegExp; args: Record<string, unknown> }[] = [
{ match: /alert/i, args: { state: "CA" } },
{ match: /forecast/i, args: { latitude: 38.5816, longitude: -121.4944 } },
];

function argumentsFor(name: string): Record<string, unknown> | undefined {
return TOOL_ARGUMENTS.find(({ match }) => match.test(name))?.args;
}

/** The root `type` of a JSON Schema, when it declares a single one. */
function rootType(schema: unknown): string | undefined {
if (typeof schema !== "object" || schema === null) return undefined;
const type = (schema as { type?: unknown }).type;
return typeof type === "string" ? type : undefined;
}

async function testServer(command: string, args: string[]) {
console.error(`Testing server: ${command} ${args.join(" ")}`);

const transport = new StdioClientTransport({
command,
args,
});
const transport = new StdioClientTransport({ command, args });

// `auto` probes for 2026-07-28 and falls back to the 2025-11-25 handshake, so
// this one helper tests servers of either era.
const client = new Client(
{
name: "mcp-test-client",
version: "1.0.0",
},
{
capabilities: {},
}
{ name: "mcp-test-client", version: "1.0.0" },
{ capabilities: {}, versionNegotiation: { mode: "auto" } },
);

try {
// Connect to server
await client.connect(transport);
console.error("✓ Connected to server");

// List tools
const { tools } = await client.listTools();
console.error(`✓ Listed ${tools.length} tools`);

// Success
let checked = 0;
for (const tool of tools) {
if (!tool.outputSchema) continue;

const toolArgs = argumentsFor(tool.name);
if (!toolArgs) {
console.error(` - ${tool.name}: no known arguments, skipping call`);
continue;
}

// A throw here is the SDK rejecting the result against `outputSchema`.
const result = await client.callTool({
name: tool.name,
arguments: toolArgs,
});

// Upstream (api.weather.gov) being unreachable surfaces as an error
// result, which is a legitimate answer and carries no structured data.
// Skip rather than fail the build on someone else's outage.
if (result.isError) {
console.error(` - ${tool.name}: returned an error result, skipping`);
continue;
}

if (result.structuredContent === undefined) {
throw new Error(
`${tool.name} declares an output schema but returned no structuredContent`,
);
}

const expected = rootType(tool.outputSchema);
const isArray = Array.isArray(result.structuredContent);

if (expected === "array" && !isArray) {
throw new Error(
`${tool.name} declares an array-rooted output schema but returned ` +
`${JSON.stringify(result.structuredContent).slice(0, 80)}`,
);
}
if (expected === "object" && isArray) {
throw new Error(
`${tool.name} declares an object-rooted output schema but returned an array`,
);
}

console.error(
`✓ ${tool.name}: structuredContent is ${isArray ? "a top-level array" : "an object"}, matching its schema`,
);
checked += 1;
}

console.error(`✓ Verified structured output for ${checked} tools`);
console.error("✓ Server test passed");
await client.close();
process.exit(0);
Comment on lines 111 to 112
Expand Down
125 changes: 98 additions & 27 deletions tests/helpers/mock-mcp-server.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,120 @@
#!/usr/bin/env node
/**
* Mock MCP Server for testing clients
* Verifies that clients call the tools/list method and returns an empty tool list
*
* Verifies that clients call `tools/list`, and advertises tools whose output
* schemas cover both shapes a structured result can take: an object root, and
* an array root (allowed as of protocol revision 2026-07-28).
*
* The array-rooted tool is deliberate. Clients that compile every declared
* `outputSchema` up front — as the Go and Rust quickstart clients do — will
* fail here if they assume an output schema is always `{"type": "object"}`.
*
* This uses the low-level `Server` rather than `McpServer` so the schemas are
* written out literally, which is the point of a mock: what goes on the wire is
* exactly what is in this file.
*/

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import type { Tool } from "@modelcontextprotocol/server";
import { Server } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";

const server = new McpServer(
// Track whether tools/list was called
let toolsListCalled = false;

const TOOLS: Tool[] = [
{
name: "mock-test-server",
version: "1.0.0",
name: "get_alerts",
description: "Mock alerts, returning a top-level array",
inputSchema: {
type: "object",
properties: { state: { type: "string" } },
required: ["state"],
},
// Array root: legal as of 2026-07-28, rejected by older revisions.
outputSchema: {
type: "array",
items: {
type: "object",
properties: { event: { type: "string" }, area: { type: "string" } },
required: ["event", "area"],
},
},
},
{
capabilities: {
tools: {},
name: "get_forecast",
description: "Mock forecast, returning an object",
inputSchema: {
type: "object",
properties: {
latitude: { type: "number" },
longitude: { type: "number" },
},
required: ["latitude", "longitude"],
},
}
);
outputSchema: {
type: "object",
properties: {
latitude: { type: "number" },
longitude: { type: "number" },
summary: { type: "string" },
},
required: ["latitude", "longitude", "summary"],
},
},
];

// Track whether tools/list was called
let toolsListCalled = false;
function buildServer(): Server {
const server = new Server(
{ name: "mock-test-server", version: "1.0.0" },
{ capabilities: { tools: {} } },
);

// Override the default tools/list handler to track calls
server.server.setRequestHandler(ListToolsRequestSchema, async () => {
toolsListCalled = true;
return { tools: [] };
});
server.setRequestHandler("tools/list", async () => {
toolsListCalled = true;
return { tools: TOOLS };
});

server.setRequestHandler("tools/call", async (request) => {
const { name, arguments: args = {} } = request.params;

if (name === "get_alerts") {
const state = String((args as { state?: unknown }).state ?? "??");
const alerts = [{ event: "Mock Warning", area: state }];
return {
content: [{ type: "text", text: `1 alert for ${state}` }],
structuredContent: alerts,
};
}

if (name === "get_forecast") {
const { latitude = 0, longitude = 0 } = args as {
latitude?: number;
longitude?: number;
};
const forecast = { latitude, longitude, summary: "Mock conditions" };
return {
content: [{ type: "text", text: forecast.summary }],
structuredContent: forecast,
};
}

async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Mock MCP Server running on stdio");
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true,
};
});

return server;
}

serveStdio(buildServer);
console.error("Mock MCP Server running on stdio");

// Verify that tools/list was called when the connection closes
process.stdin.on("end", () => {
if (!toolsListCalled) {
console.error("Error: Client did not call tools/list");
process.exit(1);
}
});

main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});
Loading
Loading