From 562779bd71458fc53d60247d026730d52cd9b66e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:49:50 +0800 Subject: [PATCH] fix(mcp): advertise absolute paths for tools that reject relative inputs (#9179) * fix(mcp): advertise absolute paths for tools that reject relative inputs The domain schemas accepted relative paths, then handlers rejected them with isAbsolute. Those inputs now use absoluteLocalPathStringSchema, and the duplicate handler checks are gone. * fix(javascript): own recovery path admission in the schema Relative recovery paths now fail in absoluteLocalPathStringSchema, including the additional_paths index. Drop the duplicate handler check, and make the CLI, MCP, and tools/list tests assert that schema fact. * test: assert absolute-path facts instead of error prose Recovery and web-script tests keep the field, format code, and regex expectation. They no longer freeze the schema's explanatory sentence. --------- Co-authored-by: Cursor Agent --- src/application/WebScriptExportService.ts | 16 --- .../binaryDiagnostics/AppleIpsService.ts | 16 +-- .../binaryDiagnostics/ByteEntropyService.ts | 5 - .../binaryDiagnostics/ElfNotesService.ts | 16 +-- .../binaryDiagnostics/KernelModuleService.ts | 16 +-- .../JavaScriptRecoveryService.test.ts | 8 +- .../javascript/JavaScriptRecoveryService.ts | 27 +--- src/cli/webScriptCommands.ts | 11 +- src/domain/javascript/javascriptRecovery.ts | 8 +- src/domain/native/appleIps.ts | 4 +- src/domain/native/byteEntropy.ts | 6 +- src/domain/native/elfNotes.ts | 4 +- src/domain/native/kernelModule.ts | 4 +- src/domain/webScriptExport.ts | 6 +- src/javascript/recovery/RecoveryFiles.ts | 5 - tests/acceptance/nonBinaryComposition.test.ts | 30 ++++- .../cli/cliInputSchemaRequired.test.ts | 6 +- .../filesystem/webScriptExport.test.ts | 6 +- .../mcp/absoluteLocalPathTools.test.ts | 117 ++++++++++++++++++ 19 files changed, 180 insertions(+), 131 deletions(-) create mode 100644 tests/boundary/mcp/absoluteLocalPathTools.test.ts diff --git a/src/application/WebScriptExportService.ts b/src/application/WebScriptExportService.ts index d0cd45ea31..24bcaab43e 100644 --- a/src/application/WebScriptExportService.ts +++ b/src/application/WebScriptExportService.ts @@ -1,5 +1,4 @@ import { createHash } from "node:crypto"; -import { isAbsolute } from "node:path"; import { z } from "zod"; @@ -85,21 +84,6 @@ export const exportWebScriptsValidated = async ( resources: ArtifactResourceScope, options: ExecutionOptions = {}, ): Promise> => { - const invalidPaths = (["capture_path", "output_directory"] as const).filter( - (field) => !isAbsolute(input[field]), - ); - if (invalidPaths.length > 0) - return err( - new AnalysisInputError( - OPERATION, - undefined, - invalidPaths.map((field) => ({ - path: [field], - reason: "invalid_format" as const, - message: `${field} must be an absolute filesystem path on this host.`, - })), - ), - ); try { if (options.signal?.aborted) return err( diff --git a/src/application/binaryDiagnostics/AppleIpsService.ts b/src/application/binaryDiagnostics/AppleIpsService.ts index 29a15cac4a..25539b3353 100644 --- a/src/application/binaryDiagnostics/AppleIpsService.ts +++ b/src/application/binaryDiagnostics/AppleIpsService.ts @@ -1,11 +1,7 @@ -import { isAbsolute } from "node:path"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { AppleIpsPort } from "./AppleIpsPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisInputError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { appleIpsSchema, @@ -38,16 +34,6 @@ export class AppleIpsService { options?.signal, ); if (!input.ok) return input; - if (!isAbsolute(input.value.path)) - return err( - new AnalysisInputError(OPERATION, undefined, [ - { - path: ["path"], - reason: "invalid_format", - message: "Expected an absolute filesystem path on this host.", - }, - ]), - ); const result = await this.provider.inspect(input.value, options); if (!result.ok) return result; const report = appleIpsSchema.safeParse(result.value); diff --git a/src/application/binaryDiagnostics/ByteEntropyService.ts b/src/application/binaryDiagnostics/ByteEntropyService.ts index 2e5776d587..a845e45b58 100644 --- a/src/application/binaryDiagnostics/ByteEntropyService.ts +++ b/src/application/binaryDiagnostics/ByteEntropyService.ts @@ -1,7 +1,6 @@ import { createHash } from "node:crypto"; import { constants } from "node:fs"; import { open, stat } from "node:fs/promises"; -import { isAbsolute } from "node:path"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import { BYTE_ENTROPY_PROVIDER } from "../../domain/investigationProviders.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -105,10 +104,6 @@ export class ByteEntropyService { ); if (!parsed.ok) return parsed; const input = parsed.value; - if (!isAbsolute(input.path)) - return err( - invalid("path", "Expected an absolute filesystem path on this host."), - ); const evidenceFromReport = (report: ByteEntropy): Evidence => createEvidence( { path: input.path, format: "file", sha256: report.artifact.sha256 }, diff --git a/src/application/binaryDiagnostics/ElfNotesService.ts b/src/application/binaryDiagnostics/ElfNotesService.ts index 47c9468af2..fae180099f 100644 --- a/src/application/binaryDiagnostics/ElfNotesService.ts +++ b/src/application/binaryDiagnostics/ElfNotesService.ts @@ -1,11 +1,7 @@ -import { isAbsolute } from "node:path"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { ElfNotesPort } from "./ElfNotesPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisInputError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { inspectElfNotesInputSchema, @@ -39,16 +35,6 @@ export class ElfNotesService { options?.signal, ); if (!input.ok) return input; - if (!isAbsolute(input.value.path)) - return err( - new AnalysisInputError(OPERATION, undefined, [ - { - path: ["path"], - reason: "invalid_format", - message: "Expected an absolute filesystem path on this host.", - }, - ]), - ); const result = await this.provider.inspect(input.value, options); if (!result.ok) return result; const report = elfNotesSchema.safeParse(result.value); diff --git a/src/application/binaryDiagnostics/KernelModuleService.ts b/src/application/binaryDiagnostics/KernelModuleService.ts index 8f8fbe1dd7..0300327657 100644 --- a/src/application/binaryDiagnostics/KernelModuleService.ts +++ b/src/application/binaryDiagnostics/KernelModuleService.ts @@ -1,11 +1,7 @@ -import { isAbsolute } from "node:path"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { KernelModulePort } from "./KernelModulePort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisInputError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { elfObjectTypeName } from "../../domain/native/elfNotes.js"; import { @@ -47,16 +43,6 @@ export class KernelModuleService { options?.signal, ); if (!input.ok) return input; - if (!isAbsolute(input.value.path)) - return err( - new AnalysisInputError(OPERATION, undefined, [ - { - path: ["path"], - reason: "invalid_format", - message: "Expected an absolute filesystem path on this host.", - }, - ]), - ); const result = await this.provider.inspect(input.value, options); if (!result.ok) return result; const report = kernelModuleSchema.safeParse(result.value); diff --git a/src/application/javascript/JavaScriptRecoveryService.test.ts b/src/application/javascript/JavaScriptRecoveryService.test.ts index 1977b6031b..41282994c0 100644 --- a/src/application/javascript/JavaScriptRecoveryService.test.ts +++ b/src/application/javascript/JavaScriptRecoveryService.test.ts @@ -154,7 +154,13 @@ describe("JavaScript recovery workflow", () => { expect(invoked).toBe(false); if (result.ok) throw new Error("expected invalid path"); expect(result.error).toMatchObject({ - issues: [{ path: ["additional_paths"], reason: "invalid_format" }], + issues: [ + { + path: ["additional_paths", 0], + reason: "invalid_format", + expected: "regex", + }, + ], }); }); }); diff --git a/src/application/javascript/JavaScriptRecoveryService.ts b/src/application/javascript/JavaScriptRecoveryService.ts index 852b5a3a6c..d57c1a9abc 100644 --- a/src/application/javascript/JavaScriptRecoveryService.ts +++ b/src/application/javascript/JavaScriptRecoveryService.ts @@ -1,10 +1,6 @@ import { parseAnalysisInput } from "../AnalysisInput.js"; -import { isAbsolute } from "node:path"; -import { - AnalysisInputError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { @@ -38,27 +34,6 @@ export class JavaScriptRecoveryService { options?.signal, ); if (!parsed.ok) return parsed; - const invalidPaths = (["path", "output_directory"] as const).filter( - (field) => !isAbsolute(parsed.value[field]), - ); - const invalidAdditional = (parsed.value.additional_paths ?? []).filter( - (additional) => !isAbsolute(additional), - ); - if (invalidPaths.length > 0 || invalidAdditional.length > 0) - return err( - new AnalysisInputError(OPERATION, undefined, [ - ...invalidPaths.map((field) => ({ - path: [field], - reason: "invalid_format" as const, - message: `${field} must be an absolute filesystem path on this host.`, - })), - ...invalidAdditional.map((additional) => ({ - path: ["additional_paths"], - reason: "invalid_format" as const, - message: `additional_paths entries must be absolute filesystem paths on this host: ${additional}`, - })), - ]), - ); const execution = await this.provider.recover(parsed.value, options); if (!execution.ok) return execution; const result = javascriptRecoveryResultSchema.safeParse( diff --git a/src/cli/webScriptCommands.ts b/src/cli/webScriptCommands.ts index cade5933a7..3f09e07b2a 100644 --- a/src/cli/webScriptCommands.ts +++ b/src/cli/webScriptCommands.ts @@ -1,6 +1,7 @@ import { Cli, z } from "incur"; import { exportWebScripts } from "../application/WebScriptExportService.js"; +import { exportWebScriptsInputSchema } from "../domain/webScriptExport.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; @@ -18,14 +19,8 @@ export const registerWebScriptCommands = ( description: "Export retained website scripts into an absent directory for static JavaScript analysis", args: z.object({ - capturePath: z - .string() - .describe("Absolute path to saved page or scenario capture JSON"), - outputDirectory: z - .string() - .describe( - "Absolute absent directory for verified scripts and manifest", - ), + capturePath: exportWebScriptsInputSchema.shape.capture_path, + outputDirectory: exportWebScriptsInputSchema.shape.output_directory, }), run: ({ args }) => withCommandCancellation((signal) => diff --git a/src/domain/javascript/javascriptRecovery.ts b/src/domain/javascript/javascriptRecovery.ts index 07f34699a6..7310cd9922 100644 --- a/src/domain/javascript/javascriptRecovery.ts +++ b/src/domain/javascript/javascriptRecovery.ts @@ -1,21 +1,21 @@ import { z } from "zod"; import { digestSchema } from "../digests.js"; import { analyzeJavaScriptApplicationInputSchema } from "./javascriptApplicationAnalysis.js"; -import { localPathStringSchema } from "../localPath.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; /** Recover derived readable modules from explicitly selected local scripts. */ export const javascriptRecoveryInputSchema = z.strictObject({ - path: localPathStringSchema.describe( + path: absoluteLocalPathStringSchema.describe( "Absolute path to a UTF-8 JavaScript file; never executed", ), additional_paths: z - .array(localPathStringSchema) + .array(absoluteLocalPathStringSchema) .max(16) .optional() .describe( "Additional absolute UTF-8 JavaScript files recovered together with path; each is snapshotted separately so imports can resolve across related chunks. All inputs share a 64 MiB total budget", ), - output_directory: localPathStringSchema.describe( + output_directory: absoluteLocalPathStringSchema.describe( "Absolute absent output directory; parent must exist", ), extraction_mode: z diff --git a/src/domain/native/appleIps.ts b/src/domain/native/appleIps.ts index b390b8de5a..b9d3a9fff2 100644 --- a/src/domain/native/appleIps.ts +++ b/src/domain/native/appleIps.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { digestSchema } from "../digests.js"; -import { localPathStringSchema } from "../localPath.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; const uint = z.number().int().min(0).max(0xffffffff); const recordedIntegerSchema = z.strictObject({ @@ -52,7 +52,7 @@ export const APPLE_IPS_IMAGE_SOURCE_MEANING: Readonly< /** Complete bounded inspection of one Apple .ips crash report. */ export const inspectAppleIpsInputSchema = z.strictObject({ - path: localPathStringSchema.describe( + path: absoluteLocalPathStringSchema.describe( "Absolute filesystem path to an Apple .ips crash report", ), max_file_bytes: z diff --git a/src/domain/native/byteEntropy.ts b/src/domain/native/byteEntropy.ts index c4f04082ec..6c21e3d94a 100644 --- a/src/domain/native/byteEntropy.ts +++ b/src/domain/native/byteEntropy.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { localPathStringSchema } from "../localPath.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; const offsetSchema = z .number() @@ -10,7 +10,9 @@ const entropySchema = z.number().min(0).max(8); /** Select blocks relative to an explicit byte window; identity remains file-wide. */ export const inspectByteEntropyInputSchema = z.strictObject({ - path: localPathStringSchema, + path: absoluteLocalPathStringSchema.describe( + "Absolute filesystem path to the file", + ), block_size: z .number() .int() diff --git a/src/domain/native/elfNotes.ts b/src/domain/native/elfNotes.ts index 915b009332..0c67c43dc9 100644 --- a/src/domain/native/elfNotes.ts +++ b/src/domain/native/elfNotes.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { digestSchema } from "../digests.js"; -import { localPathStringSchema } from "../localPath.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; const uint = z.number().int().min(0).max(0xffffffff); const range = z.strictObject({ offset: uint, bytes: uint }); @@ -86,7 +86,7 @@ export const coreNoteTypeName = (type: number): ElfNoteTypeName => { /** Complete bounded inspection of ELF PT_NOTE and SHT_NOTE tables. */ export const inspectElfNotesInputSchema = z.strictObject({ - path: localPathStringSchema.describe( + path: absoluteLocalPathStringSchema.describe( "Absolute filesystem path to an ELF image", ), max_file_bytes: z diff --git a/src/domain/native/kernelModule.ts b/src/domain/native/kernelModule.ts index cb984edb14..944fb55eaa 100644 --- a/src/domain/native/kernelModule.ts +++ b/src/domain/native/kernelModule.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { digestSchema } from "../digests.js"; import { fixedTupleSchema } from "../fixedTupleSchema.js"; -import { localPathStringSchema } from "../localPath.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; import { ELF_OBJECT_TYPE_NAMES } from "./elfNotes.js"; const uint = z.number().int().min(0).max(0xffffffff); @@ -197,7 +197,7 @@ const signatureSchema = z.strictObject({ /** Complete bounded inspection of one relocatable Linux kernel module. */ export const inspectKernelModuleInputSchema = z.strictObject({ - path: localPathStringSchema.describe( + path: absoluteLocalPathStringSchema.describe( "Absolute filesystem path to an ELF kernel module or a compressed .ko container", ), max_file_bytes: z diff --git a/src/domain/webScriptExport.ts b/src/domain/webScriptExport.ts index 9e1e914620..331c06c7ac 100644 --- a/src/domain/webScriptExport.ts +++ b/src/domain/webScriptExport.ts @@ -3,14 +3,14 @@ import { z } from "zod"; import { browserCompletenessSchema } from "./browserCompleteness.js"; import { browserScenarioCompletenessSchema } from "./browserScenarioCaptureValues.js"; import { digestSchema, prefixedDigestSchema } from "./digests.js"; -import { localPathStringSchema } from "./localPath.js"; +import { absoluteLocalPathStringSchema } from "./localPath.js"; /** Export already captured scripts into one absent, caller-selected directory. */ export const exportWebScriptsInputSchema = z.strictObject({ - capture_path: localPathStringSchema.describe( + capture_path: absoluteLocalPathStringSchema.describe( "Absolute filesystem path to saved inspect_web_page or capture_browser_scenario JSON, normalized result or complete Evidence", ), - output_directory: localPathStringSchema.describe( + output_directory: absoluteLocalPathStringSchema.describe( "Absolute absent filesystem directory for verified scripts and manifest; parent directory must exist", ), }); diff --git a/src/javascript/recovery/RecoveryFiles.ts b/src/javascript/recovery/RecoveryFiles.ts index d17937520c..e4830ee3e3 100644 --- a/src/javascript/recovery/RecoveryFiles.ts +++ b/src/javascript/recovery/RecoveryFiles.ts @@ -261,11 +261,6 @@ const recoveryInputLayoutPath = async ( path: string, signal?: AbortSignal, ): Promise => { - if (!isAbsolute(path)) - throw recoveryInputFailure( - "path", - "Select an absolute JavaScript file path", - ); try { checkRecoveryCancellation(signal); const parent = dirname(path); diff --git a/tests/acceptance/nonBinaryComposition.test.ts b/tests/acceptance/nonBinaryComposition.test.ts index 386d0fe71a..5f69868106 100644 --- a/tests/acceptance/nonBinaryComposition.test.ts +++ b/tests/acceptance/nonBinaryComposition.test.ts @@ -241,14 +241,34 @@ cliTest( }); expect(result.exitCode).toBe(1); expect(response.isError).toBe(true); - expect(parseMcpToolError(response)).toEqual({ error: result.json }); expect(result.json).toMatchObject({ - code: "invalid_request", - details: { - operation: "recover_javascript_sources", - issues: [{ path: [field], reason: "invalid_format" }], + code: "VALIDATION_ERROR", + fieldErrors: [ + expect.objectContaining({ + path: field === "path" ? "path" : "outputDirectory", + code: "invalid_format", + }), + ], + }); + expect(parseMcpToolError(response)).toMatchObject({ + error: { + code: "invalid_request", + details: { + operation: "recover_javascript_sources", + issues: [ + { + path: [field], + reason: "invalid_format", + expected: "regex", + }, + ], + }, }, }); + expect(JSON.stringify(result.json)).not.toContain("REA_WAKARU_COMMAND"); + expect(JSON.stringify(parseMcpToolError(response))).not.toContain( + "REA_WAKARU_COMMAND", + ); } }, ); diff --git a/tests/boundary/cli/cliInputSchemaRequired.test.ts b/tests/boundary/cli/cliInputSchemaRequired.test.ts index 747bbc2780..5a9f7a617e 100644 --- a/tests/boundary/cli/cliInputSchemaRequired.test.ts +++ b/tests/boundary/cli/cliInputSchemaRequired.test.ts @@ -158,7 +158,11 @@ describe("CLI advertised input schemas", () => { const validate = ajv.compile(schema.options ?? {}); for (const count of [16, 17]) { const input = { - [field]: Array.from({ length: count }, () => "./selected"), + [field]: Array.from({ length: count }, () => + command === "recover-javascript-sources" + ? "/selected" + : "./selected", + ), }; expect(validate(input)).toBe(count === 16); expect( diff --git a/tests/boundary/filesystem/webScriptExport.test.ts b/tests/boundary/filesystem/webScriptExport.test.ts index 9cbbccf650..a4bb2e544b 100644 --- a/tests/boundary/filesystem/webScriptExport.test.ts +++ b/tests/boundary/filesystem/webScriptExport.test.ts @@ -795,8 +795,7 @@ describe("captured script path validation", () => { { path: ["capture_path"], reason: "invalid_format", - message: - "capture_path must be an absolute filesystem path on this host.", + expected: "regex", }, ], }); @@ -815,8 +814,7 @@ describe("captured script path validation", () => { { path: ["output_directory"], reason: "invalid_format", - message: - "output_directory must be an absolute filesystem path on this host.", + expected: "regex", }, ], }); diff --git a/tests/boundary/mcp/absoluteLocalPathTools.test.ts b/tests/boundary/mcp/absoluteLocalPathTools.test.ts new file mode 100644 index 0000000000..e71e707e6a --- /dev/null +++ b/tests/boundary/mcp/absoluteLocalPathTools.test.ts @@ -0,0 +1,117 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import type { AnySchema } from "ajv"; +import { expect, it } from "vitest"; + +import { JAVASCRIPT_RECOVERY_TOOL_CONTRACTS } from "../../../src/contracts/javascript/javascriptRecoveryToolContracts.js"; +import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "../../../src/contracts/native/binaryDiagnosticsToolContracts.js"; +import { WEB_SCRIPT_TOOL_CONTRACTS } from "../../../src/contracts/webScriptToolContracts.js"; +import { RequestTrackingMcpServer } from "../../../src/server/RequestTrackingMcpServer.js"; +import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js"; + +const CONTRACTS = [ + ...BINARY_DIAGNOSTICS_TOOL_CONTRACTS, + ...WEB_SCRIPT_TOOL_CONTRACTS, + ...JAVASCRIPT_RECOVERY_TOOL_CONTRACTS, +]; + +const PATH_FIELDS = { + inspect_apple_ips: ["path"], + inspect_elf_notes: ["path"], + inspect_kernel_module: ["path"], + inspect_byte_entropy: ["path"], + export_web_scripts: ["capture_path", "output_directory"], + recover_javascript_sources: ["path", "output_directory", "additional_paths"], +} as const; + +const absoluteFile = + process.platform === "win32" ? "C:\\sample.bin" : "/tmp/sample.bin"; +const absoluteDirectory = process.platform === "win32" ? "C:\\out" : "/tmp/out"; + +const contractFor = (name: keyof typeof PATH_FIELDS) => { + const contract = CONTRACTS.find((candidate) => candidate.name === name); + if (contract === undefined) throw new Error(`Missing contract ${name}`); + return contract; +}; + +const validInput = ( + name: keyof typeof PATH_FIELDS, +): Record => { + if (name === "export_web_scripts") + return { + capture_path: absoluteFile, + output_directory: absoluteDirectory, + }; + if (name === "recover_javascript_sources") + return { + path: absoluteFile, + output_directory: absoluteDirectory, + additional_paths: [absoluteFile], + }; + return { path: absoluteFile }; +}; + +const withRelativeField = ( + name: keyof typeof PATH_FIELDS, + field: string, +): Record => ({ + ...validInput(name), + [field]: field === "additional_paths" ? ["sample.bin"] : "sample.bin", +}); + +it("rejects one relative path field at a time on tools/list", async () => { + const server = new RequestTrackingMcpServer({ + name: "absolute-path-schema", + version: "1", + }); + for (const name of Object.keys(PATH_FIELDS) as (keyof typeof PATH_FIELDS)[]) { + const contract = contractFor(name); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + () => ({ content: [] }), + ); + } + const client = new Client({ name: "absolute-path-client", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const checked: string[] = []; + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const listed = await client.listTools(); + for (const name of Object.keys( + PATH_FIELDS, + ) as (keyof typeof PATH_FIELDS)[]) { + const schema = listed.tools.find( + (tool) => tool.name === name, + )?.inputSchema; + expect(schema, name).toBeDefined(); + if (schema === undefined) throw new Error(`Missing schema ${name}`); + const validate = ajv.compile(schema as AnySchema); + expect(validate(validInput(name)), `${name} valid`).toBe(true); + for (const field of PATH_FIELDS[name]) { + expect( + validate(withRelativeField(name, field)), + `${name}.${field}`, + ).toBe(false); + checked.push(`${name}.${field}`); + } + } + } finally { + await client.close(); + await server.close(); + } + expect(checked).toEqual([ + "inspect_apple_ips.path", + "inspect_elf_notes.path", + "inspect_kernel_module.path", + "inspect_byte_entropy.path", + "export_web_scripts.capture_path", + "export_web_scripts.output_directory", + "recover_javascript_sources.path", + "recover_javascript_sources.output_directory", + "recover_javascript_sources.additional_paths", + ]); +});