Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fa6f917
test(mcp): serve every parameter description agents were given
only21mil Oct 21, 2026
5afab74
fix(mcp): keep parameter descriptions on trimmed-string tool fields
only21mil Oct 21, 2026
3531a3d
Merge upstream main into r13/a048-keep-parameter-descriptions-in-orch…
only21mil Oct 21, 2026
53fc06d
fix(mcp): keep the orchestrator_capabilities search description
only21mil Oct 22, 2026
0a35226
fix(mcp): describe t3_thread_update clientRequestId and pull request url
only21mil Oct 22, 2026
aaad4ec
fix(mcp): describe scheduled task fields inside unions and arrays
only21mil Oct 22, 2026
1720d4b
Merge main (3b95e7831b) into r13/a048-keep-parameter-descriptions-in-…
only21mil Oct 22, 2026
7c52e9f
Merge main (51273020db) into r13/a048-keep-parameter-descriptions-in-…
only21mil Oct 23, 2026
e88f93a
Merge main (d53873b6a4) into r13/a048-keep-parameter-descriptions-in-…
only21mil Oct 24, 2026
16787d1
fix(mcp): preserve current guidance while resolving main conflicts
maria-rcks Oct 26, 2026
a20bbc3
test(mcp): verify descriptions through authenticated tool discovery
maria-rcks Oct 26, 2026
a6ea6a8
fix(mcp): preserve parameter guidance when merging main
maria-rcks Oct 27, 2026
833c61b
fix(mcp): expose optional parameter guidance to Codex
maria-rcks Oct 28, 2026
82742d1
fix(mcp): preserve parameter guidance when merging main
maria-rcks Oct 30, 2026
7f0a338
fix(mcp): record restored parameter descriptions in context budget
maria-rcks Oct 30, 2026
d0b2d10
fix(mcp): preserve parameter guidance while merging main
maria-rcks Oct 30, 2026
9da3fce
fix(mcp): preserve parameter guidance while merging main
maria-rcks Oct 31, 2026
49b3d9a
Merge upstream main into r13/a048-keep-parameter-descriptions-in-orch…
only21mil Nov 8, 2026
7e6971d
fix(mcp): schedule_task lists the webhook prompt variables
only21mil Nov 8, 2026
f5840bb
fix(mcp): link_pull_requests and unlink_pull_requests show their thre…
only21mil Nov 8, 2026
c8cfbdf
test(mcp): check every parameter description reaches agents
only21mil Nov 8, 2026
b191622
Merge upstream main into r13/a048-keep-parameter-descriptions-in-orch…
only21mil Nov 10, 2026
063330a
Merge upstream main into r13/a048-keep-parameter-descriptions-in-orch…
only21mil Nov 11, 2026
93da9d9
test(evals): webhook task prompts use the payload placeholder
only21mil Nov 11, 2026
9d6e868
Merge main into r13/a048-keep-parameter-descriptions-in-orchestra
only21mil Nov 12, 2026
5ae0ce8
Merge branch 'main' into r13/a048-keep-parameter-descriptions-in-orch…
only21mil Nov 13, 2026
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 @@ -1583,6 +1583,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
2 changes: 1 addition & 1 deletion apps/server/src/mcp/advertisedJsonSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const isBareAnyOf = (value: Json): value is { anyOf: Array<Json> } =>
Predicate.isObject(value) && Array.isArray(value.anyOf) && Object.keys(value).length === 1;

// A `Schema.fromJsonString` fallback. Advertised beside object shapes, it invites stringified input.
const isJsonStringSchema = (value: Json) =>
export const isJsonStringSchema = (value: Json) =>
Predicate.isObject(value) &&
value.type === "string" &&
value.contentMediaType === "application/json";
Expand Down
99 changes: 98 additions & 1 deletion apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as TestClock from "effect/testing/TestClock";
import * as Layer from "effect/Layer";
import * as Predicate from "effect/Predicate";
import * as Schema from "effect/Schema";
import * as SchemaAST from "effect/SchemaAST";
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 @@ -1293,6 +1295,101 @@ it.effect("only the caller that prepared a pending upload can discard it", () =>
),
);

