| author | |
| committer | |
| log | dff412c3411f34b3150983f00686a98b600526e3 |
| tree | f082ed5b061fb7a8f144733dfad583d255831a6d |
| parent | 17c0878941ab8ab19108fb639c5138d92a89e919 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 104 insertions(+), 0 deletions(-)
apps/server/package.json+1| ... | @@ -51,6 +51,7 @@ | ... | @@ -51,6 +51,7 @@ |
| 51 | }, | 51 | }, |
| 52 | "devDependencies": { | 52 | "devDependencies": { |
| 53 | "@effect/vitest": "catalog:", | 53 | "@effect/vitest": "catalog:", |
| 54 | "@modelcontextprotocol/sdk": "1.29.0", | ||
| 54 | "@t3tools/contracts": "workspace:*", | 55 | "@t3tools/contracts": "workspace:*", |
| 55 | "@t3tools/shared": "workspace:*", | 56 | "@t3tools/shared": "workspace:*", |
| 56 | "@t3tools/ssh": "workspace:*", | 57 | "@t3tools/ssh": "workspace:*", |
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+29| ... | @@ -1,5 +1,7 @@ | ... | @@ -1,5 +1,7 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { describe, expect, it } from "@effect/vitest"; | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; | ||
| 4 | import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation"; | ||
| 3 | import { | 5 | import { |
| 4 | CommandId, | 6 | CommandId, |
| 5 | EnvironmentId, | 7 | EnvironmentId, |
| ... | @@ -766,6 +768,7 @@ describe("orchestrator MCP toolkit", () => { | ... | @@ -766,6 +768,7 @@ describe("orchestrator MCP toolkit", () => { |
| 766 | invokeAs(invocation, name, args); | 768 | invokeAs(invocation, name, args); |
| 767 | 769 | ||
| 768 | const pinned = yield* invoke("t3_thread_organize", { action: "pin" }); | 770 | const pinned = yield* invoke("t3_thread_organize", { action: "pin" }); |
| 771 | expect(pinned.isError).toBe(false); | ||
| 769 | expect(pinned.structuredContent).toHaveProperty("sequence"); | 772 | expect(pinned.structuredContent).toHaveProperty("sequence"); |
| 770 | expect((yield* orchestrator.getThreadShell(parentThreadId))?.pinnedAt).not.toBeNull(); | 773 | expect((yield* orchestrator.getThreadShell(parentThreadId))?.pinnedAt).not.toBeNull(); |
| 771 | yield* invoke("t3_thread_organize", { action: "unpin" }); | 774 | yield* invoke("t3_thread_organize", { action: "unpin" }); |
| ... | @@ -1211,10 +1214,36 @@ describe("orchestrator MCP toolkit", () => { | ... | @@ -1211,10 +1214,36 @@ describe("orchestrator MCP toolkit", () => { |
| 1211 | return yield* Effect.die(new Error("Queued user follow-up missing.")); | 1214 | return yield* Effect.die(new Error("Queued user follow-up missing.")); |
| 1212 | } | 1215 | } |
| 1213 | const queueFirstPage = yield* invoke("t3_queue_list", { limit: 1 }); | 1216 | const queueFirstPage = yield* invoke("t3_queue_list", { limit: 1 }); |
| 1217 | expect(queueFirstPage.isError).toBe(false); | ||
| 1214 | expect(queueFirstPage.structuredContent).toMatchObject({ | 1218 | expect(queueFirstPage.structuredContent).toMatchObject({ |
| 1215 | items: [{ queuedRunId: queueRace.queuedRun.id }], | 1219 | items: [{ queuedRunId: queueRace.queuedRun.id }], |
| 1216 | nextCursor: 1, | 1220 | nextCursor: 1, |
| 1217 | }); | 1221 | }); |
| 1222 | const queueDefinition = server.tools.find(({ tool }) => tool.name === "t3_queue_list"); | ||
| 1223 | const validateQueue = new AjvJsonSchemaValidator().getValidator( | ||
| 1224 | queueDefinition!.tool.outputSchema! as JsonSchemaType, | ||
| 1225 | ); | ||
| 1226 | expect(validateQueue(queueFirstPage.structuredContent).valid).toBe(true); | ||
| 1227 | const missingThreadId = ThreadId.make("00000000-0000-4000-8000-000000000000"); | ||
| 1228 | const missingThreadQueue = yield* invoke("t3_queue_list", { | ||
| 1229 | threadId: missingThreadId, | ||
| 1230 | limit: 1, | ||
| 1231 | }); | ||
| 1232 | expect(missingThreadQueue.isError).toBe(true); | ||
| 1233 | expect(declaredFailure(missingThreadQueue)).toMatchObject({ | ||
| 1234 | _tag: "OrchestratorMcpFailure", | ||
| 1235 | code: "thread_not_found", | ||
| 1236 | message: "The thread was not found.", | ||
| 1237 | }); | ||
| 1238 | expect(missingThreadQueue.structuredContent).toBeUndefined(); | ||
| 1239 | expect(validateQueue({ items: "invalid", nextCursor: null }).valid).toBe(false); | ||
| 1240 | const missingThreadRead = yield* invoke("t3_thread_read", { | ||
| 1241 | threadId: missingThreadId, | ||
| 1242 | }); | ||
| 1243 | expect(missingThreadRead.isError).toBe(true); | ||
| 1244 | expect(declaredFailure(missingThreadRead)).toMatchObject({ | ||
| 1245 | _tag: "OrchestratorMcpFailure", | ||
| 1246 | }); | ||
| 1218 | const queueSecondPage = yield* invoke("t3_queue_list", { cursor: 1, limit: 1 }); | 1247 | const queueSecondPage = yield* invoke("t3_queue_list", { cursor: 1, limit: 1 }); |
| 1219 | expect(queueSecondPage.structuredContent).toEqual({ | 1248 | expect(queueSecondPage.structuredContent).toEqual({ |
| 1220 | items: [{ queuedRunId: queuedUserRun.id, text: "🙂".repeat(1000), truncated: true }], | 1249 | items: [{ queuedRunId: queuedUserRun.id, text: "🙂".repeat(1000), truncated: true }], |
apps/server/src/mcp/toolkits/core.test.ts+71| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; | 1 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { expect, it } from "@effect/vitest"; | 3 | import { expect, it } from "@effect/vitest"; |
| 4 | import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; | ||
| 5 | import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation"; | ||
| 4 | import { | 6 | import { |
| 5 | DEFAULT_SERVER_SETTINGS, | 7 | DEFAULT_SERVER_SETTINGS, |
| 6 | ChatImageAttachment, | 8 | ChatImageAttachment, |
| ... | @@ -136,6 +138,8 @@ it.effect("checks capability before accessing services through the production re | ... | @@ -136,6 +138,8 @@ it.effect("checks capability before accessing services through the production re |
| 136 | Effect.provideService(McpSchema.McpServerClient, client), | 138 | Effect.provideService(McpSchema.McpServerClient, client), |
| 137 | ); | 139 | ); |
| 138 | expect(declaredFailure(result)).toMatchObject({ code: "capability_denied" }); | 140 | expect(declaredFailure(result)).toMatchObject({ code: "capability_denied" }); |
| 141 | expect(result.isError).toBe(true); | ||
| 142 | expect(result.structuredContent).toBeUndefined(); | ||
| 139 | }).pipe( | 143 | }).pipe( |
| 140 | Effect.provide( | 144 | Effect.provide( |
| 141 | McpHttpServer.layerThreadToolkit.pipe( | 145 | McpHttpServer.layerThreadToolkit.pipe( |
| ... | @@ -161,6 +165,22 @@ it.effect("returns a bounded public failure without serializing storage causes", | ... | @@ -161,6 +165,22 @@ it.effect("returns a bounded public failure without serializing storage causes", |
| 161 | code: "orchestration_error", | 165 | code: "orchestration_error", |
| 162 | message: "The operation could not be completed.", | 166 | message: "The operation could not be completed.", |
| 163 | }); | 167 | }); |
| 168 | expect(result.isError).toBe(true); | ||
| 169 | expect(result.content).toEqual([ | ||
| 170 | { | ||
| 171 | type: "text", | ||
| 172 | text: '{"_tag":"OrchestratorMcpFailure","code":"orchestration_error","message":"The operation could not be completed."}', | ||
| 173 | }, | ||
| 174 | ]); | ||
| 175 | const definition = server.tools.find(({ tool }) => tool.name === "t3_thread_organize"); | ||
| 176 | expect(definition?.tool.outputSchema).toBeDefined(); | ||
| 177 | const validate = new AjvJsonSchemaValidator().getValidator( | ||
| 178 | definition!.tool.outputSchema! as JsonSchemaType, | ||
| 179 | ); | ||
| 180 | expect(result.structuredContent).toBeUndefined(); | ||
| 181 | expect(validate({ sequence: 1 }).valid).toBe(true); | ||
| 182 | expect(validate({ code: "orchestration_error" }).valid).toBe(false); | ||
| 183 | expect(validate({ sequence: "invalid" }).valid).toBe(false); | ||
| 164 | }).pipe( | 184 | }).pipe( |
| 165 | Effect.provide( | 185 | Effect.provide( |
| 166 | McpHttpServer.layerThreadToolkit.pipe( | 186 | McpHttpServer.layerThreadToolkit.pipe( |
| ... | @@ -229,6 +249,57 @@ it.effect("returns an HTML render reference that Codex and Claude tool rows both | ... | @@ -229,6 +249,57 @@ it.effect("returns an HTML render reference that Codex and Claude tool rows both |
| 229 | ), | 249 | ), |
| 230 | ); | 250 | ); |
| 231 | 251 | ||
| 252 | it.effect("returns invalid parameter errors through the production registration", () => | ||
| 253 | Effect.gen(function* () { | ||
| 254 | const server = yield* McpServer.McpServer; | ||
| 255 | const error = yield* server | ||
| 256 | .callTool({ name: "t3_thread_organize", arguments: { action: "invalid" } }) | ||
| 257 | .pipe( | ||
| 258 | Effect.provideService(McpInvocationContext.McpInvocationContext, scope), | ||
| 259 | Effect.provideService(McpSchema.McpServerClient, client), | ||
| 260 | Effect.flip, | ||
| 261 | ); | ||
| 262 | expect(error._tag).toBe("InvalidParams"); | ||
| 263 | }).pipe( | ||
| 264 | Effect.provide( | ||
| 265 | McpHttpServer.layerThreadToolkit.pipe( | ||
| 266 | Layer.provideMerge(McpServer.McpServer.layer), | ||
| 267 | Layer.provide(NodeCrypto.layer), | ||
| 268 | Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), | ||
| 269 | ), | ||
| 270 | ), | ||
| 271 | ), | ||
| 272 | ); | ||
| 273 | |||
| 274 | it.effect("keeps unexpected handler defects private through the production registration", () => | ||
| 275 | Effect.gen(function* () { | ||
| 276 | const server = yield* McpServer.McpServer; | ||
| 277 | const result = yield* server | ||
| 278 | .callTool({ name: "t3_thread_organize", arguments: { action: "pin" } }) | ||
| 279 | .pipe( | ||
| 280 | Effect.provideService(McpInvocationContext.McpInvocationContext, scope), | ||
| 281 | Effect.provideService(McpSchema.McpServerClient, client), | ||
| 282 | ); | ||
| 283 | expect(result.isError).toBe(true); | ||
| 284 | expect(result.structuredContent).toBeUndefined(); | ||
| 285 | expect(result.content).toEqual([ | ||
| 286 | { type: "text", text: "Tool execution failed due to an internal server error." }, | ||
| 287 | ]); | ||
| 288 | }).pipe( | ||
| 289 | Effect.provide( | ||
| 290 | McpHttpServer.layerThreadToolkit.pipe( | ||
| 291 | Layer.provideMerge(McpServer.McpServer.layer), | ||
| 292 | Layer.provide(NodeCrypto.layer), | ||
| 293 | Layer.provide( | ||
| 294 | Layer.mock(ThreadManagement.ThreadManagementService)({ | ||
| 295 | getThreadShell: () => Effect.die(new Error("private-storage-path")), | ||
| 296 | }), | ||
| 297 | ), | ||
| 298 | ), | ||
| 299 | ), | ||
| 300 | ), | ||
| 301 | ); | ||
| 302 | |||
| 232 | it("keeps MCP preference output allowlisted and Unicode-bounded", () => { | 303 | it("keeps MCP preference output allowlisted and Unicode-bounded", () => { |
| 233 | const settings = { | 304 | const settings = { |
| 234 | ...DEFAULT_SERVER_SETTINGS, | 305 | ...DEFAULT_SERVER_SETTINGS, |
pnpm-lock.yaml+3| ... | @@ -586,6 +586,9 @@ importers: | ... | @@ -586,6 +586,9 @@ importers: |
| 586 | '@effect/vitest': | 586 | '@effect/vitest': |
| 587 | specifier: 4.0.1 | 587 | specifier: 4.0.1 |
| 588 | 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) | 588 | 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) |
| 589 | '@modelcontextprotocol/sdk': | ||
| 590 | specifier: 1.29.0 | ||
| 591 | version: 1.29.0(zod@4.6.5) | ||
| 589 | '@t3tools/contracts': | 592 | '@t3tools/contracts': |
| 590 | specifier: workspace:* | 593 | specifier: workspace:* |
| 591 | version: link:../../packages/contracts | 594 | version: link:../../packages/contracts |