From cf883ae7045b21df530b0fa52982627495170708 Mon Sep 17 00:00:00 2001 From: Naseem AlNaji Date: Wed, 26 Nov 2025 13:01:37 -0500 Subject: [PATCH 1/3] fix: add edge runtime compatibility for Cloudflare Workers Make the SDK compatible with edge runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy) by gracefully handling missing Node.js APIs: - Lazy-load fs module for context_line extraction in exceptions.ts - Guard process.once signal handlers in eventQueue.ts - Lazy-load fs/os/path modules in logging.ts with console fallback - Guard process.cwd() calls which may not exist in edge environments - Update mcpcat-api dependency to 0.1.6 Add comprehensive test suite for edge runtime compatibility scenarios. --- package.json | 2 +- pnpm-lock.yaml | 10 +- src/modules/eventQueue.ts | 15 +- src/modules/exceptions.ts | 41 +- src/modules/logging.ts | 70 +++- src/tests/edge-runtime-compatibility.test.ts | 370 +++++++++++++++++++ 6 files changed, 474 insertions(+), 34 deletions(-) create mode 100644 src/tests/edge-runtime-compatibility.test.ts diff --git a/package.json b/package.json index e74860d..751fdd4 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ }, "dependencies": { "@opentelemetry/otlp-transformer": "^0.203.0", - "mcpcat-api": "0.1.3", + "mcpcat-api": "0.1.6", "redact-pii": "3.4.0", "zod": "3.25.30" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43dad4a..9d817ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,8 +11,8 @@ importers: specifier: ^0.203.0 version: 0.203.0(@opentelemetry/api@1.9.0) mcpcat-api: - specifier: 0.1.3 - version: 0.1.3 + specifier: 0.1.6 + version: 0.1.6 redact-pii: specifier: 3.4.0 version: 3.4.0 @@ -2771,10 +2771,10 @@ packages: } engines: { node: ">= 0.4" } - mcpcat-api@0.1.3: + mcpcat-api@0.1.6: resolution: { - integrity: sha512-Lv0IkPIZoGzdoSUYCWyrj3h2l9qzeODQa3uQ2/+jhENWbDfRpoGsV1R7zrP4Bpu//O8E7GJAbpL/lArjk8Q3FA==, + integrity: sha512-jNnPMBa5mLClghI+KLVFwYzOTL6G02kGIut4qGzso+GgLjHYOQ7x7zy0Cw0Pkx1MCb1JMwxF8yJ76xgEwuopNQ==, } mdurl@2.0.0: @@ -5875,7 +5875,7 @@ snapshots: math-intrinsics@1.1.0: {} - mcpcat-api@0.1.3: {} + mcpcat-api@0.1.6: {} mdurl@2.0.0: {} diff --git a/src/modules/eventQueue.ts b/src/modules/eventQueue.ts index c6a5102..efdda7e 100644 --- a/src/modules/eventQueue.ts +++ b/src/modules/eventQueue.ts @@ -189,9 +189,18 @@ class EventQueue { } export const eventQueue = new EventQueue(); -process.once("SIGINT", () => eventQueue.destroy()); -process.once("SIGTERM", () => eventQueue.destroy()); -process.once("beforeExit", () => eventQueue.destroy()); + +// Register graceful shutdown handlers if available (Node.js only) +// Edge environments (Cloudflare Workers, etc.) don't have process signals +try { + if (typeof process !== "undefined" && typeof process.once === "function") { + process.once("SIGINT", () => eventQueue.destroy()); + process.once("SIGTERM", () => eventQueue.destroy()); + process.once("beforeExit", () => eventQueue.destroy()); + } +} catch { + // process.once not available in this environment - graceful shutdown handlers not registered +} export function setTelemetryManager(telemetryManager: TelemetryManager): void { eventQueue.setTelemetryManager(telemetryManager); diff --git a/src/modules/exceptions.ts b/src/modules/exceptions.ts index 8b140d3..7cee613 100644 --- a/src/modules/exceptions.ts +++ b/src/modules/exceptions.ts @@ -1,5 +1,21 @@ import { ErrorData, StackFrame, ChainedErrorData } from "../types.js"; -import { readFileSync } from "fs"; + +// Lazy-loaded fs module for context_line extraction (Node.js only) +// Edge environments don't have filesystem access +let fsModule: typeof import("fs") | null = null; +let fsInitAttempted = false; + +function getFsSync(): typeof import("fs") | null { + if (!fsInitAttempted) { + fsInitAttempted = true; + try { + fsModule = require("fs"); + } catch { + fsModule = null; + } + } + return fsModule; +} // Maximum number of exceptions to capture in a cause chain const MAX_EXCEPTION_CHAIN_DEPTH = 10; @@ -120,8 +136,14 @@ function addContextToFrame(frame: StackFrame): StackFrame { return frame; } + // Get fs module lazily - returns null in edge environments + const fs = getFsSync(); + if (!fs) { + return frame; // File reading not available in this environment + } + try { - const source = readFileSync(frame.abs_path, "utf8"); + const source = fs.readFileSync(frame.abs_path, "utf8"); const lines = source.split("\n"); const lineIndex = frame.lineno - 1; // Convert to 0-based index @@ -635,9 +657,18 @@ function makeRelativePath(filename: string): string { // Step 7: Strip deployment-specific paths result = stripDeploymentPaths(result); - // Step 8: Strip current working directory - const cwd = process.cwd(); - if (result.startsWith(cwd)) { + // Step 8: Strip current working directory (if available) + // process.cwd() may not be available in edge environments + let cwd: string | null = null; + try { + if (typeof process !== "undefined" && typeof process.cwd === "function") { + cwd = process.cwd(); + } + } catch { + // process.cwd() not available in this environment + } + + if (cwd && result.startsWith(cwd)) { result = result.substring(cwd.length + 1); // +1 to remove leading / } diff --git a/src/modules/logging.ts b/src/modules/logging.ts index d3db7d2..2cbb204 100644 --- a/src/modules/logging.ts +++ b/src/modules/logging.ts @@ -1,33 +1,63 @@ -import { writeFileSync, appendFileSync, existsSync } from "fs"; -import { homedir } from "os"; -import { join } from "path"; - -// Safely determine log file path, handling environments where homedir() may return null -let LOG_FILE: string | null = null; -try { - const home = homedir(); - if (home && home !== null && home !== undefined) { - LOG_FILE = join(home, "mcpcat.log"); +// Lazy-loaded module references for Node.js file logging +// These are loaded dynamically to support edge environments (Cloudflare Workers, etc.) +let fsModule: typeof import("fs") | null = null; +let logFilePath: string | null = null; +let initAttempted = false; +let useConsoleFallback = false; + +/** + * Attempts to initialize Node.js file logging. + * Falls back to console.log in edge environments where fs/os modules are unavailable. + */ +function tryInitSync(): void { + if (initAttempted) return; + initAttempted = true; + + try { + // Use dynamic require for sync initialization + // Works in Node.js, fails gracefully in Workers/edge environments + const fs = require("fs"); + const os = require("os"); + const path = require("path"); + + const home = os.homedir?.(); + if (home) { + fsModule = fs; + logFilePath = path.join(home, "mcpcat.log"); + } else { + // homedir() returned null/undefined - use console fallback + useConsoleFallback = true; + } + } catch { + // Module not available or homedir() not implemented - use console fallback + useConsoleFallback = true; + fsModule = null; + logFilePath = null; } -} catch { - // If homedir() or join() fails, LOG_FILE remains null - LOG_FILE = null; } export function writeToLog(message: string): void { - // Skip logging if we don't have a valid log file path - if (!LOG_FILE) { + tryInitSync(); + + const timestamp = new Date().toISOString(); + const logEntry = `[${timestamp}] ${message}`; + + // Edge environment: use console.log as fallback + if (useConsoleFallback) { + console.log(`[mcpcat] ${logEntry}`); return; } - const timestamp = new Date().toISOString(); - const logEntry = `[${timestamp}] ${message}\n`; + // Node.js environment: write to file + if (!logFilePath || !fsModule) { + return; + } try { - if (!existsSync(LOG_FILE)) { - writeFileSync(LOG_FILE, logEntry); + if (!fsModule.existsSync(logFilePath)) { + fsModule.writeFileSync(logFilePath, logEntry + "\n"); } else { - appendFileSync(LOG_FILE, logEntry); + fsModule.appendFileSync(logFilePath, logEntry + "\n"); } } catch { // Silently fail to avoid breaking the server diff --git a/src/tests/edge-runtime-compatibility.test.ts b/src/tests/edge-runtime-compatibility.test.ts new file mode 100644 index 0000000..9a0b502 --- /dev/null +++ b/src/tests/edge-runtime-compatibility.test.ts @@ -0,0 +1,370 @@ +/** + * Edge Runtime Compatibility Tests + * + * These tests verify that MCPCat gracefully handles environments where + * certain Node.js modules may not be available or have limited functionality + * (like Cloudflare Workers, Vercel Edge, Deno Deploy). + * + * Note: For full edge runtime testing with actual Cloudflare Workers environment, + * consider using @cloudflare/vitest-pool-workers with a separate vitest config. + * These tests simulate edge-like conditions within the Node.js test environment. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { captureException } from "../modules/exceptions.js"; + +describe("Edge Runtime Compatibility", () => { + describe("Exception Capture - Graceful Degradation", () => { + it("should capture basic exception info without filesystem access", () => { + const error = new Error("Test error"); + const captured = captureException(error); + + // Should capture basic error information regardless of environment + expect(captured.message).toBe("Test error"); + expect(captured.type).toBe("Error"); + expect(captured.platform).toBe("javascript"); + }); + + it("should parse stack traces without requiring fs module", () => { + const error = new Error("Stack trace test"); + const captured = captureException(error); + + // Stack parsing doesn't require fs - only context_line extraction does + expect(captured.frames).toBeDefined(); + expect(Array.isArray(captured.frames)).toBe(true); + expect(captured.frames!.length).toBeGreaterThan(0); + + // Frames should have basic info + const firstFrame = captured.frames![0]; + expect(firstFrame.filename).toBeDefined(); + expect(typeof firstFrame.lineno).toBe("number"); + }); + + it("should handle chained errors (Error.cause)", () => { + const rootCause = new Error("Root cause"); + const wrapper = new Error("Wrapper error", { cause: rootCause }); + + const captured = captureException(wrapper); + + expect(captured.message).toBe("Wrapper error"); + expect(captured.chained_errors).toBeDefined(); + expect(captured.chained_errors!.length).toBe(1); + expect(captured.chained_errors![0].message).toBe("Root cause"); + }); + + it("should handle non-Error objects being thrown", () => { + const captured1 = captureException("string error"); + expect(captured1.message).toBe("string error"); + + const captured2 = captureException({ code: 404 }); + expect(captured2.message).toBe('{"code":404}'); + + const captured3 = captureException(null); + expect(captured3.message).toBe("null"); + + const captured4 = captureException(undefined); + expect(captured4.message).toBe("undefined"); + }); + }); + + describe("Process Object Availability", () => { + let originalProcess: typeof process; + + beforeEach(() => { + originalProcess = globalThis.process; + }); + + afterEach(() => { + globalThis.process = originalProcess; + }); + + it("should handle missing process.cwd gracefully", () => { + // Create a mock process without cwd + const mockProcess = { ...originalProcess } as typeof process; + // @ts-expect-error - intentionally removing cwd for test + delete mockProcess.cwd; + globalThis.process = mockProcess; + + // captureException should still work + const error = new Error("Test without cwd"); + const captured = captureException(error); + + expect(captured.message).toBe("Test without cwd"); + expect(captured.type).toBe("Error"); + }); + + it("should handle process.cwd throwing", () => { + const mockProcess = { + ...originalProcess, + cwd: () => { + throw new Error("cwd not available"); + }, + } as typeof process; + globalThis.process = mockProcess; + + const error = new Error("Test with throwing cwd"); + const captured = captureException(error); + + expect(captured.message).toBe("Test with throwing cwd"); + }); + }); + + describe("Event Queue Signal Handlers", () => { + let originalProcess: typeof process; + + beforeEach(() => { + originalProcess = globalThis.process; + }); + + afterEach(() => { + globalThis.process = originalProcess; + vi.resetModules(); + }); + + it("should not throw when process.once is unavailable", async () => { + // Create mock process without once + const mockProcess = { ...originalProcess } as typeof process; + // @ts-expect-error - intentionally removing once for test + delete mockProcess.once; + globalThis.process = mockProcess; + + // Reset modules to re-run module-level code + vi.resetModules(); + + // Should not throw when importing + await expect(import("../modules/eventQueue.js")).resolves.toBeDefined(); + }); + + it("should handle process being undefined", async () => { + // @ts-expect-error - intentionally removing process for test + delete globalThis.process; + + vi.resetModules(); + + // Should not throw + const module = await import("../modules/eventQueue.js"); + expect(module.eventQueue).toBeDefined(); + + // Restore process before other tests run + globalThis.process = originalProcess; + }); + }); + + describe("Logging Module Behavior", () => { + it("should export writeToLog function", async () => { + const { writeToLog } = await import("../modules/logging.js"); + expect(typeof writeToLog).toBe("function"); + }); + + it("should not throw when called", async () => { + const { writeToLog } = await import("../modules/logging.js"); + + // writeToLog should never throw, regardless of environment + expect(() => writeToLog("Test message")).not.toThrow(); + expect(() => writeToLog("")).not.toThrow(); + expect(() => writeToLog("Special chars: \n\t\r")).not.toThrow(); + }); + + it("should handle rapid successive calls", async () => { + const { writeToLog } = await import("../modules/logging.js"); + + // Should handle many calls without issues + expect(() => { + for (let i = 0; i < 100; i++) { + writeToLog(`Message ${i}`); + } + }).not.toThrow(); + }); + }); + + describe("Full SDK API Availability", () => { + it("should export track function", async () => { + const mcpcat = await import("../index.js"); + expect(typeof mcpcat.track).toBe("function"); + }); + + it("should export publishCustomEvent function", async () => { + const mcpcat = await import("../index.js"); + expect(typeof mcpcat.publishCustomEvent).toBe("function"); + }); + + it("should export type definitions", async () => { + // IdentifyFunction type is exported for users to define their identify callbacks + const mcpcat = await import("../index.js"); + // The module should load without issues + expect(mcpcat).toBeDefined(); + }); + }); + + describe("Edge Environment Detection Patterns", () => { + it("should detect Node.js environment correctly", () => { + // Helper function that MCPCat could use internally + const isNodeJs = () => { + return ( + typeof process !== "undefined" && + process.versions != null && + process.versions.node != null + ); + }; + + // In test environment, we should be in Node.js + expect(isNodeJs()).toBe(true); + }); + + it("should detect Cloudflare Workers pattern", () => { + // Pattern for detecting Cloudflare Workers + const isCloudflareWorkers = () => { + try { + // Cloudflare Workers have caches.default + return ( + typeof caches !== "undefined" && + // @ts-expect-error - caches.default is Cloudflare-specific + typeof caches.default !== "undefined" + ); + } catch { + return false; + } + }; + + // In Node.js test environment, should return false + expect(isCloudflareWorkers()).toBe(false); + }); + + it("should detect generic edge runtime pattern", () => { + // Generic pattern for detecting edge runtimes + const isEdgeRuntime = () => { + // Edge runtimes typically don't have full Node.js process + const hasFullNodeProcess = + typeof process !== "undefined" && + typeof process.versions?.node === "string" && + typeof process.cwd === "function"; + + return !hasFullNodeProcess; + }; + + // In Node.js test environment, should return false + expect(isEdgeRuntime()).toBe(false); + }); + }); + + describe("Path Normalization Without cwd", () => { + it("should handle various path formats", () => { + // These patterns should work regardless of process.cwd availability + const testPaths = [ + "/Users/john/project/src/index.ts", + "src/index.ts", + "./relative/path.ts", + "node_modules/package/index.js", + "node:internal/modules/loader", + "native", + "", + ]; + + const error = new Error("Path test"); + const captured = captureException(error); + + // Should have parsed the stack without errors + expect(captured.frames).toBeDefined(); + }); + }); +}); + +describe("Integration: SDK in Limited Environment", () => { + let originalProcess: typeof process; + + beforeEach(() => { + originalProcess = globalThis.process; + }); + + afterEach(() => { + globalThis.process = originalProcess; + vi.resetModules(); + }); + + it("should work when process has limited functionality", async () => { + // Simulate limited process object (like some edge runtimes) + const limitedProcess = { + env: {}, + // Missing: cwd, once, versions, etc. + } as unknown as typeof process; + + globalThis.process = limitedProcess; + vi.resetModules(); + + // SDK should still load + const mcpcat = await import("../index.js"); + + // Core functions should exist + expect(mcpcat.track).toBeDefined(); + expect(mcpcat.publishCustomEvent).toBeDefined(); + + // Exception capture should work + const { captureException: capture } = await import( + "../modules/exceptions.js" + ); + const error = new Error("Limited env test"); + const captured = capture(error); + + expect(captured.message).toBe("Limited env test"); + + // Restore + globalThis.process = originalProcess; + }); + + it("should handle environment transitions gracefully", async () => { + // First call in "normal" environment + const { writeToLog: writeLog1 } = await import("../modules/logging.js"); + expect(() => writeLog1("Normal environment")).not.toThrow(); + + // Module state persists between calls (this is expected behavior) + expect(() => writeLog1("Second call")).not.toThrow(); + }); +}); + +describe("Error Handling Robustness", () => { + it("should not crash on malformed stack traces", () => { + const error = new Error("Malformed"); + // Override stack with malformed content + error.stack = + "Error: Malformed\n at malformed line without proper format\n garbage data"; + + const captured = captureException(error); + + // Should still capture basic info + expect(captured.message).toBe("Malformed"); + expect(captured.type).toBe("Error"); + // Should handle malformed frames gracefully + expect(captured.frames).toBeDefined(); + }); + + it("should handle circular reference in error.cause", () => { + const error1 = new Error("Error 1") as Error & { cause?: Error }; + const error2 = new Error("Error 2") as Error & { cause?: Error }; + + // Create circular reference + error1.cause = error2; + error2.cause = error1; + + // Should not infinite loop + const captured = captureException(error1); + + expect(captured.message).toBe("Error 1"); + expect(captured.chained_errors).toBeDefined(); + // Should stop before infinite loop + expect(captured.chained_errors!.length).toBeLessThanOrEqual(10); + }); + + it("should handle deeply nested error chains", () => { + // Create a deep chain of errors + let current = new Error("Root"); + for (let i = 0; i < 20; i++) { + current = new Error(`Level ${i}`, { cause: current }); + } + + const captured = captureException(current); + + expect(captured.message).toBe("Level 19"); + // Should be capped at MAX_EXCEPTION_CHAIN_DEPTH (10) + expect(captured.chained_errors!.length).toBeLessThanOrEqual(10); + }); +}); From 580f1fcaac33600acb70f114514a697628b31fdc Mon Sep 17 00:00:00 2001 From: Naseem AlNaji Date: Wed, 26 Nov 2025 13:02:02 -0500 Subject: [PATCH 2/3] chore: bump version to 0.1.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 751fdd4..b989d86 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcpcat", - "version": "0.1.8", + "version": "0.1.9", "description": "Analytics tool for MCP (Model Context Protocol) servers - tracks tool usage patterns and provides insights", "type": "module", "main": "dist/index.js", From 464e8f801d5dd986d6f4424a5aa6c19fcdc15873 Mon Sep 17 00:00:00 2001 From: Naseem AlNaji Date: Wed, 26 Nov 2025 13:22:52 -0500 Subject: [PATCH 3/3] fix: add Zod v3/v4 compatibility for MCP SDK 1.23.0 MCP SDK 1.23.0 now supports both Zod v3 and v4, which have different internal structures. This caused context parameter injection to fail. Changes: - Add zod-compat.ts with version-agnostic detection and utilities - Update context-parameters.ts to use zod-compat utilities - Update tracingV2.ts to use zod-compat for schema shape access - Update Zod dependency to support both v3 and v4 - Add comprehensive tests for zod-compat utilities Tested with MCP SDK versions 1.21.2 and 1.23.0. --- package.json | 4 +- pnpm-lock.yaml | 45 +++-- src/modules/context-parameters.ts | 89 ++++------ src/modules/tracingV2.ts | 4 +- src/modules/zod-compat.ts | 185 ++++++++++++++++++++ src/tests/zod-compat.test.ts | 276 ++++++++++++++++++++++++++++++ 6 files changed, 530 insertions(+), 73 deletions(-) create mode 100644 src/modules/zod-compat.ts create mode 100644 src/tests/zod-compat.test.ts diff --git a/package.json b/package.json index b989d86..fc73fff 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "packageManager": "pnpm@10.11.0", "devDependencies": { "@changesets/cli": "^2.29.4", - "@modelcontextprotocol/sdk": "1.11", + "@modelcontextprotocol/sdk": "~1.23.0", "@types/node": "^22.15.21", "@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/parser": "^8.32.1", @@ -75,7 +75,7 @@ "@opentelemetry/otlp-transformer": "^0.203.0", "mcpcat-api": "0.1.6", "redact-pii": "3.4.0", - "zod": "3.25.30" + "zod": "^3.25 || ^4.0" }, "lint-staged": { "*.{ts,js}": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d817ca..449f5ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,15 +17,15 @@ importers: specifier: 3.4.0 version: 3.4.0 zod: - specifier: 3.25.30 + specifier: ^3.25 || ^4.0 version: 3.25.30 devDependencies: "@changesets/cli": specifier: ^2.29.4 version: 2.29.4 "@modelcontextprotocol/sdk": - specifier: "1.11" - version: 1.11.5 + specifier: ~1.23.0 + version: 1.23.0(zod@3.25.30) "@types/node": specifier: ^22.15.21 version: 22.15.21 @@ -636,12 +636,18 @@ packages: integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==, } - "@modelcontextprotocol/sdk@1.11.5": + "@modelcontextprotocol/sdk@1.23.0": resolution: { - integrity: sha512-gS7Q7IHpKxjVaNLMUZyTtatZ63ca3h418zPPntAhu/MvG5yfz/8HMcDAOpvpQfx3V3dsw9QQxk8RuFNrQhLlgA==, + integrity: sha512-MCGd4K9aZKvuSqdoBkdMvZNcYXCkZRYVs/Gh92mdV5IHbctX9H9uIvd4X93+9g8tBbXv08sxc/QHXTzf8y65bA==, } engines: { node: ">=18" } + peerDependencies: + "@cfworker/json-schema": ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + "@cfworker/json-schema": + optional: true "@nodelib/fs.scandir@2.1.5": resolution: @@ -1216,6 +1222,17 @@ packages: } engines: { node: ">= 6.0.0" } + ajv-formats@3.0.1: + resolution: + { + integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==, + } + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: { @@ -4165,13 +4182,13 @@ packages: } engines: { node: ">=10" } - zod-to-json-schema@3.24.5: + zod-to-json-schema@3.25.0: resolution: { - integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==, + integrity: sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==, } peerDependencies: - zod: ^3.24.1 + zod: ^3.25 || ^4 zod@3.25.30: resolution: @@ -4543,19 +4560,21 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - "@modelcontextprotocol/sdk@1.11.5": + "@modelcontextprotocol/sdk@1.23.0(zod@3.25.30)": dependencies: ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) content-type: 1.0.5 cors: 2.8.5 cross-spawn: 7.0.6 eventsource: 3.0.7 + eventsource-parser: 3.0.3 express: 5.1.0 express-rate-limit: 7.5.1(express@5.1.0) pkce-challenge: 5.0.0 raw-body: 3.0.0 zod: 3.25.30 - zod-to-json-schema: 3.24.5(zod@3.25.30) + zod-to-json-schema: 3.25.0(zod@3.25.30) transitivePeerDependencies: - supports-color @@ -4909,6 +4928,10 @@ snapshots: transitivePeerDependencies: - supports-color + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -6678,7 +6701,7 @@ snapshots: yocto-queue@0.1.0: {} - zod-to-json-schema@3.24.5(zod@3.25.30): + zod-to-json-schema@3.25.0(zod@3.25.30): dependencies: zod: 3.25.30 diff --git a/src/modules/context-parameters.ts b/src/modules/context-parameters.ts index 2e73d3e..7256508 100644 --- a/src/modules/context-parameters.ts +++ b/src/modules/context-parameters.ts @@ -1,26 +1,12 @@ import { RegisteredTool } from "../types"; import { z } from "zod"; import { DEFAULT_CONTEXT_PARAMETER_DESCRIPTION } from "./constants"; - -// Detect if something is a Zod schema (has _def and parse methods) -function isZodSchema(schema: any): boolean { - return ( - schema && - typeof schema === "object" && - "_def" in schema && - typeof schema.parse === "function" - ); -} - -// Detect if it's shorthand Zod syntax (object with z.* values) -function isShorthandZodSyntax(schema: any): boolean { - if (!schema || typeof schema !== "object" || Array.isArray(schema)) { - return false; - } - - // Check if any value is a Zod schema - return Object.values(schema).some((value) => isZodSchema(value)); -} +import { + isZodSchema, + isShorthandZodSyntax, + schemaHasProperty, + extendObjectSchema, +} from "./zod-compat"; export function addContextParameterToTool( tool: RegisteredTool, @@ -43,36 +29,25 @@ export function addContextParameterToTool( return modifiedTool; } - // Handle Zod z.object() schemas + const contextDescription = + customContextDescription || DEFAULT_CONTEXT_PARAMETER_DESCRIPTION; + + // Handle Zod z.object() schemas (both v3 and v4) if (isZodSchema(modifiedTool.inputSchema)) { // Check if context already exists in Zod schema shape - if ( - modifiedTool.inputSchema.shape && - "context" in modifiedTool.inputSchema.shape - ) { + if (schemaHasProperty(modifiedTool.inputSchema, "context")) { return modifiedTool; } - // It's a Zod schema, augment it with context - const contextSchema = z.object({ - context: z - .string() - .describe( - customContextDescription || DEFAULT_CONTEXT_PARAMETER_DESCRIPTION, - ), - }); - - // Use extend to add context to the schema - if (typeof modifiedTool.inputSchema.extend === "function") { - modifiedTool.inputSchema = modifiedTool.inputSchema.extend( - contextSchema.shape, - ); - } else if (typeof modifiedTool.inputSchema.augment === "function") { - modifiedTool.inputSchema = - modifiedTool.inputSchema.augment(contextSchema); - } else { - // Fallback: merge with new z.object - modifiedTool.inputSchema = contextSchema.merge(modifiedTool.inputSchema); - } + + // Extend the schema with context using our compat layer + const contextShape = { + context: z.string().describe(contextDescription), + }; + + modifiedTool.inputSchema = extendObjectSchema( + modifiedTool.inputSchema, + contextShape, + ); return modifiedTool; } @@ -84,18 +59,15 @@ export function addContextParameterToTool( return modifiedTool; } - // Create a new Zod schema with context - const contextField = z - .string() - .describe( - customContextDescription || DEFAULT_CONTEXT_PARAMETER_DESCRIPTION, - ); + // Extend using our compat layer (handles both v3 and v4) + const contextShape = { + context: z.string().describe(contextDescription), + }; - // Create new z.object with context and all original fields - modifiedTool.inputSchema = z.object({ - context: contextField, - ...modifiedTool.inputSchema, - }); + modifiedTool.inputSchema = extendObjectSchema( + modifiedTool.inputSchema, + contextShape, + ); return modifiedTool; } @@ -115,8 +87,7 @@ export function addContextParameterToTool( modifiedTool.inputSchema.properties.context = { type: "string", - description: - customContextDescription || DEFAULT_CONTEXT_PARAMETER_DESCRIPTION, + description: contextDescription, }; // Add context to required array if it exists diff --git a/src/modules/tracingV2.ts b/src/modules/tracingV2.ts index febc4c9..46a7138 100644 --- a/src/modules/tracingV2.ts +++ b/src/modules/tracingV2.ts @@ -14,6 +14,7 @@ import { PublishEventRequestEventTypeEnum } from "mcpcat-api"; import { publishEvent } from "./eventQueue.js"; import { addContextParameterToTool } from "./context-parameters.js"; import { handleReportMissing } from "./tools.js"; +import { getObjectShape, getLiteralValue } from "./zod-compat.js"; import { setupInitializeTracing, setupListToolsTracing } from "./tracing.js"; import { captureException } from "./exceptions.js"; @@ -332,7 +333,8 @@ function setupToolsCallHandlerWrapping(server: HighLevelMCPServerLike): void { requestSchema: any, handler: any, ) { - const method = requestSchema?.shape?.method?.value; + const shape = getObjectShape(requestSchema); + const method = shape?.method ? getLiteralValue(shape.method) : undefined; // Only wrap tools/call handler if (method === "tools/call") { diff --git a/src/modules/zod-compat.ts b/src/modules/zod-compat.ts new file mode 100644 index 0000000..f3ab0e7 --- /dev/null +++ b/src/modules/zod-compat.ts @@ -0,0 +1,185 @@ +/** + * zod-compat.ts + * Minimal Zod v3/v4 compatibility layer for mcpcat + * Based on patterns from @modelcontextprotocol/sdk + */ + +import { z } from "zod"; + +// --- Internal property access helpers --- +// These types help us safely access internal properties that differ between v3 and v4 + +interface ZodV3Internal { + _def?: { + typeName?: string; + value?: unknown; + shape?: Record | (() => Record); + description?: string; + }; + shape?: Record | (() => Record); +} + +interface ZodV4Internal { + _zod?: { + def?: { + typeName?: string; + value?: unknown; + shape?: Record | (() => Record); + description?: string; + }; + }; +} + +/** + * Detect if something is a Zod v4 schema + * V4 schemas have `_zod` property; V3 schemas do not + */ +export function isZ4Schema(schema: unknown): boolean { + if (!schema || typeof schema !== "object") return false; + return !!(schema as ZodV4Internal)._zod; +} + +/** + * Detect if something is a Zod schema (either v3 or v4) + */ +export function isZodSchema(schema: unknown): boolean { + if (!schema || typeof schema !== "object") return false; + + const asV3 = schema as ZodV3Internal; + const asV4 = schema as ZodV4Internal; + + // Check for v3 (_def) or v4 (_zod) internal properties + const hasInternals = asV3._def !== undefined || asV4._zod !== undefined; + + // Also require parse method to distinguish from raw shapes + const hasParse = typeof (schema as { parse?: unknown }).parse === "function"; + + return hasInternals && hasParse; +} + +/** + * Detect if it's shorthand Zod syntax (object with z.* values) + * e.g., { a: z.number(), b: z.string() } + */ +export function isShorthandZodSyntax(schema: unknown): boolean { + if (!schema || typeof schema !== "object" || Array.isArray(schema)) { + return false; + } + + // If it's already a full Zod schema, it's not shorthand + if (isZodSchema(schema)) { + return false; + } + + // Check if any value is a Zod schema + return Object.values(schema as Record).some((value) => + isZodSchema(value), + ); +} + +/** + * Get the shape from a Zod object schema (works with v3 and v4) + */ +export function getObjectShape( + schema: unknown, +): Record | undefined { + if (!schema || typeof schema !== "object") return undefined; + + let rawShape: + | Record + | (() => Record) + | undefined; + + if (isZ4Schema(schema)) { + // Zod v4: shape is at _zod.def.shape + const v4Schema = schema as ZodV4Internal; + rawShape = v4Schema._zod?.def?.shape; + } else { + // Zod v3: shape is directly on the schema + const v3Schema = schema as ZodV3Internal; + rawShape = v3Schema.shape; + } + + if (!rawShape) return undefined; + + // Shape can be a function in some cases (lazy evaluation) + if (typeof rawShape === "function") { + try { + return rawShape(); + } catch { + return undefined; + } + } + + return rawShape; +} + +/** + * Get literal value from a schema (works with v3 and v4) + * Used for extracting method names from request schemas + */ +export function getLiteralValue(schema: unknown): unknown { + if (!schema || typeof schema !== "object") return undefined; + + if (isZ4Schema(schema)) { + const v4Schema = schema as ZodV4Internal; + return v4Schema._zod?.def?.value; + } else { + const v3Schema = schema as ZodV3Internal; + return v3Schema._def?.value; + } +} + +/** + * Check if a Zod object schema has a specific property + */ +export function schemaHasProperty( + schema: unknown, + propertyName: string, +): boolean { + const shape = getObjectShape(schema); + if (!shape) return false; + return propertyName in shape; +} + +/** + * Extend a Zod object schema with additional properties + * This creates a NEW schema, preserving the original + * + * Works with: + * - Zod v3 object schemas + * - Zod v4 object schemas + * - Shorthand syntax { a: z.number() } + */ +export function extendObjectSchema( + originalSchema: unknown, + additionalShape: Record, +): unknown { + // Handle shorthand syntax first + if (isShorthandZodSyntax(originalSchema)) { + // Merge shorthand with additional properties and wrap in z.object + return z.object({ + ...(originalSchema as Record), + ...additionalShape, + }); + } + + // Handle Zod object schemas + if (!isZodSchema(originalSchema)) { + // Not a Zod schema, can't extend + return originalSchema; + } + + const existingShape = getObjectShape(originalSchema); + if (!existingShape) { + // Not an object schema or couldn't get shape + return originalSchema; + } + + // Create new z.object with merged shapes + // This works because z from "zod" will be whatever version is installed + return z.object({ + ...existingShape, + ...additionalShape, + } as z.ZodRawShape); +} diff --git a/src/tests/zod-compat.test.ts b/src/tests/zod-compat.test.ts new file mode 100644 index 0000000..f434a63 --- /dev/null +++ b/src/tests/zod-compat.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect } from "vitest"; +import { + isZodSchema, + isZ4Schema, + isShorthandZodSyntax, + getObjectShape, + getLiteralValue, + schemaHasProperty, + extendObjectSchema, +} from "../modules/zod-compat"; +import { z } from "zod"; + +describe("Zod Compatibility Layer", () => { + describe("isZodSchema", () => { + it("should detect Zod object schemas", () => { + const schema = z.object({ a: z.number() }); + expect(isZodSchema(schema)).toBe(true); + }); + + it("should detect Zod string schemas", () => { + const schema = z.string(); + expect(isZodSchema(schema)).toBe(true); + }); + + it("should detect Zod number schemas", () => { + const schema = z.number(); + expect(isZodSchema(schema)).toBe(true); + }); + + it("should detect Zod array schemas", () => { + const schema = z.array(z.string()); + expect(isZodSchema(schema)).toBe(true); + }); + + it("should not detect plain objects as Zod schemas", () => { + expect(isZodSchema({ a: 1 })).toBe(false); + }); + + it("should not detect null/undefined as Zod schemas", () => { + expect(isZodSchema(null)).toBe(false); + expect(isZodSchema(undefined)).toBe(false); + }); + + it("should not detect arrays as Zod schemas", () => { + expect(isZodSchema([1, 2, 3])).toBe(false); + }); + + it("should not detect JSON Schema objects as Zod schemas", () => { + const jsonSchema = { + type: "object", + properties: { a: { type: "number" } }, + }; + expect(isZodSchema(jsonSchema)).toBe(false); + }); + }); + + describe("isZ4Schema", () => { + it("should return false for Zod v3 schemas (current installed version)", () => { + // With Zod v3 installed, all schemas should return false for isZ4Schema + const schema = z.object({ a: z.number() }); + expect(isZ4Schema(schema)).toBe(false); + }); + + it("should return false for null/undefined", () => { + expect(isZ4Schema(null)).toBe(false); + expect(isZ4Schema(undefined)).toBe(false); + }); + + it("should return false for plain objects", () => { + expect(isZ4Schema({ a: 1 })).toBe(false); + }); + }); + + describe("isShorthandZodSyntax", () => { + it("should detect shorthand Zod syntax", () => { + const shorthand = { a: z.number(), b: z.string() }; + expect(isShorthandZodSyntax(shorthand)).toBe(true); + }); + + it("should detect shorthand with mixed Zod types", () => { + const shorthand = { + name: z.string(), + count: z.number(), + active: z.boolean(), + }; + expect(isShorthandZodSyntax(shorthand)).toBe(true); + }); + + it("should not detect z.object as shorthand", () => { + const schema = z.object({ a: z.number() }); + expect(isShorthandZodSyntax(schema)).toBe(false); + }); + + it("should not detect plain objects as shorthand", () => { + expect(isShorthandZodSyntax({ a: 1, b: "test" })).toBe(false); + }); + + it("should not detect arrays as shorthand", () => { + expect(isShorthandZodSyntax([z.number()])).toBe(false); + }); + + it("should not detect null/undefined as shorthand", () => { + expect(isShorthandZodSyntax(null)).toBe(false); + expect(isShorthandZodSyntax(undefined)).toBe(false); + }); + + it("should not detect JSON Schema as shorthand", () => { + const jsonSchema = { + type: "object", + properties: { a: { type: "number" } }, + }; + expect(isShorthandZodSyntax(jsonSchema)).toBe(false); + }); + }); + + describe("getObjectShape", () => { + it("should extract shape from Zod object schema", () => { + const schema = z.object({ a: z.number(), b: z.string() }); + const shape = getObjectShape(schema); + expect(shape).toBeDefined(); + expect("a" in shape!).toBe(true); + expect("b" in shape!).toBe(true); + }); + + it("should return undefined for non-object Zod schemas", () => { + const schema = z.string(); + expect(getObjectShape(schema)).toBeUndefined(); + }); + + it("should return undefined for null", () => { + expect(getObjectShape(null)).toBeUndefined(); + }); + + it("should return undefined for undefined", () => { + expect(getObjectShape(undefined)).toBeUndefined(); + }); + + it("should return undefined for plain objects", () => { + // Plain objects don't have a Zod shape + expect(getObjectShape({ a: 1 })).toBeUndefined(); + }); + }); + + describe("getLiteralValue", () => { + it("should extract literal string value", () => { + const schema = z.literal("test"); + expect(getLiteralValue(schema)).toBe("test"); + }); + + it("should extract literal number value", () => { + const schema = z.literal(42); + expect(getLiteralValue(schema)).toBe(42); + }); + + it("should extract literal boolean value", () => { + const schema = z.literal(true); + expect(getLiteralValue(schema)).toBe(true); + }); + + it("should return undefined for non-literal schemas", () => { + const schema = z.string(); + expect(getLiteralValue(schema)).toBeUndefined(); + }); + + it("should return undefined for null/undefined", () => { + expect(getLiteralValue(null)).toBeUndefined(); + expect(getLiteralValue(undefined)).toBeUndefined(); + }); + }); + + describe("schemaHasProperty", () => { + it("should detect existing properties", () => { + const schema = z.object({ a: z.number(), b: z.string() }); + expect(schemaHasProperty(schema, "a")).toBe(true); + expect(schemaHasProperty(schema, "b")).toBe(true); + }); + + it("should return false for non-existing properties", () => { + const schema = z.object({ a: z.number() }); + expect(schemaHasProperty(schema, "b")).toBe(false); + expect(schemaHasProperty(schema, "nonexistent")).toBe(false); + }); + + it("should return false for non-object schemas", () => { + const schema = z.string(); + expect(schemaHasProperty(schema, "a")).toBe(false); + }); + + it("should return false for null/undefined", () => { + expect(schemaHasProperty(null, "a")).toBe(false); + expect(schemaHasProperty(undefined, "a")).toBe(false); + }); + }); + + describe("extendObjectSchema", () => { + it("should extend Zod object schema with new properties", () => { + const original = z.object({ a: z.number() }); + const extended = extendObjectSchema(original, { + context: z.string(), + }); + + expect(isZodSchema(extended)).toBe(true); + expect(schemaHasProperty(extended, "a")).toBe(true); + expect(schemaHasProperty(extended, "context")).toBe(true); + }); + + it("should extend shorthand syntax", () => { + const shorthand = { a: z.number() }; + const extended = extendObjectSchema(shorthand, { + context: z.string(), + }); + + expect(isZodSchema(extended)).toBe(true); + expect(schemaHasProperty(extended, "a")).toBe(true); + expect(schemaHasProperty(extended, "context")).toBe(true); + }); + + it("should preserve all original properties when extending", () => { + const original = z.object({ + a: z.number(), + b: z.string(), + c: z.boolean(), + }); + const extended = extendObjectSchema(original, { + d: z.array(z.string()), + }); + + expect(schemaHasProperty(extended, "a")).toBe(true); + expect(schemaHasProperty(extended, "b")).toBe(true); + expect(schemaHasProperty(extended, "c")).toBe(true); + expect(schemaHasProperty(extended, "d")).toBe(true); + }); + + it("should not modify the original schema", () => { + const original = z.object({ a: z.number() }); + extendObjectSchema(original, { context: z.string() }); + + // Original should not have context + expect(schemaHasProperty(original, "context")).toBe(false); + }); + + it("should return non-Zod schemas unchanged", () => { + const notZod = { type: "object", properties: {} }; + const result = extendObjectSchema(notZod, { context: z.string() }); + + // Should return the same object reference + expect(result).toBe(notZod); + }); + + it("should create valid Zod schema that can parse data", () => { + const original = z.object({ a: z.number() }); + const extended = extendObjectSchema(original, { + context: z.string(), + }) as z.ZodObject; + + // Should be able to parse valid data + const result = extended.safeParse({ a: 42, context: "test" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.a).toBe(42); + expect(result.data.context).toBe("test"); + } + }); + + it("should create schema that rejects invalid data", () => { + const original = z.object({ a: z.number() }); + const extended = extendObjectSchema(original, { + context: z.string(), + }) as z.ZodObject; + + // Should reject when context is wrong type + const result = extended.safeParse({ a: 42, context: 123 }); + expect(result.success).toBe(false); + }); + }); +});