Skip to content

Commit 3eea03b

Browse files
Fix hyphenated tool paths in artifacts (#1884)
* test: cover hyphenated paths in browser * refactor: narrow hyphenated artifact path fix * Test queue timeout with a controlled clock * Handle single-quoted artifact integration paths * Use the catalog path for the artifact schema query --------- Co-authored-by: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
1 parent 3d9ac6a commit 3eea03b

7 files changed

Lines changed: 186 additions & 23 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
Render artifacts that call integrations or tools with hyphenated slugs.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { randomBytes } from "node:crypto";
2+
import { expect } from "@effect/vitest";
3+
import { Effect, Schema } from "effect";
4+
import { composePluginApi } from "@executor-js/api/server";
5+
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
6+
import {
7+
ArtifactId,
8+
AuthTemplateSlug,
9+
ConnectionName,
10+
IntegrationSlug,
11+
} from "@executor-js/sdk/shared";
12+
13+
import { createEmulatorInstance } from "../src/emulator-instance";
14+
import { scenario } from "../src/scenario";
15+
import { Api, Browser, Mcp, Target } from "../src/services";
16+
import { visit } from "../src/surfaces/browser";
17+
18+
const api = composePluginApi([openApiHttpPlugin()] as const);
19+
const decodeCreatedArtifact = Schema.decodeUnknownSync(
20+
Schema.Struct({ structuredContent: Schema.Struct({ artifactId: ArtifactId }) }),
21+
);
22+
23+
scenario(
24+
"Artifacts · a hyphenated integration renders live tool data through a bracket path",
25+
{ timeout: 180_000 },
26+
Effect.gen(function* () {
27+
const target = yield* Target;
28+
const browser = yield* Browser;
29+
const mcp = yield* Mcp;
30+
const { client: makeClient } = yield* Api;
31+
const identity = yield* target.newIdentity();
32+
const client = yield* makeClient(api, identity);
33+
const session = mcp.session(identity);
34+
const slug = IntegrationSlug.make(`artifact-schema-${randomBytes(4).toString("hex")}`);
35+
const baseUrl = yield* createEmulatorInstance("resend", "artifact-path");
36+
let artifactId: ArtifactId | undefined;
37+
38+
yield* Effect.gen(function* () {
39+
// The emulator's public schema is a real JSON endpoint. Its descriptive
40+
// title gives the rendered query a stable caller-visible result.
41+
yield* client.openapi.addSpec({
42+
payload: {
43+
slug,
44+
baseUrl,
45+
spec: {
46+
kind: "blob",
47+
value: JSON.stringify({
48+
openapi: "3.0.3",
49+
info: { title: "Service schema", version: "1" },
50+
servers: [{ url: baseUrl }],
51+
paths: {
52+
"/openapi.json": {
53+
get: {
54+
operationId: "readSchema",
55+
responses: { "200": { description: "Schema" } },
56+
},
57+
},
58+
},
59+
}),
60+
},
61+
},
62+
});
63+
yield* client.connections.create({
64+
payload: {
65+
owner: "org",
66+
name: ConnectionName.make("public"),
67+
integration: slug,
68+
template: AuthTemplateSlug.make("none"),
69+
values: {},
70+
},
71+
});
72+
const created = yield* session.call("create-artifact", {
73+
title: `Service schema ${slug}`,
74+
code: `function App() {
75+
const query = useQuery(tools['${slug}'].openapiJson.readSchema.queryOptions({}));
76+
return <pre data-testid="live-schema">{query.isPending ? "Loading schema" : JSON.stringify(query.data ?? query.error)}</pre>;
77+
}`,
78+
});
79+
expect(created.ok, created.text).toBe(true);
80+
const envelope = decodeCreatedArtifact(created.raw);
81+
artifactId = envelope.structuredContent.artifactId;
82+
yield* browser.session(identity, async ({ page, step }) => {
83+
await step("Open the artifact and read the service schema", async () => {
84+
await visit(page, `/artifacts/${artifactId}`);
85+
const data = page
86+
.frameLocator('[data-testid="artifact-shell-frame"]')
87+
.frameLocator("iframe")
88+
.getByTestId("live-schema");
89+
await data.waitFor({ timeout: 30_000 });
90+
await data.filter({ hasText: /Resend/i }).waitFor({ timeout: 30_000 });
91+
expect(await data.textContent()).toContain("openapi");
92+
});
93+
});
94+
}).pipe(
95+
Effect.ensuring(
96+
Effect.gen(function* () {
97+
if (artifactId !== undefined)
98+
yield* client.artifacts.remove({ params: { artifactId } }).pipe(Effect.ignore);
99+
yield* client.connections
100+
.remove({
101+
params: { owner: "org", integration: slug, name: ConnectionName.make("public") },
102+
})
103+
.pipe(Effect.ignore);
104+
yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
105+
}),
106+
),
107+
);
108+
}),
109+
);

packages/hosts/mcp-apps-shell/src/shell/proxy.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,16 @@ export type RequestTrustedInteraction = (
3333
interaction: TrustedInteraction,
3434
) => Promise<TrustedInteractionResponse>;
3535

36-
const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$]*$/;
36+
const TOOL_PATH_IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
37+
const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$-]*$/;
38+
39+
const formatToolPathSegment = (segment: string): string =>
40+
TOOL_PATH_IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`;
3741

3842
/**
3943
* The ONE grammar the shell ever puts on the `execute-action` wire:
4044
*
41-
* return await tools.<ident>("<role>")?(.<ident>)*(<JSON>)
45+
* return await tools<segment>("<role>")?<segment>*(<JSON>)
4246
*
4347
* A single proxy-shaped tool call, nothing else — no statements, no loops, no
4448
* composition. The server parses `execute-action` against exactly this shape
@@ -71,10 +75,12 @@ export function toolCallCode(
7175
if (role !== undefined && (typeof role !== "string" || role.length === 0)) {
7276
throw new Error("Invalid tool role.");
7377
}
74-
const [head, ...rest] = parts;
78+
const head = parts[0];
79+
if (head === undefined) throw new Error("Invalid tool path.");
80+
const rest = parts.slice(1);
7581
const tag = role === undefined ? "" : `(${JSON.stringify(role)})`;
76-
const trailer = rest.length > 0 ? `.${rest.join(".")}` : "";
77-
return `return await tools.${head}${tag}${trailer}(${JSON.stringify(args[0] ?? {})})`;
82+
const target = `${formatToolPathSegment(head)}${tag}${rest.map(formatToolPathSegment).join("")}`;
83+
return `return await tools${target}(${JSON.stringify(args[0] ?? {})})`;
7884
}
7985

8086
/**

packages/hosts/mcp-apps-shell/src/shell/tool-call-grammar.pin.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "@effect/vitest";
2-
import { parseToolCallCode } from "@executor-js/host-mcp/tool-call-code";
2+
import { formatToolCallCode, parseToolCallCode } from "@executor-js/host-mcp/tool-call-code";
33

44
import { toolCallCode } from "./proxy";
55

@@ -30,6 +30,11 @@ describe("execute-action tool-call grammar", () => {
3030
path: ["search"],
3131
args: [{ query: "github issues", limit: 12 }],
3232
},
33+
{
34+
label: "a hyphenated integration slug",
35+
path: ["cloudflare-bindings", "d1_database_query"],
36+
args: [{ database_id: "db", sql: "SELECT 1" }],
37+
},
3338
{
3439
label: "an argument with a $ in an identifier-ish key",
3540
path: ["mongo", "org", "main", "find"],
@@ -82,6 +87,12 @@ describe("execute-action tool-call grammar", () => {
8287
});
8388
}
8489

90+
it("formats a resolved hyphenated integration safely", () => {
91+
expect(formatToolCallCode(["cloudflare-bindings", "org", "default", "query"], {})).toBe(
92+
'return await tools["cloudflare-bindings"].org.default.query({})',
93+
);
94+
});
95+
8596
it("refuses to emit a path that would not parse", () => {
8697
expect(() => toolCallCode([], [])).toThrow("Invalid tool path.");
8798
expect(() => toolCallCode(["github", "issues; drop"], [])).toThrow("Invalid tool path.");

packages/hosts/mcp/src/artifact-bindings.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,21 @@ describe("extractArtifactRoles", () => {
2929
expect(roles).toEqual([{ role: "vercel", integration: "vercel" }]);
3030
});
3131

32+
it("reads a hyphenated integration from a bracket reference", () => {
33+
const roles = extractArtifactRoles(
34+
`useQuery(tools["cloudflare-bindings"].d1_database_query.queryOptions({ sql: "SELECT 1" }));`,
35+
);
36+
expect(roles).toEqual([{ role: "cloudflare-bindings", integration: "cloudflare-bindings" }]);
37+
});
38+
39+
it("reads a hyphenated integration from single-quoted bracket references", () => {
40+
expect(
41+
extractArtifactRoles(
42+
`useQuery(tools['cloudflare-bindings']('production').query.queryOptions({}));`,
43+
),
44+
).toEqual([{ role: "production", integration: "cloudflare-bindings" }]);
45+
});
46+
3247
it("collapses repeated references to one role", () => {
3348
const roles = extractArtifactRoles(
3449
`useQuery(tools.linear.issues.list.queryOptions({}));

