diff --git a/apps/server/package.json b/apps/server/package.json index 1c86a4b402da23c4fa3fc6c991e63328c7b299fa..566526fcc37dba971c8a2238a08d22adeff4e0bf 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -51,6 +51,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", + "@modelcontextprotocol/sdk": "1.29.0", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/ssh": "workspace:*", diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index be29178896f8f15e739ba4f06f8b7bacedca998e..b099814840aeb98065abcc51f7c7db1537300661 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1,5 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; +import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation"; import { CommandId, EnvironmentId, @@ -766,6 +768,7 @@ describe("orchestrator MCP toolkit", () => { invokeAs(invocation, name, args); const pinned = yield* invoke("t3_thread_organize", { action: "pin" }); + expect(pinned.isError).toBe(false); expect(pinned.structuredContent).toHaveProperty("sequence"); expect((yield* orchestrator.getThreadShell(parentThreadId))?.pinnedAt).not.toBeNull(); yield* invoke("t3_thread_organize", { action: "unpin" }); @@ -1211,10 +1214,36 @@ describe("orchestrator MCP toolkit", () => { return yield* Effect.die(new Error("Queued user follow-up missing.")); } const queueFirstPage = yield* invoke("t3_queue_list", { limit: 1 }); + expect(queueFirstPage.isError).toBe(false); expect(queueFirstPage.structuredContent).toMatchObject({ items: [{ queuedRunId: queueRace.queuedRun.id }], nextCursor: 1, }); + const queueDefinition = server.tools.find(({ tool }) => tool.name === "t3_queue_list"); + const validateQueue = new AjvJsonSchemaValidator().getValidator( + queueDefinition!.tool.outputSchema! as JsonSchemaType, + ); + expect(validateQueue(queueFirstPage.structuredContent).valid).toBe(true); + const missingThreadId = ThreadId.make("00000000-0000-4000-8000-000000000000"); + const missingThreadQueue = yield* invoke("t3_queue_list", { + threadId: missingThreadId, + limit: 1, + }); + expect(missingThreadQueue.isError).toBe(true); + expect(declaredFailure(missingThreadQueue)).toMatchObject({ + _tag: "OrchestratorMcpFailure", + code: "thread_not_found", + message: "The thread was not found.", + }); + expect(missingThreadQueue.structuredContent).toBeUndefined(); + expect(validateQueue({ items: "invalid", nextCursor: null }).valid).toBe(false); + const missingThreadRead = yield* invoke("t3_thread_read", { + threadId: missingThreadId, + }); + expect(missingThreadRead.isError).toBe(true); + expect(declaredFailure(missingThreadRead)).toMatchObject({ + _tag: "OrchestratorMcpFailure", + }); const queueSecondPage = yield* invoke("t3_queue_list", { cursor: 1, limit: 1 }); expect(queueSecondPage.structuredContent).toEqual({ items: [{ queuedRunId: queuedUserRun.id, text: "🙂".repeat(1000), truncated: true }], diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index b3930f9c631d766e3db60bc0a1604008d19c37a2..f8aba7ec6e56049fd167e320d9a7d43be0adfbbf 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -1,6 +1,8 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; +import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation"; import { DEFAULT_SERVER_SETTINGS, ChatImageAttachment, @@ -136,6 +138,8 @@ it.effect("checks capability before accessing services through the production re Effect.provideService(McpSchema.McpServerClient, client), ); expect(declaredFailure(result)).toMatchObject({ code: "capability_denied" }); + expect(result.isError).toBe(true); + expect(result.structuredContent).toBeUndefined(); }).pipe( Effect.provide( McpHttpServer.layerThreadToolkit.pipe( @@ -161,6 +165,22 @@ it.effect("returns a bounded public failure without serializing storage causes", code: "orchestration_error", message: "The operation could not be completed.", }); + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + { + type: "text", + text: '{"_tag":"OrchestratorMcpFailure","code":"orchestration_error","message":"The operation could not be completed."}', + }, + ]); + const definition = server.tools.find(({ tool }) => tool.name === "t3_thread_organize"); + expect(definition?.tool.outputSchema).toBeDefined(); + const validate = new AjvJsonSchemaValidator().getValidator( + definition!.tool.outputSchema! as JsonSchemaType, + ); + expect(result.structuredContent).toBeUndefined(); + expect(validate({ sequence: 1 }).valid).toBe(true); + expect(validate({ code: "orchestration_error" }).valid).toBe(false); + expect(validate({ sequence: "invalid" }).valid).toBe(false); }).pipe( Effect.provide( McpHttpServer.layerThreadToolkit.pipe( @@ -229,6 +249,57 @@ it.effect("returns an HTML render reference that Codex and Claude tool rows both ), ); +it.effect("returns invalid parameter errors through the production registration", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const error = yield* server + .callTool({ name: "t3_thread_organize", arguments: { action: "invalid" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + Effect.flip, + ); + expect(error._tag).toBe("InvalidParams"); + }).pipe( + Effect.provide( + McpHttpServer.layerThreadToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + ), + ), + ), +); + +it.effect("keeps unexpected handler defects private through the production registration", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ name: "t3_thread_organize", arguments: { action: "pin" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(result.isError).toBe(true); + expect(result.structuredContent).toBeUndefined(); + expect(result.content).toEqual([ + { type: "text", text: "Tool execution failed due to an internal server error." }, + ]); + }).pipe( + Effect.provide( + McpHttpServer.layerThreadToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => Effect.die(new Error("private-storage-path")), + }), + ), + ), + ), + ), +); + it("keeps MCP preference output allowlisted and Unicode-bounded", () => { const settings = { ...DEFAULT_SERVER_SETTINGS, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7163ba7b1e79439b9fdcd92eb696ec3e00c93aa3..4d97a202489cf10838d44241b4cc62bb54e76f9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -586,6 +586,9 @@ importers: '@effect/vitest': specifier: 4.0.1 version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@modelcontextprotocol/sdk': + specifier: 1.29.0 + version: 1.29.0(zod@4.6.5) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts