From 2fa31fea73722801f08353b93fb0acd795a5a0cf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 02:22:19 +0000 Subject: [PATCH 1/4] test(server): reproduce #25723, t3_thread_configure accepts unadvertised models These tests reproduce pingdotgg/t3code#25723: t3_thread_configure dispatches a model selection the provider instance does not advertise, where delegate_task refuses it with model_unavailable. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/core.test.ts | 72 +++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index bab0eeac369dc..9b2ab72459d40 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -572,6 +572,78 @@ it.effect("a read-only client reads threads and is refused every write before it ); const dispatched: Array = []; +it.effect("t3_thread_configure refuses a model the provider instance does not advertise", () => { + const dispatchedModels: Array = []; + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const configure = (model: string) => + server + .callTool({ + name: "t3_thread_configure", + arguments: { + threadId: "other-project-thread", + modelSelection: { instanceId: "codex", model }, + }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + const refused = yield* configure("not-advertised"); + expect(declaredFailure(refused)).toMatchObject({ code: "model_unavailable" }); + expect(dispatchedModels).toEqual([]); + + const advertised = yield* configure("gpt-5.5"); + expect(advertised.isError).toBe(false); + expect(dispatchedModels).toEqual(["gpt-5.5"]); + }).pipe( + Effect.provide( + layerThreadToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + { + instanceId: ProviderInstanceId.make("codex"), + enabled: true, + status: "ready", + models: [{ slug: "gpt-5.5", name: "GPT-5.5" }], + } as never, + ]), + }), + ), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => + Effect.succeed(McpToolAccessTestkit.liveThreadShell(id, { runtimeMode: "auto" })), + getProjectThreadRecords: () => + Effect.succeed({ + thread: { + id: ThreadId.make("other-project-thread"), + projectId: "other-project", + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: "codex", model: "gpt-5.5" }, + runtimeMode: "auto", + interactionMode: "default", + deletedAt: null, + }, + } as never), + dispatch: (command) => + Effect.sync(() => { + if (command.type === "thread.model-selection.set") { + dispatchedModels.push(command.modelSelection.model); + } + return { sequence: 7, storedEvents: [] }; + }), + }), + ), + ), + ), + ); +}); + it.effect("refuses act-as-caller tools to a client caller", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; From f3a2c1d97d395f02c6308cf292341f84479e214e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 02:32:12 +0000 Subject: [PATCH 2/4] fix(server): t3_thread_configure refuses models the provider does not advertise t3_thread_configure dispatched any model slug, while delegate_task refuses one the target provider instance does not advertise. Both now share requireAdvertisedModel and check the target instance (modelSelection.instanceId), so same-instance and cross-instance changes fail with model_unavailable and dispatch nothing. An unregistered instance fails provider_unavailable, as in delegate_task. The tool declares ProviderRegistry as a dependency; the server already provides it to the MCP layer. Fixes pingdotgg/t3code#25723 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/OrchestratorMcpService.ts | 23 ++++++++++--------- apps/server/src/mcp/toolkits/core.test.ts | 9 ++++++++ .../src/mcp/toolkits/thread/handlers.ts | 12 ++++++++++ apps/server/src/mcp/toolkits/thread/tools.ts | 2 ++ 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 54c9717671087..50935b4ce2e9a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -362,6 +362,17 @@ function capabilityModelOption(descriptor: ProviderOptionDescriptor): Orchestrat }; } +/** A provider that lists no models accepts any slug. */ +export const requireAdvertisedModel = (provider: ServerProvider, model: string) => + provider.models.length > 0 && !provider.models.some((candidate) => candidate.slug === model) + ? Effect.fail( + failure( + "model_unavailable", + `Model ${model} is not advertised by provider ${provider.instanceId}.`, + ), + ) + : Effect.void; + function providerConstraints( provider: ServerProvider | undefined, supportsOrchestrationV2: boolean, @@ -1421,17 +1432,7 @@ const make = Effect.gen(function* () { `Provider ${instanceId} has no model available for inheritance.`, ); } - if ( - requestedModel !== undefined && - provider !== undefined && - provider.models.length > 0 && - !provider.models.some((candidate) => candidate.slug === requestedModel) - ) { - return yield* failure( - "model_unavailable", - `Model ${requestedModel} is not advertised by provider ${instanceId}.`, - ); - } + if (requestedModel !== undefined) yield* requireAdvertisedModel(provider, requestedModel); const requestedOptions = input.target?.options; if (requestedOptions !== undefined) { diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 9b2ab72459d40..a35f7b465cbc9 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -73,6 +73,9 @@ const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe( Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), ); +const layerNoProviders = Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), +}); const threadId = ThreadId.make("mcp-core-thread"); const scope: McpInvocationContext.McpInvocationScope = { @@ -121,6 +124,7 @@ it.effect("checks capability through the production registration", () => layerThreadToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), + Layer.provide(layerNoProviders), Layer.provide(McpToolAccessTestkit.liveThreadsLayer), ), ), @@ -156,6 +160,7 @@ it.effect("an answered question reports already answered instead of not found", layerThreadToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), + Layer.provide(layerNoProviders), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: () => Effect.succeed(shell), @@ -315,6 +320,7 @@ it.effect("records only failed calls of tools that hand off no work", () => { layerThreadToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), + Layer.provide(layerNoProviders), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), @@ -362,6 +368,7 @@ it.effect("keeps unexpected handler defects private through the production regis layerThreadToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), + Layer.provide(layerNoProviders), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: () => Effect.die(new Error("private-storage-path")), @@ -480,6 +487,7 @@ it.effect("a client caller targets any thread within its ceiling and cannot act layerThreadToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), + Layer.provide(layerNoProviders), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: (id) => @@ -540,6 +548,7 @@ it.effect("a read-only client reads threads and is refused every write before it layerThreadToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), + Layer.provide(layerNoProviders), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: () => diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 8d7d1359e8c9e..3dfbf4f41319a 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -12,6 +12,7 @@ import { modelSelectionCommandType } from "@t3tools/shared/model"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; import * as McpToolAccess from "../../McpToolAccess.ts"; +import * as OrchestrationMcp from "../../OrchestratorMcpService.ts"; import { dispatchFailure, newCommandId, @@ -20,6 +21,7 @@ import { unavailable, } from "../../threadAccess.ts"; import * as ThreadSearch from "../../../orchestration-v2/ThreadSearch.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as ScheduledTasks from "../../../scheduledTasks/ScheduledTaskService.ts"; import { queuedRunsInDeliveryOrder } from "../../../orchestration-v2/QueuedRunOrder.ts"; import { ThreadToolkit } from "./tools.ts"; @@ -206,6 +208,16 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { threads, projection: { thread }, } = yield* readThread(input.threadId); + const providers = yield* (yield* ProviderRegistry.ProviderRegistry).getProviders; + const provider = providers.find( + (candidate) => candidate.instanceId === modelSelection.instanceId, + ); + if (provider === undefined) + return yield* new OrchestratorMcpFailure({ + code: "provider_unavailable", + message: `Provider instance ${modelSelection.instanceId} is not registered.`, + }); + yield* OrchestrationMcp.requireAdvertisedModel(provider, modelSelection.model); const type = modelSelectionCommandType(thread.providerInstanceId, modelSelection); const result = yield* threads .dispatch({ type, threadId: thread.id, commandId: yield* newCommandId(), modelSelection }) diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 1e73f180bdf0f..8606488a03743 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -26,6 +26,7 @@ import { Tool, Toolkit } from "effect/ai"; import * as ThreadSearch from "../../../orchestration-v2/ThreadSearch.ts"; import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; const ThreadOrganizeTool = Tool.make("t3_thread_organize", { @@ -154,6 +155,7 @@ const ThreadConfigureTool = Tool.make("t3_thread_configure", { modelSelection: Schema.optional(ModelSelection), }), success: Schema.Union([threadConfiguration, OrchestrationV2DispatchCommandResult]), + dependencies: [...commandTool.dependencies, ProviderRegistry.ProviderRegistry], }).annotate(Tool.Destructive, true); const ThreadTransferTool = Tool.make("t3_thread_transfer", { From d287732bd9cd43cc62838956311c0cbc75299409 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 02:40:37 +0000 Subject: [PATCH 3/4] test(server): cover unregistered instances in t3_thread_configure refusal Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/core.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index a35f7b465cbc9..fdc6260da6950 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -579,19 +579,18 @@ it.effect("a read-only client reads threads and is refused every write before it ), ), ); -const dispatched: Array = []; it.effect("t3_thread_configure refuses a model the provider instance does not advertise", () => { const dispatchedModels: Array = []; return Effect.gen(function* () { const server = yield* McpServer.McpServer; - const configure = (model: string) => + const configure = (instanceId: string, model: string) => server .callTool({ name: "t3_thread_configure", arguments: { threadId: "other-project-thread", - modelSelection: { instanceId: "codex", model }, + modelSelection: { instanceId, model }, }, }) .pipe( @@ -599,11 +598,13 @@ it.effect("t3_thread_configure refuses a model the provider instance does not ad Effect.provideService(McpSchema.McpServerClient, client), ); - const refused = yield* configure("not-advertised"); + const refused = yield* configure("codex", "not-advertised"); expect(declaredFailure(refused)).toMatchObject({ code: "model_unavailable" }); + const unregistered = yield* configure("missing", "gpt-5.5"); + expect(declaredFailure(unregistered)).toMatchObject({ code: "provider_unavailable" }); expect(dispatchedModels).toEqual([]); - const advertised = yield* configure("gpt-5.5"); + const advertised = yield* configure("codex", "gpt-5.5"); expect(advertised.isError).toBe(false); expect(dispatchedModels).toEqual(["gpt-5.5"]); }).pipe( @@ -641,7 +642,10 @@ it.effect("t3_thread_configure refuses a model the provider instance does not ad } as never), dispatch: (command) => Effect.sync(() => { - if (command.type === "thread.model-selection.set") { + if ( + command.type === "thread.model-selection.set" || + command.type === "provider.switch" + ) { dispatchedModels.push(command.modelSelection.model); } return { sequence: 7, storedEvents: [] }; @@ -653,6 +657,8 @@ it.effect("t3_thread_configure refuses a model the provider instance does not ad ); }); +const dispatched: Array = []; + it.effect("refuses act-as-caller tools to a client caller", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; From 1e30b93a2f1efb3985f9e94c6c949a6e358dd847 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:08:00 +0000 Subject: [PATCH 4/4] test(server): provide the provider registry to thread toolkit tests from main AI-assisted; verified with the tests and tools listed above. --- apps/server/src/mcp/toolkits/core.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 4205e4ea03076..7b664bd8758ca 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -409,6 +409,7 @@ it.effect("rejects an unknown organize target instead of archiving the calling t layerThreadToolkit.pipe( Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provide(NodeCrypto.layer), + Layer.provide(layerNoProviders), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), @@ -688,7 +689,7 @@ it.effect("t3_thread_configure refuses a model the provider instance does not ad }).pipe( Effect.provide( layerThreadToolkit.pipe( - Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provide(NodeCrypto.layer), Layer.provide( Layer.mock(ProviderRegistry.ProviderRegistry)({ @@ -877,6 +878,7 @@ it.effect.each([ McpHttpServer.layerThreadToolkit.pipe( Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provide(NodeCrypto.layer), + Layer.provide(layerNoProviders), Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),