diff --git a/.changeset/artifact-hyphenated-tool-paths.md b/.changeset/artifact-hyphenated-tool-paths.md
new file mode 100644
index 0000000000..c659cf6787
--- /dev/null
+++ b/.changeset/artifact-hyphenated-tool-paths.md
@@ -0,0 +1,5 @@
+---
+"executor": patch
+---
+
+Render artifacts that call integrations or tools with hyphenated slugs.
diff --git a/apps/cloud/src/mcp/session-build-semaphore.test.ts b/apps/cloud/src/mcp/session-build-semaphore.test.ts
index 3d4ad76343..584b65ee0e 100644
--- a/apps/cloud/src/mcp/session-build-semaphore.test.ts
+++ b/apps/cloud/src/mcp/session-build-semaphore.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it, beforeEach } from "@effect/vitest";
+import { describe, expect, it, beforeEach, afterEach, vi } from "@effect/vitest";
import {
acquireBuildSlot,
@@ -13,6 +13,10 @@ describe("session-build-semaphore", () => {
resetBuildSlotsForTest();
});
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
it("grants up to the cap immediately, with no wait", async () => {
const results = await Promise.all([
acquireBuildSlot().promise,
@@ -214,6 +218,7 @@ describe("session-build-semaphore", () => {
});
it("proceeds without a slot when the queue wait exceeds the timeout, and does not count it as active", async () => {
+ vi.useFakeTimers();
await Promise.all([
acquireBuildSlot().promise,
acquireBuildSlot().promise,
@@ -223,6 +228,10 @@ describe("session-build-semaphore", () => {
expect(currentActiveBuildsForTest()).toBe(4);
const timedOutHandle = acquireBuildSlot(10);
+ await vi.advanceTimersByTimeAsync(9);
+ expect(currentQueueLengthForTest()).toBe(1);
+ expect(currentActiveBuildsForTest()).toBe(4);
+ await vi.advanceTimersByTimeAsync(1);
const result = await timedOutHandle.promise;
expect(result).toEqual({ acquired: false, waitMs: expect.any(Number), timedOut: true });
diff --git a/e2e/scenarios/artifact-hyphenated-path.test.ts b/e2e/scenarios/artifact-hyphenated-path.test.ts
new file mode 100644
index 0000000000..d2ec32b5df
--- /dev/null
+++ b/e2e/scenarios/artifact-hyphenated-path.test.ts
@@ -0,0 +1,109 @@
+import { randomBytes } from "node:crypto";
+import { expect } from "@effect/vitest";
+import { Effect, Schema } from "effect";
+import { composePluginApi } from "@executor-js/api/server";
+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
+import {
+ ArtifactId,
+ AuthTemplateSlug,
+ ConnectionName,
+ IntegrationSlug,
+} from "@executor-js/sdk/shared";
+
+import { createEmulatorInstance } from "../src/emulator-instance";
+import { scenario } from "../src/scenario";
+import { Api, Browser, Mcp, Target } from "../src/services";
+import { visit } from "../src/surfaces/browser";
+
+const api = composePluginApi([openApiHttpPlugin()] as const);
+const decodeCreatedArtifact = Schema.decodeUnknownSync(
+ Schema.Struct({ structuredContent: Schema.Struct({ artifactId: ArtifactId }) }),
+);
+
+scenario(
+ "Artifacts · a hyphenated integration renders live tool data through a bracket path",
+ { timeout: 180_000 },
+ Effect.gen(function* () {
+ const target = yield* Target;
+ const browser = yield* Browser;
+ const mcp = yield* Mcp;
+ const { client: makeClient } = yield* Api;
+ const identity = yield* target.newIdentity();
+ const client = yield* makeClient(api, identity);
+ const session = mcp.session(identity);
+ const slug = IntegrationSlug.make(`artifact-schema-${randomBytes(4).toString("hex")}`);
+ const baseUrl = yield* createEmulatorInstance("resend", "artifact-path");
+ let artifactId: ArtifactId | undefined;
+
+ yield* Effect.gen(function* () {
+ // The emulator's public schema is a real JSON endpoint. Its descriptive
+ // title gives the rendered query a stable caller-visible result.
+ yield* client.openapi.addSpec({
+ payload: {
+ slug,
+ baseUrl,
+ spec: {
+ kind: "blob",
+ value: JSON.stringify({
+ openapi: "3.0.3",
+ info: { title: "Service schema", version: "1" },
+ servers: [{ url: baseUrl }],
+ paths: {
+ "/openapi.json": {
+ get: {
+ operationId: "readSchema",
+ responses: { "200": { description: "Schema" } },
+ },
+ },
+ },
+ }),
+ },
+ },
+ });
+ yield* client.connections.create({
+ payload: {
+ owner: "org",
+ name: ConnectionName.make("public"),
+ integration: slug,
+ template: AuthTemplateSlug.make("none"),
+ values: {},
+ },
+ });
+ const created = yield* session.call("create-artifact", {
+ title: `Service schema ${slug}`,
+ code: `function App() {
+ const query = useQuery(tools['${slug}'].openapiJson.readSchema.queryOptions({}));
+ return
{query.isPending ? "Loading schema" : JSON.stringify(query.data ?? query.error)};
+ }`,
+ });
+ expect(created.ok, created.text).toBe(true);
+ const envelope = decodeCreatedArtifact(created.raw);
+ artifactId = envelope.structuredContent.artifactId;
+ yield* browser.session(identity, async ({ page, step }) => {
+ await step("Open the artifact and read the service schema", async () => {
+ await visit(page, `/artifacts/${artifactId}`);
+ const data = page
+ .frameLocator('[data-testid="artifact-shell-frame"]')
+ .frameLocator("iframe")
+ .getByTestId("live-schema");
+ await data.waitFor({ timeout: 30_000 });
+ await data.filter({ hasText: /Resend/i }).waitFor({ timeout: 30_000 });
+ expect(await data.textContent()).toContain("openapi");
+ });
+ });
+ }).pipe(
+ Effect.ensuring(
+ Effect.gen(function* () {
+ if (artifactId !== undefined)
+ yield* client.artifacts.remove({ params: { artifactId } }).pipe(Effect.ignore);
+ yield* client.connections
+ .remove({
+ params: { owner: "org", integration: slug, name: ConnectionName.make("public") },
+ })
+ .pipe(Effect.ignore);
+ yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
+ }),
+ ),
+ );
+ }),
+);
diff --git a/packages/hosts/mcp-apps-shell/src/shell/proxy.ts b/packages/hosts/mcp-apps-shell/src/shell/proxy.ts
index 6d7744d0c6..b0983344d9 100644
--- a/packages/hosts/mcp-apps-shell/src/shell/proxy.ts
+++ b/packages/hosts/mcp-apps-shell/src/shell/proxy.ts
@@ -33,12 +33,16 @@ export type RequestTrustedInteraction = (
interaction: TrustedInteraction,
) => Promise;
-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.("")?(.)*()
+ * return await tools("")?*()
*
* A single proxy-shaped tool call, nothing else — no statements, no loops, no
* composition. The server parses `execute-action` against exactly this shape
@@ -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] ?? {})})`;
}
/**
diff --git a/packages/hosts/mcp-apps-shell/src/shell/tool-call-grammar.pin.test.ts b/packages/hosts/mcp-apps-shell/src/shell/tool-call-grammar.pin.test.ts
index ed71c22ced..433858fedf 100644
--- a/packages/hosts/mcp-apps-shell/src/shell/tool-call-grammar.pin.test.ts
+++ b/packages/hosts/mcp-apps-shell/src/shell/tool-call-grammar.pin.test.ts
@@ -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";
@@ -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"],
@@ -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.");
diff --git a/packages/hosts/mcp/src/artifact-bindings.test.ts b/packages/hosts/mcp/src/artifact-bindings.test.ts
index bbbe7c630a..44e2fafd03 100644
--- a/packages/hosts/mcp/src/artifact-bindings.test.ts
+++ b/packages/hosts/mcp/src/artifact-bindings.test.ts
@@ -29,6 +29,21 @@ 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("reads a hyphenated integration from single-quoted bracket references", () => {
+ expect(
+ extractArtifactRoles(
+ `useQuery(tools['cloudflare-bindings']('production').query.queryOptions({}));`,
+ ),
+ ).toEqual([{ role: "production", integration: "cloudflare-bindings" }]);
+ });
+
it("collapses repeated references to one role", () => {
const roles = extractArtifactRoles(
`useQuery(tools.linear.issues.list.queryOptions({}));
diff --git a/packages/hosts/mcp/src/artifact-bindings.ts b/packages/hosts/mcp/src/artifact-bindings.ts
index 9be7a15c39..d513aca047 100644
--- a/packages/hosts/mcp/src/artifact-bindings.ts
+++ b/packages/hosts/mcp/src/artifact-bindings.ts
@@ -92,14 +92,17 @@ const withCommentsBlanked = (code: string): string =>
code.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (text) => text.replace(/[^\n]/g, " "));
/**
- * A `tools.` 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 =
- /(? {
const scannable = withCommentsBlanked(code);
const found = new Map();
for (const match of scannable.matchAll(TOOLS_REFERENCE)) {
- const integration = match[1];
+ const integration = match[1] ?? match[2] ?? match[3];
if (integration === undefined || RESERVED_TOOL_ROOTS.has(integration)) continue;
- const role = match[2] ?? match[3] ?? integration;
+ const role = match[4] ?? match[5] ?? integration;
if (role.length === 0) continue;
if (!found.has(role)) found.set(role, { role, integration });
}
diff --git a/packages/hosts/mcp/src/tool-call-code.ts b/packages/hosts/mcp/src/tool-call-code.ts
index b95933dcd1..852791a6c0 100644
--- a/packages/hosts/mcp/src/tool-call-code.ts
+++ b/packages/hosts/mcp/src/tool-call-code.ts
@@ -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.("")?(.)*()
+ * return await tools("")?*()
*
* 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
@@ -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[];
@@ -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.("")?.()` —',
+ 'The only accepted form is `return await tools("")?()` —',
"exactly what the shell's `tools.*` proxy emits.",
"Interactive UI reaches integrations declaratively:",
"`tools...queryOptions(...)` / `.infiniteQueryOptions(...)` for reads,",
@@ -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 };
@@ -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
@@ -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 ?? {})})`;
};