diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b80709a..1ceb4b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,8 @@ jobs: cgr.dev/chainguard/minio:latest server /data --address :9000 trap 'docker stop artifacts-fleet-test-s3' EXIT NATIVE_WORKER_FLEET=1 bun test scripts/native-worker/fleet.test.ts --timeout 240000 + - name: Test the nine-tool MCP connection and inline previews + run: bun run test:mcp-discovery executable: strategy: diff --git a/README.md b/README.md index f5b4df0..856c76c 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,18 @@ For the local host, use `http://127.0.0.1:4786/mcp`. Remote clients use the configured deployment's sign-in flow. Optional `workspace` and `library` query parameters select a workspace or the team library. -Ask your agent to read `artifact_guide` for React apps or `script_guide` for -TypeScript scripts before authoring. For example: +The connection advertises nine model tools: `artifact_guide`, `artifact_list`, +`artifact_read`, `artifact_write`, `artifact_edit`, `artifact_open`, +`artifacts_discover`, `artifacts_query`, and `artifacts_execute`. All 48 hosted +operations remain available through that same connection. Use `artifacts_discover` +to find an operation and retrieve its exact argument schema and relevant guide; +call it through `artifacts_query` for reads or `artifacts_execute` for actions. +Local file connections expose their supported artifact operations through the +same interface. No configuration switch is needed. + +Ask your agent to read `artifact_guide` before authoring React apps. For scripts, +inspect `script_write` through `artifacts_discover`, which also returns the script +guide. For example: > Build a team lunch poll with a React UI and a SQLite backend. diff --git a/bun.lock b/bun.lock index 0308add..369dfa8 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "@openai/mcp-extensions": "0.1.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", + "ajv": "8.20.0", "better-auth": "1.7.3", "cron-parser": "5.10.0", "esbuild": "0.28.1", @@ -35,7 +36,6 @@ "@modelcontextprotocol/sdk": "^1.30.0", "@types/bun": "^1.3.14", "@types/semver": "7.7.1", - "ajv": "8.20.0", "hono": "4.13.7", "jose": "6.2.12", "miniflare": "5.20260903.0-alpha", diff --git a/cloudflare/managed-service.ts b/cloudflare/managed-service.ts index 623984a..a6aad12 100644 --- a/cloudflare/managed-service.ts +++ b/cloudflare/managed-service.ts @@ -13,6 +13,10 @@ import { galleryAction, gallerySource } from "./gallery-tools"; import { ARTIFACTS_SETTINGS_TOOLS, settingsTool } from "../src/mcp/settings-contract"; import { parseProjectArchive } from "../src/project-archive"; import { ownershipName } from "./ownership"; +import { callDiscoveryTool, isDiscoveryTool } from "../src/mcp/discovery"; +import { validateToolArguments } from "../src/mcp/validation"; +import { CLOUD_MCP_TOOLS } from "./tool-contract"; +import * as toolValidators from "../dist/cloudflare/tool-validators.js"; const text = (value: unknown): CallToolResult => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] }); type Selection = { workspace?: string; name?: string; version_id?: string; event_id?: string }; @@ -75,6 +79,14 @@ export class ManagedArtifactService { } async callTool(name: string, args: Record): Promise { + if (isDiscoveryTool(name)) { + return callDiscoveryTool(CLOUD_MCP_TOOLS, name, args, async (operation, input) => { + validateToolArguments(toolValidators, operation, input); + // Re-enter management admission, not the physical storage service: moved + // revisions and native apps still resolve the authenticated owner here. + return this.callTool(operation, input); + }); + } if (ARTIFACTS_SETTINGS_TOOLS.some(tool => tool.name === name)) return settingsTool(this, name, args); if (name === "artifacts_tool") return galleryAction(this, args); if (name === "artifacts_source") return gallerySource(this, args); diff --git a/cloudflare/mcp-discovery-plugin.ts b/cloudflare/mcp-discovery-plugin.ts new file mode 100644 index 0000000..5238845 --- /dev/null +++ b/cloudflare/mcp-discovery-plugin.ts @@ -0,0 +1,15 @@ +import { definePlugins } from "../src/plugins/config"; + +/** Installed only by test:mcp-discovery, never by the default build. */ +export default definePlugins([{ + name: "mcp-discovery-test", description: "Authenticated MCP dispatch fixture", + operations: { + echo: { + description: "Echo a validated value for the authorized fixture user.", readOnly: true, + inputSchema: { type: "object", properties: { value: { type: "string" } }, required: ["value"], additionalProperties: false }, + outputSchema: { type: "object", properties: { value: { type: "string" } }, required: ["value"], additionalProperties: false }, + authorize: user => user.subject === "local", + handler: (input: { value: string }) => ({ value: input.value }), + }, + }, +}]); diff --git a/cloudflare/mcp-discovery.test.ts b/cloudflare/mcp-discovery.test.ts new file mode 100644 index 0000000..0ffbe44 --- /dev/null +++ b/cloudflare/mcp-discovery.test.ts @@ -0,0 +1,298 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { Miniflare, Response as RuntimeResponse } from "miniflare"; +import { chromium } from "playwright"; +import { CelldOperator } from "../scripts/native-worker/operator"; +import { ARTIFACTS_APP_URI } from "../src/mcp/app-contract"; + +const integration = process.env.MCP_DISCOVERY_INTEGRATION === "1"; +const native = process.env.NATIVE_WORKER_CELLD === "1"; +const integrationTest = test.skipIf(!integration); +let runtime: Miniflare; +let operator: CelldOperator; +let directory: string; +let origin: string; +let client: Client; +let other: Client; +let ownerFetch: typeof fetch; +let otherFetch: typeof fetch; +let privateVersion: string; +let restoredPreview: CallToolResult; +let scriptResponse: CallToolResult; +const seen = new Set(); +const details = new Map(); + +const browserSource = `import { H1, Button, Stack, useArtifactState } from "sidequery/artifacts"; +export default function Artifact() { + const [count, setCount] = useArtifactState("count", 0); + return

Nine tool artifact

