Skip to content
61 changes: 61 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2516,6 +2516,11 @@ describe("OrchestratorMcpService provider resolution", () => {
target: { options: {} },
expected: { ...inherited, options: [] },
},
{
name: "same model by alias",
target: { model: "GPT" },
expected: { ...inherited, model: "GPT" },
},
{
name: "change model",
target: { model: "other-model" },
Expand Down Expand Up @@ -2572,6 +2577,7 @@ describe("OrchestratorMcpService provider resolution", () => {
name: model,
isCustom: false,
capabilities: null,
...(model === inherited.model ? { aliases: ["gpt"] } : {}),
})),
},
providerSnapshot({ instanceId: peerId, driver, model: inherited.model }),
Expand Down Expand Up @@ -2602,6 +2608,61 @@ describe("OrchestratorMcpService provider resolution", () => {
}),
);

it.effect("validates options against the model a target names by alias", () =>
Effect.gen(function* () {
const dependencies = Layer.mergeAll(
NodeServices.layer,
Layer.mock(ThreadManagementService.ThreadManagementService)({
hasReceipt: () => Effect.succeed(false),
assertProviderInstanceEnabled: () => Effect.void,
getThreadRecords: (threadId) =>
Effect.succeed(threadId === parentThreadId ? parentProjection([]) : childProjection),
dispatch: () => Effect.die("an invalid target must not dispatch"),
}),
ServerSettings.layerTest(),
providerRegistryLayer([
{
...providerSnapshot({
instanceId: codexInstanceId,
driver: ProviderDriverKind.make("codex"),
}),
models: [
{
slug: "gpt-5.4",
name: "gpt-5.4",
aliases: ["gpt"],
isCustom: false,
capabilities: {
optionDescriptors: [{ id: "fastMode", label: "Fast", type: "boolean" }],
},
},
],
},
]),
adapterRegistryLayer([codexInstanceId]),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(RemoteDelegation.RemoteDelegation)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
);
yield* Effect.gen(function* () {
const service = yield* OrchestratorMcpService.OrchestratorMcpService;
for (const model of ["gpt-5.4", "GPT"]) {
const error = yield* service
.delegateTask(scope, {
task: "Review the change.",
target: yield* decodeTarget({ model, options: { bogus: "x" } }),
mode: "async",
clientRequestId: model,
})
.pipe(Effect.flip);
assert.equal(error.code, "invalid_request", model);
assert.include(error.message, "Unknown option bogus", model);
}
}).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(dependencies))));
}),
);

