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
5 changes: 5 additions & 0 deletions .changeset/artifact-hyphenated-tool-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": patch
---

Render artifacts that call integrations or tools with hyphenated slugs.
16 changes: 11 additions & 5 deletions packages/hosts/mcp-apps-shell/src/shell/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,16 @@ export type RequestTrustedInteraction = (
interaction: TrustedInteraction,
) => Promise<TrustedInteractionResponse>;

const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$]*$/;
const TOOL_PATH_IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$-]*$/;

const formatToolPathSegment = (segment: string): string =>
TOOL_PATH_IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`;

/**
* The ONE grammar the shell ever puts on the `execute-action` wire:
*
* return await tools.<ident>("<role>")?(.<ident>)*(<JSON>)
* return await tools<segment>("<role>")?<segment>*(<JSON>)
*
* A single proxy-shaped tool call, nothing else — no statements, no loops, no
* composition. The server parses `execute-action` against exactly this shape
Expand Down Expand Up @@ -71,10 +75,12 @@ export function toolCallCode(
if (role !== undefined && (typeof role !== "string" || role.length === 0)) {
throw new Error("Invalid tool role.");
}
const [head, ...rest] = parts;
const head = parts[0];
if (head === undefined) throw new Error("Invalid tool path.");
const rest = parts.slice(1);
const tag = role === undefined ? "" : `(${JSON.stringify(role)})`;
const trailer = rest.length > 0 ? `.${rest.join(".")}` : "";
return `return await tools.${head}${tag}${trailer}(${JSON.stringify(args[0] ?? {})})`;
const target = `${formatToolPathSegment(head)}${tag}${rest.map(formatToolPathSegment).join("")}`;
return `return await tools${target}(${JSON.stringify(args[0] ?? {})})`;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "@effect/vitest";
import { parseToolCallCode } from "@executor-js/host-mcp/tool-call-code";
import { formatToolCallCode, parseToolCallCode } from "@executor-js/host-mcp/tool-call-code";

import { toolCallCode } from "./proxy";

Expand Down Expand Up @@ -30,6 +30,11 @@ describe("execute-action tool-call grammar", () => {
path: ["search"],
args: [{ query: "github issues", limit: 12 }],
},
{
label: "a hyphenated integration slug",
path: ["cloudflare-bindings", "d1_database_query"],
args: [{ database_id: "db", sql: "SELECT 1" }],
},
{
label: "an argument with a $ in an identifier-ish key",
path: ["mongo", "org", "main", "find"],
Expand Down Expand Up @@ -82,6 +87,12 @@ describe("execute-action tool-call grammar", () => {
});
}

it("formats a resolved hyphenated integration safely", () => {
expect(formatToolCallCode(["cloudflare-bindings", "org", "default", "query"], {})).toBe(
'return await tools["cloudflare-bindings"].org.default.query({})',
);
});

it("refuses to emit a path that would not parse", () => {
expect(() => toolCallCode([], [])).toThrow("Invalid tool path.");
expect(() => toolCallCode(["github", "issues; drop"], [])).toThrow("Invalid tool path.");
Expand Down
7 changes: 7 additions & 0 deletions packages/hosts/mcp/src/artifact-bindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ describe("extractArtifactRoles", () => {
expect(roles).toEqual([{ role: "vercel", integration: "vercel" }]);
});

it("reads a hyphenated integration from a bracket reference", () => {
const roles = extractArtifactRoles(
`useQuery(tools["cloudflare-bindings"].d1_database_query.queryOptions({ sql: "SELECT 1" }));`,
);
expect(roles).toEqual([{ role: "cloudflare-bindings", integration: "cloudflare-bindings" }]);
});

it("collapses repeated references to one role", () => {
const roles = extractArtifactRoles(
`useQuery(tools.linear.issues.list.queryOptions({}));
Expand Down
13 changes: 8 additions & 5 deletions packages/hosts/mcp/src/artifact-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,17 @@ const withCommentsBlanked = (code: string): string =>
code.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (text) => text.replace(/[^\n]/g, " "));

