diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 6c502c1ef1305..630f4a95359b0 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -912,6 +912,17 @@ it.effect("lists pull request watch tools only to threads that are not delegated const body = (yield* listed.json) as { readonly result: { readonly tools: ReadonlyArray<{ readonly name: string }> }; }; + expect(body.result.tools.find((tool) => tool.name === "link_pull_request")).toMatchObject( + { + inputSchema: { + properties: { + threadId: { description: "Thread. Default: this thread." }, + repository: { description: "With `number`, when no `url`." }, + host: { description: "Default: this project's host." }, + }, + }, + }, + ); return body.result.tools.map((tool) => tool.name); }); diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index bab0eeac369dc..03eb3735ac5c7 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -25,7 +25,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { McpAttachmentInput } from "./attachment/input.ts"; -import { McpSchema, McpServer } from "effect/ai"; +import { McpSchema, McpServer, Tool } from "effect/ai"; import { FetchHttpClient } from "effect/http"; import { @@ -890,3 +890,66 @@ it.effect("only the caller that prepared a pending upload can discard it", () => ), ), ); + +import { OrchestratorToolkit } from "./orchestrator/tools.ts"; +import { PullRequestsToolkit } from "./pullRequests/tools.ts"; +import { DeviceToolkit } from "./device/tools.ts"; +import { WorktreeToolkit } from "./worktree/tools.ts"; + +interface ParameterSchema { + readonly description?: string; + readonly properties?: Readonly>; + readonly anyOf?: ReadonlyArray; + readonly items?: ParameterSchema; +} + +const parameterFields = (schema: ParameterSchema, field: string): ParameterSchema[] => [ + ...(schema.properties?.[field] ? [schema.properties[field]] : []), + ...(schema.anyOf ?? []).flatMap((member) => parameterFields(member, field)), + ...(schema.items ? parameterFields(schema.items, field) : []), +]; + +it("advertises existing string parameter guidance to MCP clients", () => { + const tools = { + ...OrchestratorToolkit.tools, + ...PullRequestsToolkit.tools, + ...DeviceToolkit.tools, + ...WorktreeToolkit.tools, + }; + for (const [name, path] of [ + ["delegate_task", "clientRequestId"], + ["delegate_task", "target.providerInstanceId"], + ["delegate_task", "target.driverKind"], + ["request_secret", "label"], + ["request_secret", "reason"], + ["schedule_task", "projectId"], + ["list_scheduled_tasks", "projectId"], + ["orchestrator_capabilities", "search"], + ["link_pull_request", "threadId"], + ["link_pull_request", "repository"], + ["link_pull_request", "host"], + ["list_thread_pull_requests", "threadId"], + ["t3_worktree_handoff", "baseRef"], + ["t3_worktree_handoff", "continuationPrompt"], + ["device_open", "deviceId"], + ["device_open", "hostId"], + ["device_screenshot", "deviceId"], + ["device_close", "deviceId"], + ["t3_thread_update", "title"], + ["t3_thread_update", "clientRequestId"], + ["schedule_task", "schedule.timeOfDay"], + ["schedule_task", "schedule.signature.header"], + ] as const) { + const schema = Tool.getJsonSchema(tools[name]) as ParameterSchema; + const fields = path + .split(".") + .reduce( + (nodes, field) => nodes.flatMap((node) => parameterFields(node, field)), + [schema], + ); + expect(fields.length, name + "." + path).toBeGreaterThan(0); + for (const field of fields) { + expect(field.description, name + "." + path).toEqual(expect.any(String)); + } + } +}); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index ef314b863ede0..d6765c30f82f2 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -33,27 +33,21 @@ const guideDependencies = [ ]; export const PullRequestTargetInput = Schema.Struct({ - threadId: Schema.optional( - ThreadId.annotate({ - description: "Thread. Default: this thread.", - }), - ), + threadId: Schema.optional(ThreadId).annotate({ + description: "Thread. Default: this thread.", + }), url: Schema.optional(TrimmedNonEmptyString), - repository: Schema.optional( - TrimmedNonEmptyString.annotate({ - description: "With `number`, when no `url`.", - }), - ), + repository: Schema.optional(TrimmedNonEmptyString).annotate({ + description: "With `number`, when no `url`.", + }), number: Schema.optional( PositiveInt.annotate({ description: "With `repository`, when no `url`.", }), ), - host: Schema.optional( - TrimmedNonEmptyString.annotate({ - description: "Default: this project's host.", - }), - ), + host: Schema.optional(TrimmedNonEmptyString).annotate({ + description: "Default: this project's host.", + }), }); export type PullRequestTargetInput = typeof PullRequestTargetInput.Type; @@ -299,7 +293,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { description: "List PRs linked to a thread, with last known state and stack order (bottom to top). Call only when you need that list. Never as an end-of-work check.", parameters: Schema.Struct({ - threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })), + threadId: Schema.optional(ThreadId).annotate({ description: "Thread. Default: this thread." }), }), success: ListThreadPullRequestsResult, failure: PullRequestToolError, diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 0fc7fde4c3bd4..20f59f0462db5 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -563,19 +563,17 @@ export const DeviceToolListResult = Schema.Struct({ export type DeviceToolListResult = typeof DeviceToolListResult.Type; export const DeviceToolOpenInput = Schema.Struct({ - deviceId: Schema.optional( - DeviceId.annotate({ - description: "Device id from `device_list`. Default: the booted or last used device.", - }), - ), + deviceId: Schema.optional(DeviceId).annotate({ + description: "Device id from `device_list`. Default: the booted or last used device.", + }), platform: Schema.optional( DevicePlatform.annotate({ description: "Needed with no `deviceId` when both platforms exist.", }), ), - hostId: Schema.optional( - DeviceHostId.annotate({ description: "Host from `device_list`. Default local." }), - ), + hostId: Schema.optional(DeviceHostId).annotate({ + description: "Host from `device_list`. Default local.", + }), }); export type DeviceToolOpenInput = typeof DeviceToolOpenInput.Type; @@ -590,11 +588,9 @@ export const DeviceToolOpenResult = Schema.Struct({ export type DeviceToolOpenResult = typeof DeviceToolOpenResult.Type; export const DeviceToolTargetInput = Schema.Struct({ - deviceId: Schema.optional( - DeviceId.annotate({ - description: "Device. Default: the last one opened here.", - }), - ), + deviceId: Schema.optional(DeviceId).annotate({ + description: "Device. Default: the last one opened here.", + }), hostId: Schema.optional(DeviceHostId), }); export type DeviceToolTargetInput = typeof DeviceToolTargetInput.Type; @@ -611,11 +607,9 @@ export const DeviceToolScreenshotResult = Schema.Struct({ export type DeviceToolScreenshotResult = typeof DeviceToolScreenshotResult.Type; export const DeviceToolCloseInput = Schema.Struct({ - deviceId: Schema.optional( - DeviceId.annotate({ - description: "Device to close. None: close all here.", - }), - ), + deviceId: Schema.optional(DeviceId).annotate({ + description: "Device to close. None: close all here.", + }), hostId: Schema.optional(DeviceHostId), shutdown: Schema.optional( Schema.Boolean.annotate({ diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index bb2820ad44282..90d40e9cb5ecf 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -42,9 +42,11 @@ import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; const OrchestratorMcpPrompt = TrimmedNonEmptyString.check(Schema.isMaxLength(120_000)); const OrchestratorMcpTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(512)); -const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check( - Schema.isMaxLength(256), -).annotate({ description: "Reuse it to retry the same call. A new call gets a new key." }); +const OrchestratorMcpClientRequestId = Schema.optional( + TrimmedNonEmptyString.check(Schema.isMaxLength(256)), +).annotate({ + description: "Reuse it to retry the same call. A new call gets a new key.", +}); // OpenCode 1.15 serializes nested MCP union objects as JSON strings; decode that form at the boundary. const OrchestratorMcpScheduleFromJsonString = Schema.fromJsonString( @@ -85,16 +87,12 @@ export const OrchestratorMcpTargetOptions = Schema.Union([ export type OrchestratorMcpTargetOptions = typeof OrchestratorMcpTargetOptions.Type; export const OrchestratorMcpTarget = Schema.Struct({ - providerInstanceId: Schema.optional( - ProviderInstanceId.annotate({ - description: "Provider instance id from `orchestrator_capabilities`.", - }), - ), - driverKind: Schema.optional( - ProviderDriverKind.annotate({ - description: "Prefer `providerInstanceId`.", - }), - ), + providerInstanceId: Schema.optional(ProviderInstanceId).annotate({ + description: "Provider instance id from `orchestrator_capabilities`.", + }), + driverKind: Schema.optional(ProviderDriverKind).annotate({ + description: "Prefer `providerInstanceId`.", + }), model: Schema.optional(TrimmedNonEmptyString), options: Schema.optional( OrchestratorMcpTargetOptions.annotate({ @@ -164,7 +162,7 @@ export const OrchestratorMcpDelegateTaskInput = Schema.Struct({ timeoutMs: Schema.optional(Schema.Finite).annotate({ description: "Mode 'wait' only. Default 10 min. A timeout does not cancel the child.", }), - clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), + clientRequestId: OrchestratorMcpClientRequestId, runtimeMode: Schema.optional( OrchestratorMcpRuntimeMode.annotate({ description: @@ -222,7 +220,7 @@ export type OrchestratorMcpTaskStatusInput = typeof OrchestratorMcpTaskStatusInp export const OrchestratorMcpTaskCancelInput = Schema.Struct({ taskId: NodeId, reason: Schema.optional(Schema.String.check(Schema.isMaxLength(2_000))), - clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), + clientRequestId: OrchestratorMcpClientRequestId, }); export type OrchestratorMcpTaskCancelInput = typeof OrchestratorMcpTaskCancelInput.Type; @@ -246,7 +244,7 @@ export const OrchestratorMcpCreateThreadsInput = Schema.Struct({ Schema.isMinLength(1), Schema.isMaxLength(20), ), - clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), + clientRequestId: OrchestratorMcpClientRequestId, }); export type OrchestratorMcpCreateThreadsInput = typeof OrchestratorMcpCreateThreadsInput.Type; @@ -283,11 +281,9 @@ export const OrchestratorMcpThreadStatus = Schema.Union([ ]); export type OrchestratorMcpThreadStatus = typeof OrchestratorMcpThreadStatus.Type; -const OrchestratorMcpProjectTarget = Schema.optional( - ProjectId.annotate({ - description: "Project to use. Default: your thread's project. Required outside a thread.", - }), -); +const OrchestratorMcpProjectTarget = Schema.optional(ProjectId).annotate({ + description: "Project to use. Default: your thread's project. Required outside a thread.", +}); export const OrchestratorMcpThreadListInput = Schema.Struct({ projectId: OrchestratorMcpProjectTarget, @@ -459,7 +455,7 @@ export const OrchestratorMcpThreadSendInput = Schema.Struct({ threadId: ThreadId, message: OrchestratorMcpPrompt, mode: Schema.optional(Schema.Literals(["auto", "queue", "steer", "restart"])), - clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), + clientRequestId: OrchestratorMcpClientRequestId, }); export type OrchestratorMcpThreadSendInput = typeof OrchestratorMcpThreadSendInput.Type; @@ -494,7 +490,7 @@ export const OrchestratorMcpThreadInterruptInput = Schema.Struct({ threadId: ThreadId, runId: Schema.optional(RunId), reason: Schema.optional(Schema.String.check(Schema.isMaxLength(2_000))), - clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), + clientRequestId: OrchestratorMcpClientRequestId, }); export type OrchestratorMcpThreadInterruptInput = typeof OrchestratorMcpThreadInterruptInput.Type; @@ -510,11 +506,9 @@ export const OrchestratorMcpThreadInterruptResult = Schema.Struct({ export type OrchestratorMcpThreadInterruptResult = typeof OrchestratorMcpThreadInterruptResult.Type; export const OrchestratorMcpCapabilitiesInput = Schema.Struct({ - search: Schema.optional( - TrimmedNonEmptyString.check(Schema.isMaxLength(200)).annotate({ - description: "Match model ids and labels. Replaces the short list.", - }), - ), + search: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(200))).annotate({ + description: "Match model ids and labels. Replaces the short list.", + }), }); export type OrchestratorMcpCapabilitiesInput = typeof OrchestratorMcpCapabilitiesInput.Type; @@ -578,7 +572,7 @@ export const OrchestratorMcpScheduleTaskInput = Schema.Struct({ description: "Default true: runs post in this thread. False: new thread per run.", }), ), - clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), + clientRequestId: OrchestratorMcpClientRequestId, }); export type OrchestratorMcpScheduleTaskInput = typeof OrchestratorMcpScheduleTaskInput.Type; @@ -614,11 +608,9 @@ export const OrchestratorMcpScheduleTaskResult = OrchestratorMcpScheduledTask; export type OrchestratorMcpScheduleTaskResult = typeof OrchestratorMcpScheduleTaskResult.Type; export const OrchestratorMcpListScheduledTasksInput = Schema.Struct({ - projectId: Schema.optional( - ProjectId.annotate({ - description: "Default: your thread's project, or all outside a thread.", - }), - ), + projectId: Schema.optional(ProjectId).annotate({ + description: "Default: your thread's project, or all outside a thread.", + }), }); export type OrchestratorMcpListScheduledTasksInput = typeof OrchestratorMcpListScheduledTasksInput.Type; @@ -641,12 +633,16 @@ export type OrchestratorMcpUpdateScheduledTaskInput = typeof OrchestratorMcpUpdateScheduledTaskInput.Type; export const OrchestratorMcpRequestSecretInput = Schema.Struct({ - label: TrimmedNonEmptyString.annotate({ - description: "Card title, such as 'GitHub webhook secret'.", - }), - reason: TrimmedNonEmptyString.annotate({ - description: "What it is for and where to get it.", - }), + label: TrimmedNonEmptyString.pipe( + Schema.annotateEncoded({ + description: "Card title, such as 'GitHub webhook secret'.", + }), + ), + reason: TrimmedNonEmptyString.pipe( + Schema.annotateEncoded({ + description: "What it is for and where to get it.", + }), + ), placeholder: Schema.optional(TrimmedNonEmptyString), delivery: Schema.optional(Schema.Literals(["secretRef", "file"])).annotate({ description: @@ -655,7 +651,7 @@ export const OrchestratorMcpRequestSecretInput = Schema.Struct({ timeoutMs: Schema.optional( Schema.Int.check(Schema.isBetween({ minimum: 1_000, maximum: 60 * 60 * 1_000 })), ).annotate({ description: "Default 10 min." }), - clientRequestId: Schema.optional(OrchestratorMcpClientRequestId).annotate({ + clientRequestId: OrchestratorMcpClientRequestId.annotate({ description: "Retry key for this ask. New key after a timeout or cancel.", }), }); diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index b4dffacd74c04..b0a4cce077357 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -20,7 +20,7 @@ import { ProviderInteractionMode, RuntimeMode } from "./providerPolicy.ts"; const TimeOfDay = TrimmedNonEmptyString.check( Schema.isPattern(/^([01]?\d|2[0-3]):([0-5]\d)$/), -).annotate({ description: "Local time as HH:MM, such as 09:30." }); +).pipe(Schema.annotateEncoded({ description: "Local time as HH:MM, such as 09:30." })); export const MIN_SCHEDULED_TASK_INTERVAL_MS = 60_000; @@ -46,9 +46,11 @@ const ScheduledTaskFixedTimeSchedule = Schema.Struct({ }); const ScheduledTaskWebhookSignatureFields = { - header: TrimmedNonEmptyString.annotate({ - description: "Header with the signature, such as x-hub-signature-256.", - }), + header: TrimmedNonEmptyString.pipe( + Schema.annotateEncoded({ + description: "Header with the signature, such as x-hub-signature-256.", + }), + ), encoding: Schema.Literals(["hex", "base64"]), prefix: Schema.String.annotate({ description: "Text before the digest, such as 'sha256='. Empty for none.", diff --git a/packages/contracts/src/threadMetadataMcp.ts b/packages/contracts/src/threadMetadataMcp.ts index 8c921ae9ba60a..8e22c2f71b2e8 100644 --- a/packages/contracts/src/threadMetadataMcp.ts +++ b/packages/contracts/src/threadMetadataMcp.ts @@ -26,19 +26,23 @@ function hasOnlyPairedSurrogates(value: string): boolean { return true; } -const ThreadMetadataTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(512)).annotate({ +const ThreadMetadataTitle = Schema.optional( + TrimmedNonEmptyString.check(Schema.isMaxLength(512)), +).annotate({ description: "New concise display title. Required only when action is rename.", }); -const ThreadMetadataClientRequestId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)) - .check( +const ThreadMetadataClientRequestId = Schema.optional( + TrimmedNonEmptyString.check(Schema.isMaxLength(256)).check( Schema.makeFilter((value) => hasOnlyPairedSurrogates(value) ? true : "clientRequestId must contain valid paired Unicode surrogates.", ), - ) - .annotate({ description: "Stable idempotency key to reuse when retrying this mutation." }); + ), +).annotate({ + description: "Stable idempotency key to reuse when retrying this mutation.", +}); export const ThreadMetadataMcpAction = Schema.Literals(["rename", "regenerate_title"]); export type ThreadMetadataMcpAction = typeof ThreadMetadataMcpAction.Type; @@ -46,8 +50,8 @@ export type ThreadMetadataMcpAction = typeof ThreadMetadataMcpAction.Type; export const ThreadMetadataMcpUpdateInput = Schema.Struct({ threadId: Schema.optional(ThreadId), action: ThreadMetadataMcpAction, - title: Schema.optional(ThreadMetadataTitle), - clientRequestId: Schema.optional(ThreadMetadataClientRequestId), + title: ThreadMetadataTitle, + clientRequestId: ThreadMetadataClientRequestId, }).check( Schema.makeFilter((input) => (input.action === "rename") === (input.title !== undefined) diff --git a/packages/contracts/src/worktreeMcp.ts b/packages/contracts/src/worktreeMcp.ts index 87f45dea27598..8cee45b43a8c6 100644 --- a/packages/contracts/src/worktreeMcp.ts +++ b/packages/contracts/src/worktreeMcp.ts @@ -5,11 +5,9 @@ import { TrimmedNonEmptyString } from "./baseSchemas.ts"; /** Uses the managed worktree folder; handoff detaches the provider, ending this turn and resuming there next run. */ export const WorktreeMcpHandoffInput = Schema.Struct({ branch: TrimmedNonEmptyString, - baseRef: Schema.optional( - TrimmedNonEmptyString.annotate({ - description: "Default: checked-out branch.", - }), - ), + baseRef: Schema.optional(TrimmedNonEmptyString).annotate({ + description: "Default: checked-out branch.", + }), startFromOrigin: Schema.optional( Schema.Boolean.annotate({ description: @@ -22,10 +20,10 @@ export const WorktreeMcpHandoffInput = Schema.Struct({ }), ), continuationPrompt: Schema.optional( - TrimmedNonEmptyString.check(Schema.isMaxLength(120_000)).annotate({ - description: "Work to resume in the worktree next turn. Omitted: thread waits.", - }), - ), + TrimmedNonEmptyString.check(Schema.isMaxLength(120_000)), + ).annotate({ + description: "Work to resume in the worktree next turn. Omitted: thread waits.", + }), }); export type WorktreeMcpHandoffInput = typeof WorktreeMcpHandoffInput.Type;