diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 066722ea6625f..9c6c13e695bfe 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -350,6 +350,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, @@ -1391,16 +1402,7 @@ const make = Effect.gen(function* () { `Provider ${instanceId} has no model available for inheritance.`, ); } - if ( - requestedModel !== 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 descriptors = provider.models.find((candidate) => candidate.slug === model)?.capabilities ?.optionDescriptors; diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index fd07107b08bc6..7b664bd8758ca 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -83,6 +83,9 @@ const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe( Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})), Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})), ); +const layerNoProviders = Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), +}); const threadId = ThreadId.make("mcp-core-thread"); const scope: McpInvocationContext.McpInvocationScope = { @@ -131,6 +134,7 @@ it.effect("checks capability through the production registration", () => layerThreadToolkit.pipe( Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provide(NodeCrypto.layer), + Layer.provide(layerNoProviders), Layer.provide(McpToolAccessTestkit.liveThreadsLayer), ), ), @@ -166,6 +170,7 @@ it.effect("an answered question reports already answered instead of not found", layerThreadToolkit.pipe( Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provide(NodeCrypto.layer), + Layer.provide(layerNoProviders), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: () => Effect.succeed(shell), @@ -351,6 +356,7 @@ it.effect("records only failed calls of tools that hand off no work", () => { 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)), @@ -403,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)), @@ -440,6 +447,7 @@ it.effect("keeps unexpected handler defects private through the production regis layerThreadToolkit.pipe( Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provide(NodeCrypto.layer), + Layer.provide(layerNoProviders), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: () => Effect.die(new Error("private-storage-path")), @@ -558,6 +566,7 @@ it.effect("a client caller targets any thread within its ceiling and cannot act layerThreadToolkit.pipe( Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provide(NodeCrypto.layer), + Layer.provide(layerNoProviders), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: (id) => @@ -618,6 +627,7 @@ it.effect("a read-only client reads threads and is refused every write before it layerThreadToolkit.pipe( Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provide(NodeCrypto.layer), + Layer.provide(layerNoProviders), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: () => @@ -648,6 +658,84 @@ it.effect("a read-only client reads threads and is refused every write before it ), ), ); + +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 = (instanceId: string, model: string) => + server + .callTool({ + name: "t3_thread_configure", + arguments: { + threadId: "other-project-thread", + modelSelection: { instanceId, model }, + }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + 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("codex", "gpt-5.5"); + expect(advertised.isError).toBe(false); + expect(dispatchedModels).toEqual(["gpt-5.5"]); + }).pipe( + Effect.provide( + layerThreadToolkit.pipe( + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), + 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" || + command.type === "provider.switch" + ) { + dispatchedModels.push(command.modelSelection.model); + } + return { sequence: 7, storedEvents: [] }; + }), + }), + ), + ), + ), + ); +}); + const dispatched: Array = []; it.effect("refuses act-as-caller tools to a client caller", () => @@ -790,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)({})), diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 32e45b5d0a1c0..c6d476c155971 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 * as McpInvocationContext from "../../McpInvocationContext.ts"; 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"; @@ -218,6 +220,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 c6670c4e953d3..efe656b510910 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -27,6 +27,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", { @@ -160,6 +161,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", {