/**
* A `tools.<root>` reference, with the optional role call that follows it.
* A tools root reference, with the optional role call that follows it.
*
* The role is captured from either quote flavour. Anything else after the root
* — property access, a call with an object — is left to the caller's own path
* handling; extraction only cares which integration slot is being reached.
*/
const TOOLS_REFERENCE =
/(?<![.\w$])tools\s*\.\s*([A-Za-z_$][\w$]*)\s*(?:\(\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')\s*\))?/g;
const TOOL_ROOT = String.raw`(?:\.\s*([A-Za-z_$][\w$]*)|\[\s*"([A-Za-z_$][\w$-]*)"\s*\])`;
const TOOLS_REFERENCE = new RegExp(
String.raw`(?<![.\w$])tools\s*${TOOL_ROOT}\s*(?:\(\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')\s*\))?`,
"g",
);

/**
* An old-style address: a tier literal in the segment right after the
Expand Down Expand Up @@ -138,9 +141,9 @@ export const extractArtifactRoles = (code: string): readonly ArtifactRole[] => {
const scannable = withCommentsBlanked(code);
const found = new Map<string, ArtifactRole>();
for (const match of scannable.matchAll(TOOLS_REFERENCE)) {
const integration = match[1];
const integration = match[1] ?? match[2];
if (integration === undefined || RESERVED_TOOL_ROOTS.has(integration)) continue;
const role = match[2] ?? match[3] ?? integration;
const role = match[3] ?? match[4] ?? integration;
if (role.length === 0) continue;
if (!found.has(role)) found.set(role, { role, integration });
}
Expand Down
38 changes: 26 additions & 12 deletions packages/hosts/mcp/src/tool-call-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@
* the shell ever writes any. So the server parses `execute-action` against the
* one grammar the proxy emits:
*
* return await tools.<ident>("<role>")?(.<ident>)*(<JSON>)
* return await tools<segment>("<role>")?<segment>*(<JSON>)
*
* One awaited tool call, one JSON-literal argument, nothing else — no
* statements, no loops, no composition. `execute` (the model-facing codemode
* tool) is untouched; this constraint is only for the app-originated channel.
*
* The leading identifier is an INTEGRATION, not a connection: artifact paths
* The leading segment is an INTEGRATION, not a connection: artifact paths
* carry no tier and no connection name (see `artifact-bindings.ts`). The
* optional string call right after it is the integration ROLE, which is how an
* artifact using two accounts of one integration says which it means. Both are
Expand All @@ -32,16 +32,26 @@

import { Option, Schema } from "effect";

const TOOL_CALL_CODE =
/^return await tools\.([A-Za-z_$][\w$]*)(?:\((("(?:[^"\\]|\\.)*"))\))?((?:\.[A-Za-z_$][\w$]*)*)\((.*)\);?$/s;
const JSON_STRING_LITERAL = String.raw`"(?:[^"\\]|\\.)*"`;
const IDENTIFIER = String.raw`[A-Za-z_$][\w$]*`;
const SLUG = String.raw`[A-Za-z_$][\w$-]*`;
const PATH_SEGMENT = String.raw`(?:\.${IDENTIFIER}|\["${SLUG}"\])`;
const TOOL_CALL_CODE = new RegExp(
String.raw`^return await tools(${PATH_SEGMENT})(?:\((${JSON_STRING_LITERAL})\))?((?:${PATH_SEGMENT})*)\((.*)\);?$`,
"s",
);
const PATH_SEGMENT_MATCHER = new RegExp(String.raw`(?:\.(${IDENTIFIER})|\["(${SLUG})"\])`, "g");

/** The proxy's argument is always `JSON.stringify` output, so anything that
* does not decode is, by construction, not something the proxy emitted. */
const decodeArgs = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown));
const decodeRole = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.String));

const decodePath = (serialized: string): readonly string[] =>
Array.from(serialized.matchAll(PATH_SEGMENT_MATCHER), (match) => match[1] ?? match[2] ?? "");

export type ParsedToolCall = {
/** The dotted path segments under `tools`, e.g. `["github", "issues", "create"]`.
/** The path segments under `tools`, e.g. `["github", "issues", "create"]`.
* The head is an integration slug (or a system-tool root); it is never a
* tier or a connection name. */
readonly path: readonly string[];
Expand All @@ -56,7 +66,7 @@ export type ParsedToolCall = {
/** The message handed back to the iframe when its code is not a tool call. */
export const TOOL_CALL_CONTRACT_MESSAGE = [
"execute-action accepts a single tool call, not arbitrary code.",
'The only accepted form is `return await tools.<integration>("<role>")?.<path>(<json>)` —',
'The only accepted form is `return await tools<integration>("<role>")?<path>(<json>)` —',
"exactly what the shell's `tools.*` proxy emits.",
"Interactive UI reaches integrations declaratively:",
"`tools.<integration>.<tool>.queryOptions(...)` / `.infiniteQueryOptions(...)` for reads,",
Expand All @@ -72,14 +82,14 @@ export const parseToolCallCode = (code: string): ParsedToolCall | null => {
const match = TOOL_CALL_CODE.exec(code.trim());
if (!match) return null;

const [, root, serializedRole, , dottedRest, serializedArgs] = match;
if (root === undefined || dottedRest === undefined || serializedArgs === undefined) return null;
const [, root, serializedRole, serializedRest, serializedArgs] = match;
if (root === undefined || serializedRest === undefined || serializedArgs === undefined)
return null;

const args = decodeArgs(serializedArgs);
if (Option.isNone(args)) return null;

const rest = dottedRest.length > 0 ? dottedRest.slice(1).split(".") : [];
const path = [root, ...rest];
const path = decodePath(`${root}${serializedRest}`);

if (serializedRole === undefined) return { path, args: args.value };

Expand All @@ -91,7 +101,11 @@ export const parseToolCallCode = (code: string): ParsedToolCall | null => {
return { path, role: role.value, args: args.value };
};

const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$]*$/;
const TOOL_PATH_IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$-]*$/;

const formatToolPathSegment = (segment: string): string =>
TOOL_PATH_IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`;

/**
* Build the codemode call for a RESOLVED address — the full
Expand All @@ -115,5 +129,5 @@ export const formatToolCallCode = (path: readonly string[], args: unknown): stri
throw new Error("Invalid resolved tool path.");
}
}
return `return await tools.${path.join(".")}(${JSON.stringify(args ?? {})})`;
return `return await tools${path.map(formatToolPathSegment).join("")}(${JSON.stringify(args ?? {})})`;
};
Loading