describe("resolveRuntimeMode", () => {
const muse = {
modelSelection: { instanceId: ProviderInstanceId.make("muse"), model: "muse-model" },
Expand Down
38 changes: 25 additions & 13 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,23 @@ function capabilityModelOption(descriptor: ProviderOptionDescriptor): Orchestrat
};
}

/** Yields the model a slug or alias names; a provider that lists no models accepts any slug. */
export const requireAdvertisedModel = (provider: ServerProvider, model: string) => {
const advertised = provider.models.find(
(candidate) =>
candidate.slug === model ||
candidate.aliases?.some((alias) => alias.toLowerCase() === model.toLowerCase()),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
return provider.models.length > 0 && advertised === undefined
? Effect.fail(
failure(
"model_unavailable",
`Model ${model} is not advertised by provider ${provider.instanceId}.`,
),
)
: Effect.succeed(advertised);
};

function providerConstraints(
provider: ServerProvider | undefined,
supportsOrchestrationV2: boolean,
Expand Down Expand Up @@ -1343,21 +1360,15 @@ 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}.`,
);
}
const slug =
requestedModel === undefined
? model
: ((yield* requireAdvertisedModel(provider, requestedModel))?.slug ?? model);

const requestedOptions = input.target?.options;
if (requestedOptions !== undefined) {
const descriptors = provider.models.find((candidate) => candidate.slug === model)
?.capabilities?.optionDescriptors;
const descriptors = provider.models.find((candidate) => candidate.slug === slug)?.capabilities
?.optionDescriptors;
const invalid = invalidOptionSelections(requestedOptions, descriptors);
if (invalid.length > 0) {
return yield* failure(
Expand All @@ -1368,7 +1379,8 @@ const make = Effect.gen(function* () {
}

const inheritsOptions =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High mcp/OrchestratorMcpService.ts:1381

When target.model is an accepted alias for the parent's selected model, inheritsOptions is false and delegateTask drops the parent's options from the child selection. slug canonicalizes only the requested model, while this comparison uses the raw inheritedSelection.model; canonicalize the inherited model with requireAdvertisedModel before comparing.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/mcp/OrchestratorMcpService.ts around line 1381:

When `target.model` is an accepted alias for the parent's selected model, `inheritsOptions` is false and `delegateTask` drops the parent's options from the child selection. `slug` canonicalizes only the requested model, while this comparison uses the raw `inheritedSelection.model`; canonicalize the inherited model with `requireAdvertisedModel` before comparing.

instanceId === inheritedSelection.instanceId && model === inheritedSelection.model;
instanceId === inheritedSelection.instanceId &&
(model === inheritedSelection.model || slug === inheritedSelection.model);
let options = inheritsOptions ? inheritedSelection.options : undefined;
if (requestedOptions !== undefined) {
const selections = new Map(
Expand Down
87 changes: 87 additions & 0 deletions apps/server/src/mcp/toolkits/project/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { expect, it } from "@effect/vitest";
import {
EnvironmentId,
ProjectId,
ProviderDriverKind,
ProviderInstanceId,
type Project as ProjectRecord,
type ModelSelection,
type RuntimeMode,
type OrchestrationV2ThreadShell,
type ServerProvider,
} from "@t3tools/contracts";
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
Expand All @@ -20,6 +22,7 @@ import * as Option from "effect/Option";
import * as Stream from "effect/Stream";

import * as DocumentText from "../../../provider/DocumentText.ts";
import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts";
import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts";
import * as ServerConfig from "../../../config.ts";
Expand All @@ -34,6 +37,30 @@ import * as McpToolAccess from "../../McpToolAccess.ts";
import * as ProjectHandlers from "./handlers.ts";
import { ProjectToolkit } from "./tools.ts";

const advertisedProvider = (
instanceId: string,
models: ReadonlyArray<string>,
aliases: Readonly<Record<string, ReadonlyArray<string>>> = {},
): ServerProvider => ({
instanceId: ProviderInstanceId.make(instanceId),
driver: ProviderDriverKind.make(instanceId),
enabled: true,
installed: true,
version: "test",
status: "ready",
auth: { status: "authenticated" },
checkedAt: "2026-10-28T00:00:00.000Z",
models: models.map((slug) => ({
slug,
name: slug,
...(aliases[slug] === undefined ? {} : { aliases: aliases[slug] }),
isCustom: false,
capabilities: null,
})),
slashCommands: [],
skills: [],
});

const clientLaunchHarness = (input: {
readonly runtimeModeCeiling: RuntimeMode;
readonly defaultRuntimeMode?: RuntimeMode;
Expand Down Expand Up @@ -126,6 +153,14 @@ const clientLaunchHarness = (input: {
: Option.none(),
),
}),
Layer.mock(ProviderRegistry.ProviderRegistry)({
getProviders: Effect.succeed([
advertisedProvider("claudeAgent", ["claude-opus", "claude-sonnet"], {
"claude-opus": ["opus"],
}),
advertisedProvider("codex", ["gpt-settings"]),
]),
}),
Layer.mock(PeerForwarding.PeerForwarding)({}),
Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }),
DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer)),
Expand Down Expand Up @@ -224,6 +259,58 @@ it.effect(
}),
);

it.effect("a launch with a model its provider does not advertise creates no thread", () =>
Effect.gen(function* () {
const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
const { projectId, dependencies } = clientLaunchHarness({
runtimeModeCeiling: "auto",
launched,
});
const toolkit = yield* ProjectToolkit.pipe(
Effect.provide(
McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
),
);
const launchWith = (instanceId: string, model: string) =>
toolkit
.handle("t3_thread_launch", {
title: "Fix",
projectId,
message: "Fix the bug",
modelSelection: { instanceId: ProviderInstanceId.make(instanceId), model },
})
.pipe(
Stream.unwrap,
Stream.runCollect,
Effect.map((result) => result.at(-1)?.result),
Effect.provide(dependencies),
);

for (const [instanceId, model] of [
["claudeAgent", "claude-issue-26619-unadvertised"],
["claudeAgent", "gpt-settings"],
["codex", "claude-opus"],
["codex", "opus"],
] as const) {
expect(yield* launchWith(instanceId, model)).toMatchObject({
code: "model_unavailable",
message: `Model ${model} is not advertised by provider ${instanceId}.`,
});
}
expect(launched).toHaveLength(0);

expect(yield* launchWith("claudeAgent", "claude-sonnet")).toMatchObject({
projectId,
modelSelection: { instanceId: "claudeAgent", model: "claude-sonnet" },
});
expect(yield* launchWith("claudeAgent", "Opus")).toMatchObject({
projectId,
modelSelection: { instanceId: "claudeAgent", model: "Opus" },
});
expect(launched).toHaveLength(2);
}),
);

it.effect("a launch retried with the same clientRequestId replays the first one", () =>
Effect.gen(function* () {
const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/mcp/toolkits/project/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts"
import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts";
import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";
import * as Project from "../../../project/ProjectService.ts";
import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts";
import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
Expand All @@ -23,6 +24,7 @@ import { formatThreadLink } from "@t3tools/shared/threadLinks";
import { resolveRuntimeMode } from "../../delegatedTaskModes.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as McpToolAccess from "../../McpToolAccess.ts";
import * as OrchestrationMcp from "../../OrchestratorMcpService.ts";
import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts";
import { ProjectToolkit } from "./tools.ts";

Expand Down Expand Up @@ -142,6 +144,13 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
});
yield* assertProjectWorktree(project.workspaceRoot, input.workspaceStrategy.worktreePath);
}
if (!replaying && input.modelSelection !== undefined) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const { instanceId, model } = input.modelSelection;
const providers = yield* (yield* ProviderRegistry.ProviderRegistry).getProviders;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const provider = providers.find((candidate) => candidate.instanceId === instanceId);
if (provider !== undefined)
yield* OrchestrationMcp.requireAdvertisedModel(provider, model);
}
// Reject unresolved base refs before accepting a launch; worktree provisioning happens later.
const strategy = input.workspaceStrategy;
if (!replaying && strategy?.type === "worktree")
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/project/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.t
import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts";
import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";

const shared = {
Expand Down Expand Up @@ -167,6 +168,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
ServerConfig.ServerConfig,
ServerSettings.ServerSettingsService,
DocumentText.DocumentText,
ProviderRegistry.ProviderRegistry,
],
})
.annotate(Tool.Destructive, true)
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/peer/PeerForwarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,9 @@ const serveBox = (seen: Ref.Ref<Seen>, linkSession: Ref.Ref<string>) =>
),
),
Layer.provide(NodeServices.layer),
Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
Layer.provide(
Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
),
Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/peer/RemoteDelegation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,9 @@ const boxToolkitLayer = (thread: BoxThread) => {
Layer.provide(NodeServices.layer),
),
),
Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
Layer.provide(
Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
),
Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
Layer.provide(
Expand Down
Loading