Skip to content

Commit 521c77b

Browse files
authored
Keep artifacts available in search and invoke mode (#1999)
1 parent e619a78 commit 521c77b

9 files changed

Lines changed: 200 additions & 63 deletions

File tree

‎apps/docs/mcp-proxy.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ agent automatically.
6060
## Search and invoke mode
6161

6262
Add `?mode=passthrough` to your MCP endpoint, or enable **Search and invoke**
63-
in the Connect card. This mode exposes four tools:
63+
in the Connect card. This mode exposes four discovery and invocation tools:
6464

6565
- `integrations`: list connected accounts with integration descriptions, account
6666
labels, and their last recorded health. Results are paginated, with one item per
@@ -83,5 +83,7 @@ has its own pagination for records.
8383

8484
The tool list stays small as you add integrations. Input schemas are loaded only
8585
for matching search results. Your client handles approval for `invoke`, and
86-
workspace block policies still apply. This mode does not expose code execution or
87-
artifact tools.
86+
workspace block policies still apply. This mode does not expose a general code execution tool. Artifacts remain
87+
available unless you disable them with `artifacts=false` or the Artifacts toggle.
88+
Use `skills({ name: "create-artifact" })` for the guide to building artifacts
89+
after discovering data with search and invoke.

‎e2e/cloud/passthrough-scale.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ scenario(
3535
);
3636
expect(visible.length, "the seeded catalog is large").toBeGreaterThan(3000);
3737

38-
const session = mcp.session(identity, { mode: "passthrough" });
38+
const session = mcp.session(identity, { mode: "passthrough", artifacts: false });
3939
const startedAt = Date.now();
4040
const served = yield* session.describeTools();
4141
const elapsedMs = Date.now() - startedAt;

‎e2e/scenarios/mcp-passthrough.test.ts‎

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Effect, Schema } from "effect";
77
import { composePluginApi } from "@executor-js/api/server";
88
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
99
import {
10+
ArtifactId,
1011
AuthTemplateSlug,
1112
ConnectionName,
1213
IntegrationSlug,
@@ -34,6 +35,12 @@ const decodeInventory = Schema.decodeUnknownSync(
3435
}),
3536
);
3637

38+
const decodeArtifact = Schema.decodeUnknownSync(
39+
Schema.Struct({
40+
structuredContent: Schema.Struct({ artifactId: ArtifactId, url: Schema.String }),
41+
}),
42+
);
43+
3744
const api = composePluginApi([openApiHttpPlugin()] as const);
3845

3946
const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
@@ -192,7 +199,14 @@ scenario(
192199
await visit(page, "/");
193200
await page.getByRole("button", { name: "Advanced" }).click();
194201
await page.getByRole("switch", { name: "Search and invoke" }).check();
202+
await page.getByRole("switch", { name: "Artifacts", exact: true }).check();
195203
await settle(page);
204+
expect(
205+
await page.getByRole("switch", { name: "Artifacts", exact: true }).isEnabled(),
206+
).toBe(true);
207+
expect(await page.locator("code").first().innerText()).not.toContain(
208+
"artifacts=false",
209+
);
196210
expect(await page.locator("code").first().innerText()).toContain("mode=passthrough");
197211
expect(
198212
await page
@@ -205,10 +219,58 @@ scenario(
205219
});
206220
});
207221

222+
const withArtifacts = mcp.session(identity, { mode: "passthrough" });
223+
expect(yield* withArtifacts.listTools()).toEqual(
224+
expect.arrayContaining([
225+
"search",
226+
"invoke",
227+
"create-artifact",
228+
"edit-artifact",
229+
"list-artifacts",
230+
"show-artifact",
231+
]),
232+
);
233+
const artifactGuide = yield* withArtifacts.call("skills", { name: "create-artifact" });
234+
expect(artifactGuide.ok).toBe(true);
235+
expect(artifactGuide.text).toContain("invoke");
236+
expect(artifactGuide.text).not.toContain("`execute`");
237+
expect((yield* withArtifacts.call("list-artifacts", {})).ok).toBe(true);
238+
const artifact = yield* withArtifacts.call("create-artifact", {
239+
title: "Search and invoke artifact",
240+
code: "function App() { return <div>Artifact available</div>; }",
241+
});
242+
expect(artifact.ok, artifact.text).toBe(true);
243+
const saved = decodeArtifact(artifact.raw).structuredContent;
244+
yield* Effect.ensuring(
245+
Effect.gen(function* () {
246+
const edited = yield* withArtifacts.call("edit-artifact", {
247+
artifactId: saved.artifactId,
248+
edits: [{ oldText: "Artifact available", newText: "Artifact restored" }],
249+
});
250+
expect(edited.ok, edited.text).toBe(true);
251+
const shown = yield* withArtifacts.call("show-artifact", { id: saved.artifactId });
252+
expect(shown.ok, shown.text).toBe(true);
253+
expect(shown.text).toContain("Artifact restored");
254+
yield* browser.session(identity, async ({ page, step }) => {
255+
await step("Open the artifact created through Search and invoke", async () => {
256+
await visit(page, saved.url);
257+
await page
258+
.frameLocator('[data-testid="artifact-shell-frame"]')
259+
.frameLocator("iframe")
260+
.getByText("Artifact restored", { exact: true })
261+
.waitFor({ timeout: 30_000 });
262+
});
263+
});
264+
}),
265+
client.artifacts
266+
.remove({ params: { artifactId: saved.artifactId } })
267+
.pipe(Effect.orDie),
268+
);
269+
208270
const codemode = mcp.session(identity);
209271
expect(yield* codemode.listTools()).toContain("execute");
210272

211-
const passthrough = mcp.session(identity, { mode: "passthrough" });
273+
const passthrough = mcp.session(identity, { mode: "passthrough", artifacts: false });
212274
const described = yield* passthrough.describeTools();
213275
expect(described.map((tool) => tool.name).sort()).toEqual([
214276
"integrations",
@@ -328,7 +390,7 @@ scenario(
328390
});
329391
yield* Effect.ensuring(
330392
Effect.gen(function* () {
331-
const afterBlock = mcp.session(identity, { mode: "passthrough" });
393+
const afterBlock = mcp.session(identity, { mode: "passthrough", artifacts: false });
332394
const afterNames = decodeToolSearch(
333395
(yield* afterBlock.call("search", { query: slug })).raw,
334396
).structuredContent.items.map((tool) => tool.id);

‎packages/core/execution/src/skills.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,17 @@ describe("skills registry", () => {
5656
expect(skillCatalogFor({ artifacts: true })).toEqual(SKILLS);
5757
});
5858
});
59+
60+
describe("artifact discovery guides", () => {
61+
it("uses the search/invoke workflow without advertising execute", () => {
62+
const catalog = skillCatalogFor({ artifacts: true, discovery: "search-invoke" });
63+
expect(catalog.map((skill) => skill.name)).toEqual(["create-artifact", "artifact-style"]);
64+
const body = findSkill("create-artifact", catalog)?.body;
65+
expect(body).toContain("integrations");
66+
expect(body).toContain("invoke");
67+
expect(body).toContain("queryOptions");
68+
expect(body).not.toContain("`execute`");
69+
expect(body).not.toContain("connections.list");
70+
expect(skillCatalogFor({ artifacts: false, discovery: "search-invoke" })).toEqual([]);
71+
});
72+
});

‎packages/core/execution/src/skills.ts‎

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ const LUCIDE_ICONS =
8686
"Plus, Minus, Check, X, Search, Loader2, AlertCircle, ExternalLink, Copy, Trash2, Edit, Settings, User, Globe, Star, TrendingUp, Activity, Database, Shield, Package, and more";
8787

8888
const CREATE_ARTIFACT_SKILL_BODY = [
89-
"# create-artifact",
89+
"## Build the artifact",
9090
"",
9191
"Render an interactive React UI component as an MCP app, and save it as an artifact.",
9292
"",
@@ -103,7 +103,7 @@ const CREATE_ARTIFACT_SKILL_BODY = [
103103
"",
104104
"## Workflow",
105105
"",
106-
"1. If you need to understand tool names, query syntax, required arguments, response shapes, IDs, mutation inputs, or a list tool's cursor field, first use the regular `execute` tool to inspect them.",
106+
"1. Follow the discovery workflow above to inspect tool names, input schemas, response shapes, and pagination before writing the artifact.",
107107
"2. Then call `create-artifact` with a component named `App` in the `code` parameter.",
108108
"3. Recreate every read from the discovery step inside `App` with `useQuery(tools.<integration>.<tool>.queryOptions(args))` so the UI stays live.",
109109
"4. Use `useMutation(tools.<integration>.<tool>.mutationOptions({ onSuccess }))` for user-triggered writes or actions.",
@@ -146,14 +146,14 @@ const CREATE_ARTIFACT_SKILL_BODY = [
146146
"",
147147
"## Addressing: Integrations, Not Connections",
148148
"",
149-
"This is the one place artifact code differs from `execute` code, and getting it",
149+
"Artifact code differs from the full tool IDs used during discovery. Getting it",
150150
"wrong is rejected outright.",
151151
"",
152-
"`execute` addresses a tool by its full five-segment address, because discovery has",
152+
"A tool ID includes its full five-segment address, because discovery has",
153153
"to say exactly which saved connection it means:",
154154
"",
155155
"```",
156-
"return await tools.linear.org.linearProd.issues.list({ first: 20 })",
156+
"tools.linear.org.linearProd.issues.list",
157157
"```",
158158
"",
159159
"Artifact code drops the middle two segments and names only the INTEGRATION:",
@@ -174,8 +174,8 @@ const CREATE_ARTIFACT_SKILL_BODY = [
174174
'connections: { "linear": "linear.org.linearProd" }',
175175
"```",
176176
"",
177-
"The value is the `<integration>.<user|org>.<connection>` triple — exactly the",
178-
"`address` field from `tools.executor.coreTools.connections.list({})`, minus the",
177+
"The value is the `<integration>.<user|org>.<connection>` triple.",
178+
"Use the integration, owner, and connection from discovery. Omit the",
179179
"leading `tools.`. The key is the ROLE, which for a single-account artifact is just",
180180
"the integration slug.",
181181
"",
@@ -188,12 +188,11 @@ const CREATE_ARTIFACT_SKILL_BODY = [
188188
'// connections: { "prod": "linear.org.linearProd", "staging": "linear.user.myLinear" }',
189189
"```",
190190
"",
191-
"Worked example, end to end. Discovery through `execute` shows the full address:",
191+
"For example, discovery identifies the account and its operation:",
192192
"",
193193
"```",
194-
"await tools.executor.coreTools.connections.list({})",
195-
'// -> [{ address: "tools.linear.org.linearProd", integration: "linear", ... }]',
196-
"return await tools.linear.org.linearProd.issues.list({ first: 5 })",
194+
"Integration: linear; owner: org; connection: linearProd",
195+
"Tool ID: tools.linear.org.linearProd.issues.list; input: { first: 5 }",
197196
"```",
198197
"",
199198
"The artifact you then create says:",
@@ -203,8 +202,7 @@ const CREATE_ARTIFACT_SKILL_BODY = [
203202
'connections: { "linear": "linear.org.linearProd" } // optional if linearProd is your only linear connection',
204203
"```",
205204
"",
206-
"System tools keep their usual paths and need no binding: `tools.search(...)`,",
207-
"`tools.describe.tool(...)`, `tools.executor.coreTools.*`.",
205+
"Keep tool discovery outside the component. The component uses integration operations.",
208206
"",
209207
"## The Contract: tools.* Only",
210208
"",
@@ -215,12 +213,12 @@ const CREATE_ARTIFACT_SKILL_BODY = [
215213
"- **Never hand-roll `useQuery({ queryKey, queryFn })`.** Always pass the proxy's options object: `useQuery(tools.<integration>.<tool>.queryOptions(args))`. A hand-written `queryKey` is invisible to `queryFilter`/`pathFilter`, so mutations silently stop refreshing the UI, and it hides which tool the artifact uses from artifact analysis.",
216214
"- **Never fetch in a loop by hand.** Cursor pagination is declarative — see below.",
217215
"",
218-
"## Using Execute For Discovery",
216+
"## Keep Discovered Data Live",
219217
"",
220-
"- `execute` is for exploration: list datasets, inspect schemas, test a query, fetch one small sample row, or learn the exact mutation input shape.",
218+
"- Use the discovery workflow above to inspect schemas, test a query, fetch a small sample, or learn mutation inputs.",
221219
"- `create-artifact` is for the final interactive surface. Do not paste discovery results into JSX as literal rows, cards, summaries, metrics, or chart series.",
222-
"- After discovering an API call with `execute`, put the same call in TanStack Query options inside the generated component.",
223-
"- Example discovery: call `execute` with `return await tools.axiom_mcp.querydataset({ ... })` to confirm columns, then call `create-artifact` with `useQuery(tools.axiom_mcp.querydataset.queryOptions({ ... }))`.",
220+
"- After discovering an API call, put that integration operation in TanStack Query options inside the generated component.",
221+
"- For example, inspect a dataset query to confirm columns, then use its integration operation with `.queryOptions(...)` in the component.",
224222
"- Use discovered result shapes exactly. If a sample or schema returns `{ renew, expiresAt }`, read `data?.renew`, not `data?.domain?.renew`.",
225223
"- Keep discovery small. Use limits, narrow time ranges, or schema/list tools when possible.",
226224
"",
@@ -243,7 +241,7 @@ const CREATE_ARTIFACT_SKILL_BODY = [
243241
"",
244242
"- `getNextPageParam(lastPage, allPages)` reads the cursor out of the tool's own response. Return `undefined` (or `null`) when there are no more pages — that is what stops the paging.",
245243
"- `initialPageParam` defaults to `null`, which means the FIRST request carries no cursor at all. Set it only when a tool requires an explicit starting value (e.g. `initialPageParam: 1` for page numbers).",
246-
'- `cursorKey` says where the page param lands in the tool input. It defaults to `"cursor"`. Use a dotted path for nested inputs — `cursorKey: "query.since"` writes `{ query: { since: <pageParam> } }`. Read the tool\'s input shape with `execute` first; do not guess the field name.',
244+
'- `cursorKey` says where the page param lands in the tool input. It defaults to `"cursor"`. Use a dotted path for nested inputs — `cursorKey: "query.since"` writes `{ query: { since: <pageParam> } }`. Read the tool\'s input schema during discovery first; do not guess the field name.',
247245
"- Render `data.pages` (an array of tool results, newest page last) and drive further loading from `hasNextPage` / `fetchNextPage` / `isFetchingNextPage`. Do not call `fetchNextPage` in a loop on mount — let the user pull more, or paginate deliberately with a bounded `useEffect`.",
248246
"",
249247
"**Never chain `useQuery` calls in a loop to page through a cursor.** This is",
@@ -326,7 +324,7 @@ const CREATE_ARTIFACT_SKILL_BODY = [
326324
"",
327325
"## Rules",
328326
"",
329-
"- Use this tool instead of `execute` whenever the output should be an interactive UI.",
327+
"- Use this tool whenever the output should be an interactive UI.",
330328
"- 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.",
331329
'- 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" })`.',
332330
"- Do not call API tools first and paste returned data into JSX.",
@@ -347,11 +345,32 @@ const CREATE_ARTIFACT_SKILL_BODY = [
347345
"- Clients that cannot display MCP apps get a link to the artifact in the web app instead; pass that URL on to the user verbatim.",
348346
].join("\n");
349347

348+
const createArtifactSkillBody = (searchAndInvoke: boolean): string =>
349+
[
350+
"# create-artifact",
351+
"",
352+
"## Discover accounts and data",
353+
"",
354+
...(searchAndInvoke
355+
? [
356+
"1. Call `integrations({})` to choose an account. Its `integration`, `owner`, and `connection` fields form the artifact binding, joined with dots.",
357+
'2. Call `search({ query: "list issues", integration: "linear", owner: "org", connection: "linearProd" })` using that account. Read the returned inputSchema.',
358+
"3. Call `invoke({ tool: <exact returned id>, arguments: <JSON input> })` to inspect a small sample response. Use search and invoke, not general code execution, to learn the data shape.",
359+
]
360+
: [
361+
'1. Use `execute` to call `tools.search({ query: "list issues" })`, then `tools.describe.tool({ path })` to inspect the matched tool.',
362+
"2. Use `tools.executor.coreTools.connections.list({})` for saved accounts. The connection address without its leading `tools.` is the artifact binding.",
363+
"3. Call the discovered tool in `execute` to inspect a small sample response.",
364+
]),
365+
"",
366+
CREATE_ARTIFACT_SKILL_BODY,
367+
].join("\n");
368+
350369
export const CREATE_ARTIFACT_SKILL: Skill = {
351370
name: "create-artifact",
352371
summary:
353372
"How to write a React component for the create-artifact tool: discover data with execute, keep it live with TanStack Query (including cursor pagination), and what is already in scope.",
354-
body: CREATE_ARTIFACT_SKILL_BODY,
373+
body: createArtifactSkillBody(false),
355374
};
356375

357376
// The design system, kept SEPARATE from the capability manifest above.
@@ -631,8 +650,25 @@ const ARTIFACT_SKILLS: ReadonlySet<Skill> = new Set([CREATE_ARTIFACT_SKILL, ARTI
631650
* artifact skills so the index never advertises a doc for tools this connection
632651
* does not have; a session that opted in gets the full {@link SKILLS} list.
633652
*/
634-
export const skillCatalogFor = (options: { readonly artifacts: boolean }): readonly Skill[] =>
635-
options.artifacts ? SKILLS : SKILLS.filter((skill) => !ARTIFACT_SKILLS.has(skill));
653+
export const skillCatalogFor = (options: {
654+
readonly artifacts: boolean;
655+
readonly discovery?: "execute" | "search-invoke";
656+
}): readonly Skill[] => {
657+
if (options.discovery === "search-invoke") {
658+
return options.artifacts
659+
? [
660+
{
661+
...CREATE_ARTIFACT_SKILL,
662+
summary:
663+
"Build a live React artifact after discovering accounts and data with integrations, search, and invoke.",
664+
body: createArtifactSkillBody(true),
665+
},
666+
ARTIFACT_STYLE_SKILL,
667+
]
668+
: [];
669+
}
670+
return options.artifacts ? SKILLS : SKILLS.filter((skill) => !ARTIFACT_SKILLS.has(skill));
671+
};
636672

637673
/** Look up a skill by its exact name within a session's catalog. */
638674
export const findSkill = (name: string, catalog: readonly Skill[] = SKILLS): Skill | undefined =>

0 commit comments

Comments
 (0)