diff --git a/.changeset/calm-tools-guide.md b/.changeset/calm-tools-guide.md new file mode 100644 index 000000000..9315a6719 --- /dev/null +++ b/.changeset/calm-tools-guide.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Clarify execute skill loading and surface schema validation constraints in tool descriptions. diff --git a/e2e/scenarios/tool-descriptions.test.ts b/e2e/scenarios/tool-descriptions.test.ts index e646ad293..aaee03cea 100644 --- a/e2e/scenarios/tool-descriptions.test.ts +++ b/e2e/scenarios/tool-descriptions.test.ts @@ -61,7 +61,7 @@ const ordersOpenApiSpec = (baseUrl: string): string => in: "path", required: true, description: "Unique order identifier (ULID).", - schema: { type: "string" }, + schema: { type: "string", minLength: 26, maxLength: 26 }, }, { name: "include", @@ -384,12 +384,65 @@ scenario( const openapiTools = yield* snapshotFor(openapiSlug); const graphqlTools = yield* snapshotFor(graphqlSlug); + const session = mcp.session(identity); + const advertisedTools = yield* session.describeTools(); + const executeDescription = + advertisedTools.find((tool) => tool.name === "execute")?.description ?? ""; + expect( + executeDescription, + "the execute description directs models to the companion MCP tool", + ).toContain("companion `skills` MCP tool"); + expect( + executeDescription, + "the execute description does not suggest a nonexistent sandbox function", + ).not.toContain("skills({"); + + const executeSkill = yield* session.call("skills", { name: "execute" }); + expect(executeSkill.ok, "the execute guide is available through skills").toBe(true); + expect(executeSkill.text, "the guide tells models to inspect validation limits").toContain( + "inputConstraints", + ); + expect(executeSkill.text, "the guide distinguishes nested MCP domain status").toContain( + "data.structuredContent", + ); + + const describedGetOrderSnapshot = openapiTools.find((tool) => + String(tool.address).endsWith(".getOrder"), + ); + expect( + describedGetOrderSnapshot, + "the constrained getOrder operation is present", + ).toBeDefined(); + const getOrderPath = String(describedGetOrderSnapshot?.address ?? "").replace( + /^tools\./, + "", + ); + const describedResult = yield* session.call("execute", { + code: `return await tools.describe.tool({ path: ${JSON.stringify(getOrderPath)} });`, + }); + expect(describedResult.ok, `the sandbox describes getOrder: ${describedResult.text}`).toBe( + true, + ); + const describedGetOrder = JSON.parse(describedResult.text) as { + readonly inputConstraints?: readonly { + readonly path: string; + readonly rules: readonly string[]; + }[]; + }; + const orderIdConstraints = describedGetOrder.inputConstraints?.find((constraint) => + constraint.path.endsWith("orderId"), + ); + expect( + orderIdConstraints?.rules, + "describe.tool preserves the ULID length constraint TypeScript cannot express", + ).toEqual(["length >= 26", "length <= 26"]); + // The execute tool's description over the real MCP surface — the // connected-integration inventory an MCP client (and its model) reads. // Only this run's lines: the shared selfhost admin may have other // integrations in the inventory. const readInventory = () => - Effect.map(mcp.session(identity).describeTools(), (mcpTools) => + Effect.map(session.describeTools(), (mcpTools) => (mcpTools.find((tool) => tool.name === "execute")?.description ?? "") .split("## Available integrations")[1] ?.split("\n") diff --git a/packages/core/execution/src/description.test.ts b/packages/core/execution/src/description.test.ts index c95573fc1..d09afc28c 100644 --- a/packages/core/execution/src/description.test.ts +++ b/packages/core/execution/src/description.test.ts @@ -99,7 +99,8 @@ describe("buildExecuteDescription", () => { expect(description).toContain("Execute TypeScript in a sandboxed runtime"); // The full how-to now lives behind the `skills` tool, so the description // points there rather than inlining the workflow/rules. - expect(description).toContain('skills({ name: "execute" })'); + expect(description).toContain("companion `skills` MCP tool"); + expect(description).toContain("Do not call `skills` inside this sandbox"); expect(description).not.toContain("Use `emit(value)` to append user-visible output"); expect(description).not.toContain("## Workflow"); expect(description).not.toContain("## Rules"); diff --git a/packages/core/execution/src/description.ts b/packages/core/execution/src/description.ts index f9cda53d6..ceec88eca 100644 --- a/packages/core/execution/src/description.ts +++ b/packages/core/execution/src/description.ts @@ -29,7 +29,7 @@ export const buildExecuteDescription = (executor: Executor): Effect.Effect 0) { diff --git a/packages/core/execution/src/schema-constraints.test.ts b/packages/core/execution/src/schema-constraints.test.ts new file mode 100644 index 000000000..52f6c6746 --- /dev/null +++ b/packages/core/execution/src/schema-constraints.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { summarizeInputConstraints } from "./schema-constraints"; + +describe("summarizeInputConstraints", () => { + it("surfaces numeric, collection, and per-item string limits", () => { + expect( + summarizeInputConstraints({ + type: "object", + properties: { + max_events: { type: "integer", minimum: 1, maximum: 100 }, + events: { + type: "array", + minItems: 1, + maxItems: 100, + uniqueItems: true, + items: { type: "string", minLength: 1, maxLength: 4000 }, + }, + }, + }), + ).toEqual([ + { path: "max_events", rules: ["value >= 1", "value <= 100"] }, + { path: "events", rules: ["items >= 1", "items <= 100", "items unique"] }, + { path: "events[]", rules: ["length >= 1", "length <= 4000"] }, + ]); + }); + + it("follows local and separately stored definitions without recursing forever", () => { + expect( + summarizeInputConstraints( + { + type: "object", + properties: { + local: { $ref: "#/$defs/Local" }, + shared: { $ref: "#/$defs/Shared" }, + }, + $defs: { + Local: { type: "string", pattern: "^[a-z]+$" }, + }, + }, + { + Shared: { + type: "object", + maxProperties: 3, + properties: { child: { $ref: "#/$defs/Shared" } }, + }, + }, + ), + ).toEqual([ + { path: "local", rules: ['matches "^[a-z]+$"'] }, + { path: "shared", rules: ["properties <= 3"] }, + { path: "shared.child", rules: ["properties <= 3"] }, + ]); + }); + + it("handles numeric and OpenAPI 3 boolean exclusive bounds", () => { + expect( + summarizeInputConstraints({ + type: "object", + properties: { + openapi3: { type: "number", minimum: 0, exclusiveMinimum: true }, + jsonSchema: { type: "number", exclusiveMaximum: 10 }, + ordinary: { type: "number", minimum: 0, maximum: 10 }, + }, + }), + ).toEqual([ + { path: "openapi3", rules: ["value > 0"] }, + { path: "jsonSchema", rules: ["value < 10"] }, + { path: "ordinary", rules: ["value >= 0", "value <= 10"] }, + ]); + }); + + it("collects allOf rules but does not conjoin anyOf or oneOf branches", () => { + expect( + summarizeInputConstraints({ + type: "object", + properties: { + conjunctive: { allOf: [{ minimum: 1 }, { maximum: 10 }] }, + alternative: { + oneOf: [ + { minimum: 1, maximum: 10 }, + { minimum: 100, maximum: 200 }, + ], + }, + nullable: { anyOf: [{ type: "string", maxLength: 50 }, { type: "null" }] }, + }, + }), + ).toEqual([{ path: "conjunctive", rules: ["value >= 1", "value <= 10"] }]); + }); + + it("supports modern and draft-4 tuple item schemas", () => { + expect( + summarizeInputConstraints({ + type: "object", + properties: { + modern: { type: "array", prefixItems: [{ maxLength: 10 }, { maximum: 5 }] }, + legacy: { type: "array", items: [{ minLength: 2 }, { minimum: 1 }] }, + }, + }), + ).toEqual([ + { path: "modern[0]", rules: ["length <= 10"] }, + { path: "modern[1]", rules: ["value <= 5"] }, + { path: "legacy[0]", rules: ["length >= 2"] }, + { path: "legacy[1]", rules: ["value >= 1"] }, + ]); + }); + + it("labels root constraints and avoids no-op minimums", () => { + expect( + summarizeInputConstraints({ + type: "array", + minItems: 0, + maxItems: 20, + items: { type: "string", minLength: 0, format: "email" }, + }), + ).toEqual([ + { path: "(root)", rules: ["items <= 20"] }, + { path: "(root)[]", rules: ["format email"] }, + ]); + }); + + it("does not guess a flat definition for a deeper unresolved pointer", () => { + expect( + summarizeInputConstraints( + { + type: "object", + properties: { name: { $ref: "#/$defs/Pet/properties/name" } }, + }, + { name: { type: "string", maxLength: 10 } }, + ), + ).toEqual([]); + }); +}); diff --git a/packages/core/execution/src/schema-constraints.ts b/packages/core/execution/src/schema-constraints.ts new file mode 100644 index 000000000..14b8af94a --- /dev/null +++ b/packages/core/execution/src/schema-constraints.ts @@ -0,0 +1,159 @@ +type JsonObject = Readonly>; + +export type ToolInputConstraint = { + readonly path: string; + readonly rules: readonly string[]; +}; + +const isJsonObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); + +const finiteNumber = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +const nonNegativeInteger = (value: unknown): number | undefined => + typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined; + +const stringValue = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; + +const rulesForSchema = (schema: JsonObject): readonly string[] => { + const rules: string[] = []; + const minimum = finiteNumber(schema.minimum); + const exclusiveMinimum = finiteNumber(schema.exclusiveMinimum); + const maximum = finiteNumber(schema.maximum); + const exclusiveMaximum = finiteNumber(schema.exclusiveMaximum); + const multipleOf = finiteNumber(schema.multipleOf); + const minLength = nonNegativeInteger(schema.minLength); + const maxLength = nonNegativeInteger(schema.maxLength); + const minItems = nonNegativeInteger(schema.minItems); + const maxItems = nonNegativeInteger(schema.maxItems); + const minProperties = nonNegativeInteger(schema.minProperties); + const maxProperties = nonNegativeInteger(schema.maxProperties); + const pattern = stringValue(schema.pattern); + const format = stringValue(schema.format); + + if (exclusiveMinimum !== undefined) { + rules.push(`value > ${exclusiveMinimum}`); + } else if (minimum !== undefined) { + rules.push(`${schema.exclusiveMinimum === true ? "value >" : "value >="} ${minimum}`); + } + if (exclusiveMaximum !== undefined) { + rules.push(`value < ${exclusiveMaximum}`); + } else if (maximum !== undefined) { + rules.push(`${schema.exclusiveMaximum === true ? "value <" : "value <="} ${maximum}`); + } + if (multipleOf !== undefined) rules.push(`multiple of ${multipleOf}`); + if (minLength !== undefined && minLength > 0) rules.push(`length >= ${minLength}`); + if (maxLength !== undefined) rules.push(`length <= ${maxLength}`); + if (minItems !== undefined && minItems > 0) rules.push(`items >= ${minItems}`); + if (maxItems !== undefined) rules.push(`items <= ${maxItems}`); + if (schema.uniqueItems === true) rules.push("items unique"); + if (minProperties !== undefined && minProperties > 0) + rules.push(`properties >= ${minProperties}`); + if (maxProperties !== undefined) rules.push(`properties <= ${maxProperties}`); + if (pattern !== undefined) rules.push(`matches ${JSON.stringify(pattern)}`); + if (format !== undefined) rules.push(`format ${format}`); + + return rules; +}; + +const decodeJsonPointerSegment = (segment: string): string => + segment.replaceAll("~1", "/").replaceAll("~0", "~"); + +const resolveReference = ( + reference: string, + root: JsonObject, + definitions: Readonly>, +): unknown => { + if (!reference.startsWith("#/")) return undefined; + const segments = reference.slice(2).split("/").map(decodeJsonPointerSegment); + let current: unknown = root; + for (const segment of segments) { + if (!isJsonObject(current) || !(segment in current)) { + current = undefined; + break; + } + current = current[segment]; + } + if (current !== undefined) return current; + + // executor.tools.schema() stores referenced definitions separately from the + // input root. Only fall back for the exact flat definition shape it exposes; + // guessing from the final segment of a deeper pointer can return a different + // schema and publish incorrect constraints. + const flatDefinition = /^#\/(?:\$defs|definitions)\/([^/]+)$/.exec(reference); + return flatDefinition === null + ? undefined + : definitions[decodeJsonPointerSegment(flatDefinition[1] ?? "")]; +}; + +/** + * Summarize the validation keywords that TypeScript cannot express. + * + * The result stays intentionally compact: callers still use the TypeScript + * preview for shape and only consult this list for numeric, collection, and + * string constraints that would otherwise be invisible. + */ +export const summarizeInputConstraints = ( + inputSchema: unknown, + schemaDefinitions: Readonly> = {}, +): readonly ToolInputConstraint[] => { + if (!isJsonObject(inputSchema)) return []; + + const byPath = new Map>(); + const activeReferences = new Set(); + + const addRules = (path: string, rules: readonly string[]): void => { + if (rules.length === 0) return; + const existing = byPath.get(path) ?? new Set(); + for (const rule of rules) existing.add(rule); + byPath.set(path, existing); + }; + + const visit = (value: unknown, path: string): void => { + if (!isJsonObject(value)) return; + + const reference = stringValue(value.$ref); + if (reference !== undefined && !activeReferences.has(reference)) { + const resolved = resolveReference(reference, inputSchema, schemaDefinitions); + if (resolved !== undefined) { + activeReferences.add(reference); + visit(resolved, path); + activeReferences.delete(reference); + } + } else if (reference !== undefined) { + const resolved = resolveReference(reference, inputSchema, schemaDefinitions); + if (isJsonObject(resolved)) addRules(path, rulesForSchema(resolved)); + } + + addRules(path, rulesForSchema(value)); + + if (isJsonObject(value.properties)) { + for (const [name, child] of Object.entries(value.properties)) { + visit(child, path === "(root)" ? name : `${path}.${name}`); + } + } + + if (Array.isArray(value.prefixItems)) { + value.prefixItems.forEach((child, index) => visit(child, `${path}[${index}]`)); + } + if (Array.isArray(value.items)) { + value.items.forEach((child, index) => visit(child, `${path}[${index}]`)); + } else if (isJsonObject(value.items)) { + visit(value.items, `${path}[]`); + } + if (isJsonObject(value.additionalProperties)) visit(value.additionalProperties, `${path}.*`); + + // allOf constraints are conjunctive. anyOf/oneOf constraints are not: a + // flat list would turn alternatives into an impossible conjunction, so we + // intentionally omit branch-local rules until the contract can represent + // per-branch groups. + if (Array.isArray(value.allOf)) { + for (const variant of value.allOf) visit(variant, path); + } + }; + + visit(inputSchema, "(root)"); + return [...byPath.entries()].map(([path, rules]) => ({ path, rules: [...rules] })); +}; diff --git a/packages/core/execution/src/skills.ts b/packages/core/execution/src/skills.ts index 473e18ca0..44ed2c3da 100644 --- a/packages/core/execution/src/skills.ts +++ b/packages/core/execution/src/skills.ts @@ -35,7 +35,7 @@ const EXECUTE_SKILL_BODY = [ '1. `const { items: matches } = await tools.search({ query: "", limit: 12 });`', '2. `const path = matches[0]?.path; if (!path) return "No matching tools found.";`', "3. `const details = await tools.describe.tool({ path });`", - "4. Use `details.inputTypeScript` / `details.outputTypeScript` and `details.typeScriptDefinitions` for compact shapes.", + "4. Use `details.inputTypeScript` / `details.outputTypeScript` and `details.typeScriptDefinitions` for compact shapes. Check `details.inputConstraints` for validation rules TypeScript cannot express, such as numeric, collection, and string-length limits.", "5. Use `tools.executor.coreTools.connections.list({})` when you need live saved-connection inventory.", "6. Call the tool: `const result = await tools.(input);`", "", @@ -45,6 +45,7 @@ const EXECUTE_SKILL_BODY = [ '- When you already know the namespace, narrow with `tools.search({ namespace: "github", query: "issues" })`.', "- `tools.executor.coreTools.connections.list({})` returns saved connections with `{ address, integration, owner, name, ... }`. The `address` field includes the leading `tools.` root.", "- Tool calls return a value union: `{ ok: true, data }` for success or `{ ok: false, error: { code, message, status?, details?, retryable? } }` for expected tool/domain failures. Branch on `result.ok`.", + "- MCP-backed tools return their successful `CallToolResult` in `result.data`. Executor converts MCP `isError: true` responses into outer `{ ok: false }`, but a server may also encode application status inside `data.structuredContent` (for example `ok`, `status`, or `error`). Follow the described output shape and check those domain fields before treating the operation as successful.", "- `data` is the upstream payload itself. HTTP-backed tools (OpenAPI) also set `http: { status, headers }` beside `data` — read `result.http?.headers` for pagination (Link) or rate-limit headers.", "- Use `emit(value)` to append user-visible output. Plain values become MCP text content. MCP content blocks are forwarded as-is. `ToolFile` values are rendered by MIME. Emitting and returning compose: emitted items come first in the tool result, the returned value follows, and the envelope reports an `emitted` count so you can confirm the items landed.", '- File-returning tools may return `ToolFile` values: `{ _tag: "ToolFile", name?, mimeType, encoding: "base64", data, byteLength }`. Emit any attachment with `emit(result.data)`.', @@ -56,7 +57,7 @@ const EXECUTE_SKILL_BODY = [ "- Always use the full address when calling tools: `tools....(args)`. The `path` returned by `tools.search()` / `tools.describe.tool()` is already the exact path under `tools` — call `tools[path]` rather than guessing segments.", "- The `tools` object is a lazy proxy — enumerating it (`Object.keys(tools)`, spread, `for...in`) throws. Use `tools.search()` or `tools.executor.coreTools.connections.list({})` instead.", '- Pass an object to system tools, e.g. `tools.search({ query: "..." })`, `tools.executor.coreTools.connections.list({})`, and `tools.describe.tool({ path })`.', - '- `tools.describe.tool()` returns compact TypeScript shapes. Use `inputTypeScript`, `outputTypeScript`, and `typeScriptDefinitions`. If the path doesn\'t resolve, the result carries `error: { code: "tool_not_found", suggestions }` — use a suggestion instead of retrying the same path.', + '- `tools.describe.tool()` returns compact TypeScript shapes plus `inputConstraints` for validation rules TypeScript cannot express. Use `inputTypeScript`, `inputConstraints`, `outputTypeScript`, and `typeScriptDefinitions`. If the path doesn\'t resolve, the result carries `error: { code: "tool_not_found", suggestions }` — use a suggestion instead of retrying the same path.', "- For tools that return large collections (e.g. `getStates`, `getAll`), filter results in code rather than calling per-item tools.", "- Do not use `fetch` — all API calls go through `tools.*`.", "- If execution pauses for interaction, resume it with the returned `resumePayload`.", @@ -90,7 +91,7 @@ const CREATE_ARTIFACT_SKILL_BODY = [ "", "This document is the API. For how the result must LOOK — the type scale, the token", "system, the chart palette, layout density and the anti-patterns — fetch", - '`skills({ name: "artifact-style" })`. Artifacts render inside the Executor console,', + "the companion `skills` MCP tool with name `artifact-style`. Artifacts render inside the Executor console,", "and an artifact that ignores the design system is visibly not part of the product.", "", "Every successful render is persisted under the `title` you supply, so the user can", @@ -326,11 +327,11 @@ const CREATE_ARTIFACT_SKILL_BODY = [ "", "- Use this tool instead of `execute` whenever the output should be an interactive UI.", "- Export a component named `App`. A top-level `const config = { maxHeight }` caps the frame height where the artifact is embedded in a scrolling page; it is ignored where the artifact has been given the whole viewport.", - '- Lay the artifact out as an APP, not a document: root `flex h-full flex-col`, headers and filters as ordinary children, and the one long list or table as `flex-1 min-h-0 overflow-auto` so it scrolls under a header that stays put. See `skills({ name: "artifact-style" })`.', + "- Lay the artifact out as an APP, not a document: root `flex h-full flex-col`, headers and filters as ordinary children, and the one long list or table as `flex-1 min-h-0 overflow-auto` so it scrolls under a header that stays put. See the `artifact-style` guide from the companion `skills` MCP tool.", "- Do not call API tools first and paste returned data into JSX.", "- Do not embed tool response rows, API results, summaries, dashboard data, or copied query output as literals. Fetch them with `useQuery` so the UI stays live; only hardcode display constants like labels, tab names, and chart configuration.", "- Always render the loading and error states from `useQuery` / `useInfiniteQuery` / `useMutation`; do not replace them with hardcoded fallback data. `ArtifactLoading` / `ArtifactEmpty` / `ArtifactError` are in scope for exactly this.", - '- Style with the design system, not by taste: tokens only (`text-muted-foreground`, `border-border`), never a hex literal or a Tailwind palette class, and never a page-level `p-6` — the shell owns the outer padding. See `skills({ name: "artifact-style" })`.', + "- Style with the design system, not by taste: tokens only (`text-muted-foreground`, `border-border`), never a hex literal or a Tailwind palette class, and never a page-level `p-6` — the shell owns the outer padding. See the `artifact-style` guide from the companion `skills` MCP tool.", "- Never write `useQuery({ queryKey: [...], queryFn: ... })` by hand. Only `tools...queryOptions(...)` / `.infiniteQueryOptions(...)` produce keys the invalidation helpers can match.", "- Never call a hook inside a `for` / `while` / `do` body. Hooks run unconditionally at the top level of the component, in the same order every render — a loop makes the hook count vary and React breaks. The server rejects it.", "- Do not redeclare or destructure provided globals. `const { useState } = React` and `const Card = ...` are rejected by the server before the UI reaches the iframe — use them directly.", diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index dd25e6168..536efc27d 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -67,7 +67,10 @@ const EmptyValidator: Validator = Schema.toStandardSchemaV1(Schema.Struct({})); // core, exactly like the openapi plugin's spec-derived schemas.) const RepoInputJson = { type: "object", - properties: { owner: { type: "string" }, repo: { type: "string" } }, + properties: { + owner: { type: "string", minLength: 1, maxLength: 100 }, + repo: { type: "string", minLength: 1, maxLength: 100 }, + }, required: ["owner", "repo"], } as const; const RepoDetailsOutputJson = { @@ -864,6 +867,10 @@ describe("tool discovery", () => { expect(described.name).toBe("listRepositoryIssues"); expect(described.description).toBe("List issues for a repository"); expect(described.inputTypeScript).toBe("{ owner: string; repo: string; }"); + expect(described.inputConstraints).toEqual([ + { path: "owner", rules: ["length >= 1", "length <= 100"] }, + { path: "repo", rules: ["length >= 1", "length <= 100"] }, + ]); expect(described.outputTypeScript).toBe( "{ ok: true; data: unknown; http?: ToolHttpMeta } | { ok: false; error: ToolError }", ); @@ -978,6 +985,7 @@ describe("tool discovery", () => { expect(described.path).toBe("github.org.main.getRepoDetails"); expect(described.name).toBe("github.org.main.getRepoDetails"); expect(described.inputTypeScript).toBeUndefined(); + expect(described.inputConstraints).toBeUndefined(); expect(described.error?.code).toBe("tool_not_found"); expect(described.error?.message).toBe("Tool not found: github.org.main.getRepoDetails"); expect(described.error?.suggestions).toContain("github.org.main.getRepositoryDetails"); diff --git a/packages/core/execution/src/tool-invoker.ts b/packages/core/execution/src/tool-invoker.ts index 4da925176..b106e1548 100644 --- a/packages/core/execution/src/tool-invoker.ts +++ b/packages/core/execution/src/tool-invoker.ts @@ -19,6 +19,7 @@ import { } from "@executor-js/sdk/core"; import type { SandboxToolInvoker } from "@executor-js/codemode-core"; import { ExecutionToolError } from "./errors"; +import { summarizeInputConstraints, type ToolInputConstraint } from "./schema-constraints"; const OPAQUE_DEFECT_MESSAGE = "Internal tool error"; const TOOL_DESCRIBE_SUGGESTION_LIMIT = 5; @@ -75,6 +76,7 @@ type DescribedTool = { readonly name: string; readonly description?: string; readonly inputTypeScript?: string; + readonly inputConstraints?: readonly ToolInputConstraint[]; readonly outputTypeScript?: string; readonly typeScriptDefinitions?: Record; /** Set when the path resolves to no tool — mirrors invoke's tool_not_found. */ @@ -125,12 +127,14 @@ const BUILTIN_TOOL_DESCRIPTIONS: ReadonlyMap = new Map< { path: "describe.tool", name: "describe.tool", - description: "Describe a tool's compact TypeScript input and output shapes.", + description: + "Describe a tool's compact TypeScript input and output shapes plus validation constraints TypeScript cannot express.", inputTypeScript: "{ path: string; }", outputTypeScript: "DescribedTool", typeScriptDefinitions: { DescribedTool: - '{ path: string; name: string; description?: string; inputTypeScript?: string; outputTypeScript?: string; typeScriptDefinitions?: { [k: string]: string; }; error?: { code: "tool_not_found"; message: string; suggestions?: string[]; }; }', + '{ path: string; name: string; description?: string; inputTypeScript?: string; inputConstraints?: ToolInputConstraint[]; outputTypeScript?: string; typeScriptDefinitions?: { [k: string]: string; }; error?: { code: "tool_not_found"; message: string; suggestions?: string[]; }; }', + ToolInputConstraint: "{ path: string; rules: string[]; }", }, }, ], @@ -860,11 +864,13 @@ export const describeTool = Effect.fn("executor.tools.describe")(function* ( // The schema's address is the tool address; name/description come from the // tool row which tools.schema() already loaded. + const inputConstraints = summarizeInputConstraints(schema.inputSchema, schema.schemaDefinitions); const described: DescribedTool = { path, name: schema.name ?? path, description: schema.description, inputTypeScript: schema.inputTypeScript, + ...(inputConstraints.length > 0 ? { inputConstraints } : {}), outputTypeScript: wrapOutputTypeScript(schema.outputTypeScript), typeScriptDefinitions: withToolResultDefinitions(schema.typeScriptDefinitions), }; diff --git a/packages/hosts/mcp/src/tool-server.test.ts b/packages/hosts/mcp/src/tool-server.test.ts index d20edfca5..b23ad2134 100644 --- a/packages/hosts/mcp/src/tool-server.test.ts +++ b/packages/hosts/mcp/src/tool-server.test.ts @@ -1796,6 +1796,9 @@ describe("MCP host server — skills tool", () => { await withClient(makeStubEngine({}), NO_CAPS, async (client) => { const { tools } = await client.listTools(); expect(tools.map((t) => t.name)).toContain("skills"); + const skills = tools.find((tool) => tool.name === "skills"); + expect(skills?.description).toContain("Invoke this companion MCP tool"); + expect(skills?.description).not.toContain("skills({"); }); }); diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index 7a44dcfd8..12bb44ddf 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -1480,7 +1480,7 @@ export const createExecutorMcpServer = ( { description: [ "Fetch a named how-to skill. Skills hold the long-form guidance that would otherwise bloat another tool's always-loaded description.", - 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool (search the catalog, call tools, emit results, resume paused runs).', + "Invoke this companion MCP tool with name `execute` for the full guide to writing code for the `execute` sandbox (search the catalog, call tools, emit results, resume paused runs).", "Call with no name to list the available skills.", ].join("\n"), inputSchema: { @@ -1943,7 +1943,7 @@ export const createExecutorMcpServer = ( { description: [ "Render an interactive React UI component as an MCP app, and save it as a reusable artifact.", - 'Call `skills({ name: "create-artifact" })` for the full guide: the discovery-then-render protocol, TanStack Query rules, and every component already in scope. Call `skills({ name: "artifact-style" })` for how it must look — artifacts render inside the Executor console and must match its design system.', + "Use the companion `skills` MCP tool with name `create-artifact` for the full guide: the discovery-then-render protocol, TanStack Query rules, and every component already in scope. Fetch `artifact-style` the same way for how it must look — artifacts render inside the Executor console and must match its design system.", "Write a component named `App` in `code`. Do not import anything and do not paste fetched data into JSX — read it live with `useQuery(tools...queryOptions(args))`.", "Lay it out as an app, not a document: an artifact may be given the whole viewport, so make the root `flex h-full flex-col`, keep headers and filters as ordinary children, and give the one long table or list `flex-1 min-h-0 overflow-auto` — its header then stays put while the rows scroll under it.", "Artifact code addresses an INTEGRATION, never a connection: write `tools.vercel.domains.getDomains`, not the full `tools.vercel.user.personalVercel.domains.getDomains` address `execute` uses for discovery. The connection is bound when the artifact is saved, so it stays portable. Code containing a `.user.` or `.org.` segment is rejected.",