quickstart-resources/tests/helpers/mcp-test-client.ts
Ola Hungerford f587167452
Assert on structured output in the smoke tests and update to v2 (#163)
* Assert on structured output in the smoke tests

The smoke tests connected to each server, listed its tools and stopped there,
so they could not catch a broken structured result.

mcp-test-client now calls every tool that declares an outputSchema and checks
the answer against it: the result must carry structuredContent, and a tool
with an array-rooted schema must return a top-level JSON array rather than an
object wrapping one. Tools with no output schema are left alone, so this is
not a new requirement on any example. The client negotiates with mode "auto",
so it exercises servers of either protocol era. Tool calls reach the live NWS
API; an error result is reported as a skip rather than a failure.

mock-mcp-server moves to the low-level Server and advertises one array-rooted
and one object-rooted tool, with the schemas written out literally. A client
that compiles every declared outputSchema at connect time fails against the
array-rooted one if it assumes output schemas are always objects.

utils.sh no longer swallows build output. A failed build left no binary behind
and the test reported a downstream spawn ENOENT instead of the compiler error,
which is what makes a broken build hard to diagnose from CI logs; #143 is the
current example. check_dependency returns instead of exiting, so a missing
toolchain fails its own test and the suite still prints its summary. Binaries
resolve through a helper that accounts for the .exe suffix.

The test helpers move to the 2.0 MCP SDK packages, where the 2026-07-28
support lives; the examples themselves are untouched. Those require Node 20 or
newer, so CI moves off Node 18 to Node 24, the Active LTS - Node 20 reached
end of life in April 2026.

The Go examples and the Rust client stay uncovered here. Both clients abort
when no .env file is present, so that coverage lands with those examples.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Update to the stable 2.0 SDK packages and address review feedback

@modelcontextprotocol/{client,server} 2.0.0 shipped on 2026-07-27, so the
helpers move off 2.0.0-beta.5.

Review fixes:
- strip the UTF-8 BOM that preceded smoke-test.sh's shebang
- ensure_built and ensure_helpers_built return rather than exit, so one
  project failing to build no longer aborts the suite before the summary
- engines.node and the documented Node requirement go to 20, matching what
  the 2.0 SDK packages actually require
- close the client on the failure path, so a failing test cannot leave the
  spawned server running
- the README claimed Go and Rust client coverage that did not exist

Adds the Go weather server to the smoke test, with the Go toolchain CI was
missing. The Go and Rust clients stay uncovered: on main both abort without
a .env, so credential-free startup has to land in their own directories.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:42:57 +02:00

136 lines
4.7 KiB
JavaScript

#!/usr/bin/env node
/**
* Minimal MCP Test Client for testing servers
*
* 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/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 });
// `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: {}, versionNegotiation: { mode: "auto" } },
);
try {
await client.connect(transport);
console.error("✓ Connected to server");
const { tools } = await client.listTools();
console.error(`✓ Listed ${tools.length} tools`);
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);
} 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);
}
}
// Parse command line arguments
const args = process.argv.slice(2);
if (args.length < 1) {
console.error("Usage: mcp-test-client <command> [args...]");
console.error("Example: mcp-test-client node server.js");
process.exit(1);
}
const command = args[0];
const commandArgs = args.slice(1);
testServer(command, commandArgs).catch((error) => {
console.error(`Fatal error: ${error}`);
process.exit(1);
});