Skip to content
Merged
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
5 changes: 4 additions & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,10 @@ function itemToolPresentation(item: OrchestrationV2TurnItem): T3McpToolPresentat
if (item.type !== "dynamic_tool") {
return null;
}
return resolveT3McpToolPresentation(item.toolName) ?? resolveT3McpToolPresentation(item.title);
return (
resolveT3McpToolPresentation(item.toolName, item.input) ??
resolveT3McpToolPresentation(item.title, item.input)
);
}

function itemSummary(
Expand Down
25 changes: 24 additions & 1 deletion apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ it.effect("refuses preview tools to a client outside a thread before they run",
client: { sessionId: "session-1", label: "Claude Code", access: "read-only" as const },
};
const click = yield* server
.callTool({ name: "preview_click", arguments: { locator: "text=Send" } })
.callTool({ name: "preview_act", arguments: { action: "click", locator: "text=Send" } })
.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, readOnly),
Effect.provideService(McpSchema.McpServerClient, client),
Expand All @@ -356,6 +356,29 @@ it.effect("refuses preview tools to a client outside a thread before they run",
}).pipe(Effect.provide(layerTest)),
);

it.effect("checks each preview_act action against that action's own input", () =>
Effect.scoped(
Effect.gen(function* () {
const inputs = yield* serveSnapshots("mcp-act-client", {});

const missingKey = yield* callTool("preview_act", { action: "press" });
expect(missingKey.isError).toBe(true);
expect(inputs).toEqual([]);

const click = yield* callTool("preview_act", {
action: "click",
locator: "text=Send",
key: "Enter",
});
expect(click.isError).toBe(false);
expect(inputs[0]).toEqual({ locator: "text=Send" });

yield* callTool("preview_act", { action: "type", selector: "#name", text: "Ada" });
expect(inputs.at(-2)).toEqual({ selector: "#name", text: "Ada" });
}),
).pipe(Effect.provide(layerTest)),
);