; +}`; +const scriptSource = `export default { + fetch(request: Request, env: ScriptEnv): Response { + env.sql.exec("create table if not exists counter (value integer not null)"); + if (!env.sql.exec("select value from counter").toArray().length) env.sql.exec("insert into counter values (0)"); + if (request.method === "POST") env.sql.exec("update counter set value = value + 1"); + return Response.json({ value: env.sql.exec("select value from counter").one().value, token: Boolean(env.secrets.TOKEN), marker: "original" }); + } +};`; + +function payload(result: Awaited>): Record { + return result.structuredContent ?? JSON.parse((result.content as { text: string }[])[0]!.text); +} + +async function inspect(operation: string) { + if (!details.has(operation)) { + const result = await client.callTool({ name: "artifacts_discover", arguments: { operation, include_docs: false } }); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + details.set(operation, payload(result) as { dispatch: string; inputSchema: unknown }); + } + return details.get(operation)!; +} + +async function invoke(operation: string, args: Record = {}, via?: string) { + const definition = await inspect(operation); + const direct = ["artifact_guide", "artifact_list", "artifact_read", "artifact_write", "artifact_edit", "artifact_open"].includes(operation); + const result = await client.callTool({ name: via ?? (direct ? operation : definition.dispatch), + arguments: via || !direct ? { operation, arguments: args } : args }); + expect(result.isError, `${operation}: ${JSON.stringify(result)}`).not.toBe(true); + seen.add(operation); + return result; +} + +beforeAll(async () => { + if (!integration) return; + directory = await mkdtemp(join(tmpdir(), "compact-mcp-")); + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const jwk = { ...await exportJWK(publicKey), kid: "compact-mcp", alg: "RS256", use: "sig" }; + const assertion = (subject: string) => new SignJWT({}).setProtectedHeader({ alg: "RS256", kid: jwk.kid }).setSubject(subject) + .setIssuer("https://compact-fixture.cloudflareaccess.com").setAudience("compact-mcp").setIssuedAt().setExpirationTime("1h").sign(privateKey); + const modulesRoot = join(import.meta.dir, "../dist/worker-app"); + const modules: Record = {}; + for (const name of await readdir(modulesRoot)) { + if (name.endsWith(".js") || name.endsWith(".html")) modules[name] = { type: name.endsWith(".js") ? "esm" : "text", contents: await readFile(join(modulesRoot, name), "utf8") }; + if (name.endsWith(".wasm")) modules[name] = { type: "wasm", contents: await readFile(join(modulesRoot, name)) }; + } + const variables: Record = { AUTH_MODE: "access", ACCESS_TEAM_DOMAIN: "compact-fixture.cloudflareaccess.com", ACCESS_AUD: "compact-mcp" }; + if (native) { + const token = "compact-operator-fixture-token-32-characters"; + operator = new CelldOperator({ root: join(directory, "operator"), token, binary: process.env.CELLD_BIN }); + await operator.start(); + variables.NATIVE_CELLD_OPERATOR_URL = operator.serve({ port: 0 }).url.origin + "/"; + variables.NATIVE_CELLD_OPERATOR_TOKEN = token; + variables.NATIVE_CELLD_TRUSTED_APPS = "true"; + } + const env: Record = Object.fromEntries(Object.entries(variables).map(([key, value]) => [key, { type: "text", value }])); + const exports: Record = {}; + for (const [binding, className] of Object.entries({ LIBRARIES: "ArtifactLibrary", BACKENDS: "ArtifactBackend", LINKS: "ArtifactLinks", SCRIPTS: "ScriptLibrary", SCRIPT_BACKENDS: "ScriptBackend", FILE_BACKENDS: "ArtifactFiles", NATIVE_APPS: "NativeApps" })) { + env[binding] = { type: "durable-object", worker: "compact", exportName: className }; + exports[className] = { type: "durable-object", storage: "sqlite" }; + } + env.FILES = { type: "r2", name: "compact-files" }; env.LOADER = { type: "worker-loader" }; env.ASSETS = { type: "assets" }; + runtime = new Miniflare({ cf: false, port: 0, workers: [{ config: { + name: "compact", type: "worker", compatibilityDate: "2026-09-06", compatibilityFlags: ["nodejs_compat"], + manifest: { mainModule: "worker.js", modulesRoot, modules }, env, exports, + assets: { directory: join(import.meta.dir, "../dist/cloudflare/assets"), hasUserWorker: true, runWorkerFirst: true, htmlHandling: "none" }, + }, dev: { outboundService: { type: "fetcher", handler: async (request: Request) => { + if (request.url === "https://compact-fixture.cloudflareaccess.com/cdn-cgi/access/certs") return new RuntimeResponse(JSON.stringify({ keys: [jwk] }), { headers: { "content-type": "application/json" } }); + const response = await fetch(request.url, { method: request.method, headers: request.headers, body: request.body, redirect: "manual" }); + return new RuntimeResponse(response.body, { status: response.status, headers: response.headers }); + } } } }] } as ConstructorParameters[0]); + origin = (await runtime.ready).origin; + const connect = async (subject: string) => { + const token = await assertion(subject); + const connected = new Client({ name: "compact-mcp-integration", version: "1" }); + const authenticatedFetch: typeof fetch = (input, init = {}) => { + const headers = new Headers(init.headers); + headers.set("cf-access-jwt-assertion", token); headers.set("Connection", "close"); + return fetch(input, { ...init, headers }); + }; + if (subject === "local") ownerFetch = authenticatedFetch; + else otherFetch = authenticatedFetch; + await connected.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=compact`), { + fetch: authenticatedFetch, + })); + return connected; + }; + client = await connect("local"); + other = await connect("other-user"); +}, 90_000); + +afterAll(async () => { + await client?.close(); await other?.close(); await runtime?.dispose(); await operator?.close(); + if (directory) await rm(directory, { recursive: true, force: true }); +}); + +integrationTest("one HTTP MCP connection discovers all 48 operations and performs complete artifact, script and native app workflows", async () => { + const listed = (await client.listTools()).tools; + const model = listed.filter(tool => !((tool._meta?.ui as { visibility?: string[] } | undefined)?.visibility?.every(scope => scope === "app"))); + expect(model.map(tool => tool.name).sort()).toEqual([ + "artifact_edit", "artifact_guide", "artifact_list", "artifact_open", "artifact_read", "artifact_write", + "artifacts_discover", "artifacts_execute", "artifacts_query", + ]); + const catalog = payload(await client.callTool({ name: "artifacts_discover", arguments: { limit: 50 } })); + expect(catalog.total).toBe(48); + for (const operation of catalog.operations) await inspect(operation.operation); + const backend = await readFile(join(import.meta.dir, "../examples/counter.artifact.server.ts"), "utf8"); + const guide = await invoke("artifact_guide"); + expect(JSON.stringify(guide.content)).not.toContain("Hosted standalone scripts and root URLs"); + expect(JSON.stringify(guide.content)).not.toContain("## Hosted servers and SQLite"); + const created = await invoke("artifact_write", { name: "compact-artifact", contents: browserSource, server: backend }); + privateVersion = (created._meta!.artifact as { versionId: string }).versionId; + await invoke("artifact_list"); + const read = payload(await invoke("artifact_read", { name: "compact-artifact" })); + await invoke("artifact_edit", { name: "compact-artifact", expected_hash: read.source_hash, edits: [{ old_text: "Nine tool artifact", new_text: "Edited artifact" }] }); + await invoke("artifact_open", { name: "compact-artifact" }); + restoredPreview = await invoke("artifact_restore", { version_id: privateVersion }) as CallToolResult; + expect((restoredPreview._meta!.artifact as { js: string }).js).toContain("Nine tool artifact"); + await invoke("artifact_history", { name: "compact-artifact" }); + await invoke("artifact_version", { version_id: privateVersion }); + await invoke("artifact_typecheck", { name: "compact-artifact" }); + await invoke("artifact_compile", { name: "compact-artifact" }); + await invoke("artifact_remix", { name: "compact-artifact", new_name: "compact-remix" }); + const archive = payload(await invoke("artifact_export", { name: "compact-artifact" })); + await invoke("artifact_import", { new_name: "compact-import", archive }); + await invoke("artifact_link", { name: "compact-artifact", kind: "artifact", slug: "compact-artifact" }); + await invoke("artifact_secrets", { name: "compact-artifact", secrets: { TOKEN: "artifact-fixture-value" } }); + const names = payload(await invoke("artifact_secrets", { name: "compact-artifact" }, "artifacts_query")); + expect(names.names).toEqual(["TOKEN"]); + await invoke("artifact_request", { name: "compact-artifact", request: { path: "/counter", method: "POST" } }); + await invoke("artifact_schedule", { name: "compact-artifact", action: "set", interval_seconds: 86400, request: { path: "/counter", method: "POST" } }); + await invoke("artifact_schedule", { name: "compact-artifact", action: "pause" }); + await invoke("artifact_schedule", { name: "compact-artifact", action: "run_now" }); + const schedule = payload(await invoke("artifact_schedule", { name: "compact-artifact" }, "artifacts_query")); + expect(JSON.stringify(schedule)).toContain("86400"); + const runs = payload(await invoke("artifact_runs", { name: "compact-artifact" })); + expect(runs.runs.length).toBeGreaterThan(0); + const upload = payload(await invoke("artifact_files", { name: "compact-artifact", request: { operation: "upload", name: "fixture.txt", size: 5, type: "text/plain" } })).result; + expect((await fetch(upload.url, { method: "PUT", body: "hello" })).ok).toBe(true); + const files = payload(await invoke("artifact_files", { name: "compact-artifact", request: { operation: "list" } }, "artifacts_query")).result; + expect(files.files).toHaveLength(1); + const download = payload(await invoke("artifact_files", { name: "compact-artifact", request: { operation: "download", id: upload.file.id } }, "artifacts_query")).result; + expect(await (await fetch(download.url)).text()).toBe("hello"); + await invoke("artifact_files", { name: "compact-artifact", request: { operation: "delete", id: upload.file.id } }); + + await invoke("script_guide"); + await invoke("script_write", { name: "compact-script", contents: scriptSource }); + await invoke("script_list"); + const scriptRead = payload(await invoke("script_read", { name: "compact-script" })); + const history = payload(await invoke("script_history", { name: "compact-script" })); + await invoke("script_version", { version_id: history.versions[0].id }); + await invoke("script_edit", { name: "compact-script", expected_hash: scriptRead.source_hash, edits: [{ old_text: 'marker: "original"', new_text: 'marker: "edited"' }] }); + await invoke("script_restore", { name: "compact-script", version_id: history.versions[0].id }); + await invoke("script_secrets", { name: "compact-script", secrets: { TOKEN: "script-fixture-value" } }); + scriptResponse = await invoke("script_run", { name: "compact-script", request: { path: "/compact-script", method: "POST" } }) as CallToolResult; + expect(JSON.parse(atob(payload(scriptResponse).response.body))).toMatchObject({ value: 1, token: true, marker: "original" }); + await invoke("script_schedule", { name: "compact-script", action: "set", interval_seconds: 86400, request: { path: "/compact-script", method: "POST" } }); + await invoke("script_schedule", { name: "compact-script", action: "pause" }); + await invoke("script_schedule", { name: "compact-script", action: "run_now" }); + await invoke("script_schedule", { name: "compact-script" }, "artifacts_query"); + await invoke("script_runs", { name: "compact-script" }); + await invoke("script_logs", { name: "compact-script" }); + await invoke("script_remix", { name: "compact-script", new_name: "compact-script-remix" }); + const scriptArchive = payload(await invoke("script_export", { name: "compact-script" })); + await invoke("script_import", { new_name: "compact-script-import", archive: scriptArchive }); + expect(JSON.stringify(scriptArchive)).not.toContain("script-fixture-value"); + + await invoke("plugin_guide"); + const plugins = payload(await invoke("plugins_list")); + expect(plugins.plugins.some((plugin: { name: string }) => plugin.name === "mcp-discovery-test")).toBe(true); + expect(payload(await invoke("artifact_plugin_call", { plugin: "mcp-discovery-test", operation: "echo", input: { value: "authorized" } })).result).toEqual({ value: "authorized" }); + + if (native) { + await invoke("app_guide"); + await invoke("app_list"); + const source = 'export default { fetch() { return new Response("native-original"); } };'; + const manifest = { main: "worker.ts", compatibility_date: "2026-09-06", vars: {}, secrets: [], bindings: {}, triggers: {} }; + await invoke("app_write", { name: "compact-native", source, manifest, provider: "celld-local", expected_revision: null }); + const appUrl = `${origin}/apps/compact-native/?workspace=compact`; + expect(await (await ownerFetch(appUrl)).text()).toBe("native-original"); + const appRead = payload(await invoke("app_read", { name: "compact-native" })); + await invoke("app_history", { name: "compact-native" }); + await invoke("app_write", { name: "compact-native", source: source.replace("native-original", "native-updated"), manifest, expected_revision: appRead.revision_token }); + expect(await (await ownerFetch(appUrl)).text()).toBe("native-updated"); + await invoke("app_restore", { name: "compact-native", revision_id: appRead.active_revision }); + expect(await (await ownerFetch(appUrl)).text()).toBe("native-original"); + await invoke("app_secrets", { name: "compact-native", secrets: {} }); + await invoke("app_secrets", { name: "compact-native" }, "artifacts_query"); + await invoke("app_reconcile", { name: "compact-native" }); + await invoke("app_move", { name: "compact-native", library: "team" }); + expect((await ownerFetch(appUrl)).status).toBe(404); + expect(await (await otherFetch(appUrl + "&library=team")).text()).toBe("native-original"); + } + const expected = catalog.operations.map((operation: { operation: string }) => operation.operation).filter((name: string) => native || !name.startsWith("app_")); + expect([...seen].sort()).toEqual(expected.sort()); + expect((await client.listTools()).tools).toEqual(listed); +}, 180_000); + +integrationTest("query and nested schema guards reject side effects without changing script state", async () => { + for (const input of [ + { operation: "script_run", arguments: { name: "compact-script", request: { path: "/", method: "GET" } } }, + { operation: "artifact_request", arguments: { name: "compact-artifact", request: { path: "/counter", method: "GET" } } }, + { operation: "script_schedule", arguments: { name: "compact-script", action: "run_now" } }, + { operation: "script_secrets", arguments: { name: "compact-script", secrets: { TOKEN: null } } }, + { operation: "artifact_plugin_call", arguments: { plugin: "mcp-discovery-test", operation: "echo", input: { value: "read" } } }, + ]) { + const result = await client.callTool({ name: "artifacts_query", arguments: input }); + expect(result.isError).toBe(true); + expect(payload(result).error).toContain("Use artifacts_execute"); + } + const malformed = await client.callTool({ name: "artifacts_execute", arguments: { operation: "script_write", arguments: { name: "compact-script" } } }); + expect(malformed.isError).toBe(true); + expect(payload(malformed).error).toContain("Invalid arguments for script_write"); + const result = await invoke("script_run", { name: "compact-script", request: { path: "/", method: "GET" } }); + expect(JSON.parse(atob(payload(result).response.body))).toMatchObject({ value: 2, token: true }); + for (const operation of ["artifacts_execute", "artifacts_tool", "artifacts_source", "artifacts_settings_update", "constructor"]) { + expect((await client.callTool({ name: "artifacts_execute", arguments: { operation, arguments: {} } })).isError).toBe(true); + } +}); + +integrationTest("dispatch preserves authenticated ownership, revision access and plugin authorization", async () => { + // Authentication rejects before reading the body; do not pool that socket + // with the authenticated MCP clients used for the following access probes. + expect((await fetch(`${origin}/mcp`, { method: "POST", headers: { Connection: "close" }, body: "{}" })).status).toBe(401); + for (const [name, operation, argumentsValue] of [ + ["artifacts_query", "artifact_version", { version_id: privateVersion }], + ["artifacts_query", "artifact_files", { version_id: privateVersion, request: { operation: "list" } }], + ["artifacts_execute", "artifact_restore", { version_id: privateVersion }], + ["artifacts_execute", "artifact_plugin_call", { plugin: "mcp-discovery-test", operation: "echo", input: { value: "denied" } }], + ] as const) { + const result = await other.callTool({ name, arguments: { operation, arguments: argumentsValue } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).not.toContain("Nine tool artifact"); + expect(JSON.stringify(result)).not.toContain("artifact-fixture-value"); + } + const forged = await other.callTool({ name: "artifacts_query", arguments: { operation: "artifact_read", arguments: { name: "compact-artifact", owner: "local" } } }); + expect(payload(forged).error).toContain("Invalid arguments"); + expect(payload(await other.callTool({ name: "artifact_list", arguments: {} })).artifacts).toEqual([]); +}); + +integrationTest("real execution results render inline previews and completed non-preview actions through the MCP App bridge", async () => { + const resource = await client.readResource({ uri: ARTIFACTS_APP_URI }); + const html = resource.contents[0]!.text as string; + const build = await Bun.build({ entrypoints: [join(import.meta.dir, "../e2e/mcp-app-host.ts")], target: "browser", format: "esm" }); + if (!build.success) throw new Error(build.logs.join("\n")); + const hostHtml = ``; + const server = Bun.serve({ port: 0, fetch: () => new Response(hostHtml, { headers: { "content-type": "text/html" } }) }); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + try { + await page.goto(server.url.href); + await page.waitForFunction(() => window.mcpHost?.initialized); + const app = page.frameLocator("#app"); + await page.evaluate(result => window.mcpHost!.sendResult(result), scriptResponse); + await app.locator("#status").getByText('"status"', { exact: false }).waitFor(); + expect(await app.locator("#status").innerText()).not.toContain("Waiting for artifact"); + await page.evaluate(result => window.mcpHost!.sendResult(result), restoredPreview); + await app.getByRole("heading", { name: "Nine tool artifact" }).waitFor(); + await app.getByRole("button", { name: "Count 0", exact: true }).click(); + await app.getByRole("button", { name: "Count 1", exact: true }).waitFor(); + await page.evaluate(result => window.mcpHost!.sendResult(result), scriptResponse); + expect(await app.getByRole("button", { name: "Count 1", exact: true }).count()).toBe(1); + } finally { await page.close(); await browser.close(); server.stop(true); } +}, 30_000); diff --git a/cloudflare/plugins.ts b/cloudflare/plugins.ts index 9ec3023..4d08489 100644 --- a/cloudflare/plugins.ts +++ b/cloudflare/plugins.ts @@ -9,7 +9,7 @@ export { createPluginDispatcher, PluginError, PLUGIN_JSON_LIMIT, type PluginInvo export const PLUGIN_GUIDE = `Deployment plugins provide installed browser modules and authenticated server functions. Call plugins_list to discover operation names, schemas, and read-only hints. Call artifact_plugin_call with {plugin, operation, input}, or use pluginCall from sidequery/artifacts in a hosted artifact. Calls run as the authenticated deployment user, independently of artifact, version, workspace, or library selectors. Plugins may restrict users and must check row-level access in their handlers. Public standalone artifacts cannot call plugins. Provider credentials stay on the server. Artifact backends and scripts do not receive this bridge.`; export const pluginCatalog = catalog; -const installedNames = new Set(installed.map(plugin => plugin.name)); +const installedNames = new Set(installed.map(plugin => plugin.name)); const dispatchInstalled = createPluginDispatcher(installed, { input: validatePluginInput, output: validatePluginOutput }); /** Build-time plugins take precedence; a published plugin cannot shadow one. */ diff --git a/cloudflare/service.ts b/cloudflare/service.ts index 19ecca5..3af26a1 100644 --- a/cloudflare/service.ts +++ b/cloudflare/service.ts @@ -85,8 +85,8 @@ export class CloudArtifactService { return { ...text({ result }), structuredContent: { result } }; } case "artifact_guide": { - const guide = artifactGuideResult(); - if (this.hosted) guide.content.push({ type: "text", text: HOSTED_SCRIPT_GUIDE }); + const guide = artifactGuideResult(args.topic as Parameters[0]); + if (this.hosted && args.topic === "all") guide.content.push({ type: "text", text: HOSTED_SCRIPT_GUIDE }); return guide; } case "artifact_list": { diff --git a/cloudflare/tool-contract.ts b/cloudflare/tool-contract.ts index 764e52f..4c43777 100644 --- a/cloudflare/tool-contract.ts +++ b/cloudflare/tool-contract.ts @@ -6,6 +6,7 @@ import { ARTIFACTS_SETTINGS_TOOLS } from "../src/mcp/settings-contract"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { MCP_TOOLS } from "../src/mcp/tools"; import { PROJECT_ARCHIVE_SCHEMA } from "../src/project-archive-contract"; +import { DISCOVERY_TOOLS } from "../src/mcp/discovery"; // Share the local tool contract. A hosted service cannot open a terminal pane. export const CLOUD_MCP_TOOLS: Tool[] = JSON.parse(JSON.stringify(MCP_TOOLS)); @@ -173,3 +174,4 @@ for (const tool of CLOUD_MCP_TOOLS) { applyToolVisibility(CLOUD_MCP_TOOLS); CLOUD_MCP_TOOLS.push(galleryToolDefinition(CLOUD_MCP_TOOLS)); CLOUD_MCP_TOOLS.push(...ARTIFACTS_SETTINGS_TOOLS); +CLOUD_MCP_TOOLS.push(...DISCOVERY_TOOLS); diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts index cd94671..21b391b 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -125,7 +125,8 @@ test("official HTTP MCP client lists contracts, writes, edits, restores and retr expect(guide.isError).not.toBe(true); const guideText = (guide.content as { text: string }[])[0]!.text; expect(guideText).toContain("sidequery/artifacts"); - expect(tools.find(tool => tool.name === "script_guide")?.annotations?.readOnlyHint).toBe(true); + const discoveredScript = payload(await client.callTool({ name: "artifacts_discover", arguments: { operation: "script_guide", include_docs: false } })); + expect(discoveredScript.annotations.readOnlyHint).toBe(true); const scriptGuide = await client.callTool({ name: "script_guide", arguments: {} }); expect(scriptGuide.isError).not.toBe(true); expect((scriptGuide.content as { text: string }[])[0]!.text).toContain("Third-party dependencies"); @@ -139,7 +140,7 @@ test("official HTTP MCP client lists contracts, writes, edits, restores and retr version = artifact.versionId; const resource = await client.readResource({ uri: ARTIFACTS_APP_URI }); expect(resource.contents[0]!.mimeType).toContain("text/html"); - for (const uri of ["ui://artifacts/viewer.html", "ui://artifacts/v4/viewer.html", "ui://artifacts/v5/viewer.html"]) { + for (const uri of ["ui://artifacts/viewer.html", "ui://artifacts/v4/viewer.html", "ui://artifacts/v5/viewer.html", "ui://artifacts/v6/viewer.html"]) { const legacy = await client.readResource({ uri }); expect(legacy.contents[0]).toEqual({ ...resource.contents[0], uri }); } @@ -162,9 +163,10 @@ test("official HTTP MCP client lists contracts, writes, edits, restores and retr }, 60000); test("plugin HTTP and MCP routes expose catalog hints and validate requests", async () => { - const tools = (await client.listTools()).tools; - for (const name of ["plugins_list", "plugin_guide"]) expect(tools.find(tool => tool.name === name)?.annotations?.readOnlyHint).toBe(true); - expect(tools.find(tool => tool.name === "artifact_plugin_call")?.annotations?.readOnlyHint).toBe(false); + for (const operation of ["plugins_list", "plugin_guide", "artifact_plugin_call"]) { + const discovered = payload(await client.callTool({ name: "artifacts_discover", arguments: { operation, include_docs: false } })); + expect(discovered.annotations.readOnlyHint).toBe(operation !== "artifact_plugin_call"); + } const list = await client.callTool({ name: "plugins_list", arguments: {} }); expect(list.isError).not.toBe(true); expect(Array.isArray((list.structuredContent as { plugins: unknown[] }).plugins)).toBe(true); diff --git a/cloudflare/worker.ts b/cloudflare/worker.ts index 8f4c608..74c3775 100644 --- a/cloudflare/worker.ts +++ b/cloudflare/worker.ts @@ -3,10 +3,11 @@ import { Hono } from "hono"; import type { DurableObjectNamespace, Fetcher } from "@cloudflare/workers-types"; import { ArtifactLibrary } from "./library"; import { authenticate, type AuthEnvironment } from "./auth"; -import { ManagedArtifactService } from "./managed-service"; // esbuild 0.28.1 must initialize Better Auth's shared Zod module before -// the MCP SDK constructs its top-level schemas (Zod 4.5.4). +// the MCP SDK constructs its top-level schemas, including through managed +// dispatch's tool registry (Zod 4.5.4). import { authenticateBetterAuth, getArtifactAuth, ArtifactAuthConfigurationError, type BetterAuthEnvironment, type ArtifactUser } from "./better-auth"; +import { ManagedArtifactService } from "./managed-service"; import { handleCloudMcp } from "./mcp"; import { servicePrincipal, SERVICE_TOKEN_AUTHORITY, type ServiceTokenEnvironment } from "./service-tokens"; import { readRequestText, readToolBody } from "./http"; diff --git a/docs/mcp-plugin.md b/docs/mcp-plugin.md index 6571a62..0716f6c 100644 --- a/docs/mcp-plugin.md +++ b/docs/mcp-plugin.md @@ -42,9 +42,31 @@ The server builds on MCP Apps and advertises optional OpenAI extensions. Actual Tool visibility distinguishes model calls from app calls; it does not replace server authorization. Host context and file resource identifiers are untrusted input. UI selection, attachments, and cached state cannot expand library, owner, or team access. +## Model tools and operation discovery + +Every connection advertises the same nine model tools: `artifact_guide`, `artifact_list`, `artifact_read`, `artifact_write`, `artifact_edit`, `artifact_open`, `artifacts_discover`, `artifacts_query`, and `artifacts_execute`. Hosted connections retain all 48 artifact, script, file, schedule, secret, plugin and native Worker operations. Local file connections expose their supported subset. MCP Apps also receive the existing app-only gallery and host-integration tools. + +Call `artifacts_discover` with `query` to search concise operation summaries, with neither selector to browse a paginated catalog, or with `operation` to retrieve its original JSON Schema, annotations, dispatch recommendation and relevant guide. Set `include_docs: false` after reading that guide. Discovery does not execute authored code, unlock tools or change `tools/list`; no connection or configuration switch is required. + +For example, inspect a script operation: + +```json +{"operation":"script_write"} +``` + +Then call `artifacts_execute` with the discovered operation and its arguments: + +```json +{"operation":"script_write","arguments":{"name":"hello","contents":"export default { fetch() { return new Response('Hello'); } };"}} +``` + +`artifacts_query` accepts the same envelope but enforces read-only behavior. Schedule inspection, secret-name listing and file list/download are permitted; schedule changes, secret values, uploads/deletes, backend HTTP requests, script runs and plugin calls require `artifacts_execute`. An HTTP GET to authored code still executes it. Unknown and app-only operations, recursive dispatch and arguments outside the original schema are rejected. + +Each dispatched call enters the original authorization and validation path and returns its original result, including inline previews, diagnostics and optimistic conflicts. Existing direct operation calls remain accepted for compatibility. Host tool grants for advanced actions apply to the shared `artifacts_execute` tool; operation discovery and UI visibility do not grant access or bypass owner/team checks. + ## Authoring and verification -The author skill reads `artifact_guide`, guards source edits using `source_hash`/`expected_hash`, suppresses intermediate previews with `preview: false` when supported, and deliberately calls `artifact_open` when the result is ready. Validation diagnostics may accompany an applied edit; they are not a rollback guarantee. Compatible preview refreshes should preserve view state. +The author skill reads `artifact_guide`, guards source edits using `source_hash`/`expected_hash`, suppresses intermediate previews with `preview: false` when supported, and deliberately calls `artifact_open` when the result is ready. Specialized operations and guides are discovered as needed; the browser guide is the default, with backend, files and plugins topics available explicitly. Validation diagnostics may accompany an applied edit; they are not a rollback guarantee. Compatible preview refreshes should preserve view state. Setup verifies connection using tool discovery, `artifact_guide`, and `artifact_list`. It does not create example artifacts or alter the user's library. Host file editing requires its own advertised resource and write capabilities; it is separate from hosted artifact file storage described in [files](files.md). diff --git a/package.json b/package.json index c24e081..0ae6351 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,8 @@ "src/mcp/app.ts", "src/mcp/guide.ts", "src/mcp/local-tools.ts", + "src/mcp/discovery.ts", + "src/mcp/validation.ts", "src/mcp/protocol.ts", "src/mcp/stdio.ts", "src/mcp/tools.ts", @@ -168,6 +170,7 @@ "build:cloudflare-compiler": "wrangler deploy --config cloudflare/wrangler.compiler.jsonc --dry-run --outdir ../dist/worker", "typecheck:cloudflare": "tsc -p cloudflare/tsconfig.json --noEmit && tsc -p examples/runner-status/tsconfig.worker.json --noEmit", "test:cloudflare": "bun run build:cloudflare-compiler && bun run build:cloudflare && bun test cloudflare --timeout 60000", + "test:mcp-discovery": "ARTIFACTS_PLUGINS_CONFIG=cloudflare/mcp-discovery-plugin.ts bun run build:cloudflare && MCP_DISCOVERY_INTEGRATION=1 NATIVE_WORKER_CELLD=1 bun test cloudflare/mcp-discovery.test.ts --timeout 180000", "test:celld": "bun run build:cloudflare && bun run prepare:celld && CELLD_INTEGRATION=1 CELLD_AUTH_INTEGRATION=1 CELLD_BACKEND_INTEGRATION=1 CELLD_SCHEDULING_INTEGRATION=1 bun test cloudflare/backend.test.ts cloudflare/celld.test.ts cloudflare/auth-flow.test.ts cloudflare/scheduling-celld.test.ts cloudflare/runtime-plugins-celld.test.ts --timeout 180000", "deploy:cloudflare": "wrangler deploy", "seed:cloudflare": "bun run scripts/seed-cloudflare.ts", @@ -176,6 +179,7 @@ "build:executable": "bun run scripts/build-executable.ts" }, "dependencies": { + "ajv": "8.20.0", "@aws-sdk/client-s3": "3.1150.0", "@base-ui/react": "^1.8.0", "@better-auth/oauth-provider": "1.7.3", @@ -207,7 +211,6 @@ "@modelcontextprotocol/sdk": "^1.30.0", "@types/bun": "^1.3.14", "@types/semver": "7.7.1", - "ajv": "8.20.0", "hono": "4.13.7", "jose": "6.2.12", "miniflare": "5.20260903.0-alpha", diff --git a/plugins/artifacts/skills/author/SKILL.md b/plugins/artifacts/skills/author/SKILL.md index d862f1a..5fc1875 100644 --- a/plugins/artifacts/skills/author/SKILL.md +++ b/plugins/artifacts/skills/author/SKILL.md @@ -5,6 +5,8 @@ description: Create or edit React artifacts using the configured Artifacts MCP s Read `artifact_guide` once in the current conversation before authoring. It owns the SDK exports, allowed imports, runtime restrictions, and validation behavior. Use the connected server's current tool schemas rather than assuming every deployment exposes the same tools. +The server advertises six direct artifact tools plus `artifacts_discover`, `artifacts_query` and `artifacts_execute`. For history, restore, files, scripts, schedules, secrets, plugins or native Workers, search `artifacts_discover` with `query`, then inspect the chosen `operation` for its exact schema and relevant guide. Call it using `{ operation, arguments }` through the recommended dispatcher. Use `include_docs: false` when its guide is already loaded. All supported operations use the same connection; do not switch configuration. Query enforces reads; execution, including authored HTTP GETs and plugin calls, requires execute. Shared dispatch retains validation, authorization and preview results. + For an existing artifact, read the relevant source with `artifact_read` before editing. Pass its `source_hash` as `expected_hash` to `artifact_edit`; on conflict, reread and reconcile the user's changes. Use exact replacements with unique matches. A failed validation may leave source applied: inspect the result and repair diagnostics instead of assuming rollback. Use `preview: false` for intermediate writes, edits, and restores when supported by the advertised tool schema. After the requested result passes validation, call `artifact_open` deliberately to show it. Preserve compatible view state and internal navigation. Do not send a user prompt merely to publish selection context; sending a prompt requires the user's action or request. diff --git a/plugins/artifacts/skills/setup/SKILL.md b/plugins/artifacts/skills/setup/SKILL.md index aff3427..90827fa 100644 --- a/plugins/artifacts/skills/setup/SKILL.md +++ b/plugins/artifacts/skills/setup/SKILL.md @@ -11,6 +11,8 @@ For remote MCP, use the endpoint of the user's existing Artifacts deployment. Re Verify by discovering the advertised tools and calling `artifact_guide` and `artifact_list` with their current schemas. When available, `artifacts_connection_check` reports the actual workspace and gallery capabilities without running artifact code. Do not write, remix, restore, or create a demonstration artifact as a connection check. If tools are unavailable, inspect the host's connection error and configured transport before changing anything. +Expect nine model tools: six direct artifact tools plus `artifacts_discover`, `artifacts_query` and `artifacts_execute`. Use `artifacts_discover` to inspect the supported operation catalog without changing configuration or executing application code. Hosted connections expose all artifact, script and native Worker operations; local file connections expose their supported subset. App-only gallery and connection tools depend on the host's MCP Apps support. + Report whether the connection succeeded, which workspace or deployment it uses, and which host surfaces are actually available. MCP Apps, sidebar and conversation views, mentions, deep links, and file editing depend on advertised capabilities and client support. Offer the author skill for the user's next artifact request; setup itself is complete after read-only verification. For an already connected host, lead the user to the Artifacts library or its conversation panel. The library shares the product's folder navigation, Preview, Source, and revision history; the conversation panel starts empty until an artifact is chosen or an attached selection is restored. Choosing a library item does not attach it automatically: Use in conversation adds its explicit context. Source edits use the connected service's capabilities and revision checks. Host file Save writes the opened file only; Save and deploy in a hosted project updates that project. Native connection settings provide a read-only connection check; endpoint configuration remains with the host. Remix uses the existing product form because these server transports do not yet implement native rich-form callbacks. diff --git a/scripts/package.integration.test.ts b/scripts/package.integration.test.ts index 49e2760..66047f8 100644 --- a/scripts/package.integration.test.ts +++ b/scripts/package.integration.test.ts @@ -226,6 +226,12 @@ try { if (result.isError || !JSON.stringify(result.content).includes("Package smoke")) throw new Error("installed MCP read failed"); const opened = await client.callTool({ name: "artifact_open", arguments: { name: "package-smoke" } }); if (opened.isError || !opened._meta?.artifact) throw new Error("installed MCP preview failed"); + const discovered = await client.callTool({ name: "artifacts_discover", arguments: { operation: "artifact_restore", include_docs: false } }); + if (discovered.structuredContent?.dispatch !== "artifacts_execute") throw new Error("installed MCP discovery failed"); + const history = await client.callTool({ name: "artifacts_query", arguments: { operation: "artifact_history", arguments: { name: "package-smoke" } } }); + if (history.isError || !JSON.parse(history.content[0].text).versions.length) throw new Error("installed MCP query failed"); + const restored = await client.callTool({ name: "artifacts_execute", arguments: { operation: "artifact_restore", arguments: { version_id: opened._meta.artifact.versionId } } }); + if (restored.isError || !restored._meta?.artifact) throw new Error("installed MCP dispatched preview failed"); } finally { await client.close(); } `); await run([process.execPath, "run", mcpSmoke], clientDirectory, isolatedEnv); diff --git a/src/mcp/app-contract.ts b/src/mcp/app-contract.ts index a43b56a..ca2f605 100644 --- a/src/mcp/app-contract.ts +++ b/src/mcp/app-contract.ts @@ -1,5 +1,5 @@ // Bump to refresh cached shells; keep older tool-result payloads readable. -const ARTIFACTS_APP_VERSION = 6; +const ARTIFACTS_APP_VERSION = 7; export const ARTIFACTS_APP_URI = `ui://artifacts/v${ARTIFACTS_APP_VERSION}/viewer.html`; /** Saved chat cards keep their original URI across compatible shell updates. */ diff --git a/src/mcp/app.test.ts b/src/mcp/app.test.ts index 36a0887..91ef519 100644 --- a/src/mcp/app.test.ts +++ b/src/mcp/app.test.ts @@ -106,7 +106,7 @@ test("library previews resolve legacy names and preserve their sidecar state", a } }); -test("official MCP client can initialize, discover UI, create and show over standard stdio", async () => { +test("official stdio MCP client discovers operations, validates dispatch and preserves history previews", async () => { const dir = tempDir(); const client = new Client({ name: "artifact-test", version: "1" }, { capabilities: { extensions: { "io.modelcontextprotocol/ui": { mimeTypes: [ARTIFACTS_APP_MIME] } } } }); const transport = new StdioClientTransport({ @@ -118,16 +118,24 @@ test("official MCP client can initialize, discover UI, create and show over stan await client.connect(transport); expect(client.getServerCapabilities()?.resources).toBeDefined(); const tools = await client.listTools(); - for (const name of ["artifact_write", "artifact_open", "artifact_edit", "artifact_restore"]) { + for (const name of ["artifact_write", "artifact_open", "artifact_edit", "artifacts_execute"]) { expect(tools.tools.find(tool => tool.name === name)?._meta).toMatchObject({ ui: { resourceUri: ARTIFACTS_APP_URI } }); } expect(tools.tools.find(tool => tool.name === "artifact_list")?._meta).toMatchObject({ ui: { visibility: ["model", "app"] } }); + const model = tools.tools.filter(tool => (tool._meta?.ui as { visibility: string[] }).visibility.includes("model")); + expect(model).toHaveLength(9); + const payload = (result: Awaited>) => result.structuredContent ?? JSON.parse((result.content as { text: string }[])[0]!.text); + const catalog = payload(await client.callTool({ name: "artifacts_discover", arguments: { limit: 50 } })); + expect(catalog.total).toBe(14); + expect(catalog.operations.some((item: { operation: string }) => item.operation === "artifact_restore")).toBe(true); + const restore = payload(await client.callTool({ name: "artifacts_discover", arguments: { operation: "artifact_restore", include_docs: false } })); + expect(restore).toMatchObject({ dispatch: "artifacts_execute", inputSchema: { required: ["version_id"] } }); const resources = await client.listResources(); expect(resources.resources[0]?.mimeType).toBe(ARTIFACTS_APP_MIME); const resource = await client.readResource({ uri: ARTIFACTS_APP_URI }); expect(resource.contents[0]?.mimeType).toBe(ARTIFACTS_APP_MIME); expect(resource.contents[0]).toHaveProperty("text"); - for (const uri of ["ui://artifacts/viewer.html", ...[1, 2, 3, 4, 5].map(version => `ui://artifacts/v${version}/viewer.html`)]) { + for (const uri of ["ui://artifacts/viewer.html", ...[1, 2, 3, 4, 5, 6].map(version => `ui://artifacts/v${version}/viewer.html`)]) { const legacy = await client.readResource({ uri }); expect(legacy.contents[0]).toEqual({ ...resource.contents[0], uri }); } @@ -137,7 +145,30 @@ test("official MCP client can initialize, discover UI, create and show over stan const shown = await client.callTool({ name: "artifact_open", arguments: { name: "overview" } }); expect(shown.isError).toBe(false); expect(shown._meta).toHaveProperty("artifact"); + const versionId = (created._meta!.artifact as { versionId: string }).versionId; + const query = (operation: string, args: Record) => client.callTool({ name: "artifacts_query", arguments: { operation, arguments: args } }); + const execute = (operation: string, args: Record) => client.callTool({ name: "artifacts_execute", arguments: { operation, arguments: args } }); + expect(payload(await query("artifact_version", { version_id: versionId })).source).toBe(VALID_ARTIFACT); + expect(payload(await query("artifact_history", { name: "overview" })).versions.length).toBeGreaterThan(0); + const read = payload(await query("artifact_read", { name: "overview" })); + await expect(query("artifact_write", { name: "overview", contents: "must not write" })).rejects.toThrow("Use artifacts_execute"); + await expect(execute("artifact_restore", {})).rejects.toThrow("Invalid arguments"); + await expect(execute("artifact_read", { name: "overview", owner: "other" })).rejects.toThrow("Invalid arguments"); + for (const operation of ["artifacts_execute", "artifacts_preview"]) { + await expect(execute(operation, {})).rejects.toThrow("Unknown operation"); + } + expect(readFileSync(join(dir, "overview.artifact.tsx"), "utf8")).toBe(VALID_ARTIFACT); + const edited = await execute("artifact_edit", { name: "overview", expected_hash: read.source_hash, + edits: [{ old_text: "Embedded sample data.", new_text: "Dispatched change." }], preview: false }); + expect(edited.isError).toBe(false); + expect(edited._meta).toBeUndefined(); + await expect(execute("artifact_edit", { name: "overview", expected_hash: read.source_hash, + edits: [{ old_text: "Dispatched change.", new_text: "Stale edit." }] })).rejects.toThrow("changed"); + const restored = await execute("artifact_restore", { version_id: versionId }); + expect(restored.isError).toBe(false); + expect((restored._meta!.artifact as { js: string }).js).toContain("Embedded sample data."); + expect(readFileSync(join(dir, "overview.artifact.tsx"), "utf8")).toBe(VALID_ARTIFACT); await expect(client.readResource({ uri: "ui://artifacts/missing" })).rejects.toThrow("unknown resource"); await expect(client.readResource({ uri: "ui://artifacts/v999/viewer.html" })).rejects.toThrow("unknown resource"); } finally { await client.close(); } -}); +}, 30_000); diff --git a/src/mcp/discovery.test.ts b/src/mcp/discovery.test.ts new file mode 100644 index 0000000..34d5f13 --- /dev/null +++ b/src/mcp/discovery.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from "bun:test"; +import Ajv from "ajv"; +import { CLOUD_MCP_TOOLS } from "../../cloudflare/tool-contract"; +import { callDiscoveryTool, operationCatalog } from "./discovery"; +import { toolsForClient } from "./host-contract"; +import { validateToolArguments } from "./validation"; + +const ajv = new Ajv({ strict: false }); +const validators = Object.fromEntries(CLOUD_MCP_TOOLS.map(tool => [tool.name, ajv.compile(tool.inputSchema)])); + +test("every hosted operation can be inspected through the stable nine-tool surface", async () => { + const before = toolsForClient(CLOUD_MCP_TOOLS, false); + expect(before.map(tool => tool.name).sort()).toEqual([ + "artifact_edit", "artifact_guide", "artifact_list", "artifact_open", "artifact_read", "artifact_write", + "artifacts_discover", "artifacts_execute", "artifacts_query", + ]); + const catalog = operationCatalog(CLOUD_MCP_TOOLS); + expect(catalog).toHaveLength(48); + const noExecution = async () => { throw new Error("Discovery without docs must not execute anything"); }; + const listing = await callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_discover", { limit: 50 }, noExecution); + expect(listing.structuredContent).toMatchObject({ total: 48, next_offset: null }); + for (const operation of catalog) { + const result = await callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_discover", { operation: operation.name, include_docs: false }, noExecution); + expect(result.structuredContent).toMatchObject({ operation: operation.name, inputSchema: operation.inputSchema }); + expect(["artifacts_query", "artifacts_execute"]).toContain(result.structuredContent!.dispatch as string); + } + expect(toolsForClient(CLOUD_MCP_TOOLS, false)).toEqual(before); +}); + +test("discovery ranks related operations, paginates and loads only the selected guide", async () => { + const calls: unknown[] = []; + const guide = async (operation: string, args: Record) => { + calls.push({ operation, args }); + return { content: [{ type: "text" as const, text: "Selected guide" }] }; + }; + const matches = await callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_discover", { query: "schedule a script", limit: 1 }, guide); + expect(matches.structuredContent).toMatchObject({ operations: [{ operation: "script_schedule" }], next_offset: 1 }); + const next = await callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_discover", { query: "schedule a script", offset: 1, limit: 1 }, guide); + expect(next.structuredContent!.operations).not.toEqual(matches.structuredContent!.operations); + expect(calls).toEqual([]); + const details = await callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_discover", { operation: "artifact_files" }, guide); + expect(calls).toEqual([{ operation: "artifact_guide", args: { topic: "files" } }]); + expect(details.structuredContent!.documentation).toMatchObject({ content: [{ text: "Selected guide" }] }); +}); + +test("query rejects application execution, writes and mixed-operation changes before calling the service", async () => { + const invoked: string[] = []; + const service = async (operation: string, args: Record) => { + validateToolArguments(validators, operation, args); + invoked.push(operation); + return { content: [] }; + }; + const blocked = [ + { operation: "script_run", arguments: { name: "test", request: { path: "/", method: "GET" } } }, + { operation: "artifact_request", arguments: { name: "test", request: { path: "/", method: "GET" } } }, + { operation: "artifact_plugin_call", arguments: { plugin: "test", operation: "read", input: {} } }, + { operation: "artifact_open", arguments: { name: "test" } }, + { operation: "artifact_compile", arguments: { name: "test" } }, + { operation: "app_write", arguments: { name: "test", source: "source", manifest: {} } }, + ...["artifact_schedule", "script_schedule"].flatMap(operation => ["set", "pause", "resume", "run_now"].map(action => ({ operation, arguments: { name: "test", action } }))), + ...["artifact_secrets", "script_secrets", "app_secrets"].map(operation => ({ operation, arguments: { name: "test", secrets: {} } })), + { operation: "artifact_files", arguments: { name: "test", request: { operation: "delete", id: crypto.randomUUID() } } }, + { operation: "artifact_files", arguments: { name: "test", request: { operation: "upload", name: "file", type: "text/plain", size: 0 } } }, + { operation: "script_schedule", arguments: { name: "test", action: "get", interval_seconds: 60 } }, + ]; + for (const input of blocked) { + await expect(callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_query", input, service)).rejects.toThrow("Use artifacts_execute"); + } + expect(invoked).toEqual([]); + for (const input of [ + { operation: "script_schedule", arguments: { name: "test" } }, + { operation: "artifact_schedule", arguments: { name: "test", action: "get" } }, + ...["artifact_secrets", "script_secrets", "app_secrets"].map(operation => ({ operation, arguments: { name: "test" } })), + { operation: "artifact_files", arguments: { name: "test", request: { operation: "list" } } }, + { operation: "artifact_files", arguments: { name: "test", request: { operation: "download", id: crypto.randomUUID() } } }, + ]) await callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_query", input, service); + expect(invoked).toHaveLength(7); +}); + +test("dispatch rejects hidden and recursive operations, validates original schemas and preserves results", async () => { + let calls = 0; + const original = { content: [], isError: true, structuredContent: { applied: true, revision_token: "saved", diagnostics: [{ message: "Invalid source" }] }, _meta: { artifact: { name: "preview" } } }; + const service = async (operation: string, args: Record) => { + validateToolArguments(validators, operation, args); + calls++; + return original; + }; + for (const operation of ["artifacts_execute", "artifacts_query", "artifacts_discover", "artifacts_tool", "artifacts_source", "artifacts_settings_update", "constructor", "__proto__"]) { + await expect(callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_execute", { operation, arguments: {} }, service)).rejects.toThrow("Unknown operation"); + } + await expect(callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_execute", { operation: "script_write", arguments: { name: "test" } }, service)).rejects.toThrow("Invalid arguments"); + await expect(callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_query", { operation: "app_read", arguments: { name: "test", owner: "someone-else" } }, service)).rejects.toThrow("Invalid arguments"); + expect(calls).toBe(0); + const result = await callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_execute", { operation: "artifact_restore", arguments: { version_id: "version" } }, service); + expect(result).toBe(original); +}); diff --git a/src/mcp/discovery.ts b/src/mcp/discovery.ts new file mode 100644 index 0000000..821af52 --- /dev/null +++ b/src/mcp/discovery.ts @@ -0,0 +1,190 @@ +import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; +import { ARTIFACTS_APP_META } from "./app-contract"; + +export const DIRECT_TOOL_NAMES = [ + "artifact_guide", "artifact_list", "artifact_read", "artifact_write", "artifact_edit", "artifact_open", +] as const; +export const DISCOVERY_TOOL_NAMES = ["artifacts_discover", "artifacts_query", "artifacts_execute"] as const; + +const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false }; +const dispatchSchema: Tool["inputSchema"] = { + type: "object", + properties: { + operation: { type: "string", minLength: 1, maxLength: 128, description: "Operation name from artifacts_discover." }, + arguments: { type: "object", description: "Arguments matching that operation's discovered inputSchema. Validated before execution." }, + }, + required: ["operation", "arguments"], additionalProperties: false, +}; + +export const DISCOVERY_TOOLS: Tool[] = [ + { + name: "artifacts_discover", + description: "Discover every operation available through this connection. Search with query for concise matches; inspect with operation for its exact schema, dispatch tool and relevant guide. Omit both to browse the paginated catalog. Discovery never runs application code or unlocks tools. Use artifacts_query for reads and artifacts_execute for actions.", + annotations: readOnly, _meta: { ui: { visibility: ["model"] } }, + inputSchema: { + type: "object", + properties: { + query: { type: "string", maxLength: 256 }, + operation: { type: "string", minLength: 1, maxLength: 128 }, + offset: { type: "integer", minimum: 0, default: 0 }, + limit: { type: "integer", minimum: 1, maximum: 50, default: 8 }, + include_docs: { type: "boolean", default: true, description: "Include the relevant guide when inspecting an operation. False avoids rereading a guide already loaded." }, + }, + not: { required: ["query", "operation"] }, additionalProperties: false, + }, + }, + { + name: "artifacts_query", + description: "Invoke a discovered read-only operation with its original validation and authorization. Rejects changes and application execution. Mixed operations permit only schedule get, secret-name listing without a secrets field, and file list/download. Use artifacts_execute for all other cases.", + annotations: readOnly, _meta: { ui: { visibility: ["model"] } }, inputSchema: dispatchSchema, + }, + { + name: "artifacts_execute", + description: "Invoke any discovered operation with its original validation and authorization, including writes, restores, scripts, backend requests, plugins and native app deployment. May modify storage or call external services. Preserves inline preview results; preview:false suppresses delivery where supported. Never automatically retry an uncertain execution.", + annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }, + _meta: { ...ARTIFACTS_APP_META, ui: { ...ARTIFACTS_APP_META.ui, visibility: ["model"] } }, + inputSchema: dispatchSchema, + }, +]; + +export function isDiscoveryTool(name: string): boolean { + return DISCOVERY_TOOL_NAMES.some(candidate => candidate === name); +} + +/** Keep the complete operation registry separate from the advertised model surface. */ +export function operationCatalog(tools: readonly Tool[]): Tool[] { + return tools.filter(tool => { + const visibility = (tool._meta?.ui as { visibility?: string[] } | undefined)?.visibility; + return !isDiscoveryTool(tool.name) && (!visibility || visibility.includes("model")); + }); +} + +/** Apps retain the operations they already use; text clients see only nine tools. */ +export function compactToolSurface(tools: readonly Tool[]): Tool[] { + const direct = DIRECT_TOOL_NAMES.map(name => { + const tool = tools.find(tool => tool.name === name); + if (!tool) throw new Error(`Missing direct MCP tool: ${name}`); + return tool; + }); + const appTools = tools.filter(tool => { + const visibility = (tool._meta?.ui as { visibility?: string[] } | undefined)?.visibility; + return !DIRECT_TOOL_NAMES.some(name => name === tool.name) && visibility?.includes("app"); + }).map(tool => ({ + ...tool, _meta: { ...tool._meta, ui: { ...(tool._meta?.ui as Record), visibility: ["app"] } }, + })); + return [...direct, ...DISCOVERY_TOOLS, ...appTools]; +} + +// An explicit allowlist is intentional: newly added operations default to actions. +// An HTTP GET to authored code is still execution, irrespective of its method. +const READ_OPERATIONS = new Set([ + "artifact_guide", "artifact_list", "artifact_read", "artifact_history", "artifact_version", "artifact_export", "artifact_typecheck", "artifact_runs", + "script_guide", "script_list", "script_read", "script_history", "script_version", "script_export", "script_logs", "script_runs", + "app_guide", "app_list", "app_read", "app_history", "plugins_list", "plugin_guide", +]); +const MIXED_OPERATIONS: Record = { + artifact_schedule: "action omitted or get, with no schedule configuration fields", + script_schedule: "action omitted or get, with no schedule configuration fields", + artifact_secrets: "secrets field omitted; returns names only", + script_secrets: "secrets field omitted; returns names only", + app_secrets: "secrets field omitted; returns names only", + artifact_files: "request.operation is list or download", +}; + +export function isReadOnlyOperation(operation: string, args: Record): boolean { + if (READ_OPERATIONS.has(operation)) return true; + if (operation === "artifact_schedule" || operation === "script_schedule") { + return (args.action === undefined || args.action === "get") && + !["interval_seconds", "cron", "timezone", "request"].some(field => Object.hasOwn(args, field)); + } + if (["artifact_secrets", "script_secrets", "app_secrets"].includes(operation)) return !Object.hasOwn(args, "secrets"); + if (operation === "artifact_files") { + const request = args.request as { operation?: unknown } | undefined; + return request?.operation === "list" || request?.operation === "download"; + } + return false; +} + +function operationSummary(tool: Tool) { + const mixed = Object.hasOwn(MIXED_OPERATIONS, tool.name); + return { + operation: tool.name, description: tool.description, + dispatch: READ_OPERATIONS.has(tool.name) ? "artifacts_query" : "artifacts_execute", + ...(mixed ? { read_only_when: MIXED_OPERATIONS[tool.name] } : {}), + }; +} + +function findOperation(catalog: readonly Tool[], name: unknown): Tool { + const tool = typeof name === "string" ? catalog.find(tool => tool.name === name) : undefined; + if (!tool) throw new Error("Unknown operation. Use artifacts_discover to find an available operation."); + return tool; +} + +function guideOperation(operation: string): string { + if (operation.startsWith("app_")) return "app_guide"; + if (operation.startsWith("script_")) return "script_guide"; + if (["plugins_list", "plugin_guide", "artifact_plugin_call"].includes(operation)) return "plugin_guide"; + return "artifact_guide"; +} + +function searchCatalog(catalog: readonly Tool[], query: string): Tool[] { + const ignoredWords = new Set(["the", "and", "for", "with", "how", "to", "of", "an"]); + const words = query.toLowerCase().split(/[^a-z0-9]+/) + .filter(word => word.length > 1 && !ignoredWords.has(word)); + const matches = catalog.map(tool => { + const name = tool.name.toLowerCase(); + const description = tool.description?.toLowerCase() ?? ""; + let score = 0; + for (const word of words) { + if (name.includes(word)) score += 3; + if (description.includes(word)) score += 1; + } + return { tool, score }; + }); + return matches + .filter(match => !words.length || match.score > 0) + .sort((a, b) => b.score - a.score || a.tool.name.localeCompare(b.tool.name)) + .map(match => match.tool); +} + +function jsonResult(payload: Record): CallToolResult { + return { content: [{ type: "text", text: JSON.stringify(payload) }], structuredContent: payload }; +} + +/** The callback must validate the original schema and enter the original admission path. */ +export async function callDiscoveryTool( + tools: readonly Tool[], name: string, args: Record, + callOperation: (operation: string, args: Record) => Promise, +): Promise { + const catalog = operationCatalog(tools); + if (name === "artifacts_discover") { + if (args.operation !== undefined) { + const tool = findOperation(catalog, args.operation); + const guide = guideOperation(tool.name); + let guideArgs: Record = {}; + if (tool.name === "artifact_files") guideArgs = { topic: "files" }; + else if (["artifact_request", "artifact_schedule", "artifact_runs", "artifact_secrets"].includes(tool.name)) guideArgs = { topic: "backend" }; + const documentation = args.include_docs === false ? undefined : await callOperation(guide, guideArgs); + return jsonResult({ + ...operationSummary(tool), inputSchema: tool.inputSchema, + ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}), + annotations: tool.annotations ?? {}, + ...(documentation ? { documentation: { operation: guide, content: documentation.content } } : {}), + }); + } + const matches = searchCatalog(catalog, typeof args.query === "string" ? args.query : ""); + const offset = typeof args.offset === "number" ? args.offset : 0; + const limit = typeof args.limit === "number" ? args.limit : 8; + return jsonResult({ operations: matches.slice(offset, offset + limit).map(operationSummary), total: matches.length, + next_offset: offset + limit < matches.length ? offset + limit : null }); + } + if (name !== "artifacts_query" && name !== "artifacts_execute") throw new Error("Unknown discovery tool"); + const tool = findOperation(catalog, args.operation); + const input = args.arguments; + if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("Operation arguments must be an object"); + if (name === "artifacts_query" && !isReadOnlyOperation(tool.name, input as Record)) { + throw new Error(`${tool.name} may change state or execute code with these arguments. Use artifacts_execute.`); + } + // Return the original envelope, including preview _meta, diagnostics and isError. + return callOperation(tool.name, input as Record); +} diff --git a/src/mcp/file-contract.ts b/src/mcp/file-contract.ts index 9624d14..6c52361 100644 --- a/src/mcp/file-contract.ts +++ b/src/mcp/file-contract.ts @@ -1,8 +1,9 @@ import { OpenAIFileEntrypointInputSchema } from "@openai/mcp-extensions/server"; import { ARTIFACTS_APP_URI } from "./app-contract"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; /** Open only our owned format; ordinary TSX remains with the host's normal viewer. */ -export const ARTIFACTS_FILE_TOOL = { +export const ARTIFACTS_FILE_TOOL: Tool = { name: "artifacts_file", title: "Artifact source editor", description: "View or explicitly edit an owned .artifact.tsx file using host-managed resource access. Opening a file does not execute, import, or publish its source.", diff --git a/src/mcp/guide.ts b/src/mcp/guide.ts index 3622812..652ad1a 100644 --- a/src/mcp/guide.ts +++ b/src/mcp/guide.ts @@ -13,7 +13,7 @@ export const LEGACY_GUIDE_EXPORTS = [ "canvasPaletteDark", "canvasPaletteLight", "canvasTypography", ]; -export function artifactGuide() { +function fullArtifactGuide() { return `# Sidequery Artifacts SDK contract Read this guide before your first artifact_write in the conversation. Reuse it for subsequent creates and edits; read it again when unsure about the SDK. @@ -88,6 +88,21 @@ artifact_write creates or replaces the full source and then validates it. artifa `; } -export function artifactGuideResult() { - return { content: [{ type: "text" as const, text: artifactGuide() }], isError: false }; +export type ArtifactGuideTopic = "browser" | "backend" | "files" | "plugins" | "all"; + +export function artifactGuide(topic: ArtifactGuideTopic = "browser") { + const sections = fullArtifactGuide().split(/(?=^## )/m); + const specialized = ["## Hosted servers and SQLite", "## Persistent files", "## Deployment plugins"]; + const heading = { backend: specialized[0]!, files: specialized[1]!, plugins: specialized[2]! }; + let selected = sections; + if (topic === "browser") selected = sections.filter(section => !specialized.some(title => section.startsWith(title))); + else if (topic !== "all") { + if (!Object.hasOwn(heading, topic)) throw new Error("Unknown artifact guide topic"); + selected = sections.filter(section => section.startsWith(heading[topic])); + } + return selected.join("") + "\nAdvanced names in this guide are catalog operations: inspect them with artifacts_discover({operation: name}), then use artifacts_query({operation, arguments}) for reads or artifacts_execute({operation, arguments}) for actions. All capabilities stay in this connection. Read artifact_guide with topic backend, files, plugins or all when needed.\n"; +} + +export function artifactGuideResult(topic?: ArtifactGuideTopic) { + return { content: [{ type: "text" as const, text: artifactGuide(topic) }], isError: false }; } diff --git a/src/mcp/host-contract.ts b/src/mcp/host-contract.ts index 96466ce..2e26a01 100644 --- a/src/mcp/host-contract.ts +++ b/src/mcp/host-contract.ts @@ -1,4 +1,6 @@ /** Host UI support is negotiated independently from server authentication. */ +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { compactToolSurface } from "./discovery"; export const ARTIFACTS_DISPLAY_MODES = ["inline", "fullscreen"] as const; const sharedTools = new Set([ @@ -21,14 +23,15 @@ export function clientSupportsApps(capabilities: unknown): boolean { return Array.isArray(mimeTypes) && mimeTypes.includes("text/html;profile=mcp-app"); } -/** Text clients keep every model tool and its ordinary annotations/schema. */ -export function toolsForClient }>(tools: readonly T[], apps: boolean): readonly T[] { - if (apps) return tools; - return tools.filter(tool => { +/** Compact discovery is the same for every client; Apps retain their UI tools. */ +export function toolsForClient(tools: readonly Tool[], apps: boolean): Tool[] { + const surface = compactToolSurface(tools); + if (apps) return surface; + return surface.filter(tool => { const visibility = (tool._meta?.ui as { visibility?: string[] } | undefined)?.visibility; return !visibility || visibility.includes("model"); }).map(tool => { const { _meta, ...rest } = tool; - return rest as T; + return rest; }); } diff --git a/src/mcp/local-tools.test.ts b/src/mcp/local-tools.test.ts index 102c6a4..30ced43 100644 --- a/src/mcp/local-tools.test.ts +++ b/src/mcp/local-tools.test.ts @@ -46,9 +46,10 @@ test("MCP initialize and tools/list", async () => { expect(tools).toContain("artifact_read"); expect(tools).toContain("artifact_edit"); expect(tools).toContain("artifact_open"); - expect(tools).toContain("artifact_history"); - expect(tools).toContain("artifact_version"); - expect(tools).toContain("artifact_restore"); + expect(tools.sort()).toEqual([ + "artifact_edit", "artifact_guide", "artifact_list", "artifact_open", "artifact_read", "artifact_write", + "artifacts_discover", "artifacts_execute", "artifacts_query", + ]); }); test("MCP guide is read-only", async () => { diff --git a/src/mcp/local-tools.ts b/src/mcp/local-tools.ts index 1f36a86..bd42929 100644 --- a/src/mcp/local-tools.ts +++ b/src/mcp/local-tools.ts @@ -15,6 +15,13 @@ import { localWorkspace, localMentions, localResource, localSource } from "./loc import { ARTIFACTS_FILE_TOOL, artifactFileResult } from "./file-contract"; import { clientSupportsApps, toolsForClient } from "./host-contract"; import { ARTIFACTS_SETTINGS_CAPABILITY, ARTIFACTS_SETTINGS_TOOLS, settingsTool } from "./settings-contract"; +import { callDiscoveryTool, DISCOVERY_TOOLS, isDiscoveryTool } from "./discovery"; +import { validateToolArguments } from "./validation"; +import Ajv from "ajv"; + +const LOCAL_MCP_TOOLS = [...MCP_TOOLS, ...ARTIFACTS_WORKSPACE_TOOLS, ARTIFACTS_FILE_TOOL, ...ARTIFACTS_SETTINGS_TOOLS, ...DISCOVERY_TOOLS]; +const ajv = new Ajv({ allErrors: true, strict: false }); +const validators = Object.fromEntries(LOCAL_MCP_TOOLS.map(tool => [tool.name, ajv.compile(tool.inputSchema)])); const appClients = new WeakMap(); @@ -40,7 +47,7 @@ export async function handleMcpRequest( return ok(request.id, {}); } if (request.method === "tools/list") { - return ok(request.id, { tools: toolsForClient([...MCP_TOOLS, ...ARTIFACTS_WORKSPACE_TOOLS, ARTIFACTS_FILE_TOOL, ...ARTIFACTS_SETTINGS_TOOLS], appClients.get(service) ?? true) }); + return ok(request.id, { tools: toolsForClient(LOCAL_MCP_TOOLS, appClients.get(service) ?? true) }); } if (request.method === "resources/list") return ok(request.id, { resources: [ARTIFACTS_RESOURCE] }); if (request.method === "resources/templates/list") return ok(request.id, { resourceTemplates: [] }); @@ -53,6 +60,7 @@ export async function handleMcpRequest( if (request.method === "tools/call") { const params = (request.params ?? {}) as { name?: string; arguments?: Record }; const apps = appClients.get(service) ?? true; + if (isDiscoveryTool(params.name ?? "")) validateToolArguments(validators, params.name!, params.arguments ?? {}); if (!apps && [...ARTIFACTS_WORKSPACE_TOOLS, ARTIFACTS_FILE_TOOL].some(tool => tool.name === params.name)) throw new Error("MCP Apps support is required for this tool"); const result = await callTool(service, params.name ?? "", params.arguments ?? {}); if (!apps) { const { _meta, ...textResult } = result; return ok(request.id, textResult); } @@ -69,6 +77,12 @@ async function callTool( name: string, args: Record, ): Promise { + if (isDiscoveryTool(name)) { + return callDiscoveryTool(LOCAL_MCP_TOOLS, name, args, async (operation, input) => { + validateToolArguments(validators, operation, input); + return callTool(service, operation, input); + }); + } if (name === "artifacts_file") return artifactFileResult(args); if (ARTIFACTS_SETTINGS_TOOLS.some(tool => tool.name === name)) return settingsTool({ workspace: service.artifactsDir, async gallery() { return localWorkspace(service, "library").gallery!; } }, name, args); if (name === "artifacts_source") { @@ -89,7 +103,7 @@ async function callTool( } const deliver = args.preview !== false && (appClients.get(service) ?? true); if (args.preview !== undefined && typeof args.preview !== "boolean") throw new Error("preview must be a boolean"); - if (name === "artifact_guide") return artifactGuideResult(); + if (name === "artifact_guide") return artifactGuideResult(args.topic as Parameters[0]); if (name === "artifact_export") return text(JSON.stringify(service.exportProject({ name: args.name as string | undefined, version_id: args.version_id as string | undefined }))); if (name === "artifact_import") { const archive = parseProjectArchive(args.archive, "artifact"); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 6172ee5..f5a5c98 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1,8 +1,9 @@ import { ARTIFACTS_APP_META } from "./app-contract"; import { PROJECT_ARCHIVE_SCHEMA } from "../project-archive-contract"; import { applyToolVisibility } from "./host-contract"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; -export const MCP_TOOLS = [ +export const MCP_TOOLS: Tool[] = [ { name: "artifact_export", description: "Export a complete versioned project archive: client, server, helper modules and exact dependency snapshot. Select name or version_id. Never includes UI state, database/files, secrets or URL policy.", @@ -19,7 +20,7 @@ export const MCP_TOOLS = [ name: "artifact_guide", description: "Read the Sidequery Artifacts SDK contract: exports, host APIs, component conventions, restrictions, and validation behavior. Call before creating an artifact if you have not read it in this conversation.", annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - inputSchema: { type: "object", properties: {}, additionalProperties: false }, + inputSchema: { type: "object", properties: { topic: { type: "string", enum: ["browser", "backend", "files", "plugins", "all"], default: "browser", description: "Read only the SDK topic needed for this task." } }, additionalProperties: false }, }, { name: "artifact_read", @@ -124,7 +125,7 @@ export const MCP_TOOLS = [ additionalProperties: false, }, }, -] as const; +]; for (const tool of MCP_TOOLS) { if (["artifact_write", "artifact_edit", "artifact_restore", "artifact_remix", "artifact_import"].includes(tool.name)) { diff --git a/src/mcp/validation.ts b/src/mcp/validation.ts new file mode 100644 index 0000000..6031649 --- /dev/null +++ b/src/mcp/validation.ts @@ -0,0 +1,13 @@ +export type ArgumentValidator = { + (args: unknown): boolean; + errors?: readonly { instancePath?: string; message?: string }[] | null; +}; + +export function validateToolArguments(validators: Record, name: string, args: unknown): void { + if (!Object.hasOwn(validators, name)) throw new Error("Unknown tool"); + const validate = validators[name]!; + if (!validate(args)) { + const details = validate.errors?.slice(0, 5).map(error => `${error.instancePath || "/"} ${error.message}`).join("; "); + throw new Error(`Invalid arguments for ${name}${details ? ": " + details : ""}`); + } +} diff --git a/src/mcp/workspace-contract.ts b/src/mcp/workspace-contract.ts index 10807df..272a106 100644 --- a/src/mcp/workspace-contract.ts +++ b/src/mcp/workspace-contract.ts @@ -1,5 +1,6 @@ import { ARTIFACTS_APP_URI } from "./app-contract"; import type { GalleryData } from "../gallery/types"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; export type ArtifactWorkspaceItem = { name: string; @@ -19,10 +20,10 @@ export type ArtifactWorkspacePayload = { const ui = { resourceUri: ARTIFACTS_APP_URI, visibility: ["app"] }; const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false }; -const emptySchema = { type: "object", properties: {}, additionalProperties: false }; +const emptySchema = { type: "object" as const, properties: {}, additionalProperties: false }; /** Entrypoints never mutate the library and accept the host's empty arguments. */ -export const ARTIFACTS_WORKSPACE_TOOLS = [ +export const ARTIFACTS_WORKSPACE_TOOLS: Tool[] = [ { name: "artifacts_library", title: "Artifacts", description: "Browse accessible artifacts, scripts and their saved versions.", annotations: readOnly, diff --git a/src/runtime/mcp-app.ts b/src/runtime/mcp-app.ts index 4a6913f..bc5fd2e 100644 --- a/src/runtime/mcp-app.ts +++ b/src/runtime/mcp-app.ts @@ -391,7 +391,14 @@ app.ontoolresult = result => { return; } // Diagnostics-only mutations deliberately leave the working view mounted. - workspaceView?.refresh(); + if (workspaceView) workspaceView.refresh(); + else if (!hasArtifact && !fileEditor) { + // Shared execution can finish without an artifact preview, as with scripts + // and deployments. A fresh view must show the result instead of waiting. + status.textContent = result.content?.filter(item => item.type === "text").map(item => item.text).join("\n") + || (result.structuredContent ? JSON.stringify(result.structuredContent, null, 2) : "Operation completed."); + reportSize(); + } }; app.ontoolinput = async ({ arguments: args }) => { const file = args?.file as { resourceUri?: string; name?: string } | undefined;