import { advertisedJsonSchema, isJsonStringSchema } from "../advertisedJsonSchema.ts";
import { AttachmentToolkit } from "./attachment/tools.ts";
import { DeviceToolkit } from "./device/tools.ts";
import { EnvironmentToolkit } from "./environment/tools.ts";
import { HtmlToolkit } from "./html/tools.ts";
import { OrchestratorToolkit } from "./orchestrator/tools.ts";
import { PreviewToolkit } from "./preview/tools.ts";
import { PreviewControlsToolkit } from "./previewControls/tools.ts";
import { ProjectToolkit } from "./project/tools.ts";
import { PullRequestsToolkit } from "./pullRequests/tools.ts";
import { WorktreeToolkit } from "./worktree/tools.ts";

// advertisedJsonSchema drops `Schema.fromJsonString` union branches, and their text with them.
const isJsonStringFallback = (ast: SchemaAST.AST) =>
isJsonStringSchema(Schema.toJsonSchemaDocument(Schema.make(ast)).schema);

// An outer description replaces the one on its encoded side, and on a union's only kept
// branch, so only the outermost counts.
const sourceDescriptions = (
ast: SchemaAST.AST,
path: string,
out: Array<readonly [path: string, text: string]>,
described = false,
): void => {
const description = SchemaAST.resolveDescription(ast);
if (!described && description !== undefined) out.push([path, description]);
const covered = described || description !== undefined;
const end = ast.encoding?.at(-1)?.to;
if (end) return sourceDescriptions(end, path, out, covered);
if (SchemaAST.isObjects(ast)) {
for (const property of ast.propertySignatures)
sourceDescriptions(property.type, `${path}.${String(property.name)}`, out);
for (const index of ast.indexSignatures) sourceDescriptions(index.type, `${path}.*`, out);
} else if (SchemaAST.isUnion(ast)) {
const kept = ast.types.filter((member) => !isJsonStringFallback(member));
const branches = kept.filter(
(member) => !SchemaAST.isUndefined(member) && !SchemaAST.isNull(member),
);
for (const member of kept)
sourceDescriptions(member, path, out, branches.length === 1 && covered);
} else if (SchemaAST.isArrays(ast)) {
for (const item of ast.rest) sourceDescriptions(item, `${path}[]`, out);
}
};

const advertisedDescriptions = (schema: unknown, path: string, out: Set<string>): void => {
if (Array.isArray(schema)) {
for (const member of schema) advertisedDescriptions(member, path, out);
return;
}
if (!Predicate.isObject(schema)) return;
if (typeof schema.description === "string") out.add(`${path}: ${schema.description}`);
if (Predicate.isObject(schema.properties))
for (const [name, property] of Object.entries(schema.properties))
advertisedDescriptions(property, `${path}.${name}`, out);
advertisedDescriptions(schema.anyOf, path, out);
advertisedDescriptions(schema.items, `${path}[]`, out);
advertisedDescriptions(schema.additionalProperties, `${path}.*`, out);
};

// Restoring these is a context-budget call: about 3.6 KB on every thread's tool list.
const awaitingBudgetCall = new Set([
"Linked environment to act in. Omit for this one. Ids are then that environment's.",
"Linked environment to run in. Provider and model default to yours; others come from `orchestrator_capabilities` with this `environmentId`.",
"With `environmentId`: its project to run in. Default: the one with this project's repository.",
"The linked environment to forget, from t3_environment_links.",
]);

it("advertises every parameter description written in the tool schemas", () => {
const tools: Record<string, Tool.Any> = {
...AttachmentToolkit.tools,
...DeviceToolkit.tools,
...EnvironmentToolkit.tools,
...HtmlToolkit.tools,
...OrchestratorToolkit.tools,
...PreviewToolkit.tools,
...PreviewControlsToolkit.tools,
...ProjectToolkit.tools,
...PullRequestsToolkit.tools,
...ThreadToolkit.tools,
...WorktreeToolkit.tools,
};
const missing: string[] = [];
for (const [name, tool] of Object.entries(tools)) {
const source: Array<readonly [path: string, text: string]> = [];
sourceDescriptions(tool.parametersSchema.ast, name, source);
const advertised = new Set<string>();
advertisedDescriptions(advertisedJsonSchema(tool), name, advertised);
for (const [path, text] of source)
if (!awaitingBudgetCall.has(text) && !advertised.has(`${path}: ${text}`))
missing.push(`${path}: ${text}`);
}
expect(missing).toEqual([]);
});

