Converted CLI tests to use vitest (#1012)
* Pinned server-everything, fixed all tests to work with current pinned version, fixed problem with undetected failures (isError: true payloads). * First working vitest implementation * Refactoring some single-use configs fixtures and into the refeencing tests * No tests refere to server-everything (or any other server from a registry), all tests actually validate what they say they test. * Removed server-everything dep from CI, minor cleanup * Addressed Claude PR review comments: Added tsx dev dependency, beefed up process termination (possible leak on Windows), beefed up http server cleanup (close all connections), removed unused hasValidJsonOutput, reduced CLI timeout to give it breathing room with vitest timeout. * Made both stdio and http test servers fully composable, cleaned up types, terminology, and usage.
This commit is contained in:
parent
94ba4c3b56
commit
44d0e580f6
6 changed files with 4206 additions and 2490 deletions
52
cli/__tests__/helpers/assertions.ts
Normal file
52
cli/__tests__/helpers/assertions.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { expect } from "vitest";
|
||||
import type { CliResult } from "./cli-runner.js";
|
||||
|
||||
/**
|
||||
* Assert that CLI command succeeded (exit code 0)
|
||||
*/
|
||||
export function expectCliSuccess(result: CliResult) {
|
||||
expect(result.exitCode).toBe(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that CLI command failed (non-zero exit code)
|
||||
*/
|
||||
export function expectCliFailure(result: CliResult) {
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that output contains expected text
|
||||
*/
|
||||
export function expectOutputContains(result: CliResult, text: string) {
|
||||
expect(result.output).toContain(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that output contains valid JSON
|
||||
* Uses stdout (not stderr) since JSON is written to stdout and warnings go to stderr
|
||||
*/
|
||||
export function expectValidJson(result: CliResult) {
|
||||
expect(() => JSON.parse(result.stdout)).not.toThrow();
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that output contains JSON with error flag
|
||||
*/
|
||||
export function expectJsonError(result: CliResult) {
|
||||
const json = expectValidJson(result);
|
||||
expect(json.isError).toBe(true);
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that output contains expected JSON structure
|
||||
*/
|
||||
export function expectJsonStructure(result: CliResult, expectedKeys: string[]) {
|
||||
const json = expectValidJson(result);
|
||||
expectedKeys.forEach((key) => {
|
||||
expect(json).toHaveProperty(key);
|
||||
});
|
||||
return json;
|
||||
}
|
||||
98
cli/__tests__/helpers/cli-runner.ts
Normal file
98
cli/__tests__/helpers/cli-runner.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { spawn } from "child_process";
|
||||
import { resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname } from "path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CLI_PATH = resolve(__dirname, "../../build/cli.js");
|
||||
|
||||
export interface CliResult {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
output: string; // Combined stdout + stderr
|
||||
}
|
||||
|
||||
export interface CliOptions {
|
||||
timeout?: number;
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the CLI with given arguments and capture output
|
||||
*/
|
||||
export async function runCli(
|
||||
args: string[],
|
||||
options: CliOptions = {},
|
||||
): Promise<CliResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("node", [CLI_PATH, ...args], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
cwd: options.cwd,
|
||||
env: { ...process.env, ...options.env },
|
||||
signal: options.signal,
|
||||
// Kill child process tree on exit
|
||||
detached: false,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let resolved = false;
|
||||
|
||||
// Default timeout of 10 seconds (less than vitest's 15s)
|
||||
const timeoutMs = options.timeout ?? 10000;
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
// Kill the process and all its children
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
child.kill("SIGTERM");
|
||||
} else {
|
||||
// On Unix, kill the process group
|
||||
process.kill(-child.pid!, "SIGTERM");
|
||||
}
|
||||
} catch (e) {
|
||||
// Process might already be dead, try direct kill
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch (e2) {
|
||||
// Process is definitely dead
|
||||
}
|
||||
}
|
||||
reject(new Error(`CLI command timed out after ${timeoutMs}ms`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
exitCode: code,
|
||||
stdout,
|
||||
stderr,
|
||||
output: stdout + stderr,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
89
cli/__tests__/helpers/fixtures.ts
Normal file
89
cli/__tests__/helpers/fixtures.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import crypto from "crypto";
|
||||
import { getTestMcpServerCommand } from "./test-server-stdio.js";
|
||||
|
||||
/**
|
||||
* Sentinel value for tests that don't need a real server
|
||||
* (tests that expect failure before connecting)
|
||||
*/
|
||||
export const NO_SERVER_SENTINEL = "invalid-command-that-does-not-exist";
|
||||
|
||||
/**
|
||||
* Create a sample test config with test-stdio and test-http servers
|
||||
* Returns a temporary config file path that should be cleaned up with deleteConfigFile()
|
||||
* @param httpUrl - Optional full URL (including /mcp path) for test-http server.
|
||||
* If not provided, uses a placeholder URL. The test-http server exists
|
||||
* to test server selection logic and may not actually be used.
|
||||
*/
|
||||
export function createSampleTestConfig(httpUrl?: string): string {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
return createTestConfig({
|
||||
mcpServers: {
|
||||
"test-stdio": {
|
||||
type: "stdio",
|
||||
command,
|
||||
args,
|
||||
env: {
|
||||
HELLO: "Hello MCP!",
|
||||
},
|
||||
},
|
||||
"test-http": {
|
||||
type: "streamable-http",
|
||||
url: httpUrl || "http://localhost:3001/mcp",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temporary directory for test files
|
||||
* Uses crypto.randomUUID() to ensure uniqueness even when called in parallel
|
||||
*/
|
||||
function createTempDir(prefix: string = "mcp-inspector-test-"): string {
|
||||
const uniqueId = crypto.randomUUID();
|
||||
const tempDir = path.join(os.tmpdir(), `${prefix}${uniqueId}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up temporary directory
|
||||
*/
|
||||
function cleanupTempDir(dir: string) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test config file
|
||||
*/
|
||||
export function createTestConfig(config: {
|
||||
mcpServers: Record<string, any>;
|
||||
}): string {
|
||||
const tempDir = createTempDir("mcp-inspector-config-");
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an invalid config file (malformed JSON)
|
||||
*/
|
||||
export function createInvalidConfig(): string {
|
||||
const tempDir = createTempDir("mcp-inspector-config-");
|
||||
const configPath = path.join(tempDir, "invalid-config.json");
|
||||
fs.writeFileSync(configPath, '{\n "mcpServers": {\n "invalid": {');
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a config file and its containing directory
|
||||
*/
|
||||
export function deleteConfigFile(configPath: string): void {
|
||||
cleanupTempDir(path.dirname(configPath));
|
||||
}
|
||||
267
cli/__tests__/helpers/test-fixtures.ts
Normal file
267
cli/__tests__/helpers/test-fixtures.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
/**
|
||||
* Shared types and test fixtures for composable MCP test servers
|
||||
*/
|
||||
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { Implementation } from "@modelcontextprotocol/sdk/types.js";
|
||||
import * as z from "zod/v4";
|
||||
import { ZodRawShapeCompat } from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
||||
|
||||
type ToolInputSchema = ZodRawShapeCompat;
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema?: ToolInputSchema;
|
||||
handler: (params: Record<string, any>) => Promise<any>;
|
||||
}
|
||||
|
||||
export interface ResourceDefinition {
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
mimeType?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
type PromptArgsSchema = ZodRawShapeCompat;
|
||||
|
||||
export interface PromptDefinition {
|
||||
name: string;
|
||||
description?: string;
|
||||
argsSchema?: PromptArgsSchema;
|
||||
}
|
||||
|
||||
// This allows us to compose tests servers using the metadata and features we want in a given scenario
|
||||
export interface ServerConfig {
|
||||
serverInfo: Implementation; // Server metadata (name, version, etc.) - required
|
||||
tools?: ToolDefinition[]; // Tools to register (optional, empty array means no tools, but tools capability is still advertised)
|
||||
resources?: ResourceDefinition[]; // Resources to register (optional, empty array means no resources, but resources capability is still advertised)
|
||||
prompts?: PromptDefinition[]; // Prompts to register (optional, empty array means no prompts, but prompts capability is still advertised)
|
||||
logging?: boolean; // Whether to advertise logging capability (default: false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an "echo" tool that echoes back the input message
|
||||
*/
|
||||
export function createEchoTool(): ToolDefinition {
|
||||
return {
|
||||
name: "echo",
|
||||
description: "Echo back the input message",
|
||||
inputSchema: {
|
||||
message: z.string().describe("Message to echo back"),
|
||||
},
|
||||
handler: async (params: Record<string, any>) => {
|
||||
return { message: `Echo: ${params.message as string}` };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an "add" tool that adds two numbers together
|
||||
*/
|
||||
export function createAddTool(): ToolDefinition {
|
||||
return {
|
||||
name: "add",
|
||||
description: "Add two numbers together",
|
||||
inputSchema: {
|
||||
a: z.number().describe("First number"),
|
||||
b: z.number().describe("Second number"),
|
||||
},
|
||||
handler: async (params: Record<string, any>) => {
|
||||
const a = params.a as number;
|
||||
const b = params.b as number;
|
||||
return { result: a + b };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a "get-sum" tool that returns the sum of two numbers (alias for add)
|
||||
*/
|
||||
export function createGetSumTool(): ToolDefinition {
|
||||
return {
|
||||
name: "get-sum",
|
||||
description: "Get the sum of two numbers",
|
||||
inputSchema: {
|
||||
a: z.number().describe("First number"),
|
||||
b: z.number().describe("Second number"),
|
||||
},
|
||||
handler: async (params: Record<string, any>) => {
|
||||
const a = params.a as number;
|
||||
const b = params.b as number;
|
||||
return { result: a + b };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a "get-annotated-message" tool that returns a message with optional image
|
||||
*/
|
||||
export function createGetAnnotatedMessageTool(): ToolDefinition {
|
||||
return {
|
||||
name: "get-annotated-message",
|
||||
description: "Get an annotated message",
|
||||
inputSchema: {
|
||||
messageType: z
|
||||
.enum(["success", "error", "warning", "info"])
|
||||
.describe("Type of message"),
|
||||
includeImage: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Whether to include an image"),
|
||||
},
|
||||
handler: async (params: Record<string, any>) => {
|
||||
const messageType = params.messageType as string;
|
||||
const includeImage = params.includeImage as boolean | undefined;
|
||||
const message = `This is a ${messageType} message`;
|
||||
const content: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; data: string; mimeType: string }
|
||||
> = [
|
||||
{
|
||||
type: "text",
|
||||
text: message,
|
||||
},
|
||||
];
|
||||
|
||||
if (includeImage) {
|
||||
content.push({
|
||||
type: "image",
|
||||
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", // 1x1 transparent PNG
|
||||
mimeType: "image/png",
|
||||
});
|
||||
}
|
||||
|
||||
return { content };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a "simple-prompt" prompt definition
|
||||
*/
|
||||
export function createSimplePrompt(): PromptDefinition {
|
||||
return {
|
||||
name: "simple-prompt",
|
||||
description: "A simple prompt for testing",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an "args-prompt" prompt that accepts arguments
|
||||
*/
|
||||
export function createArgsPrompt(): PromptDefinition {
|
||||
return {
|
||||
name: "args-prompt",
|
||||
description: "A prompt that accepts arguments for testing",
|
||||
argsSchema: {
|
||||
city: z.string().describe("City name"),
|
||||
state: z.string().describe("State name"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an "architecture" resource definition
|
||||
*/
|
||||
export function createArchitectureResource(): ResourceDefinition {
|
||||
return {
|
||||
name: "architecture",
|
||||
uri: "demo://resource/static/document/architecture.md",
|
||||
description: "Architecture documentation",
|
||||
mimeType: "text/markdown",
|
||||
text: `# Architecture Documentation
|
||||
|
||||
This is a test resource for the MCP test server.
|
||||
|
||||
## Overview
|
||||
|
||||
This resource is used for testing resource reading functionality in the CLI.
|
||||
|
||||
## Sections
|
||||
|
||||
- Introduction
|
||||
- Design
|
||||
- Implementation
|
||||
- Testing
|
||||
|
||||
## Notes
|
||||
|
||||
This is a static resource provided by the test MCP server.
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a "test-cwd" resource that exposes the current working directory (generally useful when testing with the stdio test server)
|
||||
*/
|
||||
export function createTestCwdResource(): ResourceDefinition {
|
||||
return {
|
||||
name: "test-cwd",
|
||||
uri: "test://cwd",
|
||||
description: "Current working directory of the test server",
|
||||
mimeType: "text/plain",
|
||||
text: process.cwd(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a "test-env" resource that exposes environment variables (generally useful when testing with the stdio test server)
|
||||
*/
|
||||
export function createTestEnvResource(): ResourceDefinition {
|
||||
return {
|
||||
name: "test-env",
|
||||
uri: "test://env",
|
||||
description: "Environment variables available to the test server",
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(process.env, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a "test-argv" resource that exposes command-line arguments (generally useful when testing with the stdio test server)
|
||||
*/
|
||||
export function createTestArgvResource(): ResourceDefinition {
|
||||
return {
|
||||
name: "test-argv",
|
||||
uri: "test://argv",
|
||||
description: "Command-line arguments the test server was started with",
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(process.argv, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create minimal server info for test servers
|
||||
*/
|
||||
export function createTestServerInfo(
|
||||
name: string = "test-server",
|
||||
version: string = "1.0.0",
|
||||
): Implementation {
|
||||
return {
|
||||
name,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default server config with common test tools, prompts, and resources
|
||||
*/
|
||||
export function getDefaultServerConfig(): ServerConfig {
|
||||
return {
|
||||
serverInfo: createTestServerInfo("test-mcp-server", "1.0.0"),
|
||||
tools: [
|
||||
createEchoTool(),
|
||||
createGetSumTool(),
|
||||
createGetAnnotatedMessageTool(),
|
||||
],
|
||||
prompts: [createSimplePrompt(), createArgsPrompt()],
|
||||
resources: [
|
||||
createArchitectureResource(),
|
||||
createTestCwdResource(),
|
||||
createTestEnvResource(),
|
||||
createTestArgvResource(),
|
||||
],
|
||||
};
|
||||
}
|
||||
443
cli/__tests__/helpers/test-server-http.ts
Normal file
443
cli/__tests__/helpers/test-server-http.ts
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import { SetLevelRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { Request, Response } from "express";
|
||||
import express from "express";
|
||||
import { createServer as createHttpServer, Server as HttpServer } from "http";
|
||||
import { createServer as createNetServer } from "net";
|
||||
import * as z from "zod/v4";
|
||||
import type { ServerConfig } from "./test-fixtures.js";
|
||||
|
||||
export interface RecordedRequest {
|
||||
method: string;
|
||||
params?: any;
|
||||
headers?: Record<string, string>;
|
||||
metadata?: Record<string, string>;
|
||||
response: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an available port starting from the given port
|
||||
*/
|
||||
async function findAvailablePort(startPort: number): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createNetServer();
|
||||
server.listen(startPort, () => {
|
||||
const port = (server.address() as { port: number })?.port;
|
||||
server.close(() => resolve(port || startPort));
|
||||
});
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
// Try next port
|
||||
findAvailablePort(startPort + 1)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract headers from Express request
|
||||
*/
|
||||
function extractHeaders(req: Request): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (typeof value === "string") {
|
||||
headers[key] = value;
|
||||
} else if (Array.isArray(value) && value.length > 0) {
|
||||
headers[key] = value[value.length - 1];
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
// With this test server, your test can hold an instance and you can get the server's recorded message history at any time.
|
||||
//
|
||||
export class TestServerHttp {
|
||||
private mcpServer: McpServer;
|
||||
private config: ServerConfig;
|
||||
private recordedRequests: RecordedRequest[] = [];
|
||||
private httpServer?: HttpServer;
|
||||
private transport?: StreamableHTTPServerTransport | SSEServerTransport;
|
||||
private url?: string;
|
||||
private currentRequestHeaders?: Record<string, string>;
|
||||
private currentLogLevel: string | null = null;
|
||||
|
||||
constructor(config: ServerConfig) {
|
||||
this.config = config;
|
||||
const capabilities: {
|
||||
tools?: {};
|
||||
resources?: {};
|
||||
prompts?: {};
|
||||
logging?: {};
|
||||
} = {};
|
||||
|
||||
// Only include capabilities for features that are present in config
|
||||
if (config.tools !== undefined) {
|
||||
capabilities.tools = {};
|
||||
}
|
||||
if (config.resources !== undefined) {
|
||||
capabilities.resources = {};
|
||||
}
|
||||
if (config.prompts !== undefined) {
|
||||
capabilities.prompts = {};
|
||||
}
|
||||
if (config.logging === true) {
|
||||
capabilities.logging = {};
|
||||
}
|
||||
|
||||
this.mcpServer = new McpServer(config.serverInfo, {
|
||||
capabilities,
|
||||
});
|
||||
|
||||
this.setupHandlers();
|
||||
if (config.logging === true) {
|
||||
this.setupLoggingHandler();
|
||||
}
|
||||
}
|
||||
|
||||
private setupHandlers() {
|
||||
// Set up tools
|
||||
if (this.config.tools && this.config.tools.length > 0) {
|
||||
for (const tool of this.config.tools) {
|
||||
this.mcpServer.registerTool(
|
||||
tool.name,
|
||||
{
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
},
|
||||
async (args) => {
|
||||
const result = await tool.handler(args as Record<string, any>);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up resources
|
||||
if (this.config.resources && this.config.resources.length > 0) {
|
||||
for (const resource of this.config.resources) {
|
||||
this.mcpServer.registerResource(
|
||||
resource.name,
|
||||
resource.uri,
|
||||
{
|
||||
description: resource.description,
|
||||
mimeType: resource.mimeType,
|
||||
},
|
||||
async () => {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: resource.uri,
|
||||
mimeType: resource.mimeType || "text/plain",
|
||||
text: resource.text || "",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up prompts
|
||||
if (this.config.prompts && this.config.prompts.length > 0) {
|
||||
for (const prompt of this.config.prompts) {
|
||||
this.mcpServer.registerPrompt(
|
||||
prompt.name,
|
||||
{
|
||||
description: prompt.description,
|
||||
argsSchema: prompt.argsSchema,
|
||||
},
|
||||
async (args) => {
|
||||
// Return a simple prompt response
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Prompt: ${prompt.name}${args ? ` with args: ${JSON.stringify(args)}` : ""}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setupLoggingHandler() {
|
||||
// Intercept logging/setLevel requests to track the level
|
||||
this.mcpServer.server.setRequestHandler(
|
||||
SetLevelRequestSchema,
|
||||
async (request) => {
|
||||
this.currentLogLevel = request.params.level;
|
||||
// Return empty result as per MCP spec
|
||||
return {};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the server with the specified transport
|
||||
*/
|
||||
async start(
|
||||
transport: "http" | "sse",
|
||||
requestedPort?: number,
|
||||
): Promise<number> {
|
||||
const port = requestedPort
|
||||
? await findAvailablePort(requestedPort)
|
||||
: await findAvailablePort(transport === "http" ? 3001 : 3000);
|
||||
|
||||
this.url = `http://localhost:${port}`;
|
||||
|
||||
if (transport === "http") {
|
||||
return this.startHttp(port);
|
||||
} else {
|
||||
return this.startSse(port);
|
||||
}
|
||||
}
|
||||
|
||||
private async startHttp(port: number): Promise<number> {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Create HTTP server
|
||||
this.httpServer = createHttpServer(app);
|
||||
|
||||
// Create StreamableHTTP transport
|
||||
this.transport = new StreamableHTTPServerTransport({});
|
||||
|
||||
// Set up Express route to handle MCP requests
|
||||
app.post("/mcp", async (req: Request, res: Response) => {
|
||||
// Capture headers for this request
|
||||
this.currentRequestHeaders = extractHeaders(req);
|
||||
|
||||
try {
|
||||
await (this.transport as StreamableHTTPServerTransport).handleRequest(
|
||||
req,
|
||||
res,
|
||||
req.body,
|
||||
);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Intercept messages to record them
|
||||
const originalOnMessage = this.transport.onmessage;
|
||||
this.transport.onmessage = async (message) => {
|
||||
const timestamp = Date.now();
|
||||
const method =
|
||||
"method" in message && typeof message.method === "string"
|
||||
? message.method
|
||||
: "unknown";
|
||||
const params = "params" in message ? message.params : undefined;
|
||||
|
||||
try {
|
||||
// Extract metadata from params if present
|
||||
const metadata =
|
||||
params && typeof params === "object" && "_meta" in params
|
||||
? ((params as any)._meta as Record<string, string>)
|
||||
: undefined;
|
||||
|
||||
// Let the server handle the message
|
||||
if (originalOnMessage) {
|
||||
await originalOnMessage.call(this.transport, message);
|
||||
}
|
||||
|
||||
// Record successful request (response will be sent by transport)
|
||||
// Note: We can't easily capture the response here, so we'll record
|
||||
// that the request was processed
|
||||
this.recordedRequests.push({
|
||||
method,
|
||||
params,
|
||||
headers: { ...this.currentRequestHeaders },
|
||||
metadata: metadata ? { ...metadata } : undefined,
|
||||
response: { processed: true },
|
||||
timestamp,
|
||||
});
|
||||
} catch (error) {
|
||||
// Extract metadata from params if present
|
||||
const metadata =
|
||||
params && typeof params === "object" && "_meta" in params
|
||||
? ((params as any)._meta as Record<string, string>)
|
||||
: undefined;
|
||||
|
||||
// Record error
|
||||
this.recordedRequests.push({
|
||||
method,
|
||||
params,
|
||||
headers: { ...this.currentRequestHeaders },
|
||||
metadata: metadata ? { ...metadata } : undefined,
|
||||
response: {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
timestamp,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Connect transport to server
|
||||
await this.mcpServer.connect(this.transport);
|
||||
|
||||
// Start listening
|
||||
return new Promise((resolve, reject) => {
|
||||
this.httpServer!.listen(port, () => {
|
||||
resolve(port);
|
||||
});
|
||||
this.httpServer!.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
private async startSse(port: number): Promise<number> {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Create HTTP server
|
||||
this.httpServer = createHttpServer(app);
|
||||
|
||||
// For SSE, we need to set up an Express route that creates the transport per request
|
||||
// This is a simplified version - SSE transport is created per connection
|
||||
app.get("/mcp", async (req: Request, res: Response) => {
|
||||
this.currentRequestHeaders = extractHeaders(req);
|
||||
const sseTransport = new SSEServerTransport("/mcp", res);
|
||||
|
||||
// Intercept messages
|
||||
const originalOnMessage = sseTransport.onmessage;
|
||||
sseTransport.onmessage = async (message) => {
|
||||
const timestamp = Date.now();
|
||||
const method =
|
||||
"method" in message && typeof message.method === "string"
|
||||
? message.method
|
||||
: "unknown";
|
||||
const params = "params" in message ? message.params : undefined;
|
||||
|
||||
try {
|
||||
// Extract metadata from params if present
|
||||
const metadata =
|
||||
params && typeof params === "object" && "_meta" in params
|
||||
? ((params as any)._meta as Record<string, string>)
|
||||
: undefined;
|
||||
|
||||
if (originalOnMessage) {
|
||||
await originalOnMessage.call(sseTransport, message);
|
||||
}
|
||||
|
||||
this.recordedRequests.push({
|
||||
method,
|
||||
params,
|
||||
headers: { ...this.currentRequestHeaders },
|
||||
metadata: metadata ? { ...metadata } : undefined,
|
||||
response: { processed: true },
|
||||
timestamp,
|
||||
});
|
||||
} catch (error) {
|
||||
// Extract metadata from params if present
|
||||
const metadata =
|
||||
params && typeof params === "object" && "_meta" in params
|
||||
? ((params as any)._meta as Record<string, string>)
|
||||
: undefined;
|
||||
|
||||
this.recordedRequests.push({
|
||||
method,
|
||||
params,
|
||||
headers: { ...this.currentRequestHeaders },
|
||||
metadata: metadata ? { ...metadata } : undefined,
|
||||
response: {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
timestamp,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
await this.mcpServer.connect(sseTransport);
|
||||
await sseTransport.start();
|
||||
});
|
||||
|
||||
// Note: SSE transport is created per request, so we don't store a single instance
|
||||
this.transport = undefined;
|
||||
|
||||
// Start listening
|
||||
return new Promise((resolve, reject) => {
|
||||
this.httpServer!.listen(port, () => {
|
||||
resolve(port);
|
||||
});
|
||||
this.httpServer!.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the server
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
await this.mcpServer.close();
|
||||
|
||||
if (this.transport) {
|
||||
await this.transport.close();
|
||||
this.transport = undefined;
|
||||
}
|
||||
|
||||
if (this.httpServer) {
|
||||
return new Promise((resolve) => {
|
||||
// Force close all connections
|
||||
this.httpServer!.closeAllConnections?.();
|
||||
this.httpServer!.close(() => {
|
||||
this.httpServer = undefined;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recorded requests
|
||||
*/
|
||||
getRecordedRequests(): RecordedRequest[] {
|
||||
return [...this.recordedRequests];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear recorded requests
|
||||
*/
|
||||
clearRecordings(): void {
|
||||
this.recordedRequests = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server URL
|
||||
*/
|
||||
getUrl(): string {
|
||||
if (!this.url) {
|
||||
throw new Error("Server not started");
|
||||
}
|
||||
return this.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent log level that was set
|
||||
*/
|
||||
getCurrentLogLevel(): string | null {
|
||||
return this.currentLogLevel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTP/SSE MCP test server
|
||||
*/
|
||||
export function createTestServerHttp(config: ServerConfig): TestServerHttp {
|
||||
return new TestServerHttp(config);
|
||||
}
|
||||
241
cli/__tests__/helpers/test-server-stdio.ts
Normal file
241
cli/__tests__/helpers/test-server-stdio.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test MCP server for stdio transport testing
|
||||
* Can be used programmatically or run as a standalone executable
|
||||
*/
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import * as z from "zod/v4";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname } from "path";
|
||||
import type {
|
||||
ServerConfig,
|
||||
ToolDefinition,
|
||||
PromptDefinition,
|
||||
ResourceDefinition,
|
||||
} from "./test-fixtures.js";
|
||||
import { getDefaultServerConfig } from "./test-fixtures.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export class TestServerStdio {
|
||||
private mcpServer: McpServer;
|
||||
private config: ServerConfig;
|
||||
private transport?: StdioServerTransport;
|
||||
|
||||
constructor(config: ServerConfig) {
|
||||
this.config = config;
|
||||
const capabilities: {
|
||||
tools?: {};
|
||||
resources?: {};
|
||||
prompts?: {};
|
||||
logging?: {};
|
||||
} = {};
|
||||
|
||||
// Only include capabilities for features that are present in config
|
||||
if (config.tools !== undefined) {
|
||||
capabilities.tools = {};
|
||||
}
|
||||
if (config.resources !== undefined) {
|
||||
capabilities.resources = {};
|
||||
}
|
||||
if (config.prompts !== undefined) {
|
||||
capabilities.prompts = {};
|
||||
}
|
||||
if (config.logging === true) {
|
||||
capabilities.logging = {};
|
||||
}
|
||||
|
||||
this.mcpServer = new McpServer(config.serverInfo, {
|
||||
capabilities,
|
||||
});
|
||||
|
||||
this.setupHandlers();
|
||||
}
|
||||
|
||||
private setupHandlers() {
|
||||
// Set up tools
|
||||
if (this.config.tools && this.config.tools.length > 0) {
|
||||
for (const tool of this.config.tools) {
|
||||
this.mcpServer.registerTool(
|
||||
tool.name,
|
||||
{
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
},
|
||||
async (args) => {
|
||||
const result = await tool.handler(args as Record<string, any>);
|
||||
// If handler returns content array directly (like get-annotated-message), use it
|
||||
if (result && Array.isArray(result.content)) {
|
||||
return { content: result.content };
|
||||
}
|
||||
// If handler returns message (like echo), format it
|
||||
if (result && typeof result.message === "string") {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: result.message,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
// Otherwise, stringify the result
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(result),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up resources
|
||||
if (this.config.resources && this.config.resources.length > 0) {
|
||||
for (const resource of this.config.resources) {
|
||||
this.mcpServer.registerResource(
|
||||
resource.name,
|
||||
resource.uri,
|
||||
{
|
||||
description: resource.description,
|
||||
mimeType: resource.mimeType,
|
||||
},
|
||||
async () => {
|
||||
// For dynamic resources, get fresh text
|
||||
let text = resource.text;
|
||||
if (resource.name === "test-cwd") {
|
||||
text = process.cwd();
|
||||
} else if (resource.name === "test-env") {
|
||||
text = JSON.stringify(process.env, null, 2);
|
||||
} else if (resource.name === "test-argv") {
|
||||
text = JSON.stringify(process.argv, null, 2);
|
||||
}
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: resource.uri,
|
||||
mimeType: resource.mimeType || "text/plain",
|
||||
text: text || "",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up prompts
|
||||
if (this.config.prompts && this.config.prompts.length > 0) {
|
||||
for (const prompt of this.config.prompts) {
|
||||
this.mcpServer.registerPrompt(
|
||||
prompt.name,
|
||||
{
|
||||
description: prompt.description,
|
||||
argsSchema: prompt.argsSchema,
|
||||
},
|
||||
async (args) => {
|
||||
if (prompt.name === "args-prompt" && args) {
|
||||
const city = (args as any).city as string;
|
||||
const state = (args as any).state as string;
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `This is a prompt with arguments: city=${city}, state=${state}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "This is a simple prompt for testing purposes.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the server with stdio transport
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
this.transport = new StdioServerTransport();
|
||||
await this.mcpServer.connect(this.transport);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the server
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
await this.mcpServer.close();
|
||||
if (this.transport) {
|
||||
await this.transport.close();
|
||||
this.transport = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a stdio MCP test server
|
||||
*/
|
||||
export function createTestServerStdio(config: ServerConfig): TestServerStdio {
|
||||
return new TestServerStdio(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the test MCP server script
|
||||
*/
|
||||
export function getTestMcpServerPath(): string {
|
||||
return path.resolve(__dirname, "test-server-stdio.ts");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the command and args to run the test MCP server
|
||||
*/
|
||||
export function getTestMcpServerCommand(): { command: string; args: string[] } {
|
||||
return {
|
||||
command: "tsx",
|
||||
args: [getTestMcpServerPath()],
|
||||
};
|
||||
}
|
||||
|
||||
// If run as a standalone script, start with default config
|
||||
// Check if this file is being executed directly (not imported)
|
||||
const isMainModule =
|
||||
import.meta.url.endsWith(process.argv[1]) ||
|
||||
process.argv[1]?.endsWith("test-server-stdio.ts") ||
|
||||
process.argv[1]?.endsWith("test-server-stdio.js");
|
||||
|
||||
if (isMainModule) {
|
||||
const server = new TestServerStdio(getDefaultServerConfig());
|
||||
server
|
||||
.start()
|
||||
.then(() => {
|
||||
// Server is now running and listening on stdio
|
||||
// Keep the process alive
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to start test MCP server:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue