import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { InitializeRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import createServer from "../src/server/smithery.js";
import { getServerContext } from "../src/mcp/server.js";
const ORIGINAL_ENV = { ...process.env };
async function invokeInitialize(server: Server): Promise<unknown> {
const handlers = (server as unknown as {
_requestHandlers?: Map<string, unknown>;
})._requestHandlers;
const handler = handlers?.get("initialize");
if (typeof handler !== "function") {
throw new Error("Initialize handler not registered");
}
const request = InitializeRequestSchema.parse({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-05-01",
capabilities: {},
clientInfo: { name: "smithery-test", version: "1.0.0" }
}
});
const abortController = new AbortController();
return (handler as (
request: typeof request,
extra: {
signal: AbortSignal;
requestId: typeof request.id;
sendNotification: (notification: unknown) => Promise<void>;
sendRequest: (request: unknown) => Promise<unknown>;
}
) => Promise<unknown>)(request, {
signal: abortController.signal,
requestId: request.id,
sendNotification: async () => {},
sendRequest: async () => {
throw new Error("sendRequest is not supported in tests");
}
});
}
async function invokeToolCall(server: Server, toolName: string, args: Record<string, unknown>): Promise<unknown> {
const handlers = (server as unknown as {
_requestHandlers?: Map<string, unknown>;
})._requestHandlers;
const handler = handlers?.get("tools/call");
if (typeof handler !== "function") {
throw new Error("CallTool handler not registered");
}
const request = CallToolRequestSchema.parse({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: toolName,
arguments: args
}
});
const abortController = new AbortController();
return (handler as (
request: typeof request,
extra: {
signal: AbortSignal;
requestId: typeof request.id;
sendNotification: (notification: unknown) => Promise<void>;
sendRequest: (request: unknown) => Promise<unknown>;
}
) => Promise<unknown>)(request, {
signal: abortController.signal,
requestId: request.id,
sendNotification: async () => {},
sendRequest: async () => {
throw new Error("sendRequest is not supported in tests");
}
});
}
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.CLICKUP_TOKEN;
delete process.env.CLICKUP_DEFAULT_TEAM_ID;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
describe("Python MCP behavior parity", () => {
it("mimics Python MCP: initialization never fails for missing credentials", async () => {
// This matches Python MCP server behavior exactly - server initializes
// without credentials and tools fail at invocation time
const server = await createServer({});
try {
// Initialize should succeed
const initResponse = await invokeInitialize(server);
expect(initResponse).toBeDefined();
expect(initResponse).toHaveProperty("protocolVersion");
const context = getServerContext(server);
expect(context.tools.length).toBeGreaterThan(0);
// Tool invocation should fail with clear error (not initialization)
const toolResponse = await invokeToolCall(server, "clickup_list_spaces", { teamId: 123 });
expect(toolResponse).toHaveProperty("structuredContent");
const content = (toolResponse as { structuredContent: unknown }).structuredContent;
expect(content).toHaveProperty("isError", true);
expect(content).toHaveProperty("code", "INVALID_PARAMETER");
} finally {
await server.close();
}
});
it("mimics Python MCP: accepts empty Smithery config from UI", async () => {
// Smithery sends empty config object when no fields are configured
const server = await createServer({
config: {}
});
try {
const response = await invokeInitialize(server);
expect(response).toBeDefined();
const context = getServerContext(server);
expect(context.session.apiToken).toBe("");
expect(context.session.defaultTeamId).toBeUndefined();
expect(context.session.baseUrl).toBe("https://api.clickup.com/api/v2");
} finally {
await server.close();
}
});
it("mimics Python MCP: accepts partial Smithery config", async () => {
// Smithery sends partial config when user fills some fields
const server = await createServer({
config: {
apiToken: "pk_smithery_token"
// defaultTeamId omitted
}
});
try {
const response = await invokeInitialize(server);
expect(response).toBeDefined();
const context = getServerContext(server);
expect(context.session.apiToken).toBe("pk_smithery_token");
expect(context.session.defaultTeamId).toBeUndefined();
} finally {
await server.close();
}
});
it("mimics Python MCP: handles malformed Smithery input gracefully", async () => {
// Smithery might send unexpected data types from UI
const server = await createServer({
config: {
apiToken: "token",
defaultTeamId: "{{TEAM_ID}}", // Template placeholder not yet filled
baseUrl: 12345 as any, // Wrong type
requestTimeoutMs: "not-a-number" as any
}
});
try {
const response = await invokeInitialize(server);
expect(response).toBeDefined();
const context = getServerContext(server);
// Valid values preserved
expect(context.session.apiToken).toBe("token");
// Invalid values normalized
expect(context.session.defaultTeamId).toBeUndefined();
expect(context.session.baseUrl).toBe("https://api.clickup.com/api/v2");
expect(context.session.requestTimeout).toBe(30);
} finally {
await server.close();
}
});
it("mimics Python MCP: environment variables act as fallback", async () => {
process.env.CLICKUP_TOKEN = "env_token";
process.env.CLICKUP_DEFAULT_TEAM_ID = "12345";
// Smithery sends empty config
const server = await createServer({
config: {}
});
try {
const response = await invokeInitialize(server);
expect(response).toBeDefined();
const context = getServerContext(server);
// Should fall back to environment
expect(context.session.apiToken).toBe("env_token");
expect(context.session.defaultTeamId).toBe(12345);
} finally {
await server.close();
}
});
it("mimics Python MCP: Smithery config overrides environment", async () => {
process.env.CLICKUP_TOKEN = "env_token";
process.env.CLICKUP_DEFAULT_TEAM_ID = "11111";
const server = await createServer({
config: {
apiToken: "smithery_token",
defaultTeamId: 22222
}
});
try {
const response = await invokeInitialize(server);
expect(response).toBeDefined();
const context = getServerContext(server);
// Smithery config should take precedence
expect(context.session.apiToken).toBe("smithery_token");
expect(context.session.defaultTeamId).toBe(22222);
} finally {
await server.close();
}
});
it.skip("mimics Python MCP: startup diagnostics printed unconditionally", async () => {
// NOTE: Startup diagnostics were removed as part of factory.ts simplification
// Diagnostic logging still happens via the logger, but not via console.log
// Capture console output to verify diagnostics are printed
const originalLog = console.log;
const logs: string[] = [];
console.log = (...args: unknown[]) => {
logs.push(args.join(" "));
originalLog(...args);
};
try {
const server = await createServer({});
await invokeInitialize(server);
// Should have printed startup diagnostics
const diagnosticsLog = logs.find(log => log.includes("MCP Server Startup Diagnostics"));
expect(diagnosticsLog).toBeDefined();
// Should contain configuration details
const configLog = logs.find(log => log.includes('"configuration"'));
expect(configLog).toBeDefined();
await server.close();
} finally {
console.log = originalLog;
}
});
it("mimics Python MCP: all config fields remain optional in schema", async () => {
// Verify schema doesn't require any fields
const server = await createServer({
config: {
// All fields omitted
}
});
try {
const response = await invokeInitialize(server);
expect(response).toBeDefined();
const context = getServerContext(server);
expect(context.session).toBeDefined();
expect(context.tools.length).toBeGreaterThan(0);
} finally {
await server.close();
}
});
it("mimics Python MCP: never returns 500 error at initialization", async () => {
// Test various problematic inputs that might cause 500 errors
const problematicInputs = [
{ config: { apiToken: NaN as any } },
{ config: { defaultTeamId: Infinity } },
{ config: { apiToken: null } },
{ config: null as any },
{ env: { INVALID_VAR: "{{not resolved}}" } }
];
for (const input of problematicInputs) {
let server: Server | undefined;
try {
// Should not throw during creation
server = await createServer(input as any);
// Should not throw during initialization
const response = await invokeInitialize(server);
expect(response).toBeDefined();
expect(response).toHaveProperty("protocolVersion");
} finally {
if (server) {
await server.close();
}
}
}
});
});