it.effect(
"t3_thread_list settled:false with an empty statuses list lists the unsettled threads",
() => {
Expand Down
30 changes: 12 additions & 18 deletions apps/server/src/mcp/toolkits/pullRequests/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,27 +45,21 @@ const watchDependencies = [...dependencies, PullRequestWatchService.PullRequestW

export const PullRequestTargetInput = Schema.Struct({
environmentId: OrchestratorMcpEnvironmentTarget,
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 @@ -260,7 +254,7 @@ const LinkPullRequestsTool = Tool.make("link_pull_requests", {
"Link several PRs to a thread. Use it instead of one `link_pull_request` per PR. URLs that are not PRs come back in `invalidUrls`.",
parameters: Schema.Struct({
environmentId: OrchestratorMcpEnvironmentTarget,
threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })),
threadId: Schema.optional(ThreadId).annotate({ description: "Thread. Default: this thread." }),
urls: Schema.Array(TrimmedNonEmptyString).check(Schema.isMinLength(1)),
}),
success: LinkPullRequestsResult,
Expand Down Expand Up @@ -292,7 +286,7 @@ const UnlinkPullRequestsTool = Tool.make("unlink_pull_requests", {
"Unlink a thread's PRs whose last known state is in `states`. Use it instead of one `unlink_pull_request` per PR. PRs with no known state stay linked.",
parameters: Schema.Struct({
environmentId: OrchestratorMcpEnvironmentTarget,
threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })),
threadId: Schema.optional(ThreadId).annotate({ description: "Thread. Default: this thread." }),
states: Schema.Array(PullRequestState).check(Schema.isMinLength(1)),
}),
success: UnlinkPullRequestsResult,
Expand All @@ -310,7 +304,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {
"List a thread's PRs, with last known state and stack order (bottom to top). Not an end-of-work check.",
parameters: Schema.Struct({
environmentId: OrchestratorMcpEnvironmentTarget,
threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })),
threadId: Schema.optional(ThreadId).annotate({ description: "Thread. Default: this thread." }),
states: Schema.optional(Schema.Array(PullRequestState)),
cursor: Schema.optional(Schema.String),
}),
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/provider/agentContextBudget.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"claudeSystemAppendBytes": 5597,
"codexAdditionalContextBytes": 7327,
"mcpToolListJsonBytes": 48094
"mcpToolListJsonBytes": 51027
}
2 changes: 1 addition & 1 deletion evals/mcp-tool-use/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Cases are JSON lines in `cases/`, one file per area. The case schema in `run.ts`
- `experience`: the real mistake, in a sentence. `source`: where it happened.
- `setup`: the fake thread, its `world` (repo, branch, files, PRs, pages), `priorTurns`, and `mocks` for tool results. With `world.backgroundTasks`, `t3_background_wait` answers from T3's own handler and ThreadManagementService, so its result and errors are the checkout's; a prior wait with `"result": "default"` gets that result too.
- `setup.priorTurns[].toolCalls[].resultFile`: load a repository-relative file as the tool result instead of copying its contents into the case. An empty `prompt` resumes a top-level thread after its last prior tool result without adding a user message. Side-chat and subagent setups require a nonempty prompt.
- `expect`: the checks, such as `calls`, `callsMustNot`, `maxCalls`, `order`, `shellMust`, `textMust`, `finalTextMust` (the last text, once the turn ended), and `linked`. A call's `args` match partially: `~text` is a case-insensitive substring, `"<=N"` / `">=N"` bound a number, `null` matches a missing key, and keys may be dotted paths. Device cases also use `device` to check every child brief, forbid raw UI commands, bound failed device actions per user turn, and require artifact paths in completed final answers. Secrets cases use `secret` (see [Secrets cases](#secrets-cases)).
- `expect`: the checks, such as `calls`, `callsMustNot`, `maxCalls`, `order`, `shellMust`, `textMust`, `finalTextMust` (the last text, once the turn ended), and `linked`. A call's `args` match partially: `~text` is a case-insensitive substring, `/pattern/` is a case-sensitive regular expression, `"<=N"` / `">=N"` bound a number, `null` matches a missing key, and keys may be dotted paths. Device cases also use `device` to check every child brief, forbid raw UI commands, bound failed device actions per user turn, and require artifact paths in completed final answers. Secrets cases use `secret` (see [Secrets cases](#secrets-cases)).

