Skip to content
Merged
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
9 changes: 8 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 All @@ -34,5 +36,10 @@ jobs:
with:
toolchain: stable

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25"

- name: Run smoke tests
run: ./tests/smoke-test.sh
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ Gemfile.lock
# Environment variables file
.env

# Go-generated binaries
# `smoke-test.sh` builds the Go examples as `server`; `go build` with no -o
# names the binary after its directory.
*.exe
/weather-server-go/server
/weather-server-go/weather-server-go
/mcp-client-go/mcp-client-go

# Rust-generated files
debug
target
Expand Down
34 changes: 25 additions & 9 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**: The Python and TypeScript MCP clients can connect to a mock server and list tools

The Go and Rust clients are not covered here: on `main` both abort when no `.env` file is present, so they cannot be driven without credentials. Making them start credential-free is a change in their own directories, so their coverage lands with those changes rather than here. The Ruby examples are not covered either — the `mcp` gem cannot negotiate protocol revision `2026-07-28`.

## Structured content

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 @@ -17,23 +30,24 @@ The smoke tests verify:

## Requirements

- **Node.js** 16+
- **Node.js** 20+ (required by the 2.0 MCP SDK packages)
- **npm** (for Node.js dependencies)
- **Python** 3.10+
- **uv** (Python package manager)
- **Rust** stable
- **Cargo** (for Rust builds)
- **Go** 1.25+
Comment thread
olaservo marked this conversation as resolved.

## 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 All @@ -91,7 +107,7 @@ Install required dependencies:
curl -LsSf https://astral.sh/uv/install.sh | sh

# Node.js (via nvm)
nvm install 18
nvm install 24

# Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Expand Down
109 changes: 92 additions & 17 deletions tests/helpers/mcp-test-client.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,120 @@
#!/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 content for ${checked} tools`);
console.error("✓ Server test passed");
await client.close();
process.exit(0);
Comment thread
olaservo marked this conversation as resolved.
} catch (error) {
console.error(`✗ Server test failed: ${error}`);
// Close on the way out too, or the spawned server outlives this process and
// a failing test hangs instead of reporting.
await client.close().catch(() => {});
process.exit(1);
}
}
Expand Down
Loading
Loading