packages/hosts/mcp/src/artifact-bindings.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,17 @@ const withCommentsBlanked = (code: string): string =>
9292
code.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (text) => text.replace(/[^\n]/g, " "));
9393

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

104107
/**
105108
* An old-style address: a tier literal in the segment right after the
@@ -138,9 +141,9 @@ export const extractArtifactRoles = (code: string): readonly ArtifactRole[] => {
138141
const scannable = withCommentsBlanked(code);
139142
const found = new Map<string, ArtifactRole>();
140143
for (const match of scannable.matchAll(TOOLS_REFERENCE)) {
141-
const integration = match[1];
144+
const integration = match[1] ?? match[2] ?? match[3];
142145
if (integration === undefined || RESERVED_TOOL_ROOTS.has(integration)) continue;
143-
const role = match[2] ?? match[3] ?? integration;
146+
const role = match[4] ?? match[5] ?? integration;
144147
if (role.length === 0) continue;
145148
if (!found.has(role)) found.set(role, { role, integration });
146149
}

packages/hosts/mcp/src/tool-call-code.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@
1212
* the shell ever writes any. So the server parses `execute-action` against the
1313
* one grammar the proxy emits:
1414
*
15-
* return await tools.<ident>("<role>")?(.<ident>)*(<JSON>)
15+
* return await tools<segment>("<role>")?<segment>*(<JSON>)
1616
*
1717
* One awaited tool call, one JSON-literal argument, nothing else — no
1818
* statements, no loops, no composition. `execute` (the model-facing codemode
1919
* tool) is untouched; this constraint is only for the app-originated channel.
2020
*
21-
* The leading identifier is an INTEGRATION, not a connection: artifact paths
21+
* The leading segment is an INTEGRATION, not a connection: artifact paths
2222
* carry no tier and no connection name (see `artifact-bindings.ts`). The
2323
* optional string call right after it is the integration ROLE, which is how an
2424
* artifact using two accounts of one integration says which it means. Both are
@@ -32,16 +32,26 @@
3232

3333
import { Option, Schema } from "effect";
3434

35-
const TOOL_CALL_CODE =
36-
/^return await tools\.([A-Za-z_$][\w$]*)(?:\((("(?:[^"\\]|\\.)*"))\))?((?:\.[A-Za-z_$][\w$]*)*)\((.*)\);?$/s;
35+
const JSON_STRING_LITERAL = String.raw`"(?:[^"\\]|\\.)*"`;
36+
const IDENTIFIER = String.raw`[A-Za-z_$][\w$]*`;
37+
const SLUG = String.raw`[A-Za-z_$][\w$-]*`;
38+
const PATH_SEGMENT = String.raw`(?:\.${IDENTIFIER}|\["${SLUG}"\])`;
39+
const TOOL_CALL_CODE = new RegExp(
40+
String.raw`^return await tools(${PATH_SEGMENT})(?:\((${JSON_STRING_LITERAL})\))?((?:${PATH_SEGMENT})*)\((.*)\);?$`,
41+
"s",
42+
);
43+
const PATH_SEGMENT_MATCHER = new RegExp(String.raw`(?:\.(${IDENTIFIER})|\["(${SLUG})"\])`, "g");
3744

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

50+
const decodePath = (serialized: string): readonly string[] =>
51+
Array.from(serialized.matchAll(PATH_SEGMENT_MATCHER), (match) => match[1] ?? match[2] ?? "");
52+
4353
export type ParsedToolCall = {
44-
/** The dotted path segments under `tools`, e.g. `["github", "issues", "create"]`.
54+
/** The path segments under `tools`, e.g. `["github", "issues", "create"]`.
4555
* The head is an integration slug (or a system-tool root); it is never a
4656
* tier or a connection name. */
4757
readonly path: readonly string[];
@@ -56,7 +66,7 @@ export type ParsedToolCall = {
5666
/** The message handed back to the iframe when its code is not a tool call. */
5767
export const TOOL_CALL_CONTRACT_MESSAGE = [
5868
"execute-action accepts a single tool call, not arbitrary code.",
59-
'The only accepted form is `return await tools.<integration>("<role>")?.<path>(<json>)` —',
69+
'The only accepted form is `return await tools<integration>("<role>")?<path>(<json>)` —',
6070
"exactly what the shell's `tools.*` proxy emits.",
6171
"Interactive UI reaches integrations declaratively:",
6272
"`tools.<integration>.<tool>.queryOptions(...)` / `.infiniteQueryOptions(...)` for reads,",
@@ -72,14 +82,14 @@ export const parseToolCallCode = (code: string): ParsedToolCall | null => {
7282
const match = TOOL_CALL_CODE.exec(code.trim());
7383
if (!match) return null;
7484

75-
const [, root, serializedRole, , dottedRest, serializedArgs] = match;
76-
if (root === undefined || dottedRest === undefined || serializedArgs === undefined) return null;
85+
const [, root, serializedRole, serializedRest, serializedArgs] = match;
86+
if (root === undefined || serializedRest === undefined || serializedArgs === undefined)
87+
return null;
7788

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

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

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

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

94-
const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$]*$/;
104+
const TOOL_PATH_IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
105+
const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$-]*$/;
106+
107+
const formatToolPathSegment = (segment: string): string =>
108+
TOOL_PATH_IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`;
95109

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

0 commit comments

Comments
 (0)