Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
65 changes: 64 additions & 1 deletion apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Record<string, ParameterSchema>>;
readonly anyOf?: ReadonlyArray<ParameterSchema>;
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<ParameterSchema[]>(
(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));
}
}
});
26 changes: 10 additions & 16 deletions apps/server/src/mcp/toolkits/pullRequests/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
30 changes: 12 additions & 18 deletions packages/contracts/src/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -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({
Expand Down
78 changes: 37 additions & 41 deletions packages/contracts/src/orchestratorMcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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;

Expand All @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand All @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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:
Expand All @@ -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.",
}),
});
Expand Down
10 changes: 6 additions & 4 deletions packages/contracts/src/scheduledTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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.",
Expand Down
Loading
Loading