Unified · Split
| @@ -385,6 +385,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 384 unmodified lines | ||
| 385 | 385 | artifacts: executor.artifacts, |
| 386 | 386 | connections: executor.connections, |
| 387 | 387 | tools: executor.tools, |
| 388 | + integrations: executor.integrations, | |
| 388 | 389 | // Artifacts are on by default, opt-out per connection. A session |
| 389 | 390 | // persisted without a value restores to the default, same as a fresh |
| 390 | 391 | // connection whose URL says nothing about `?artifacts=`. |
| @@ -56,3 +56,32 @@ Executor: · 55 unmodified lines | ||
| 56 | 56 | |
| 57 | 57 | Once a client is connected, every integration you add to Executor appears in that |
| 58 | 58 | agent automatically. |
| 59 | + | |
| 60 | +## Search and invoke mode | |
| 61 | + | |
| 62 | +Add `?mode=passthrough` to your MCP endpoint, or enable **Search and invoke** | |
| 63 | +in the Connect card. This mode exposes four tools: | |
| 64 | + | |
| 65 | +- `integrations`: list connected accounts with integration descriptions, account | |
| 66 | + labels, and their last recorded health. Results are paginated, with one item per | |
| 67 | + account. Health is not checked again by this call. | |
| 68 | +- `skills`: list this server's guides, or read `skills({ name: "search-invoke" })`. | |
| 69 | +- `search`: find tools and read their JSON input schemas. Use the exact | |
| 70 | + `integration`, `owner`, and `connection` fields from `integrations` to select an | |
| 71 | + account. | |
| 72 | +- `invoke`: call the exact tool ID from search with JSON arguments. | |
| 73 | + | |
| 74 | +For example, call `integrations({})`, then | |
| 75 | +`search({ query: "create issue", integration: "github", owner: "org", connection: "main" })` | |
| 76 | +using the account names returned by your server. Call `invoke` with the matching | |
| 77 | +result's `id` and arguments that follow its `inputSchema`. | |
| 78 | + | |
| 79 | +Both `integrations` and `search` return `items`, `total`, `hasMore`, and | |
| 80 | +`nextOffset`. To get another page, repeat the same query and filters with | |
| 81 | +`offset: nextOffset`. This pagination finds tools and accounts; each upstream API | |
| 82 | +has its own pagination for records. | |
| 83 | + | |
| 84 | +The tool list stays small as you add integrations. Input schemas are loaded only | |
| 85 | +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. | |
| @@ -167,6 +167,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 166 unmodified lines | ||
| 167 | 167 | artifacts: executor.artifacts, |
| 168 | 168 | connections: executor.connections, |
| 169 | 169 | tools: executor.tools, |
| 170 | + integrations: executor.integrations, | |
| 170 | 171 | // Artifacts are on by default, opt-out per connection. A session |
| 171 | 172 | // persisted without a value restores to the default, same as a fresh |
| 172 | 173 | // connection whose URL says nothing about `?artifacts=`. |
| @@ -124,6 +124,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 123 unmodified lines | ||
| 124 | 124 | artifacts: executor.artifacts, |
| 125 | 125 | connections: executor.connections, |
| 126 | 126 | tools: executor.tools, |
| 127 | + integrations: executor.integrations, | |
| 127 | 128 | ...appsConfig, |
| 128 | 129 | }, |
| 129 | 130 | webBaseUrl: process.env.EXECUTOR_WEB_BASE_URL || undefined, |
| @@ -135,6 +136,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 5 unmodified lines | ||
| 135 | 136 | artifacts: executor.artifacts, |
| 136 | 137 | connections: executor.connections, |
| 137 | 138 | tools: executor.tools, |
| 139 | + integrations: executor.integrations, | |
| 138 | 140 | ...appsConfig, |
| 139 | 141 | }, |
| 140 | 142 | }; |
| @@ -161,6 +163,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 20 unmodified lines | ||
| 161 | 163 | artifacts: handle.executor.artifacts, |
| 162 | 164 | connections: handle.executor.connections, |
| 163 | 165 | tools: handle.executor.tools, |
| 166 | + integrations: handle.executor.integrations, | |
| 164 | 167 | ...appsConfig, |
| 165 | 168 | }, |
| 166 | 169 | close: handle.dispose, |
| @@ -1,4 +1,4 @@ | ||
| 1 | −// Real OpenCode codemode discovers and invokes tools through a two-tool MCP surface. | |
| 1 | +// Real OpenCode codemode discovers and invokes tools through a fixed MCP tool surface. | |
| 2 | 2 | // The server retains 10,200 tools; the replay model scripts discovery and a real upstream call. |
| 3 | 3 | import { randomBytes } from "node:crypto"; |
| 4 | 4 | import { createServer } from "node:http"; |
| @@ -45,7 +45,12 @@ scenario( · 44 unmodified lines | ||
| 45 | 45 | `a ${visible.length}-tool passthrough connect stays bounded (took ${elapsedMs}ms)`, |
| 46 | 46 | ).toBeLessThan(MAX_PASSTHROUGH_CONNECT_MS); |
| 47 | 47 | |
| 48 | − expect(served.map((tool) => tool.name).sort()).toEqual(["invoke", "search"]); | |
| 48 | + expect(served.map((tool) => tool.name).sort()).toEqual([ | |
| 49 | + "integrations", | |
| 50 | + "invoke", | |
| 51 | + "search", | |
| 52 | + "skills", | |
| 53 | + ]); | |
| 49 | 54 | const first = decodeToolSearch( |
| 50 | 55 | (yield* session.call("search", { query: "org", limit: 20 })).raw, |
| 51 | 56 | ).structuredContent; |
| @@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto"; · 2 unmodified lines | ||
| 3 | 3 | import { createServer, type IncomingMessage } from "node:http"; |
| 4 | 4 | |
| 5 | 5 | import { expect } from "@effect/vitest"; |
| 6 | −import { Effect } from "effect"; | |
| 6 | +import { Effect, Schema } from "effect"; | |
| 7 | 7 | import { composePluginApi } from "@executor-js/api/server"; |
| 8 | 8 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 9 | 9 | import { |
| @@ -16,7 +16,23 @@ import { · 6 unmodified lines | ||
| 16 | 16 | import { decodeToolSearch } from "./support/search-invoke"; |
| 17 | 17 | |
| 18 | 18 | import { scenario } from "../src/scenario"; |
| 19 | −import { Api, Mcp, Target } from "../src/services"; | |
| 19 | +import { Api, Browser, Mcp, Target } from "../src/services"; | |
| 20 | +import { visit, settle } from "../src/surfaces/browser"; | |
| 21 | + | |
| 22 | +const decodeInventory = Schema.decodeUnknownSync( | |
| 23 | + Schema.Struct({ | |
| 24 | + structuredContent: Schema.Struct({ | |
| 25 | + items: Schema.Array( | |
| 26 | + Schema.Struct({ | |
| 27 | + integration: Schema.String, | |
| 28 | + owner: Schema.String, | |
| 29 | + connection: Schema.String, | |
| 30 | + }), | |
| 31 | + ), | |
| 32 | + total: Schema.Number, | |
| 33 | + }), | |
| 34 | + }), | |
| 35 | +); | |
| 20 | 36 | |
| 21 | 37 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 22 | 38 | |
| @@ -133,6 +149,7 @@ scenario( · 110 unmodified lines | ||
| 133 | 149 | Effect.gen(function* () { |
| 134 | 150 | const target = yield* Target; |
| 135 | 151 | const mcp = yield* Mcp; |
| 152 | + const browser = yield* Browser; | |
| 136 | 153 | const { client: makeClient } = yield* Api; |
| 137 | 154 | |
| 138 | 155 | const identity = yield* target.newIdentity(); |
| @@ -170,12 +187,35 @@ scenario( · 31 unmodified lines | ||
| 170 | 187 | }); |
| 171 | 188 | } |
| 172 | 189 | |
| 190 | + yield* browser.session(identity, async ({ page, step }) => { | |
| 191 | + await step("Choose Search and invoke in the Connect card", async () => { | |
| 192 | + await visit(page, "/"); | |
| 193 | + await page.getByRole("button", { name: "Advanced" }).click(); | |
| 194 | + await page.getByRole("switch", { name: "Search and invoke" }).check(); | |
| 195 | + await settle(page); | |
| 196 | + expect(await page.locator("code").first().innerText()).toContain("mode=passthrough"); | |
| 197 | + expect( | |
| 198 | + await page | |
| 199 | + .getByText( | |
| 200 | + "Discover connected accounts with integrations and read the guide with skills.", | |
| 201 | + { exact: false }, | |
| 202 | + ) | |
| 203 | + .isVisible(), | |
| 204 | + ).toBe(true); | |
| 205 | + }); | |
| 206 | + }); | |
| 207 | + | |
| 173 | 208 | const codemode = mcp.session(identity); |
| 174 | 209 | expect(yield* codemode.listTools()).toContain("execute"); |
| 175 | 210 | |
| 176 | 211 | const passthrough = mcp.session(identity, { mode: "passthrough" }); |
| 177 | 212 | const described = yield* passthrough.describeTools(); |
| 178 | − expect(described.map((tool) => tool.name).sort()).toEqual(["invoke", "search"]); | |
| 213 | + expect(described.map((tool) => tool.name).sort()).toEqual([ | |
| 214 | + "integrations", | |
| 215 | + "invoke", | |
| 216 | + "search", | |
| 217 | + "skills", | |
| 218 | + ]); | |
| 179 | 219 | expect(described.find((tool) => tool.name === "search")?.annotations).toMatchObject({ |
| 180 | 220 | readOnlyHint: true, |
| 181 | 221 | destructiveHint: false, |
| @@ -184,9 +224,42 @@ scenario( · 2 unmodified lines | ||
| 184 | 224 | readOnlyHint: false, |
| 185 | 225 | destructiveHint: true, |
| 186 | 226 | }); |
| 227 | + const inventory = yield* passthrough.call("integrations", { | |
| 228 | + integration: slug, | |
| 229 | + owner: "org", | |
| 230 | + }); | |
| 231 | + expect(inventory.ok).toBe(true); | |
| 232 | + const decodedInventory = decodeInventory(inventory.raw).structuredContent; | |
| 233 | + expect(decodedInventory).toEqual({ | |
| 234 | + items: [{ integration: slug, owner: "org", connection: "main" }], | |
| 235 | + total: 1, | |
| 236 | + }); | |
| 237 | + expect(inventory.text).not.toContain(`tok_${slug}`); | |
| 238 | + const skills = yield* passthrough.call("skills", {}); | |
| 239 | + expect(skills.ok).toBe(true); | |
| 240 | + expect(skills.text).toContain("search-invoke"); | |
| 241 | + const guide = yield* passthrough.call("skills", { name: "search-invoke" }); | |
| 242 | + expect(guide.ok).toBe(true); | |
| 243 | + expect(guide.text).toContain("integrations({})"); | |
| 244 | + expect((yield* passthrough.call("skills", { name: "execute" })).ok).toBe(false); | |
| 187 | 245 | const found = decodeToolSearch( |
| 188 | − (yield* passthrough.call("search", { query: slug })).raw, | |
| 246 | + (yield* passthrough.call("search", { | |
| 247 | + query: "notes", | |
| 248 | + integration: slug, | |
| 249 | + owner: "org", | |
| 250 | + connection: "main", | |
| 251 | + })).raw, | |
| 252 | + ).structuredContent; | |
| 253 | + expect(found.items.every((tool) => tool.integration === slug)).toBe(true); | |
| 254 | + const missingAccount = decodeToolSearch( | |
| 255 | + (yield* passthrough.call("search", { | |
| 256 | + query: "notes", | |
| 257 | + integration: slug, | |
| 258 | + owner: "user", | |
| 259 | + connection: "main", | |
| 260 | + })).raw, | |
| 189 | 261 | ).structuredContent; |
| 262 | + expect(missingAccount.items).toEqual([]); | |
| 190 | 263 | const listDef = found.items.find((tool) => tool.id.endsWith(".listNotes")); |
| 191 | 264 | const createDef = found.items.find((tool) => tool.id.endsWith(".createNote")); |
| 192 | 265 | if (!listDef || !createDef) return yield* Effect.die("Search omitted notes operations"); |
| @@ -72,6 +72,7 @@ export const makeMcpBuildServer = · 71 unmodified lines | ||
| 72 | 72 | artifacts: executor.artifacts, |
| 73 | 73 | connections: executor.connections, |
| 74 | 74 | tools: executor.tools, |
| 75 | + integrations: executor.integrations, | |
| 75 | 76 | ...(hostOptions?.loadAppShellHtml |
| 76 | 77 | ? { loadAppShellHtml: hostOptions.loadAppShellHtml } |
| 77 | 78 | : {}), |
| @@ -61,10 +61,15 @@ const toolPort = ( · 60 unmodified lines | ||
| 61 | 61 | schemaReads: string[] = [], |
| 62 | 62 | lists: string[] = [], |
| 63 | 63 | ): McpToolsPort => ({ |
| 64 | − list: () => | |
| 64 | + list: (filter) => | |
| 65 | 65 | Effect.sync(() => { |
| 66 | 66 | lists.push("list"); |
| 67 | − return catalog; | |
| 67 | + return catalog.filter( | |
| 68 | + (tool) => | |
| 69 | + (filter?.integration === undefined || tool.integration === filter.integration) && | |
| 70 | + (filter?.owner === undefined || tool.owner === filter.owner) && | |
| 71 | + (filter?.connection === undefined || tool.connection === filter.connection), | |
| 72 | + ); | |
| 68 | 73 | }), |
| 69 | 74 | schema: (address) => |
| 70 | 75 | Effect.sync(() => { |
| @@ -113,7 +118,13 @@ const withClient = async <E extends Cause.YieldableError>( · 42 unmodified lines | ||
| 113 | 118 | config: ExecutorMcpServerConfig<E>, |
| 114 | 119 | fn: (client: Client) => Promise<void>, |
| 115 | 120 | ) => { |
| 116 | − const mcpServer = await Effect.runPromise(createExecutorMcpServer(config)); | |
| 121 | + const mcpServer = await Effect.runPromise( | |
| 122 | + createExecutorMcpServer({ | |
| 123 | + connections: { list: () => Effect.succeed([]) }, | |
| 124 | + integrations: { list: () => Effect.succeed([]) }, | |
| 125 | + ...config, | |
| 126 | + }), | |
| 127 | + ); | |
| 117 | 128 | const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); |
| 118 | 129 | const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} }); |
| 119 | 130 | await mcpServer.connect(serverTransport); |
| @@ -203,7 +214,221 @@ describe("readToolMode", () => { · 83 unmodified lines | ||
| 203 | 214 | // --------------------------------------------------------------------------- |
| 204 | 215 | |
| 205 | 216 | describe("passthrough mode server", () => { |
| 206 | − it("serves exactly search and invoke even for 10000 tools", async () => { | |
| 217 | + it("lists live account metadata with paging, exact filters, and no schemas or secrets", async () => { | |
| 218 | + const { engine, executed } = makeRecordingEngine(); | |
| 219 | + const schemaReads: string[] = []; | |
| 220 | + const lists: string[] = []; | |
| 221 | + const reads: string[] = []; | |
| 222 | + const account = (integration: string, owner: "org" | "user", name: string) => ({ | |
| 223 | + integration, | |
| 224 | + owner, | |
| 225 | + name, | |
| 226 | + identityLabel: "Example account", | |
| 227 | + description: "Use for test issues", | |
| 228 | + lastHealth: { | |
| 229 | + status: "healthy" as const, | |
| 230 | + checkedAt: 123, | |
| 231 | + detail: "private probe details", | |
| 232 | + responseSample: [{ path: "token", value: "secret" }], | |
| 233 | + }, | |
| 234 | + oauthScope: "private grants", | |
| 235 | + provider: "private credential provider", | |
| 236 | + }); | |
| 237 | + let accounts = [ | |
| 238 | + account("github", "user", "main"), | |
| 239 | + account("github", "org", "main"), | |
| 240 | + account("github_other", "org", "main"), | |
| 241 | + account("unavailable", "org", "hidden"), | |
| 242 | + ]; | |
| 243 | + await withClient( | |
| 244 | + { | |
| 245 | + engine, | |
| 246 | + mode: "passthrough", | |
| 247 | + tools: toolPort(CATALOG, schemaReads, lists), | |
| 248 | + connections: { | |
| 249 | + list: () => | |
| 250 | + Effect.sync(() => { | |
| 251 | + reads.push("connections"); | |
| 252 | + return accounts; | |
| 253 | + }), | |
| 254 | + }, | |
| 255 | + integrations: { | |
| 256 | + list: () => | |
| 257 | + Effect.sync(() => { | |
| 258 | + reads.push("integrations"); | |
| 259 | + return [ | |
| 260 | + { | |
| 261 | + slug: IntegrationSlug.make("github"), | |
| 262 | + name: "GitHub", | |
| 263 | + description: "Issues and repositories", | |
| 264 | + }, | |
| 265 | + { | |
| 266 | + slug: IntegrationSlug.make("github_other"), | |
| 267 | + name: "Other GitHub", | |
| 268 | + description: "Another integration", | |
| 269 | + }, | |
| 270 | + { | |
| 271 | + slug: IntegrationSlug.make("not_connected"), | |
| 272 | + name: "Not connected", | |
| 273 | + description: "No account", | |
| 274 | + }, | |
| 275 | + ]; | |
| 276 | + }), | |
| 277 | + }, | |
| 278 | + }, | |
| 279 | + async (client) => { | |
| 280 | + await client.listTools(); | |
| 281 | + expect(reads).toEqual([]); | |
| 282 | + const first = await client.callTool({ | |
| 283 | + name: "integrations", | |
| 284 | + arguments: { integration: "github", limit: 1 }, | |
| 285 | + }); | |
| 286 | + expect(first.structuredContent).toEqual({ | |
| 287 | + items: [ | |
| 288 | + { | |
| 289 | + integration: "github", | |
| 290 | + integrationName: "GitHub", | |
| 291 | + integrationDescription: "Issues and repositories", | |
| 292 | + owner: "org", | |
| 293 | + connection: "main", | |
| 294 | + identityLabel: "Example account", | |
| 295 | + description: "Use for test issues", | |
| 296 | + lastHealth: { status: "healthy", checkedAt: 123 }, | |
| 297 | + }, | |
| 298 | + ], | |
| 299 | + total: 2, | |
| 300 | + hasMore: true, | |
| 301 | + nextOffset: 1, | |
| 302 | + }); | |
| 303 | + const next = await client.callTool({ | |
| 304 | + name: "integrations", | |
| 305 | + arguments: { integration: "github", limit: 1, offset: 1 }, | |
| 306 | + }); | |
| 307 | + expect(next.structuredContent).toMatchObject({ | |
| 308 | + items: [{ owner: "user" }], | |
| 309 | + total: 2, | |
| 310 | + hasMore: false, | |
| 311 | + nextOffset: null, | |
| 312 | + }); | |
| 313 | + const filtered = await client.callTool({ | |
| 314 | + name: "integrations", | |
| 315 | + arguments: { owner: "org", integration: "github" }, | |
| 316 | + }); | |
| 317 | + expect(filtered.structuredContent).toMatchObject({ | |
| 318 | + items: [{ owner: "org", integration: "github" }], | |
| 319 | + total: 1, | |
| 320 | + }); | |
| 321 | + const all = await client.callTool({ name: "integrations", arguments: {} }); | |
| 322 | + expect(all.structuredContent).toMatchObject({ total: 3 }); | |
| 323 | + expect(JSON.stringify(all)).not.toContain("secret"); | |
| 324 | + expect(JSON.stringify(all)).not.toContain("private"); | |
| 325 | + accounts = []; | |
| 326 | + const empty = await client.callTool({ name: "integrations", arguments: {} }); | |
| 327 | + expect(empty.structuredContent).toEqual({ | |
| 328 | + items: [], | |
| 329 | + total: 0, | |
| 330 | + hasMore: false, | |
| 331 | + nextOffset: null, | |
| 332 | + }); | |
| 333 | + expect(lists).toEqual([]); | |
| 334 | + expect(schemaReads).toEqual([]); | |
| 335 | + expect(executed).toEqual([]); | |
| 336 | + }, | |
| 337 | + ); | |
| 338 | + }); | |
| 339 | + | |
| 340 | + it("serves only the search/invoke guide as text", async () => { | |
| 341 | + const { engine } = makeRecordingEngine(); | |
| 342 | + await withClient({ engine, mode: "passthrough", tools: toolPort(CATALOG) }, async (client) => { | |
| 343 | + const index = await client.callTool({ name: "skills", arguments: {} }); | |
| 344 | + expect(JSON.stringify(index.content)).toContain("search-invoke"); | |
| 345 | + expect(JSON.stringify(index.content)).not.toContain("create-artifact"); | |
| 346 | + const guide = await client.callTool({ name: "skills", arguments: { name: "search-invoke" } }); | |
| 347 | + expect(guide.structuredContent).toBeUndefined(); | |
| 348 | + expect(JSON.stringify(guide.content)).toContain("integrations({})"); | |
| 349 | + expect(JSON.stringify(guide.content)).toContain("nextOffset"); | |
| 350 | + expect(JSON.stringify(guide.content)).not.toContain("tools.describe"); | |
| 351 | + for (const name of ["execute", "create-artifact", "artifact-style", "/tmp/SKILL.md"]) { | |
| 352 | + expect((await client.callTool({ name: "skills", arguments: { name } })).isError).toBe(true); | |
| 353 | + } | |
| 354 | + }); | |
| 355 | + }); | |
| 356 | + | |
| 357 | + it("filters exact accounts before ranking, pagination, and schema reads", async () => { | |
| 358 | + const { engine } = makeRecordingEngine(); | |
| 359 | + const schemaReads: string[] = []; | |
| 360 | + const catalog = [ | |
| 361 | + projection({ integration: "github", owner: "org", connection: "main", name: "issues.first" }), | |
| 362 | + projection({ | |
| 363 | + integration: "github", | |
| 364 | + owner: "org", | |
| 365 | + connection: "main", | |
| 366 | + name: "issues.second", | |
| 367 | + }), | |
| 368 | + projection({ | |
| 369 | + integration: "github", | |
| 370 | + owner: "user", | |
| 371 | + connection: "main", | |
| 372 | + name: "issues.first", | |
| 373 | + }), | |
| 374 | + projection({ | |
| 375 | + integration: "github", | |
| 376 | + owner: "org", | |
| 377 | + connection: "main2", | |
| 378 | + name: "issues.first", | |
| 379 | + }), | |
| 380 | + projection({ | |
| 381 | + integration: "github_other", | |
| 382 | + owner: "org", | |
| 383 | + connection: "main", | |
| 384 | + name: "issues.first", | |
| 385 | + }), | |
| 386 | + projection({ | |
| 387 | + integration: "github", | |
| 388 | + owner: "org", | |
| 389 | + connection: "main", | |
| 390 | + name: "issues.static", | |
| 391 | + static: true, | |
| 392 | + }), | |
| 393 | + ]; | |
| 394 | + await withClient( | |
| 395 | + { engine, mode: "passthrough", tools: toolPort(catalog, schemaReads) }, | |
| 396 | + async (client) => { | |
| 397 | + const args = { | |
| 398 | + query: "issues", | |
| 399 | + integration: "github", | |
| 400 | + owner: "org", | |
| 401 | + connection: "main", | |
| 402 | + limit: 1, | |
| 403 | + }; | |
| 404 | + const first = await client.callTool({ name: "search", arguments: args }); | |
| 405 | + expect(first.structuredContent).toMatchObject({ total: 2, hasMore: true, nextOffset: 1 }); | |
| 406 | + const next = await client.callTool({ name: "search", arguments: { ...args, offset: 1 } }); | |
| 407 | + expect(next.structuredContent).toMatchObject({ | |
| 408 | + total: 2, | |
| 409 | + hasMore: false, | |
| 410 | + nextOffset: null, | |
| 411 | + }); | |
| 412 | + expect(schemaReads.sort()).toEqual([ | |
| 413 | + "tools.github.org.main.issues.first", | |
| 414 | + "tools.github.org.main.issues.second", | |
| 415 | + ]); | |
| 416 | + const missing = await client.callTool({ | |
| 417 | + name: "search", | |
| 418 | + arguments: { ...args, connection: "absent" }, | |
| 419 | + }); | |
| 420 | + expect(missing.structuredContent).toEqual({ | |
| 421 | + items: [], | |
| 422 | + total: 0, | |
| 423 | + hasMore: false, | |
| 424 | + nextOffset: null, | |
| 425 | + }); | |
| 426 | + expect(schemaReads).toHaveLength(2); | |
| 427 | + }, | |
| 428 | + ); | |
| 429 | + }); | |
| 430 | + | |
| 431 | + it("serves four discovery and call tools even for 10000 tools", async () => { | |
| 207 | 432 | const { engine, executed } = makeRecordingEngine(); |
| 208 | 433 | const schemaReads: string[] = []; |
| 209 | 434 | const lists: string[] = []; |
| @@ -223,7 +448,12 @@ describe("passthrough mode server", () => { · 13 unmodified lines | ||
| 223 | 448 | }, |
| 224 | 449 | async (client) => { |
| 225 | 450 | const listed = await client.listTools(); |
| 226 | − expect(listed.tools.map((tool) => tool.name).sort()).toEqual(["invoke", "search"]); | |
| 451 | + expect(listed.tools.map((tool) => tool.name).sort()).toEqual([ | |
| 452 | + "integrations", | |
| 453 | + "invoke", | |
| 454 | + "search", | |
| 455 | + "skills", | |
| 456 | + ]); | |
| 227 | 457 | expect(JSON.stringify(listed).length).toBeLessThan(4000); |
| 228 | 458 | expect(lists).toEqual([]); |
| 229 | 459 | expect(schemaReads).toEqual([]); |
| @@ -563,7 +793,7 @@ describe("passthrough mode server", () => { · 333 unmodified lines | ||
| 563 | 793 | }, |
| 564 | 794 | async (client) => { |
| 565 | 795 | const names = (await client.listTools()).tools.map((tool) => tool.name); |
| 566 | − expect(names.sort()).toEqual(["invoke", "search"]); | |
| 796 | + expect(names.sort()).toEqual(["integrations", "invoke", "search", "skills"]); | |
| 567 | 797 | }, |
| 568 | 798 | ); |
| 569 | 799 | }); |
| @@ -1,3 +1,5 @@ | ||
| 1 | +import type { Skill } from "@executor-js/execution"; | |
| 2 | + | |
| 1 | 3 | /** |
| 2 | 4 | * The sandbox code a passthrough call runs. Built HERE from the session's |
| 3 | 5 | * resolved address and a JSON-encoded argument — never concatenated from raw |
| @@ -23,7 +25,30 @@ export const passthroughCallCode = (address: string, args: unknown): string => { · 19 unmodified lines | ||
| 23 | 25 | |
| 24 | 26 | /** Describe the fixed search/invoke surface without listing the underlying catalog. */ |
| 25 | 27 | export const passthroughInstructions = (): string => |
| 28 | + 'Use integrations to see connected accounts and skills({ name: "search-invoke" }) for the workflow. ' + | |
| 26 | 29 | "Find connected integration tools with search, then call invoke with the returned tool ID and JSON arguments. " + |
| 27 | 30 | "Search returns input schemas and account details. Use its nextOffset to get more matches. " + |
| 28 | 31 | "Invoke can change external state; your client handles approval for each call. Workspace block policies remain enforced. " + |
| 29 | 32 | "No JavaScript, execute, resume, or artifact tools are exposed in this mode."; |
| 33 | + | |
| 34 | +/** On-demand guidance for the JSON tool surface; no sandbox or artifact instructions. */ | |
| 35 | +export const SEARCH_INVOKE_SKILL: Skill = { | |
| 36 | + name: "search-invoke", | |
| 37 | + summary: "Discover connected accounts, search for actions, and invoke tools with JSON arguments.", | |
| 38 | + body: [ | |
| 39 | + "# Search and invoke", | |
| 40 | + "", | |
| 41 | + "1. Call `integrations({})` to see connected integrations and accounts. Each item includes an integration description, account label, and last recorded health. A null health verdict means the account has not been checked; a saved connection does not guarantee a working credential.", | |
| 42 | + '2. Call `search({ query: "create issue", integration: "github", owner: "org", connection: "main" })`. Use the exact integration, owner, and connection returned by integrations to select an account. Omit filters to search across accounts visible to you.', | |
| 43 | + "3. Read the matching tool's `inputSchema`. Call `invoke({ tool: <exact returned id>, arguments: <JSON object matching inputSchema> })`. Do not guess tool IDs or arguments.", | |
| 44 | + "", | |
| 45 | + "## Pagination", | |
| 46 | + "Both integrations and search return `{ items, total, hasMore, nextOffset }`. If hasMore is true, repeat the call with the same filters and `offset: nextOffset`. Search also needs the same query. Search returns at most 20 tools per page, with schemas only for those matches.", | |
| 47 | + "Tool-search pagination is separate from an upstream API's pagination. Follow the invoked tool's schema and response for cursor or page arguments when retrieving more records.", | |
| 48 | + "", | |
| 49 | + "## Results and approval", | |
| 50 | + "Invoke forwards the tool's result, including supported MCP content. Check `isError` and any returned error before treating a call as successful. Your client handles approval for invoke; workspace block policies still apply. An upstream request for user input needs a client that supports native elicitation.", | |
| 51 | + "If a tool is no longer available, search again. If an account needs authentication, ask the user to reconnect it in Executor. Never ask for credentials in chat.", | |
| 52 | + "This mode accepts JSON tool arguments. It does not expose execute, resume, or artifact tools. The skills tool serves only this server's guides, not files or skills from your harness or project.", | |
| 53 | + ].join("\n"), | |
| 54 | +}; | |
| @@ -24,6 +24,8 @@ import * as z from "zod/v4"; · 23 unmodified lines | ||
| 24 | 24 | import { |
| 25 | 25 | CurrentOrgWriteAccess, |
| 26 | 26 | ToolAddress, |
| 27 | + IntegrationSlug, | |
| 28 | + ConnectionName, | |
| 27 | 29 | parseToolAddress, |
| 28 | 30 | isToolFile, |
| 29 | 31 | isToolResult, |
| @@ -33,6 +35,8 @@ import { · 3 unmodified lines | ||
| 33 | 35 | } from "@executor-js/sdk"; |
| 34 | 36 | import type { |
| 35 | 37 | Artifact, |
| 38 | + Connection, | |
| 39 | + Integration, | |
| 36 | 40 | ArtifactBinding, |
| 37 | 41 | ArtifactSummary, |
| 38 | 42 | ElicitationResponse, |
| @@ -82,7 +86,11 @@ import { · 43 unmodified lines | ||
| 82 | 86 | type BindableConnection, |
| 83 | 87 | } from "./artifact-bindings"; |
| 84 | 88 | import { MCP_ORG_WRITE_ACCESS_HEADER } from "./seams"; |
| 85 | −import { passthroughCallCode, passthroughInstructions } from "./passthrough-tools"; | |
| 89 | +import { | |
| 90 | + passthroughCallCode, | |
| 91 | + passthroughInstructions, | |
| 92 | + SEARCH_INVOKE_SKILL, | |
| 93 | +} from "./passthrough-tools"; | |
| 86 | 94 | import type { McpToolMode } from "./browser-approval"; |
| 87 | 95 | |
| 88 | 96 | // --------------------------------------------------------------------------- |
| @@ -234,14 +242,16 @@ type SharedMcpServerConfig = { · 145 unmodified lines | ||
| 234 | 242 | */ |
| 235 | 243 | readonly artifacts?: McpArtifactsPort; |
| 236 | 244 | /** |
| 237 | − * The caller's saved connections, for binding an artifact's integration roles | |
| 238 | − * at create time. Structurally satisfied by `executor.connections`; hosts pass | |
| 245 | + * The caller's saved connections, for the search/invoke account inventory | |
| 246 | + * and binding artifact integration roles at create time. Structurally satisfied by `executor.connections`; hosts pass | |
| 239 | 247 | * the same scoped executor they pass `artifacts`. |
| 240 | 248 | * |
| 241 | 249 | * Absent means `create-artifact` cannot bind, so it refuses code that calls an |
| 242 | 250 | * integration rather than saving an artifact that could never run. |
| 243 | 251 | */ |
| 244 | 252 | readonly connections?: McpConnectionsPort; |
| 253 | + /** Scoped integration metadata for the search/invoke account inventory. */ | |
| 254 | + readonly integrations?: McpIntegrationsPort; | |
| 245 | 255 | /** |
| 246 | 256 | * Builds the web-app deep link for a saved artifact. Clients that can't |
| 247 | 257 | * render MCP Apps get this URL instead of an inline widget. Absent (stdio has |
| @@ -291,12 +301,24 @@ export type McpArtifactsPort = { · 43 unmodified lines | ||
| 291 | 301 | }; |
| 292 | 302 | |
| 293 | 303 | /** |
| 294 | − * The connection surface binding needs: list what this caller can reach. The | |
| 304 | + * The connection surface binding and discovery need: list what this caller can reach. The | |
| 295 | 305 | * scoped executor has already narrowed it, so an inferred binding can never |
| 296 | 306 | * name a connection the caller couldn't call themselves. |
| 297 | 307 | */ |
| 298 | 308 | export type McpConnectionsPort = { |
| 299 | − readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>; | |
| 309 | + readonly list: () => Effect.Effect< | |
| 310 | + readonly (BindableConnection & | |
| 311 | + Pick<Connection, "identityLabel" | "description" | "lastHealth">)[], | |
| 312 | + unknown | |
| 313 | + >; | |
| 314 | +}; | |
| 315 | + | |
| 316 | +/** Catalog metadata visible to this caller; no tool schemas or credentials. */ | |
| 317 | +export type McpIntegrationsPort = { | |
| 318 | + readonly list: () => Effect.Effect< | |
| 319 | + readonly Pick<Integration, "slug" | "name" | "description">[], | |
| 320 | + unknown | |
| 321 | + >; | |
| 300 | 322 | }; |
| 301 | 323 | |
| 302 | 324 | /** The same list and schema APIs used by codemode discovery. */ |
| @@ -1249,6 +1271,8 @@ const passthroughInputSchema = (view: ToolSchemaView): unknown => · 946 unmodified lines | ||
| 1249 | 1271 | const registerPassthroughTools = <E extends Cause.YieldableError>( |
| 1250 | 1272 | server: McpServer, |
| 1251 | 1273 | tools: McpToolsPort, |
| 1274 | + connections: McpConnectionsPort, | |
| 1275 | + integrations: McpIntegrationsPort, | |
| 1252 | 1276 | run: ( |
| 1253 | 1277 | address: ToolAddress, |
| 1254 | 1278 | args: unknown, |
| @@ -1258,14 +1282,6 @@ const registerPassthroughTools = <E extends Cause.YieldableError>( · 3 unmodified lines | ||
| 1258 | 1282 | Effect.gen(function* () { |
| 1259 | 1283 | const context = yield* Effect.context<never>(); |
| 1260 | 1284 | const validator = new CfWorkerJsonSchemaValidator(); |
| 1261 | − const discovery = { | |
| 1262 | − tools: { | |
| 1263 | − list: (filter?: Parameters<McpToolsPort["list"]>[0]) => | |
| 1264 | − tools | |
| 1265 | − .list(filter) | |
| 1266 | − .pipe(Effect.map((items) => items.filter((tool) => tool.static !== true))), | |
| 1267 | − }, | |
| 1268 | − }; | |
| 1269 | 1285 | const boundary = <A extends McpToolResult, F>( |
| 1270 | 1286 | effect: Effect.Effect<A, F>, |
| 1271 | 1287 | extra: McpRequestJoinKeys, |
| @@ -1280,11 +1296,82 @@ const registerPassthroughTools = <E extends Cause.YieldableError>( · 8 unmodified lines | ||
| 1280 | 1296 | ), |
| 1281 | 1297 | ); |
| 1282 | 1298 | yield* Effect.sync(() => { |
| 1299 | + server.registerTool( | |
| 1300 | + "integrations", | |
| 1301 | + { | |
| 1302 | + description: | |
| 1303 | + "List connected integrations and accounts visible to you. Returns integration descriptions, account labels, exact search filters, and last recorded health (null means unchecked). One item per account; use nextOffset for more. Does not load tool schemas or check credentials.", | |
| 1304 | + inputSchema: { | |
| 1305 | + integration: z.string().trim().min(1).optional().describe("Exact integration slug."), | |
| 1306 | + owner: z.enum(["org", "user"]).optional(), | |
| 1307 | + limit: z.number().int().min(1).max(50).default(20), | |
| 1308 | + offset: z.number().int().min(0).default(0), | |
| 1309 | + }, | |
| 1310 | + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, | |
| 1311 | + }, | |
| 1312 | + ({ integration, owner, limit, offset }, extra) => | |
| 1313 | + boundary( | |
| 1314 | + Effect.gen(function* () { | |
| 1315 | + const [accounts, catalog] = yield* Effect.all([ | |
| 1316 | + connections.list(), | |
| 1317 | + integrations.list(), | |
| 1318 | + ]); | |
| 1319 | + const metadata = new Map(catalog.map((item) => [String(item.slug), item])); | |
| 1320 | + const visible = accounts | |
| 1321 | + .flatMap((account) => { | |
| 1322 | + const item = metadata.get(account.integration); | |
| 1323 | + if ( | |
| 1324 | + !item || | |
| 1325 | + (integration !== undefined && account.integration !== integration) || | |
| 1326 | + (owner !== undefined && account.owner !== owner) | |
| 1327 | + ) | |
| 1328 | + return []; | |
| 1329 | + return [ | |
| 1330 | + { | |
| 1331 | + integration: account.integration, | |
| 1332 | + integrationName: item.name, | |
| 1333 | + integrationDescription: item.description, | |
| 1334 | + owner: account.owner, | |
| 1335 | + connection: account.name, | |
| 1336 | + identityLabel: account.identityLabel ?? null, | |
| 1337 | + description: account.description ?? null, | |
| 1338 | + lastHealth: | |
| 1339 | + account.lastHealth == null | |
| 1340 | + ? null | |
| 1341 | + : { | |
| 1342 | + status: account.lastHealth.status, | |
| 1343 | + checkedAt: account.lastHealth.checkedAt, | |
| 1344 | + }, | |
| 1345 | + }, | |
| 1346 | + ]; | |
| 1347 | + }) | |
| 1348 | + .sort( | |
| 1349 | + (a, b) => | |
| 1350 | + a.integration.localeCompare(b.integration) || | |
| 1351 | + a.owner.localeCompare(b.owner) || | |
| 1352 | + a.connection.localeCompare(b.connection), | |
| 1353 | + ); | |
| 1354 | + const items = visible.slice(offset, offset + limit); | |
| 1355 | + const hasMore = offset + items.length < visible.length; | |
| 1356 | + const result = { | |
| 1357 | + items, | |
| 1358 | + total: visible.length, | |
| 1359 | + hasMore, | |
| 1360 | + nextOffset: hasMore ? offset + items.length : null, | |
| 1361 | + }; | |
| 1362 | + return { | |
| 1363 | + content: [{ type: "text" as const, text: JSON.stringify(result) }], | |
| 1364 | + structuredContent: result, | |
| 1365 | + }; | |
| 1366 | + }), | |
| 1367 | + extra, | |
| 1368 | + ), | |
| 1369 | + ); | |
| 1283 | 1370 | server.registerTool( |
| 1284 | 1371 | "search", |
| 1285 | 1372 | { |
| 1286 | 1373 | description: |
| 1287 | − "Search connected integration tools by action, integration, or account. Returns matching tool IDs, account details, and full JSON input schemas. Pass the returned ID and arguments to invoke. Use nextOffset to page through matches.", | |
| 1374 | + "Search connected integration tools by action, integration, or account. Returns matching tool IDs, account details, and full JSON input schemas. Pass the returned ID and arguments to invoke. Use integrations to discover accounts, then pass exact integration, owner, and connection filters. Use nextOffset to page through matches.", | |
| 1288 | 1375 | inputSchema: { |
| 1289 | 1376 | query: z |
| 1290 | 1377 | .string() |
| @@ -1292,14 +1379,46 @@ const registerPassthroughTools = <E extends Cause.YieldableError>( · 1 unmodified line | ||
| 1292 | 1379 | .min(1) |
| 1293 | 1380 | .max(500) |
| 1294 | 1381 | .describe("Keywords describing the tool or task, such as github create issue."), |
| 1382 | + integration: z | |
| 1383 | + .string() | |
| 1384 | + .trim() | |
| 1385 | + .min(1) | |
| 1386 | + .optional() | |
| 1387 | + .describe("Exact integration slug from integrations."), | |
| 1388 | + owner: z.enum(["org", "user"]).optional(), | |
| 1389 | + connection: z | |
| 1390 | + .string() | |
| 1391 | + .trim() | |
| 1392 | + .min(1) | |
| 1393 | + .optional() | |
| 1394 | + .describe( | |
| 1395 | + "Exact account name from integrations; pair with integration and owner to select one account.", | |
| 1396 | + ), | |
| 1295 | 1397 | limit: z.number().int().min(1).max(20).default(10), |
| 1296 | 1398 | offset: z.number().int().min(0).default(0), |
| 1297 | 1399 | }, |
| 1298 | 1400 | annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, |
| 1299 | 1401 | }, |
| 1300 | − ({ query, limit, offset }, extra) => | |
| 1402 | + ({ query, integration, owner, connection, limit, offset }, extra) => | |
| 1301 | 1403 | boundary( |
| 1302 | 1404 | Effect.gen(function* () { |
| 1405 | + const discovery = { | |
| 1406 | + tools: { | |
| 1407 | + list: (filter?: Parameters<McpToolsPort["list"]>[0]) => | |
| 1408 | + tools | |
| 1409 | + .list({ | |
| 1410 | + ...filter, | |
| 1411 | + ...(integration === undefined | |
| 1412 | + ? {} | |
| 1413 | + : { integration: IntegrationSlug.make(integration) }), | |
| 1414 | + ...(owner === undefined ? {} : { owner }), | |
| 1415 | + ...(connection === undefined | |
| 1416 | + ? {} | |
| 1417 | + : { connection: ConnectionName.make(connection) }), | |
| 1418 | + }) | |
| 1419 | + .pipe(Effect.map((items) => items.filter((tool) => tool.static !== true))), | |
| 1420 | + }, | |
| 1421 | + }; | |
| 1303 | 1422 | const page = yield* searchTools(discovery, query, limit, { offset }); |
| 1304 | 1423 | const candidates = yield* Effect.forEach( |
| 1305 | 1424 | page.items, |
| @@ -1362,14 +1481,15 @@ const registerPassthroughTools = <E extends Cause.YieldableError>( · 56 unmodified lines | ||
| 1362 | 1481 | if (!identity) return unavailable; |
| 1363 | 1482 | const address = ToolAddress.make(id); |
| 1364 | 1483 | // Use the existing visibility filter and exclude static configuration tools. |
| 1365 | − const visible = yield* discovery.tools.list({ | |
| 1484 | + const visible = yield* tools.list({ | |
| 1366 | 1485 | integration: identity.integration, |
| 1367 | 1486 | owner: identity.owner, |
| 1368 | 1487 | connection: identity.connection, |
| 1369 | 1488 | query: String(identity.tool), |
| 1370 | 1489 | includeAnnotations: false, |
| 1371 | 1490 | }); |
| 1372 | − if (!visible.some((tool) => tool.address === address)) return unavailable; | |
| 1491 | + if (!visible.some((tool) => tool.static !== true && tool.address === address)) | |
| 1492 | + return unavailable; | |
| 1373 | 1493 | const schema = yield* tools.schema(address); |
| 1374 | 1494 | if (!schema) return unavailable; |
| 1375 | 1495 | // The SDK validator checks this dynamic JSON schema at the MCP boundary. |
| @@ -1416,7 +1536,10 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 40 unmodified lines | ||
| 1416 | 1536 | // Search/invoke serves no artifact tools: artifacts run sandboxed code. |
| 1417 | 1537 | const artifactsEnabled = |
| 1418 | 1538 | config.mode === "passthrough" ? false : (config.artifactsEnabled ?? true); |
| 1419 | − const skillCatalog: readonly Skill[] = skillCatalogFor({ artifacts: artifactsEnabled }); | |
| 1539 | + const skillCatalog: readonly Skill[] = | |
| 1540 | + config.mode === "passthrough" | |
| 1541 | + ? [SEARCH_INVOKE_SKILL] | |
| 1542 | + : skillCatalogFor({ artifacts: artifactsEnabled }); | |
| 1420 | 1543 | // Per-integration search tools are off unless this connection opted in |
| 1421 | 1544 | // (`?search_tools=true`). |
| 1422 | 1545 | const searchToolsEnabled = config.searchToolsEnabled ?? false; |
| @@ -1426,9 +1549,10 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 3 unmodified lines | ||
| 1426 | 1549 | // each other. |
| 1427 | 1550 | const mode: McpToolMode = config.mode ?? "codemode"; |
| 1428 | 1551 | const passthrough = mode === "passthrough"; |
| 1429 | − if (passthrough && !config.tools) { | |
| 1552 | + if (passthrough && (!config.tools || !config.connections || !config.integrations)) { | |
| 1430 | 1553 | return yield* new McpPassthroughUnavailableError({ |
| 1431 | − reason: "passthrough mode requires tool list and schema APIs", | |
| 1554 | + reason: | |
| 1555 | + "passthrough mode requires tool list/schema, connection list, and integration list APIs", | |
| 1432 | 1556 | }); |
| 1433 | 1557 | } |
| 1434 | 1558 | |
| @@ -1914,8 +2038,14 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 479 unmodified lines | ||
| 1914 | 2038 | // --- tools --- |
| 1915 | 2039 | |
| 1916 | 2040 | // Passthrough serves search and invoke in place of the codemode tools. |
| 1917 | − if (passthrough && config.tools) { | |
| 1918 | − yield* registerPassthroughTools(server, config.tools, executePassthroughCall); | |
| 2041 | + if (passthrough && config.tools && config.connections && config.integrations) { | |
| 2042 | + yield* registerPassthroughTools( | |
| 2043 | + server, | |
| 2044 | + config.tools, | |
| 2045 | + config.connections, | |
| 2046 | + config.integrations, | |
| 2047 | + executePassthroughCall, | |
| 2048 | + ); | |
| 1919 | 2049 | } |
| 1920 | 2050 | |
| 1921 | 2051 | if (!passthrough) |
| @@ -1934,37 +2064,36 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 12 unmodified lines | ||
| 1934 | 2064 | }), |
| 1935 | 2065 | ); |
| 1936 | 2066 | |
| 1937 | − if (!passthrough) | |
| 1938 | − yield* Effect.sync(() => | |
| 1939 | − server.registerTool( | |
| 1940 | − "skills", | |
| 1941 | − { | |
| 1942 | − description: [ | |
| 1943 | − "Documentation for THIS server's own tools. Not a general skill reader: it serves a short, fixed set of how-to docs about using `execute` and artifacts here, and it cannot reach your harness's skills, a SKILL.md on disk, or any user- or project-authored skill. The argument is a name from its own catalog, never a path or an outside skill's id.", | |
| 1944 | − "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.", | |
| 1945 | − 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool (search the catalog, call tools, emit results, resume paused runs).', | |
| 1946 | − "Call with no name to list the few docs available.", | |
| 1947 | − ].join("\n"), | |
| 1948 | − inputSchema: { | |
| 1949 | − name: z | |
| 1950 | − .string() | |
| 1951 | − .optional() | |
| 1952 | − .describe( | |
| 1953 | − 'A doc from this server\'s own catalog, e.g. "execute" — not a path or an outside skill name. Omit to list the catalog.', | |
| 1954 | − ), | |
| 1955 | − }, | |
| 2067 | + yield* Effect.sync(() => | |
| 2068 | + server.registerTool( | |
| 2069 | + "skills", | |
| 2070 | + { | |
| 2071 | + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, | |
| 2072 | + description: passthrough | |
| 2073 | + ? 'Documentation for this server only, not harness or project skills. Call with no name to list guides, or skills({ name: "search-invoke" }) for account discovery, tool search, invocation, and pagination.' | |
| 2074 | + : [ | |
| 2075 | + "Documentation for THIS server's own tools. Not a general skill reader: it serves a short, fixed set of how-to docs about using `execute` and artifacts here, and it cannot reach your harness's skills, a SKILL.md on disk, or any user- or project-authored skill. The argument is a name from its own catalog, never a path or an outside skill's id.", | |
| 2076 | + "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.", | |
| 2077 | + 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool (search the catalog, call tools, emit results, resume paused runs).', | |
| 2078 | + "Call with no name to list the few docs available.", | |
| 2079 | + ].join("\n"), | |
| 2080 | + inputSchema: { | |
| 2081 | + name: z | |
| 2082 | + .string() | |
| 2083 | + .optional() | |
| 2084 | + .describe( | |
| 2085 | + `A doc from this server's own catalog, e.g. "${passthrough ? "search-invoke" : "execute"}". Omit to list the catalog.`, | |
| 2086 | + ), | |
| 1956 | 2087 | }, |
| 1957 | − ({ name }, extra) => | |
| 1958 | − runToolEffect( | |
| 1959 | − Effect.succeed(skillsResult(name, executeInventory, skillCatalog)), | |
| 1960 | − extra, | |
| 1961 | − ), | |
| 1962 | − ), | |
| 1963 | − ).pipe( | |
| 1964 | − Effect.withSpan("mcp.host.register_tool", { | |
| 1965 | − attributes: { "mcp.tool.name": "skills" }, | |
| 1966 | − }), | |
| 1967 | − ); | |
| 2088 | + }, | |
| 2089 | + ({ name }, extra) => | |
| 2090 | + runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog)), extra), | |
| 2091 | + ), | |
| 2092 | + ).pipe( | |
| 2093 | + Effect.withSpan("mcp.host.register_tool", { | |
| 2094 | + attributes: { "mcp.tool.name": "skills" }, | |
| 2095 | + }), | |
| 2096 | + ); | |
| 1968 | 2097 | |
| 1969 | 2098 | if (!passthrough) |
| 1970 | 2099 | yield* Effect.sync(() => { |
| @@ -313,7 +313,7 @@ export function McpInstallCard(props: { className?: string }) { · 312 unmodified lines | ||
| 313 | 313 | <div className="text-xs font-medium text-foreground">Search and invoke</div> |
| 314 | 314 | <div className="mt-0.5 text-xs leading-5 text-muted-foreground"> |
| 315 | 315 | {toolMode === "passthrough" |
| 316 | − ? "Find connected tools with search, then call them with invoke. Your client handles approval for each call." | |
| 316 | + ? "Discover connected accounts with integrations and read the guide with skills. Find tools with search, then call them with invoke. Your client handles approval." | |
| 317 | 317 | : "Disabled: agents write code against your tools through one execute tool."} |
| 318 | 318 | </div> |
| 319 | 319 | </div> |