Check that the case builds with `--dry-run --case <id>`. Then run it on the text before your fix, where it must fail, and on the text after, where it must pass.

Expand Down
2 changes: 2 additions & 0 deletions evals/mcp-tool-use/cases/pull-requests.ndjson
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,5 @@
{"id": "link-one-pr", "area": "pull-requests", "privacy": "commit-safe", "kind": "control", "pattern": "single link stays single", "experience": "Linking one named PR is ordinary work.", "setup": {"role": "top", "context": "No pull requests are linked to this thread.", "world": {"repo": "acme/app", "branch": "main"}}, "prompt": "link https://github.com/acme/app/pull/130 here", "expect": {"calls": [{"tool": "link_pull_request", "args": {"url": "~/pull/130"}}], "notCalls": ["link_pull_requests"]}, "maxSteps": 6, "source": {"thread": "Synthetic control for batch link"}}
{"id": "schedule-one-time-date", "area": "pull-requests", "kind": "bad", "pattern": "one-off date scheduled as a daily task", "experience": "Theo asked whether a scheduled task can run at a specific date and time. schedule_task only had daily times, intervals, and webhooks, so a one-off request became a daily task.", "source": {"report": "maintainer request, 2026-10-31: scheduled tasks could not run at one date and time"}, "rule": "schedule: {type:'once',runAt} for one date and time", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-4", "title": "Cut 2.0 release branch", "schedule": {"type": "once", "runAt": "2026-11-03T09:00:00.000Z"}, "enabled": true, "nextRunAt": "2026-11-03T09:00:00.000Z"}}}, "prompt": "On November 3rd at 9am UTC, start a new thread that cuts the 2.0 release branch from main in acme/app. Only that one time.", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "once", "schedule.runAt": "~11-03T09:00"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"]}, "notes": "Mock result shape is an approximation."}
{"id": "schedule-fresh-session-per-run", "area": "pull-requests", "kind": "guard", "pattern": "recurring task that should not accumulate context", "experience": "A recurring triage task kept one thread so the user could read its history, but each run carried every earlier run's context into the next.", "rule": "schedule_task `freshSession: true` keeps runs in one thread while each run starts a fresh session.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Monday 2026-11-02, 10:00 local time.", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-7", "title": "Triage new issues", "enabled": true, "projectId": "project-1", "boundThreadId": null, "freshSession": true, "schedule": {"type": "fixed_time", "timeOfDay": "09:00"}, "nextRunAt": "2026-11-03T09:00:00-08:00", "lastRunStatus": "never"}}}, "prompt": "Every morning at 9, triage new issues in acme/app. Keep all the runs in one thread so I can scroll back, but each run should start clean without remembering the previous days.", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "fixed_time", "freshSession": true}}], "notCalls": ["delegate_task", "run_scheduled_task_now"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-11-03: automations in one thread with separate provider sessions"}, "notes": "schedule-one-off-tomorrow is the control: an ordinary schedule must not set freshSession."}
{"id": "schedule-webhook-prompt-payload-field", "area": "pull-requests", "kind": "bad", "pattern": "webhook prompt without the payload placeholder syntax", "experience": "schedule_task's input schema lost its webhook prompt variables, so a Codex agent asked to quote them from its tool list found none.", "rule": "Webhook prompts can use {{body.path}}, {{headers.name}}, {{query.name}}, {{body}}, {{request}}.", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-3", "title": "Review pushed commits", "schedule": {"type": "webhook"}, "enabled": true, "bindToCurrentThread": true, "webhookUrl": "https://hooks.example.test/t3/def"}}, "world": {"repo": "acme/app", "files": {"README.md": "# acme/app\nA small web app.\n", "src/index.ts": "export const start = () => {};\n"}, "facts": "The checkout holds only README.md and src/index.ts. No other file exists, including any T3 Code source or docs."}}, "prompt": "Set up a webhook task for acme/app: each push GitHub sends should start a run in this thread that reviews the commits on the pushed branch. Put the pushed branch, the `ref` field of GitHub's push payload, into the task's prompt so each run gets it filled in. No signature for now. End your reply with one line: `Placeholders: <the placeholder text in the saved prompt, or none>`", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "webhook", "prompt": "/\\{\\{\\s*body\\.ref\\s*\\}\\}/"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"], "finalTextMust": ["(?:^|\\n)Placeholders: (?=[^\\n]*\\{\\{\\s*body\\.ref\\s*\\}\\})`?\\{\\{\\s*(?:(?:body|headers|query)(?:\\.[\\w-]+)*|request)\\s*\\}\\}`?(?:, `?\\{\\{\\s*(?:(?:body|headers|query)(?:\\.[\\w-]+)*|request)\\s*\\}\\}`?)*\\.?\\s*$"], "maxCalls": {"schedule_task": 1}}, "source": {"pr": "https://github.com/pingdotgg/t3code/pull/23285", "run": "Real Codex thread on a main test server, 2026-11-11: asked to quote the webhook prompt variables from its tool list, it answered (no description)."}, "notes": "Graded on one saved prompt, matched case-sensitively as T3 renders it, and one fixed-shape last line, not prose: a list of valid placeholders that includes {{body.ref}}. One save only, so a later save cannot drop the placeholder after the first one passed; a correct second save is a false fail, which is accepted. The world pins the checkout's files so the simulated shell cannot supply T3's template docs. schedule-fixed-time-no-placeholder is the control."}
{"id": "schedule-fixed-time-no-placeholder", "area": "pull-requests", "kind": "control", "pattern": "ordinary schedule with no request to fill in", "experience": "Control for schedule-webhook-prompt-payload-field: a fixed-time task has no request, so its prompt needs no placeholder.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Monday 2026-11-02, 10:00 local time.", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-4", "title": "Failing PR checks", "enabled": true, "bindToCurrentThread": true, "schedule": {"type": "fixed_time", "timeOfDay": "08:30", "weekdays": [1, 2, 3, 4, 5]}, "nextRunAt": "2026-11-03T08:30:00-08:00", "lastRunStatus": "never"}}, "world": {"repo": "acme/app", "files": {"README.md": "# acme/app\nA small web app.\n", "src/index.ts": "export const start = () => {};\n"}, "facts": "The checkout holds only README.md and src/index.ts. No other file exists, including any T3 Code source or docs."}}, "prompt": "Every weekday at 08:30, start a run in this thread that lists acme/app's open PRs with failing checks. End your reply with one line: `Placeholders: <the placeholder text in the saved prompt, or none>`", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "fixed_time", "schedule.timeOfDay": "08:30"}}], "callsMustNot": [{"tool": "schedule_task", "args": {"prompt": "~{{"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"], "finalTextMust": ["(?:^|\\n)Placeholders: `?[Nn]one`?\\.?\\s*$"]}, "source": {"pr": "https://github.com/pingdotgg/t3code/pull/23285"}, "notes": "Catches a description that makes agents put placeholders into prompts that have no request."}
9 changes: 9 additions & 0 deletions evals/mcp-tool-use/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ it("grades Codex shell and subagent calls with the shared rules", () => {
input: { ...args, label: args.label.slice(1) },
}));
const run = (calls: SampleRun["calls"]) => ({ calls, text: "", stopReason: "end_turn" });
// A `/regex/` arg is case-sensitive, unlike `~text`: webhook placeholders are.
const placeholder = {
calls: [{ tool: "schedule_task", args: { prompt: "/\\{\\{body\\.ref\\}\\}/" } }],
};
const saved = (prompt: string) => run([{ step: 0, tool: "schedule_task", input: { prompt } }]);
assert.deepEqual(failed(placeholder, saved("Review {{body.ref}}.")), []);
assert.deepEqual(failed(placeholder, saved("Review {{BODY.REF}}.")), [
`calls schedule_task ${JSON.stringify(placeholder.calls[0]!.args)}`,
]);
assert.deepEqual(failed({ distinctCalls }, run(cards)), []);
assert.deepEqual(
failed(
Expand Down
Loading
Loading