it.effect("decodes null optional arguments the way it decodes omitted ones", () => {
const calls: Array<unknown> = [];
return Effect.gen(function* () {
Expand Down
44 changes: 29 additions & 15 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
MAX_REPORTED_DEPTH,
toolOutcome,
} from "../telemetry/ProviderDimensions.ts";
import { advertisedJsonSchema } from "./advertisedJsonSchema.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";
import * as McpToolAccess from "./McpToolAccess.ts";
import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
Expand Down Expand Up @@ -450,7 +451,7 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
tool: new McpSchema.Tool({
name: tool.name,
description: Tool.getDescription(tool),
inputSchema: Tool.getJsonSchema(tool),
inputSchema: advertisedJsonSchema(tool),
annotations: {
...Context.getOption(tool.annotations, Tool.Title).pipe(
Option.map((title) => ({ title })),
Expand Down Expand Up @@ -634,7 +635,7 @@ const registerImageTool = Effect.fn("McpHttpServer.registerImageTool")(function*
tool: new McpSchema.Tool({
name: tool.name,
description: Tool.getDescription(tool),
inputSchema: Tool.getJsonSchema(tool),
inputSchema: advertisedJsonSchema(tool),
annotations: {
...Context.getOption(tool.annotations, Tool.Title).pipe(
Option.map((title) => ({ title })),
Expand Down Expand Up @@ -751,21 +752,31 @@ const HANDOFF_TOOLS: ReadonlySet<string> = new Set([
"t3_thread_launch",
"t3_thread_send",
"t3_thread_send_attachments",
"t3_thread_fork",
"t3_thread_merge_back",
"t3_queue_edit",
"t3_queue_promote_to_steer",
"t3_pending_request_respond",
"schedule_task",
"run_scheduled_task_now",
]);

/** Handoff tools whose result is only `{ sequence }`; their input names the receiving thread. */
const INPUT_TARGET_TOOLS: ReadonlySet<string> = new Set([
"t3_queue_edit",
"t3_queue_promote_to_steer",
"t3_pending_request_respond",
]);
/** Tool actions that hand work over with only a `{ sequence }` result; the input names the thread. */
const INPUT_TARGET_ACTIONS: Readonly<Record<string, ReadonlySet<string>>> = {
t3_queue: new Set(["edit", "promote_to_steer"]),
t3_pending_request: new Set(["respond"]),
};

/** Tool actions that hand work over and name the receiving thread in their result. */
const RESULT_TARGET_ACTIONS: Readonly<Record<string, ReadonlySet<string>>> = {
t3_thread_transfer: new Set(["fork", "merge_back"]),
};

const hasAction = (
actions: Readonly<Record<string, ReadonlySet<string>>>,
tool: string,
args: unknown,
) =>
actions[tool]?.has(String((args as { readonly action?: unknown } | null | undefined)?.action)) ===
true;

const isInputTargetHandoff = (tool: string, args: unknown) =>
hasAction(INPUT_TARGET_ACTIONS, tool, args);

const resultThreadKeys = ["childThreadId", "targetThreadId", "boundThreadId", "threadId"] as const;

Expand All @@ -787,7 +798,7 @@ const handoffTargetIds = (
args: unknown,
result: McpSchema.CallToolResult | undefined,
): ReadonlyArray<string> => {
if (!INPUT_TARGET_TOOLS.has(tool)) return resultThreadIds(result?.structuredContent);
if (!isInputTargetHandoff(tool, args)) return resultThreadIds(result?.structuredContent);
if (result === undefined || toolOutcome(result).outcome !== "ok") return [];
const threadId = (args as { readonly threadId?: unknown } | null | undefined)?.threadId;
return typeof threadId === "string" ? [threadId] : [];
Expand Down Expand Up @@ -853,7 +864,10 @@ const withToolAnalytics =
result: McpSchema.CallToolResult | undefined,
durationMs: number,
) {
const handoff = HANDOFF_TOOLS.has(tool);
const handoff =
HANDOFF_TOOLS.has(tool) ||
isInputTargetHandoff(tool, args) ||
hasAction(RESULT_TARGET_ACTIONS, tool, args);
const outcome = toolOutcome(result);
// Agents call status, read, and preview tools in tight loops, so an
// event per successful call floods analytics. Only failures count there.
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/mcp/McpToolAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,16 @@ export const startsThreads = <P, A, E, R>(
}),
);

/** One tool with two access rules, picked per call, such as create versus update. */
export const either = <P, A, E, R, A2, E2, R2>(
useFirst: (params: P) => boolean,
first: Declaration<(params: P) => Effect.Effect<A, E, R>>,
second: Declaration<(params: P) => Effect.Effect<A2, E2, R2>>,
) =>
declare((params: P): Effect.Effect<A | A2, E | E2, R | R2> =>
useFirst(params) ? checkedHandler(first)(params) : checkedHandler(second)(params),
);

const fullAccessRequired =
"Changing projects or environment settings needs a live full-access/default calling thread or a full-access client.";

Expand Down
30 changes: 0 additions & 30 deletions apps/server/src/mcp/ThreadMetadataMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,6 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

function threadLookupFailure(
error: ThreadManagementService.ThreadManagementError,
): OrchestratorMcpFailure {
return error._tag === "ThreadManagementThreadNotFoundError"
? failure("thread_not_found", error.message)
: failure("orchestration_error", error.message);
}

function stablePart(value: string): string {
return encodeURIComponent(value);
}
Expand All @@ -70,7 +62,6 @@ function commandId(input: {
function metadataCommand(input: {
readonly commandId: CommandId;
readonly threadId: ThreadId;
readonly projectId: OrchestrationV2AppThread["projectId"];
readonly update: ThreadMetadataMcpUpdateInput;
}): Extract<OrchestrationV2Command, { readonly type: "thread.metadata.update" }> {
switch (input.update.action) {
Expand All @@ -88,23 +79,6 @@ function metadataCommand(input: {
threadId: input.threadId,
regenerateTitle: true,
};
case "link_pull_request":
return {
type: "thread.metadata.update",
commandId: input.commandId,
threadId: input.threadId,
linkedPullRequest: {
projectId: input.projectId,
...input.update.pullRequest!,
},
};
case "unlink_pull_request":
return {
type: "thread.metadata.update",
commandId: input.commandId,
threadId: input.threadId,
linkedPullRequest: null,
};
}
}

Expand Down Expand Up @@ -170,9 +144,6 @@ const make = Effect.gen(function* () {
if (shell === null || shell.deletedAt !== null) {
return yield* threadNotFound(threadManagement, threadId, `Thread ${threadId} was not found.`);
}
const target = yield* threadManagement
.getProjectThreadRecords({ projectId: shell.projectId, threadId }, [])
.pipe(Effect.mapError(threadLookupFailure));
const requestKey =
input.clientRequestId === undefined
? yield* crypto.randomUUIDv4().pipe(Effect.orDie)
Expand All @@ -188,7 +159,6 @@ const make = Effect.gen(function* () {
metadataCommand({
commandId: updateCommandId,
threadId,
projectId: target.thread.projectId,
update: input,
}),
)
Expand Down
45 changes: 45 additions & 0 deletions apps/server/src/mcp/advertisedJsonSchema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { expect, it } from "@effect/vitest";
import * as Schema from "effect/Schema";
import { Tool } from "effect/ai";

import { advertisedJsonSchema } from "./advertisedJsonSchema.ts";

it("drops JSON noise, merges tagged unions, and keeps null where it is required", () => {
const Every = Schema.Struct({ ms: Schema.Int });
const tool = Tool.make("example", {
parameters: Schema.Struct({
threadId: Schema.optional(Schema.NullOr(Schema.String)),
beforeRunId: Schema.NullOr(Schema.String),
limit: Schema.optional(Schema.Int),
every: Schema.Union([Every, Schema.fromJsonString(Every)]),
target: Schema.optional(
Schema.NullOr(
Schema.Union([
Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }),
Schema.Struct({ kind: Schema.Literal("number"), number: Schema.Int }),
]),
),
),
}),
});

expect(advertisedJsonSchema(tool)).toEqual({
type: "object",
properties: {
threadId: { type: "string" },
beforeRunId: { anyOf: [{ type: "string" }, { type: "null" }] },
limit: { type: "integer" },
every: { type: "object", properties: { ms: { type: "integer" } }, required: ["ms"] },
target: {
type: "object",
properties: {
kind: { type: "string", enum: ["url", "number"] },
url: { type: "string" },
number: { type: "integer" },
},
required: ["kind"],
},
},
required: ["beforeRunId", "every"],
});
});
106 changes: 106 additions & 0 deletions apps/server/src/mcp/advertisedJsonSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import * as Predicate from "effect/Predicate";
import { Tool } from "effect/ai";

type Json = unknown;
type JsonObject = { [key: string]: Json };

const isNullSchema = (value: Json) =>
Predicate.isObject(value) && value.type === "null" && Object.keys(value).length === 1;

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) =>
Predicate.isObject(value) &&
value.type === "string" &&
value.contentMediaType === "application/json";

const SAFE_INTEGER_BOUNDS: Record<string, number> = {
maximum: Number.MAX_SAFE_INTEGER,
minimum: Number.MIN_SAFE_INTEGER,
};

const tagOf = (branch: JsonObject, key: string): Json => {
const property = Predicate.isObject(branch.properties) ? branch.properties[key] : undefined;
return Predicate.isObject(property) && Array.isArray(property.enum) && property.enum.length === 1
? property.enum[0]
: undefined;
};

/** One object for a tagged union of objects: each property once, the tag listing every value. */
const mergeTaggedObjects = (branches: ReadonlyArray<Json>): JsonObject | undefined => {
if (branches.length < 2) return undefined;
const objects = branches.filter(
(branch): branch is JsonObject & { properties: JsonObject } =>
Predicate.isObject(branch) &&
branch.type === "object" &&
Predicate.isObject(branch.properties),
);
if (objects.length !== branches.length) return undefined;
const tag = Object.keys(objects[0]!.properties).find((key) => {
const values = objects.map((object) => tagOf(object, key));
return values.every((value) => value !== undefined) && new Set(values).size === values.length;
});
if (tag === undefined) return undefined;
const properties: JsonObject = {
[tag]: { type: "string", enum: objects.map((object) => tagOf(object, tag)) },
};
for (const object of objects) {
for (const [name, property] of Object.entries(object.properties)) {
if (name === tag) continue;
const seen = properties[name];
if (seen === undefined) properties[name] = property;
else if (JSON.stringify(seen) !== JSON.stringify(property)) {
const options = Predicate.isObject(seen) && isBareAnyOf(seen) ? seen.anyOf : [seen];
if (!options.some((option) => JSON.stringify(option) === JSON.stringify(property)))
properties[name] = { anyOf: [...options, property] };
}
}
}
const [first = [], ...others] = objects.map((object) =>
Array.isArray(object.required) ? object.required : [],
);
const requiredByAll = first.filter(
(name) => name !== tag && others.every((required) => required.includes(name)),
);
return { type: "object", properties, required: [tag, ...requiredByAll] };
};

const compact = (schema: Json, optional: boolean): Json => {
if (Array.isArray(schema)) return schema.map((item) => compact(item, false));
if (!Predicate.isObject(schema)) return schema;
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
let result: JsonObject = {};
for (const [key, value] of Object.entries(schema)) {
if (key === "additionalProperties" && value === true) continue;
if (SAFE_INTEGER_BOUNDS[key] === value) continue;
result[key] =
key === "properties" && Predicate.isObject(value)
? Object.fromEntries(
Object.entries(value).map(([name, property]) => [
name,
compact(property, !required.has(name)),
]),
)
: compact(value, false);
}
if (Array.isArray(result.anyOf)) {
const branches = result.anyOf
.flatMap((branch) => (isBareAnyOf(branch) ? branch.anyOf : [branch]))
.filter((branch) => !(optional && isNullSchema(branch)) && !isJsonStringSchema(branch));
const { anyOf: _anyOf, ...rest } = result;
const only = branches.length === 1 ? branches[0] : mergeTaggedObjects(branches);
result = Predicate.isObject(only) ? { ...only, ...rest } : { ...rest, anyOf: branches };
}
return result;
};

/**
* The input schema T3 advertises for a tool. Drops JSON noise that every thread pays for:
* `additionalProperties: true`, safe-integer bounds, nested `anyOf`, JSON-string fallbacks, and
* the `null` branch of optional properties. A tagged union of objects becomes one object.
* Decoding still uses the full schema, so each tag's own required fields still apply.
*/
export const advertisedJsonSchema = (tool: Tool.Any) =>
compact(Tool.getJsonSchema(tool), false) as ReturnType<typeof Tool.getJsonSchema>;
Loading
Loading