diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 54c971767108..3e7a51dc31b7 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -251,6 +251,20 @@ export const threadNotFound = Effect.fn("OrchestratorMcpService.threadNotFound") ); }); +/** An instance that advertises no models accepts any slug. */ +export const assertModelAdvertised = ( + provider: Pick, + model: string, +): Effect.Effect => + provider.models.length === 0 || provider.models.some((candidate) => candidate.slug === model) + ? Effect.void + : Effect.fail( + failure( + "model_unavailable", + `Model ${model} is not advertised by provider ${provider.instanceId}.`, + ), + ); + function threadManagementFailure(error: unknown): OrchestratorMcpFailure { if (!isThreadManagementError(error)) return failure("orchestration_error", errorMessage(error)); switch (error._tag) { @@ -1421,17 +1435,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* assertModelAdvertised(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 bab0eeac369d..a107762fba45 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -72,6 +72,9 @@ const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadR const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe( Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide( + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + ), ); const threadId = ThreadId.make("mcp-core-thread"); @@ -572,6 +575,77 @@ 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 that delegate_task would refuse", () => { + // The caller's thread runs codex on gpt-5 (McpToolAccessTestkit.liveThreadShell), which codex no longer advertises. + const providers = [ + { instanceId: "codex", models: [{ slug: "gpt-5.5", name: "gpt-5.5" }] }, + { instanceId: "claude", models: [] }, + ]; + const configured: Array = []; + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const configure = (modelSelection: Record) => + server + .callTool({ name: "t3_thread_configure", arguments: { modelSelection } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + const refused = yield* configure({ instanceId: "codex", model: "gpt-6" }); + expect(declaredFailure(refused)).toEqual({ + _tag: "OrchestratorMcpFailure", + code: "model_unavailable", + message: "Model gpt-6 is not advertised by provider codex.", + }); + expect(configured).toEqual([]); + + expect((yield* configure({ instanceId: "codex", model: "gpt-5.5" })).isError).toBe(false); + expect((yield* configure({ instanceId: "claude", model: "any-slug" })).isError).toBe(false); + const optionsOnly = { + instanceId: "codex", + model: "gpt-5", + options: [{ id: "effort", value: "high" }], + }; + expect((yield* configure(optionsOnly)).isError).toBe(false); + expect(configured).toEqual([ + { instanceId: "codex", model: "gpt-5.5" }, + { instanceId: "claude", model: "any-slug" }, + optionsOnly, + ]); + }).pipe( + Effect.provide( + McpHttpServer.layerThreadToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed(providers as never), + }), + ), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), + getProjectThreadRecords: ({ threadId: id }) => + Effect.succeed( + McpToolAccessTestkit.idleThreadProjection( + McpToolAccessTestkit.liveThreadShell(id), + ) as never, + ), + dispatch: (command) => + Effect.sync(() => { + if ("modelSelection" in command) configured.push(command.modelSelection); + return { sequence: 7 } as never; + }), + }), + ), + ), + ), + ); +}); + it.effect("refuses act-as-caller tools to a client caller", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 8d7d1359e8c9..71c1a8ce98bc 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,18 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { threads, projection: { thread }, } = yield* readThread(input.threadId); + const { instanceId, model } = modelSelection; + // Keeping the current model only changes options, as an inherited model does in delegate_task. + if ( + instanceId !== thread.modelSelection.instanceId || + model !== thread.modelSelection.model + ) { + const registry = yield* ProviderRegistry.ProviderRegistry; + const providers = yield* registry.getProviders; + const provider = providers.find((candidate) => candidate.instanceId === instanceId); + if (provider !== undefined) + yield* OrchestrationMcp.assertModelAdvertised(provider, 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 1e73f180bdf0..c5066a35bb20 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -24,6 +24,7 @@ import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/ai"; import * as ThreadSearch from "../../../orchestration-v2/ThreadSearch.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -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", {