Unified · Split
| @@ -48,7 +48,7 @@ Open `http://127.0.0.1:4788`, go to **Add Source**, paste a URL, and Executor wi · 47 unmodified lines | |||
| 48 | ### Via the CLI | 48 | ### Via the CLI |
| 49 | 49 | ||
| 50 | ```bash | 50 | ```bash |
| 51 | executor call openapi addSource '{ | 51 | executor call executor openapi addSource '{ |
| 52 | "spec": "https://petstore3.swagger.io/api/v3/openapi.json", | 52 | "spec": "https://petstore3.swagger.io/api/v3/openapi.json", |
| 53 | "namespace": "petstore", | 53 | "namespace": "petstore", |
| 54 | "baseUrl": "https://petstore3.swagger.io/api/v3" | 54 | "baseUrl": "https://petstore3.swagger.io/api/v3" |
| @@ -14,7 +14,7 @@ · 13 unmodified lines | |||
| 14 | // | 14 | // |
| 15 | // Elicitation coverage uses the openapi plugin: a tiny Effect HttpApi | 15 | // Elicitation coverage uses the openapi plugin: a tiny Effect HttpApi |
| 16 | // upstream is stood up in-process, its generated spec is handed to | 16 | // upstream is stood up in-process, its generated spec is handed to |
| 17 | // `tools.openapi.addSource`, and the cloud engine invokes a POST operation. | 17 | // `tools.executor.openapi.addSource`, and the cloud engine invokes a POST operation. |
| 18 | // `requiresApproval: true` fires the executor's approval elicitation, which | 18 | // `requiresApproval: true` fires the executor's approval elicitation, which |
| 19 | // round-trips back to the SDK Client's `ElicitRequestSchema` handler. | 19 | // round-trips back to the SDK Client's `ElicitRequestSchema` handler. |
| 20 | // --------------------------------------------------------------------------- | 20 | // --------------------------------------------------------------------------- |
| @@ -455,7 +455,7 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) · 434 unmodified lines | |||
| 455 | method: "POST", | 455 | method: "POST", |
| 456 | headers: { | 456 | headers: { |
| 457 | accept: "application/json, text/event-stream", | 457 | accept: "application/json, text/event-stream", |
| 458 | authorization: "Bearer bogus", | 458 | authorization: "Bearer bogus.bogus.bogus", |
| 459 | "content-type": "application/json", | 459 | "content-type": "application/json", |
| 460 | }, | 460 | }, |
| 461 | body: JSON.stringify({ | 461 | body: JSON.stringify({ |
| @@ -794,7 +794,7 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) · 332 unmodified lines | |||
| 794 | // `HttpApiGroup` name ("approve") becomes part of the sandbox path, | 794 | // `HttpApiGroup` name ("approve") becomes part of the sandbox path, |
| 795 | // so the invocation reads `tools.approveapi.approve.approveThing`. | 795 | // so the invocation reads `tools.approveapi.approve.approveThing`. |
| 796 | const code = [ | 796 | const code = [ |
| 797 | `await tools.openapi.addSource({ scope: ${JSON.stringify(orgId)}, spec: ${JSON.stringify(specJson)}, namespace: "approveapi" });`, | 797 | `await tools.executor.openapi.addSource({ scope: ${JSON.stringify(orgId)}, spec: ${JSON.stringify(specJson)}, namespace: "approveapi" });`, |
| 798 | `return await tools.approveapi.approve.approveThing({});`, | 798 | `return await tools.approveapi.approve.approveThing({});`, |
| 799 | ].join("\n"); | 799 | ].join("\n"); |
| 800 | const result = yield* Effect.promise(() => | 800 | const result = yield* Effect.promise(() => |
| @@ -15,7 +15,7 @@ · 14 unmodified lines | |||
| 15 | // before prod does. | 15 | // before prod does. |
| 16 | 16 | ||
| 17 | import { describe, expect, it } from "@effect/vitest"; | 17 | import { describe, expect, it } from "@effect/vitest"; |
| 18 | import { Effect } from "effect"; | 18 | import { Effect, Schema } from "effect"; |
| 19 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 19 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 20 | import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; | 20 | import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; |
| 21 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; | 21 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; |
| @@ -44,11 +44,9 @@ import { DbService } from "./services/db"; · 22 unmodified lines | |||
| 44 | // eliciting test drive the real engine + sandbox rather than a stub engine. | 44 | // eliciting test drive the real engine + sandbox rather than a stub engine. |
| 45 | // --------------------------------------------------------------------------- | 45 | // --------------------------------------------------------------------------- |
| 46 | 46 | ||
| 47 | const EMPTY_INPUT_SCHEMA = { | 47 | const EMPTY_INPUT_SCHEMA = Schema.toStandardSchemaV1( |
| 48 | type: "object", | 48 | Schema.toStandardJSONSchemaV1(Schema.Struct({})), |
| 49 | properties: {}, | 49 | ); |
| 50 | additionalProperties: false, | ||
| 51 | } as const; | ||
| 52 | 50 | ||
| 53 | const elicitingTestPlugin = definePlugin(() => ({ | 51 | const elicitingTestPlugin = definePlugin(() => ({ |
| 54 | id: "eliciting-test" as const, | 52 | id: "eliciting-test" as const, |
| @@ -676,13 +676,13 @@ describe("sources api (HTTP)", () => { · 675 unmodified lines | |||
| 676 | it.effect("sources.remove on a static source is rejected", () => | 676 | it.effect("sources.remove on a static source is rejected", () => |
| 677 | Effect.gen(function* () { | 677 | Effect.gen(function* () { |
| 678 | // `canRemove: false` is reserved for static (plugin-declared) | 678 | // `canRemove: false` is reserved for static (plugin-declared) |
| 679 | // sources. The openapi plugin declares one with id "openapi" | 679 | // sources. Plugin-owned executor tools are mounted under the |
| 680 | // for its control tools. | 680 | // built-in executor source. |
| 681 | const org = `org_${crypto.randomUUID()}`; | 681 | const org = `org_${crypto.randomUUID()}`; |
| 682 | 682 | ||
| 683 | const result = yield* asOrg(org, (client) => | 683 | const result = yield* asOrg(org, (client) => |
| 684 | client.sources | 684 | client.sources |
| 685 | .remove({ params: { scopeId: ScopeId.make(org), sourceId: "openapi" } }) | 685 | .remove({ params: { scopeId: ScopeId.make(org), sourceId: "executor" } }) |
| 686 | .pipe(Effect.result), | 686 | .pipe(Effect.result), |
| 687 | ); | 687 | ); |
| 688 | expect(Result.isFailure(result)).toBe(true); | 688 | expect(Result.isFailure(result)).toBe(true); |
| @@ -298,7 +298,7 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { · 297 unmodified lines | |||
| 298 | : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", | 298 | : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", |
| 299 | ].join(" ")} | 299 | ].join(" ")} |
| 300 | > | 300 | > |
| 301 | <SourceFavicon url={s.url} /> | 301 | <SourceFavicon sourceId={s.id} url={s.url} /> |
| 302 | <span className="flex-1 truncate">{s.name}</span> | 302 | <span className="flex-1 truncate">{s.name}</span> |
| 303 | <span className="rounded bg-secondary/50 px-1 py-px text-xs font-medium text-muted-foreground"> | 303 | <span className="rounded bg-secondary/50 px-1 py-px text-xs font-medium text-muted-foreground"> |
| 304 | {s.kind} | 304 | {s.kind} |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { readFileSync } from "node:fs"; | 1 | import { readFileSync } from "node:fs"; |
| 2 | import { resolve } from "node:path"; | ||
| 2 | import { Readable } from "node:stream"; | 3 | import { Readable } from "node:stream"; |
| 3 | import { defineConfig, type Plugin } from "vite"; | 4 | import { defineConfig, type Plugin } from "vite"; |
| 4 | import react from "@vitejs/plugin-react"; | 5 | import react from "@vitejs/plugin-react"; |
| @@ -38,6 +39,11 @@ function executorApiPlugin(): Plugin { · 33 unmodified lines | |||
| 38 | return { | 39 | return { |
| 39 | name: "executor-api", | 40 | name: "executor-api", |
| 40 | configureServer(server) { | 41 | configureServer(server) { |
| 42 | server.watcher.on("change", (path) => { | ||
| 43 | if (path.includes("/src/server/") || path.endsWith("/executor.config.ts")) { | ||
| 44 | handlers = null; | ||
| 45 | } | ||
| 46 | }); | ||
| 41 | server.middlewares.use(async (req, res, next) => { | 47 | server.middlewares.use(async (req, res, next) => { |
| 42 | const rawUrl = req.url ?? "/"; | 48 | const rawUrl = req.url ?? "/"; |
| 43 | const isApi = rawUrl.startsWith("/api/") || rawUrl === "/api"; | 49 | const isApi = rawUrl.startsWith("/api/") || rawUrl === "/api"; |
| @@ -121,6 +127,9 @@ export default defineConfig({ · 77 unmodified lines | |||
| 121 | usePolling: true, | 127 | usePolling: true, |
| 122 | interval: 200, | 128 | interval: 200, |
| 123 | }, | 129 | }, |
| 130 | fs: { | ||
| 131 | allow: [resolve(import.meta.dirname, "../..")], | ||
| 132 | }, | ||
| 124 | }, | 133 | }, |
| 125 | plugins: [ | 134 | plugins: [ |
| 126 | tailwindcss(), | 135 | tailwindcss(), |
| @@ -316,6 +316,7 @@ · 315 unmodified lines | |||
| 316 | "version": "0.2.0", | 316 | "version": "0.2.0", |
| 317 | "dependencies": { | 317 | "dependencies": { |
| 318 | "@executor-js/storage-core": "workspace:*", | 318 | "@executor-js/storage-core": "workspace:*", |
| 319 | "@standard-schema/spec": "^1.1.0", | ||
| 319 | "effect": "catalog:", | 320 | "effect": "catalog:", |
| 320 | "fractional-indexing": "^3.2.0", | 321 | "fractional-indexing": "^3.2.0", |
| 321 | "oauth4webapi": "^3.8.5", | 322 | "oauth4webapi": "^3.8.5", |
| @@ -90,7 +90,7 @@ Pattern used in `mcp-miniflare.e2e.node.test.ts`: · 89 unmodified lines | |||
| 90 | 5. SDK Client advertises `capabilities: { elicitation: { form: {} } }` and | 90 | 5. SDK Client advertises `capabilities: { elicitation: { form: {} } }` and |
| 91 | registers `client.setRequestHandler(ElicitRequestSchema, ...)` that returns | 91 | registers `client.setRequestHandler(ElicitRequestSchema, ...)` that returns |
| 92 | `{ action: "accept", content: {} }`. | 92 | `{ action: "accept", content: {} }`. |
| 93 | 6. Call `execute` with code that (a) calls `tools.openapi.addSource({ spec, | 93 | 6. Call `execute` with code that (a) calls `tools.executor.openapi.addSource({ spec, |
| 94 | namespace })`, (b) invokes the POST operation. | 94 | namespace })`, (b) invokes the POST operation. |
| 95 | 95 | ||
| 96 | **Tool id format inside the `execute` sandbox.** | 96 | **Tool id format inside the `execute` sandbox.** |
| @@ -1,15 +1,13 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect } from "effect"; | 2 | import { Effect, Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { createExecutor, definePlugin, makeTestConfig } from "@executor-js/sdk"; | 4 | import { createExecutor, definePlugin, makeTestConfig } from "@executor-js/sdk"; |
| 5 | 5 | ||
| 6 | import { buildExecuteDescription } from "./description"; | 6 | import { buildExecuteDescription } from "./description"; |
| 7 | 7 | ||
| 8 | const EmptyInputSchema = { | 8 | const EmptyInputSchema = Schema.toStandardSchemaV1( |
| 9 | type: "object", | 9 | Schema.toStandardJSONSchemaV1(Schema.Struct({})), |
| 10 | properties: {}, | 10 | ); |
| 11 | additionalProperties: false, | ||
| 12 | } as const; | ||
| 13 | 11 | ||
| 14 | // Two plugins registering static sources whose ids are distinct from their | 12 | // Two plugins registering static sources whose ids are distinct from their |
| 15 | // pluginIds/names. If `buildExecuteDescription` ever renders the wrong field | 13 | // pluginIds/names. If `buildExecuteDescription` ever renders the wrong field |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect, Fiber } from "effect"; | 2 | import { Effect, Fiber, Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | ElicitationResponse, | 5 | ElicitationResponse, |
| @@ -14,30 +14,17 @@ import { describeTool, makeExecutorToolInvoker, searchTools } from "./tool-invok · 8 unmodified lines | |||
| 14 | 14 | ||
| 15 | const codeExecutor = makeQuickJsExecutor(); | 15 | const codeExecutor = makeQuickJsExecutor(); |
| 16 | 16 | ||
| 17 | const RepoInputSchema = { | 17 | const RepoInputSchema = Schema.toStandardSchemaV1( |
| 18 | type: "object", | 18 | Schema.toStandardJSONSchemaV1(Schema.Struct({ owner: Schema.String, repo: Schema.String })), |
| 19 | properties: { | 19 | ); |
| 20 | owner: { type: "string" }, | 20 | |
| 21 | repo: { type: "string" }, | 21 | const ContactInputSchema = Schema.toStandardSchemaV1( |
| 22 | }, | 22 | Schema.toStandardJSONSchemaV1(Schema.Struct({ email: Schema.String })), |
| 23 | required: ["owner", "repo"], | 23 | ); |
| 24 | additionalProperties: false, | 24 | |
| 25 | } as const; | 25 | const EmptyInputSchema = Schema.toStandardSchemaV1( |
| 26 | 26 | Schema.toStandardJSONSchemaV1(Schema.Struct({})), | |
| 27 | const ContactInputSchema = { | 27 | ); |
| 28 | type: "object", | ||
| 29 | properties: { | ||
| 30 | email: { type: "string" }, | ||
| 31 | }, | ||
| 32 | required: ["email"], | ||
| 33 | additionalProperties: false, | ||
| 34 | } as const; | ||
| 35 | |||
| 36 | const EmptyInputSchema = { | ||
| 37 | type: "object", | ||
| 38 | properties: {}, | ||
| 39 | additionalProperties: false, | ||
| 40 | } as const; | ||
| 41 | 28 | ||
| 42 | const acceptAll = () => Effect.succeed(new ElicitationResponse({ action: "accept" })); | 29 | const acceptAll = () => Effect.succeed(new ElicitationResponse({ action: "accept" })); |
| 43 | 30 | ||
| @@ -59,6 +59,7 @@ · 58 unmodified lines | |||
| 59 | }, | 59 | }, |
| 60 | "dependencies": { | 60 | "dependencies": { |
| 61 | "@executor-js/storage-core": "workspace:*", | 61 | "@executor-js/storage-core": "workspace:*", |
| 62 | "@standard-schema/spec": "^1.1.0", | ||
| 62 | "effect": "catalog:", | 63 | "effect": "catalog:", |
| 63 | "fractional-indexing": "^3.2.0", | 64 | "fractional-indexing": "^3.2.0", |
| 64 | "oauth4webapi": "^3.8.5" | 65 | "oauth4webapi": "^3.8.5" |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Data, Effect, Exit, Predicate, Result } from "effect"; | 2 | import { Data, Effect, Exit, Predicate, Result, Schema } from "effect"; |
| 3 | import { FetchHttpClient } from "effect/unstable/http"; | 3 | import { FetchHttpClient } from "effect/unstable/http"; |
| 4 | 4 | ||
| 5 | import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory"; | 5 | import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory"; |
| @@ -9,7 +9,7 @@ import { makeInMemoryBlobStore } from "./blob"; · 3 unmodified lines | |||
| 9 | import { CreateConnectionInput, TokenMaterial } from "./connections"; | 9 | import { CreateConnectionInput, TokenMaterial } from "./connections"; |
| 10 | import { collectSchemas, createExecutor } from "./executor"; | 10 | import { collectSchemas, createExecutor } from "./executor"; |
| 11 | import { ElicitationResponse, FormElicitation, UrlElicitation } from "./elicitation"; | 11 | import { ElicitationResponse, FormElicitation, UrlElicitation } from "./elicitation"; |
| 12 | import { defineSchema, definePlugin } from "./plugin"; | 12 | import { defineSchema, definePlugin, tool } from "./plugin"; |
| 13 | import { RemoveSecretInput, SetSecretInput } from "./secrets"; | 13 | import { RemoveSecretInput, SetSecretInput } from "./secrets"; |
| 14 | import { makeTestConfig } from "./testing"; | 14 | import { makeTestConfig } from "./testing"; |
| 15 | import type { SecretProvider } from "./secrets"; | 15 | import type { SecretProvider } from "./secrets"; |
| @@ -45,7 +45,7 @@ const recordFindMany = (adapter: DBAdapter, calls: FindManyCall[]): DBAdapter => · 29 unmodified lines | |||
| 45 | }); | 45 | }); |
| 46 | 46 | ||
| 47 | // --------------------------------------------------------------------------- | 47 | // --------------------------------------------------------------------------- |
| 48 | // Tiny test plugin — declares a static source with two control tools, a | 48 | // Tiny test plugin — declares a static source with two built-in tools, a |
| 49 | // plugin schema for a per-row key/value table, and a dynamic invokeTool | 49 | // plugin schema for a per-row key/value table, and a dynamic invokeTool |
| 50 | // handler. Exercises everything createExecutor has to wire up. | 50 | // handler. Exercises everything createExecutor has to wire up. |
| 51 | // --------------------------------------------------------------------------- | 51 | // --------------------------------------------------------------------------- |
| @@ -837,21 +837,19 @@ describe("createExecutor", () => { · 785 unmodified lines | |||
| 837 | kind: "control", | 837 | kind: "control", |
| 838 | name: "Preview Ctl", | 838 | name: "Preview Ctl", |
| 839 | tools: [ | 839 | tools: [ |
| 840 | { | 840 | tool({ |
| 841 | name: "createContact", | 841 | name: "createContact", |
| 842 | description: "create", | 842 | description: "create", |
| 843 | inputSchema: { | 843 | inputSchema: Schema.toStandardSchemaV1( |
| 844 | type: "object", | 844 | Schema.toStandardJSONSchemaV1( |
| 845 | properties: { | 845 | Schema.Struct({ name: Schema.String, age: Schema.Finite }), |
| 846 | name: { type: "string" }, | 846 | ), |
| 847 | age: { type: "number" }, | 847 | ), |
| 848 | }, | 848 | outputSchema: Schema.toStandardSchemaV1( |
| 849 | required: ["name", "age"], | 849 | Schema.toStandardJSONSchemaV1(Schema.String), |
| 850 | additionalProperties: false, | 850 | ), |
| 851 | }, | 851 | execute: (input) => Effect.succeed(input), |
| 852 | outputSchema: { type: "string" }, | 852 | }), |
| 853 | handler: ({ args }) => Effect.succeed(args), | ||
| 854 | }, | ||
| 855 | ], | 853 | ], |
| 856 | }, | 854 | }, |
| 857 | ], | 855 | ], |
| @@ -1147,12 +1145,12 @@ const tenantPlugin = definePlugin(() => ({ · 289 unmodified lines | |||
| 1147 | kind: "control" as const, | 1145 | kind: "control" as const, |
| 1148 | name: "Tenant Ctl", | 1146 | name: "Tenant Ctl", |
| 1149 | tools: [ | 1147 | tools: [ |
| 1150 | { | 1148 | tool({ |
| 1151 | name: "noop", | 1149 | name: "noop", |
| 1152 | description: "noop", | 1150 | description: "noop", |
| 1153 | inputSchema: { type: "object", additionalProperties: false }, | 1151 | inputSchema: Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(Schema.Struct({}))), |
| 1154 | handler: () => Effect.succeed(null), | 1152 | execute: () => Effect.succeed(null), |
| 1155 | }, | 1153 | }), |
| 1156 | ], | 1154 | ], |
| 1157 | }, | 1155 | }, |
| 1158 | ], | 1156 | ], |
| @@ -103,6 +103,7 @@ import type { · 102 unmodified lines | |||
| 103 | PluginExtensions, | 103 | PluginExtensions, |
| 104 | StaticSourceDecl, | 104 | StaticSourceDecl, |
| 105 | StaticToolDecl, | 105 | StaticToolDecl, |
| 106 | StaticToolSchema, | ||
| 106 | StorageDeps, | 107 | StorageDeps, |
| 107 | } from "./plugin"; | 108 | } from "./plugin"; |
| 108 | import type { Scope } from "./scope"; | 109 | import type { Scope } from "./scope"; |
| @@ -457,11 +458,32 @@ const staticDeclToTool = ( · 348 unmodified lines | |||
| 457 | pluginId, | 458 | pluginId, |
| 458 | name: tool.name, | 459 | name: tool.name, |
| 459 | description: tool.description, | 460 | description: tool.description, |
| 460 | inputSchema: tool.inputSchema, | 461 | inputSchema: toToolJsonSchema(tool.inputSchema), |
| 461 | outputSchema: tool.outputSchema, | 462 | outputSchema: toToolJsonSchema(tool.outputSchema, "output"), |
| 462 | annotations: tool.annotations, | 463 | annotations: tool.annotations, |
| 463 | }); | 464 | }); |
| 464 | 465 | ||
| 466 | const toToolJsonSchema = ( | ||
| 467 | schema: StaticToolSchema | undefined, | ||
| 468 | direction: "input" | "output" = "input", | ||
| 469 | ): unknown => { | ||
| 470 | if (schema == null) return undefined; | ||
| 471 | return schema["~standard"].jsonSchema[direction]({ | ||
| 472 | target: "draft-2020-12", | ||
| 473 | }); | ||
| 474 | }; | ||
| 475 | |||
| 476 | const EXECUTOR_SOURCE_ID = "executor"; | ||
| 477 | const EXECUTOR_SOURCE: StaticSourceDecl = { | ||
| 478 | id: EXECUTOR_SOURCE_ID, | ||
| 479 | kind: "built-in", | ||
| 480 | name: "Executor", | ||
| 481 | canRemove: false, | ||
| 482 | canRefresh: false, | ||
| 483 | canEdit: false, | ||
| 484 | tools: [], | ||
| 485 | }; | ||
| 486 | |||
| 465 | // --------------------------------------------------------------------------- | 487 | // --------------------------------------------------------------------------- |
| 466 | // Dynamic-row writers — used by ctx.core.sources.register. Static sources | 488 | // Dynamic-row writers — used by ctx.core.sources.register. Static sources |
| 467 | // never touch these functions. | 489 | // never touch these functions. |
| @@ -2719,18 +2741,40 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 2251 unmodified lines | |||
| 2719 | } | 2741 | } |
| 2720 | 2742 | ||
| 2721 | // Resolve static declarations to the in-memory pools. NO DB WRITES. | 2743 | // Resolve static declarations to the in-memory pools. NO DB WRITES. |
| 2744 | // Plugin-owned executor tools are intentionally mounted under the | ||
| 2745 | // single `executor` namespace so source inventory is about configured | ||
| 2746 | // integrations, not plugin management surfaces: | ||
| 2747 | // openapi.addSource -> executor.openapi.addSource | ||
| 2722 | const decls = plugin.staticSources ? plugin.staticSources(extension) : []; | 2748 | const decls = plugin.staticSources ? plugin.staticSources(extension) : []; |
| 2723 | for (const source of decls) { | 2749 | for (const source of decls) { |
| 2724 | if (staticSources.has(source.id)) { | 2750 | const mountUnderExecutor = source.kind === "executor" && source.id === plugin.id; |
| 2725 | return yield* new StorageError({ | 2751 | const mountedSource = mountUnderExecutor ? EXECUTOR_SOURCE : source; |
| 2726 | message: `Duplicate static source id: ${source.id} (plugin ${plugin.id})`, | 2752 | |
| 2727 | cause: undefined, | 2753 | if (mountUnderExecutor) { |
| 2728 | }); | 2754 | if (!staticSources.has(EXECUTOR_SOURCE_ID)) { |
| 2755 | staticSources.set(EXECUTOR_SOURCE_ID, { | ||
| 2756 | source: EXECUTOR_SOURCE, | ||
| 2757 | pluginId: EXECUTOR_SOURCE_ID, | ||
| 2758 | }); | ||
| 2759 | } | ||
| 2760 | } else { | ||
| 2761 | if (staticSources.has(source.id)) { | ||
| 2762 | return yield* new StorageError({ | ||
| 2763 | message: `Duplicate static source id: ${source.id} (plugin ${plugin.id})`, | ||
| 2764 | cause: undefined, | ||
| 2765 | }); | ||
| 2766 | } | ||
| 2767 | staticSources.set(source.id, { source, pluginId: plugin.id }); | ||
| 2729 | } | 2768 | } |
| 2730 | staticSources.set(source.id, { source, pluginId: plugin.id }); | ||
| 2731 | 2769 | ||
| 2732 | for (const tool of source.tools) { | 2770 | for (const tool of source.tools) { |
| 2733 | const fqid = `${source.id}.${tool.name}`; | 2771 | const mountedTool = mountUnderExecutor |
| 2772 | ? { | ||
| 2773 | ...tool, | ||
| 2774 | name: `${plugin.id}.${tool.name}`, | ||
| 2775 | } | ||
| 2776 | : tool; | ||
| 2777 | const fqid = `${mountedSource.id}.${mountedTool.name}`; | ||
| 2734 | if (staticTools.has(fqid)) { | 2778 | if (staticTools.has(fqid)) { |
| 2735 | return yield* new StorageError({ | 2779 | return yield* new StorageError({ |
| 2736 | message: `Duplicate static tool id: ${fqid} (plugin ${plugin.id})`, | 2780 | message: `Duplicate static tool id: ${fqid} (plugin ${plugin.id})`, |
| @@ -2738,8 +2782,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 1 unmodified line | |||
| 2738 | }); | 2782 | }); |
| 2739 | } | 2783 | } |
| 2740 | staticTools.set(fqid, { | 2784 | staticTools.set(fqid, { |
| 2741 | source, | 2785 | source: mountedSource, |
| 2742 | tool, | 2786 | tool: mountedTool, |
| 2743 | pluginId: plugin.id, | 2787 | pluginId: plugin.id, |
| 2744 | ctx, | 2788 | ctx, |
| 2745 | }); | 2789 | }); |
| @@ -3028,8 +3072,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 282 unmodified lines | |||
| 3028 | name: staticEntry.tool.name, | 3072 | name: staticEntry.tool.name, |
| 3029 | description: staticEntry.tool.description, | 3073 | description: staticEntry.tool.description, |
| 3030 | sourceId: undefined, | 3074 | sourceId: undefined, |
| 3031 | rawInput: staticEntry.tool.inputSchema, | 3075 | rawInput: toToolJsonSchema(staticEntry.tool.inputSchema), |
| 3032 | rawOutput: staticEntry.tool.outputSchema, | 3076 | rawOutput: toToolJsonSchema(staticEntry.tool.outputSchema, "output"), |
| 3033 | }); | 3077 | }); |
| 3034 | } | 3078 | } |
| 3035 | // Innermost-wins lookup: the scope-wrapped adapter returns rows | 3079 | // Innermost-wins lookup: the scope-wrapped adapter returns rows |
| @@ -280,13 +280,16 @@ export { · 279 unmodified lines | |||
| 280 | type StorageDeps, | 280 | type StorageDeps, |
| 281 | type StaticSourceDecl, | 281 | type StaticSourceDecl, |
| 282 | type StaticToolDecl, | 282 | type StaticToolDecl, |
| 283 | type StaticToolExecuteContext, | ||
| 283 | type StaticToolHandlerInput, | 284 | type StaticToolHandlerInput, |
| 285 | type StaticToolInput, | ||
| 284 | type InvokeToolInput, | 286 | type InvokeToolInput, |
| 285 | type SourceLifecycleInput, | 287 | type SourceLifecycleInput, |
| 286 | type SecretListEntry, | 288 | type SecretListEntry, |
| 287 | type Elicit, | 289 | type Elicit, |
| 288 | definePlugin, | 290 | definePlugin, |
| 289 | defineSchema, | 291 | defineSchema, |
| 292 | tool, | ||
| 290 | } from "./plugin"; | 293 | } from "./plugin"; |
| 291 | 294 | ||
| 292 | // Executor | 295 | // Executor |
| @@ -1,6 +1,8 @@ | |||
| 1 | import type { Context, Effect, Layer } from "effect"; | 1 | import { Effect } from "effect"; |
| 2 | import type { Context, Layer } from "effect"; | ||
| 2 | import type { HttpClient } from "effect/unstable/http"; | 3 | import type { HttpClient } from "effect/unstable/http"; |
| 3 | import type { HttpApiGroup } from "effect/unstable/httpapi"; | 4 | import type { HttpApiGroup } from "effect/unstable/httpapi"; |
| 5 | import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"; | ||
| 4 | import type { DBSchema, StorageFailure } from "@executor-js/storage-core"; | 6 | import type { DBSchema, StorageFailure } from "@executor-js/storage-core"; |
| 5 | 7 | ||
| 6 | import type { PluginBlobStore } from "./blob"; | 8 | import type { PluginBlobStore } from "./blob"; |
| @@ -249,11 +251,21 @@ export interface StaticToolHandlerInput<TStore = unknown> { · 242 unmodified lines | |||
| 249 | readonly elicit: Elicit; | 251 | readonly elicit: Elicit; |
| 250 | } | 252 | } |
| 251 | 253 | ||
| 254 | export interface StaticToolExecuteContext<TStore = unknown> { | ||
| 255 | readonly ctx: PluginCtx<TStore>; | ||
| 256 | /** Suspend the fiber to request user input. The handler passed to | ||
| 257 | * `createExecutor({ onElicitation })` is called. */ | ||
| 258 | readonly elicit: Elicit; | ||
| 259 | } | ||
| 260 | |||
| 261 | export type StaticToolSchema<Input = unknown, Output = Input> = StandardSchemaV1<Input, Output> & | ||
| 262 | StandardJSONSchemaV1<Input, Output>; | ||
| 263 | |||
| 252 | export interface StaticToolDecl<TStore = unknown> { | 264 | export interface StaticToolDecl<TStore = unknown> { |
| 253 | readonly name: string; | 265 | readonly name: string; |
| 254 | readonly description: string; | 266 | readonly description: string; |
| 255 | readonly inputSchema?: unknown; | 267 | readonly inputSchema?: StaticToolSchema; |
| 256 | readonly outputSchema?: unknown; | 268 | readonly outputSchema?: StaticToolSchema; |
| 257 | /** Default-policy annotations — `requiresApproval`, `approvalDescription`, | 269 | /** Default-policy annotations — `requiresApproval`, `approvalDescription`, |
| 258 | * `mayElicit`. Enforced by the executor before the handler runs. | 270 | * `mayElicit`. Enforced by the executor before the handler runs. |
| 259 | * Inline because static tools have no plugin storage to resolve from; | 271 | * Inline because static tools have no plugin storage to resolve from; |
| @@ -262,13 +274,68 @@ export interface StaticToolDecl<TStore = unknown> { · 2 unmodified lines | |||
| 262 | readonly handler: (input: StaticToolHandlerInput<TStore>) => Effect.Effect<unknown, unknown>; | 274 | readonly handler: (input: StaticToolHandlerInput<TStore>) => Effect.Effect<unknown, unknown>; |
| 263 | } | 275 | } |
| 264 | 276 | ||
| 277 | const decodeStaticToolArgs = ( | ||
| 278 | schema: StaticToolSchema | undefined, | ||
| 279 | args: unknown, | ||
| 280 | ): Effect.Effect<unknown, unknown> => { | ||
| 281 | if (schema == null) return Effect.succeed(args); | ||
| 282 | return Effect.promise(() => Promise.resolve(schema["~standard"].validate(args))).pipe( | ||
| 283 | Effect.flatMap((result) => | ||
| 284 | "value" in result ? Effect.succeed(result.value) : Effect.fail(result), | ||
| 285 | ), | ||
| 286 | ); | ||
| 287 | }; | ||
| 288 | |||
| 289 | export interface StaticToolInput< | ||
| 290 | TStore = unknown, | ||
| 291 | TInputSchema extends StaticToolSchema | undefined = StaticToolSchema | undefined, | ||
| 292 | > { | ||
| 293 | readonly name: string; | ||
| 294 | readonly description: string; | ||
| 295 | readonly inputSchema?: TInputSchema; | ||
| 296 | readonly outputSchema?: StaticToolSchema; | ||
| 297 | /** Default-policy annotations — `requiresApproval`, `approvalDescription`, | ||
| 298 | * `mayElicit`. Enforced by the executor before the handler runs. */ | ||
| 299 | readonly annotations?: ToolAnnotations; | ||
| 300 | readonly execute: ( | ||
| 301 | args: TInputSchema extends StaticToolSchema | ||
| 302 | ? StandardSchemaV1.InferOutput<TInputSchema> | ||
| 303 | : unknown, | ||
| 304 | context: StaticToolExecuteContext<TStore>, | ||
| 305 | ) => Effect.Effect<unknown, unknown>; | ||
| 306 | } | ||
| 307 | |||
| 308 | export const tool = < | ||
| 309 | TStore = unknown, | ||
| 310 | TInputSchema extends StaticToolSchema | undefined = StaticToolSchema | undefined, | ||
| 311 | >( | ||
| 312 | input: StaticToolInput<TStore, TInputSchema>, | ||
| 313 | ): StaticToolDecl<TStore> => ({ | ||
| 314 | name: input.name, | ||
| 315 | description: input.description, | ||
| 316 | inputSchema: input.inputSchema, | ||
| 317 | outputSchema: input.outputSchema, | ||
| 318 | annotations: input.annotations, | ||
| 319 | handler: ({ args, ctx, elicit }) => | ||
| 320 | decodeStaticToolArgs(input.inputSchema, args).pipe( | ||
| 321 | Effect.flatMap((decoded) => | ||
| 322 | input.execute( | ||
| 323 | decoded as TInputSchema extends StaticToolSchema | ||
| 324 | ? StandardSchemaV1.InferOutput<TInputSchema> | ||
| 325 | : unknown, | ||
| 326 | { ctx, elicit }, | ||
| 327 | ), | ||
| 328 | ), | ||
| 329 | ), | ||
| 330 | }); | ||
| 331 | |||
| 265 | export interface StaticSourceDecl<TStore = unknown> { | 332 | export interface StaticSourceDecl<TStore = unknown> { |
| 266 | readonly id: string; | 333 | readonly id: string; |
| 267 | readonly kind: string; | 334 | readonly kind: string; |
| 268 | readonly name: string; | 335 | readonly name: string; |
| 269 | readonly url?: string; | 336 | readonly url?: string; |
| 270 | /** Static sources default to `canRemove: false` — they represent | 337 | /** Static sources default to `canRemove: false` because they are |
| 271 | * plugin-provided control surfaces and shouldn't be user-removable. | 338 | * plugin-provided surfaces and shouldn't usually be user-removable. |
| 272 | * Override only if you really want that. */ | 339 | * Override only if you really want that. */ |
| 273 | readonly canRemove?: boolean; | 340 | readonly canRemove?: boolean; |
| 274 | readonly canRefresh?: boolean; | 341 | readonly canRefresh?: boolean; |
| @@ -1,8 +1,8 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { createExecutor } from "./promise"; | 3 | import { createExecutor } from "./promise"; |
| 4 | import { definePlugin, defineSchema } from "./plugin"; | 4 | import { definePlugin, defineSchema, tool } from "./plugin"; |
| 5 | import { Effect } from "effect"; | 5 | import { Effect, Schema } from "effect"; |
| 6 | 6 | ||
| 7 | // A minimal static-tool plugin built on the Effect surface, consumed | 7 | // A minimal static-tool plugin built on the Effect surface, consumed |
| 8 | // through the Promise façade. Exercises the proxy's ability to promisify | 8 | // through the Promise façade. Exercises the proxy's ability to promisify |
| @@ -17,18 +17,14 @@ const echoPlugin = definePlugin(() => ({ · 8 unmodified lines | |||
| 17 | kind: "control" as const, | 17 | kind: "control" as const, |
| 18 | name: "Echo Ctl", | 18 | name: "Echo Ctl", |
| 19 | tools: [ | 19 | tools: [ |
| 20 | { | 20 | tool({ |
| 21 | name: "say", | 21 | name: "say", |
| 22 | description: "Echo the input", | 22 | description: "Echo the input", |
| 23 | inputSchema: { | 23 | inputSchema: Schema.toStandardSchemaV1( |
| 24 | type: "object", | 24 | Schema.toStandardJSONSchemaV1(Schema.Struct({ message: Schema.String })), |
| 25 | properties: { message: { type: "string" } }, | 25 | ), |
| 26 | required: ["message"], | 26 | execute: (input) => Effect.succeed(input.message), |
| 27 | additionalProperties: false, | 27 | }), |
| 28 | }, | ||
| 29 | handler: ({ args }: { args: unknown }) => | ||
| 30 | Effect.succeed((args as { message: string }).message), | ||
| 31 | }, | ||
| 32 | ], | 28 | ], |
| 33 | }, | 29 | }, |
| 34 | ], | 30 | ], |
| @@ -80,13 +76,15 @@ describe("promise/createExecutor", () => { · 45 unmodified lines | |||
| 80 | kind: "control" as const, | 76 | kind: "control" as const, |
| 81 | name: "Ap Ctl", | 77 | name: "Ap Ctl", |
| 82 | tools: [ | 78 | tools: [ |
| 83 | { | 79 | tool({ |
| 84 | name: "go", | 80 | name: "go", |
| 85 | description: "Requires approval", | 81 | description: "Requires approval", |
| 86 | annotations: { requiresApproval: true } as const, | 82 | annotations: { requiresApproval: true } as const, |
| 87 | inputSchema: { type: "object", additionalProperties: false }, | 83 | inputSchema: Schema.toStandardSchemaV1( |
| 88 | handler: () => Effect.succeed("ran"), | 84 | Schema.toStandardJSONSchemaV1(Schema.Struct({})), |
| 89 | }, | 85 | ), |
| 86 | execute: () => Effect.succeed("ran"), | ||
| 87 | }), | ||
| 90 | ], | 88 | ], |
| 91 | }, | 89 | }, |
| 92 | ], | 90 | ], |
| @@ -283,8 +283,8 @@ describe("graphqlPlugin", () => { · 282 unmodified lines | |||
| 283 | const ids = tools.map((t) => t.id); | 283 | const ids = tools.map((t) => t.id); |
| 284 | expect(ids).toContain("test_api.query.hello"); | 284 | expect(ids).toContain("test_api.query.hello"); |
| 285 | expect(ids).toContain("test_api.mutation.setGreeting"); | 285 | expect(ids).toContain("test_api.mutation.setGreeting"); |
| 286 | // static control tool also present | 286 | // static executor tool also present under the executor namespace |
| 287 | expect(ids).toContain("graphql.addSource"); | 287 | expect(ids).toContain("executor.graphql.addSource"); |
| 288 | 288 | ||
| 289 | const queryTool = tools.find((t) => t.id === "test_api.query.hello"); | 289 | const queryTool = tools.find((t) => t.id === "test_api.query.hello"); |
| 290 | expect(queryTool?.description).toBe("Say hello"); | 290 | expect(queryTool?.description).toBe("Say hello"); |
| @@ -320,7 +320,7 @@ describe("graphqlPlugin", () => { · 29 unmodified lines | |||
| 320 | }), | 320 | }), |
| 321 | ); | 321 | ); |
| 322 | 322 | ||
| 323 | it.effect("lists sources with the static control source", () => | 323 | it.effect("lists sources with the executor built-in source", () => |
| 324 | Effect.gen(function* () { | 324 | Effect.gen(function* () { |
| 325 | const executor = yield* createExecutor( | 325 | const executor = yield* createExecutor( |
| 326 | makeTestConfig({ plugins: [graphqlPlugin()] as const }), | 326 | makeTestConfig({ plugins: [graphqlPlugin()] as const }), |
| @@ -341,7 +341,8 @@ describe("graphqlPlugin", () => { · 14 unmodified lines | |||
| 341 | expect(dynamic!.canEdit).toBe(true); | 341 | expect(dynamic!.canEdit).toBe(true); |
| 342 | expect(dynamic!.runtime).toBe(false); | 342 | expect(dynamic!.runtime).toBe(false); |
| 343 | 343 | ||
| 344 | const control = sources.find((s) => s.id === "graphql"); | 344 | expect(sources.find((s) => s.id === "graphql")).toBeUndefined(); |
| 345 | const control = sources.find((s) => s.id === "executor"); | ||
| 345 | expect(control).toBeDefined(); | 346 | expect(control).toBeDefined(); |
| 346 | expect(control!.runtime).toBe(true); | 347 | expect(control!.runtime).toBe(true); |
| 347 | }), | 348 | }), |
| @@ -401,7 +402,7 @@ describe("graphqlPlugin", () => { · 53 unmodified lines | |||
| 401 | }), | 402 | }), |
| 402 | ); | 403 | ); |
| 403 | 404 | ||
| 404 | it.effect("static graphql.addSource delegates to extension", () => | 405 | it.effect("static executor.graphql.addSource delegates to extension", () => |
| 405 | Effect.gen(function* () { | 406 | Effect.gen(function* () { |
| 406 | const userScope = ScopeId.make("static-user"); | 407 | const userScope = ScopeId.make("static-user"); |
| 407 | const orgScope = ScopeId.make("static-org"); | 408 | const orgScope = ScopeId.make("static-org"); |
| @@ -416,7 +417,7 @@ describe("graphqlPlugin", () => { · 8 unmodified lines | |||
| 416 | ); | 417 | ); |
| 417 | 418 | ||
| 418 | const result = yield* executor.tools.invoke( | 419 | const result = yield* executor.tools.invoke( |
| 419 | "graphql.addSource", | 420 | "executor.graphql.addSource", |
| 420 | { | 421 | { |
| 421 | scope: String(orgScope), | 422 | scope: String(orgScope), |
| 422 | endpoint: "http://localhost:4000/graphql", | 423 | endpoint: "http://localhost:4000/graphql", |
| @@ -436,6 +437,22 @@ describe("graphqlPlugin", () => { · 13 unmodified lines | |||
| 436 | }), | 437 | }), |
| 437 | ); | 438 | ); |
| 438 | 439 | ||
| 440 | it.effect("describes static addSource parameters from Standard Schema", () => | ||
| 441 | Effect.gen(function* () { | ||
| 442 | const executor = yield* createExecutor(makeTestConfig({ plugins: [graphqlPlugin()] })); | ||
| 443 | |||
| 444 | const schema = yield* executor.tools.schema("executor.graphql.addSource"); | ||
| 445 | |||
| 446 | expect(schema).not.toBeNull(); | ||
| 447 | expect(schema!.inputTypeScript).toContain("scope: string"); | ||
| 448 | expect(schema!.inputTypeScript).toContain("endpoint: string"); | ||
| 449 | expect( | ||
| 450 | (schema!.inputSchema as { properties?: Record<string, unknown> }).properties, | ||
| 451 | ).toHaveProperty("credentialTargetScope"); | ||
| 452 | expect(schema!.inputTypeScript).not.toBe("Record<string, unknown>"); | ||
| 453 | }), | ||
| 454 | ); | ||
| 455 | |||
| 439 | it.effect("requires approval before a runtime-added query sends prior tool output", () => | 456 | it.effect("requires approval before a runtime-added query sends prior tool output", () => |
| 440 | Effect.gen(function* () { | 457 | Effect.gen(function* () { |
| 441 | const server = yield* serveGreetingServer; | 458 | const server = yield* serveGreetingServer; |
| @@ -451,7 +468,7 @@ describe("graphqlPlugin", () => { · 9 unmodified lines | |||
| 451 | expect(trusted).toBe("sample-value"); | 468 | expect(trusted).toBe("sample-value"); |
| 452 | const declined = yield* executor.tools | 469 | const declined = yield* executor.tools |
| 453 | .invoke( | 470 | .invoke( |
| 454 | "graphql.addSource", | 471 | "executor.graphql.addSource", |
| 455 | { | 472 | { |
| 456 | endpoint: server.endpoint, | 473 | endpoint: server.endpoint, |
| 457 | scope: TEST_SCOPE, | 474 | scope: TEST_SCOPE, |
| @@ -478,7 +495,7 @@ describe("graphqlPlugin", () => { · 20 unmodified lines | |||
| 478 | ); | 495 | ); |
| 479 | 496 | ||
| 480 | yield* executor.tools.invoke( | 497 | yield* executor.tools.invoke( |
| 481 | "graphql.addSource", | 498 | "executor.graphql.addSource", |
| 482 | { | 499 | { |
| 483 | endpoint: server.endpoint, | 500 | endpoint: server.endpoint, |
| 484 | scope: TEST_SCOPE, | 501 | scope: TEST_SCOPE, |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | ConfiguredCredentialBinding, | 7 | ConfiguredCredentialBinding, |
| 8 | type CredentialBindingRef, | 8 | type CredentialBindingRef, |
| 9 | definePlugin, | 9 | definePlugin, |
| 10 | tool, | ||
| 10 | ScopeId, | 11 | ScopeId, |
| 11 | SecretId, | 12 | SecretId, |
| 12 | SourceDetectionResult, | 13 | SourceDetectionResult, |
| @@ -108,6 +109,13 @@ const StaticAddSourceInputSchema = Schema.Struct({ · 95 unmodified lines | |||
| 108 | auth: Schema.optional(GraphqlSourceAuthInputSchema), | 109 | auth: Schema.optional(GraphqlSourceAuthInputSchema), |
| 109 | }); | 110 | }); |
| 110 | 111 | ||
| 112 | const StaticAddSourceInputStandardSchema = Schema.toStandardSchemaV1( | ||
| 113 | Schema.toStandardJSONSchemaV1(StaticAddSourceInputSchema), | ||
| 114 | ); | ||
| 115 | const StaticAddSourceOutputStandardSchema = Schema.toStandardSchemaV1( | ||
| 116 | Schema.toStandardJSONSchemaV1(Schema.Struct({ toolCount: Schema.Number })), | ||
| 117 | ); | ||
| 118 | |||
| 111 | // --------------------------------------------------------------------------- | 119 | // --------------------------------------------------------------------------- |
| 112 | // Plugin extension | 120 | // Plugin extension |
| 113 | // --------------------------------------------------------------------------- | 121 | // --------------------------------------------------------------------------- |
| @@ -974,44 +982,20 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 860 unmodified lines | |||
| 974 | staticSources: (self) => [ | 982 | staticSources: (self) => [ |
| 975 | { | 983 | { |
| 976 | id: "graphql", | 984 | id: "graphql", |
| 977 | kind: "control", | 985 | kind: "executor", |
| 978 | name: "GraphQL", | 986 | name: "GraphQL", |
| 979 | tools: [ | 987 | tools: [ |
| 980 | { | 988 | tool({ |
| 981 | name: "addSource", | 989 | name: "addSource", |
| 982 | description: "Add a GraphQL endpoint and register its operations as tools", | 990 | description: "Add a GraphQL endpoint and register its operations as tools", |
| 983 | annotations: { | 991 | annotations: { |
| 984 | requiresApproval: true, | 992 | requiresApproval: true, |
| 985 | approvalDescription: "Add a GraphQL source", | 993 | approvalDescription: "Add a GraphQL source", |
| 986 | }, | 994 | }, |
| 987 | inputSchema: { | 995 | inputSchema: StaticAddSourceInputStandardSchema, |
| 988 | type: "object", | 996 | outputSchema: StaticAddSourceOutputStandardSchema, |
| 989 | properties: { | 997 | execute: (input) => self.addSource(input), |
| 990 | scope: { type: "string" }, | 998 | }), |
| 991 | endpoint: { type: "string" }, | ||
| 992 | name: { type: "string" }, | ||
| 993 | introspectionJson: { type: "string" }, | ||
| 994 | namespace: { type: "string" }, | ||
| 995 | headers: { type: "object" }, | ||
| 996 | queryParams: { type: "object" }, | ||
| 997 | credentialTargetScope: { type: "string" }, | ||
| 998 | auth: { type: "object" }, | ||
| 999 | }, | ||
| 1000 | required: ["scope", "endpoint"], | ||
| 1001 | }, | ||
| 1002 | outputSchema: { | ||
| 1003 | type: "object", | ||
| 1004 | properties: { | ||
| 1005 | toolCount: { type: "number" }, | ||
| 1006 | }, | ||
| 1007 | required: ["toolCount"], | ||
| 1008 | }, | ||
| 1009 | handler: ({ args }) => | ||
| 1010 | Effect.gen(function* () { | ||
| 1011 | const input = yield* Schema.decodeUnknownEffect(StaticAddSourceInputSchema)(args); | ||
| 1012 | return yield* self.addSource(input); | ||
| 1013 | }), | ||
| 1014 | }, | ||
| 1015 | ], | 999 | ], |
| 1016 | }, | 1000 | }, |
| 1017 | ], | 1001 | ], |
| @@ -212,7 +212,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 211 unmodified lines | |||
| 212 | }), | 212 | }), |
| 213 | ); | 213 | ); |
| 214 | 214 | ||
| 215 | it.effect("registers static openapi control tools", () => | 215 | it.effect("registers static openapi executor tools", () => |
| 216 | Effect.gen(function* () { | 216 | Effect.gen(function* () { |
| 217 | const httpClient = yield* HttpClient.HttpClient; | 217 | const httpClient = yield* HttpClient.HttpClient; |
| 218 | const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); | 218 | const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); |
| @@ -228,12 +228,12 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 9 unmodified lines | |||
| 228 | 228 | ||
| 229 | const tools = yield* executor.tools.list(); | 229 | const tools = yield* executor.tools.list(); |
| 230 | const ids = tools.map((t) => t.id); | 230 | const ids = tools.map((t) => t.id); |
| 231 | expect(ids).toContain("openapi.previewSpec"); | 231 | expect(ids).toContain("executor.openapi.previewSpec"); |
| 232 | expect(ids).toContain("openapi.addSource"); | 232 | expect(ids).toContain("executor.openapi.addSource"); |
| 233 | }), | 233 | }), |
| 234 | ); | 234 | ); |
| 235 | 235 | ||
| 236 | it.effect("lists openapi as a static runtime source", () => | 236 | it.effect("lists executor as the static runtime source", () => |
| 237 | Effect.gen(function* () { | 237 | Effect.gen(function* () { |
| 238 | const httpClient = yield* HttpClient.HttpClient; | 238 | const httpClient = yield* HttpClient.HttpClient; |
| 239 | const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); | 239 | const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); |
| @@ -248,7 +248,8 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 8 unmodified lines | |||
| 248 | ); | 248 | ); |
| 249 | 249 | ||
| 250 | const sources = yield* executor.sources.list(); | 250 | const sources = yield* executor.sources.list(); |
| 251 | const control = sources.find((s) => s.id === "openapi"); | 251 | expect(sources.find((s) => s.id === "openapi")).toBeUndefined(); |
| 252 | const control = sources.find((s) => s.id === "executor"); | ||
| 252 | expect(control).toBeDefined(); | 253 | expect(control).toBeDefined(); |
| 253 | expect(control!.runtime).toBe(true); | 254 | expect(control!.runtime).toBe(true); |
| 254 | expect(control!.canRemove).toBe(false); | 255 | expect(control!.canRemove).toBe(false); |
| @@ -270,7 +271,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 15 unmodified lines | |||
| 270 | ); | 271 | ); |
| 271 | 272 | ||
| 272 | const result = (yield* executor.tools.invoke( | 273 | const result = (yield* executor.tools.invoke( |
| 273 | "openapi.previewSpec", | 274 | "executor.openapi.previewSpec", |
| 274 | { spec: specJson }, | 275 | { spec: specJson }, |
| 275 | autoApprove, | 276 | autoApprove, |
| 276 | )) as { operationCount: number }; | 277 | )) as { operationCount: number }; |
| @@ -279,6 +280,26 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 2 unmodified lines | |||
| 279 | }), | 280 | }), |
| 280 | ); | 281 | ); |
| 281 | 282 | ||
| 283 | it.effect("describes static addSource parameters from Standard Schema", () => | ||
| 284 | Effect.gen(function* () { | ||
| 285 | const executor = yield* createExecutor( | ||
| 286 | makeTestConfig({ | ||
| 287 | plugins: [openApiPlugin(), memorySecretsPlugin()] as const, | ||
| 288 | }), | ||
| 289 | ); | ||
| 290 | |||
| 291 | const schema = yield* executor.tools.schema("executor.openapi.addSource"); | ||
| 292 | |||
| 293 | expect(schema).not.toBeNull(); | ||
| 294 | expect(schema!.inputTypeScript).toContain("scope: string"); | ||
| 295 | expect(schema!.inputTypeScript).toContain("spec: string"); | ||
| 296 | expect( | ||
| 297 | (schema!.inputSchema as { properties?: Record<string, unknown> }).properties, | ||
| 298 | ).toHaveProperty("credentialTargetScope"); | ||
| 299 | expect(schema!.inputTypeScript).not.toBe("Record<string, unknown>"); | ||
| 300 | }), | ||
| 301 | ); | ||
| 302 | |||
| 282 | it.effect("invokes static addSource through executor.tools.invoke", () => | 303 | it.effect("invokes static addSource through executor.tools.invoke", () => |
| 283 | Effect.gen(function* () { | 304 | Effect.gen(function* () { |
| 284 | const httpClient = yield* HttpClient.HttpClient; | 305 | const httpClient = yield* HttpClient.HttpClient; |
| @@ -300,7 +321,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 15 unmodified lines | |||
| 300 | ); | 321 | ); |
| 301 | 322 | ||
| 302 | const result = (yield* executor.tools.invoke( | 323 | const result = (yield* executor.tools.invoke( |
| 303 | "openapi.addSource", | 324 | "executor.openapi.addSource", |
| 304 | { scope: String(orgScope), spec: specJson, namespace: "runtime" }, | 325 | { scope: String(orgScope), spec: specJson, namespace: "runtime" }, |
| 305 | autoApprove, | 326 | autoApprove, |
| 306 | )) as { sourceId: string; toolCount: number }; | 327 | )) as { sourceId: string; toolCount: number }; |
| @@ -322,7 +343,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 15 unmodified lines | |||
| 322 | 343 | ||
| 323 | const declined = yield* executor.tools | 344 | const declined = yield* executor.tools |
| 324 | .invoke( | 345 | .invoke( |
| 325 | "openapi.addSource", | 346 | "executor.openapi.addSource", |
| 326 | { scope: TEST_SCOPE, spec: specJson, namespace: "runtime_declined" }, | 347 | { scope: TEST_SCOPE, spec: specJson, namespace: "runtime_declined" }, |
| 327 | { onElicitation: () => Effect.succeed({ action: "decline" as const }) }, | 348 | { onElicitation: () => Effect.succeed({ action: "decline" as const }) }, |
| 328 | ) | 349 | ) |
| @@ -825,7 +846,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 496 unmodified lines | |||
| 825 | 846 | ||
| 826 | const remaining = yield* executor.tools.list(); | 847 | const remaining = yield* executor.tools.list(); |
| 827 | const ids = remaining.map((t) => t.id).sort(); | 848 | const ids = remaining.map((t) => t.id).sort(); |
| 828 | expect(ids).toEqual(["openapi.addSource", "openapi.previewSpec"]); | 849 | expect(ids).toEqual(["executor.openapi.addSource", "executor.openapi.previewSpec"]); |
| 829 | }), | 850 | }), |
| 830 | ); | 851 | ); |
| 831 | 852 | ||
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | |||
| 8 | SourceDetectionResult, | 8 | SourceDetectionResult, |
| 9 | StorageError, | 9 | StorageError, |
| 10 | definePlugin, | 10 | definePlugin, |
| 11 | tool, | ||
| 11 | resolveSecretBackedMap, | 12 | resolveSecretBackedMap, |
| 12 | type CredentialBindingRef, | 13 | type CredentialBindingRef, |
| 13 | type PluginCtx, | 14 | type PluginCtx, |
| @@ -169,7 +170,6 @@ const PreviewSpecInputSchema = Schema.Struct({ · 155 unmodified lines | |||
| 169 | }), | 170 | }), |
| 170 | ), | 171 | ), |
| 171 | }); | 172 | }); |
| 172 | type PreviewSpecInput = typeof PreviewSpecInputSchema.Type; | ||
| 173 | 173 | ||
| 174 | const OpenApiHeaderInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]); | 174 | const OpenApiHeaderInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]); |
| 175 | const OpenApiOAuthInputSchema = OAuth2SourceConfig; | 175 | const OpenApiOAuthInputSchema = OAuth2SourceConfig; |
| @@ -191,7 +191,21 @@ const AddSourceInputSchema = Schema.Struct({ · 15 unmodified lines | |||
| 191 | }), | 191 | }), |
| 192 | ), | 192 | ), |
| 193 | }); | 193 | }); |
| 194 | type AddSourceInput = typeof AddSourceInputSchema.Type; | 194 | |
| 195 | const AddSourceOutputSchema = Schema.Struct({ | ||
| 196 | sourceId: Schema.String, | ||
| 197 | toolCount: Schema.Number, | ||
| 198 | }); | ||
| 199 | |||
| 200 | const PreviewSpecInputStandardSchema = Schema.toStandardSchemaV1( | ||
| 201 | Schema.toStandardJSONSchemaV1(PreviewSpecInputSchema), | ||
| 202 | ); | ||
| 203 | const AddSourceInputStandardSchema = Schema.toStandardSchemaV1( | ||
| 204 | Schema.toStandardJSONSchemaV1(AddSourceInputSchema), | ||
| 205 | ); | ||
| 206 | const AddSourceOutputStandardSchema = Schema.toStandardSchemaV1( | ||
| 207 | Schema.toStandardJSONSchemaV1(AddSourceOutputSchema), | ||
| 208 | ); | ||
| 195 | 209 | ||
| 196 | // --------------------------------------------------------------------------- | 210 | // --------------------------------------------------------------------------- |
| 197 | // Helpers | 211 | // Helpers |
| @@ -1172,60 +1186,26 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 974 unmodified lines | |||
| 1172 | staticSources: (self) => [ | 1186 | staticSources: (self) => [ |
| 1173 | { | 1187 | { |
| 1174 | id: "openapi", | 1188 | id: "openapi", |
| 1175 | kind: "control", | 1189 | kind: "executor", |
| 1176 | name: "OpenAPI", | 1190 | name: "OpenAPI", |
| 1177 | tools: [ | 1191 | tools: [ |
| 1178 | { | 1192 | tool({ |
| 1179 | name: "previewSpec", | 1193 | name: "previewSpec", |
| 1180 | description: "Preview an OpenAPI document before adding it as a source", | 1194 | description: "Preview an OpenAPI document before adding it as a source", |
| 1181 | inputSchema: { | 1195 | inputSchema: PreviewSpecInputStandardSchema, |
| 1182 | type: "object", | 1196 | execute: (input) => self.previewSpec(input), |
| 1183 | properties: { | 1197 | }), |
| 1184 | spec: { type: "string" }, | 1198 | tool({ |
| 1185 | specFetchCredentials: { type: "object" }, | ||
| 1186 | }, | ||
| 1187 | required: ["spec"], | ||
| 1188 | }, | ||
| 1189 | handler: ({ args }) => self.previewSpec(args as PreviewSpecInput), | ||
| 1190 | }, | ||
| 1191 | { | ||
| 1192 | name: "addSource", | 1199 | name: "addSource", |
| 1193 | description: "Add an OpenAPI source and register its operations as tools", | 1200 | description: "Add an OpenAPI source and register its operations as tools", |
| 1194 | annotations: { | 1201 | annotations: { |
| 1195 | requiresApproval: true, | 1202 | requiresApproval: true, |
| 1196 | approvalDescription: "Add an OpenAPI source", | 1203 | approvalDescription: "Add an OpenAPI source", |
| 1197 | }, | 1204 | }, |
| 1198 | inputSchema: { | 1205 | inputSchema: AddSourceInputStandardSchema, |
| 1199 | type: "object", | 1206 | outputSchema: AddSourceOutputStandardSchema, |
| 1200 | properties: { | 1207 | execute: (input) => self.addSpec(input), |
| 1201 | scope: { type: "string" }, | 1208 | }), |
| 1202 | spec: { type: "string" }, | ||
| 1203 | name: { type: "string" }, | ||
| 1204 | baseUrl: { type: "string" }, | ||
| 1205 | namespace: { type: "string" }, | ||
| 1206 | headers: { type: "object" }, | ||
| 1207 | queryParams: { type: "object" }, | ||
| 1208 | oauth2: { type: "object" }, | ||
| 1209 | credentialTargetScope: { type: "string" }, | ||
| 1210 | specFetchCredentials: { type: "object" }, | ||
| 1211 | }, | ||
| 1212 | required: ["scope", "spec"], | ||
| 1213 | }, | ||
| 1214 | outputSchema: { | ||
| 1215 | type: "object", | ||
| 1216 | properties: { | ||
| 1217 | sourceId: { type: "string" }, | ||
| 1218 | toolCount: { type: "number" }, | ||
| 1219 | }, | ||
| 1220 | required: ["sourceId", "toolCount"], | ||
| 1221 | }, | ||
| 1222 | handler: ({ args }) => | ||
| 1223 | Effect.gen(function* () { | ||
| 1224 | const input: AddSourceInput = | ||
| 1225 | yield* Schema.decodeUnknownEffect(AddSourceInputSchema)(args); | ||
| 1226 | return yield* self.addSpec(input); | ||
| 1227 | }), | ||
| 1228 | }, | ||
| 1229 | ], | 1209 | ], |
| 1230 | }, | 1210 | }, |
| 1231 | ], | 1211 | ], |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { sourceFaviconUrl } from "./source-favicon"; | 3 | import { sourceFaviconUrl, sourceLocalIconUrl } from "./source-favicon"; |
| 4 | 4 | ||
| 5 | describe("SourceFavicon", () => { | 5 | describe("SourceFavicon", () => { |
| 6 | it("uses the favicon service that handles provider-specific icon locations", () => { | 6 | it("uses the favicon service that handles provider-specific icon locations", () => { |
| @@ -19,4 +19,9 @@ describe("SourceFavicon", () => { · 12 unmodified lines | |||
| 19 | "https://www.google.com/s2/favicons?domain=github.com&sz=40", | 19 | "https://www.google.com/s2/favicons?domain=github.com&sz=40", |
| 20 | ); | 20 | ); |
| 21 | }); | 21 | }); |
| 22 | |||
| 23 | it("uses the Executor favicon for the built-in executor source", () => { | ||
| 24 | expect(sourceLocalIconUrl("executor")).toBe("/favicon-32.png"); | ||
| 25 | expect(sourceLocalIconUrl("openapi")).toBeNull(); | ||
| 26 | }); | ||
| 22 | }); | 27 | }); |
| @@ -14,9 +14,22 @@ export function sourceFaviconUrl(url: string | undefined, size: number): string · 13 unmodified lines | |||
| 14 | return `https://www.google.com/s2/favicons?domain=${domain}&sz=${size * 2}`; | 14 | return `https://www.google.com/s2/favicons?domain=${domain}&sz=${size * 2}`; |
| 15 | } | 15 | } |
| 16 | 16 | ||
| 17 | export function SourceFavicon({ url, size = 16 }: { url?: string; size?: number }) { | 17 | export function sourceLocalIconUrl(sourceId: string | undefined): string | null { |
| 18 | if (sourceId !== "executor") return null; | ||
| 19 | return "/favicon-32.png"; | ||
| 20 | } | ||
| 21 | |||
| 22 | export function SourceFavicon({ | ||
| 23 | sourceId, | ||
| 24 | url, | ||
| 25 | size = 16, | ||
| 26 | }: { | ||
| 27 | sourceId?: string; | ||
| 28 | url?: string; | ||
| 29 | size?: number; | ||
| 30 | }) { | ||
| 18 | const [failed, setFailed] = useState(false); | 31 | const [failed, setFailed] = useState(false); |
| 19 | const src = failed ? null : sourceFaviconUrl(url, size); | 32 | const src = failed ? null : (sourceLocalIconUrl(sourceId) ?? sourceFaviconUrl(url, size)); |
| 20 | 33 | ||
| 21 | if (!src) { | 34 | if (!src) { |
| 22 | return ( | 35 | return ( |
| @@ -157,7 +157,9 @@ export function SourceDetailPage(props: { namespace: string }) { · 156 unmodified lines | |||
| 157 | {sourceData?.runtime && ( | 157 | {sourceData?.runtime && ( |
| 158 | <Badge className="bg-muted text-muted-foreground">built-in</Badge> | 158 | <Badge className="bg-muted text-muted-foreground">built-in</Badge> |
| 159 | )} | 159 | )} |
| 160 | <Badge variant="secondary">{sourceData?.kind ?? "source"}</Badge> | 160 | {(!sourceData?.runtime || sourceData.kind !== "built-in") && ( |
| 161 | <Badge variant="secondary">{sourceData?.kind ?? "source"}</Badge> | ||
| 162 | )} | ||
| 161 | {AsyncResult.isSuccess(tools) && !editing && ( | 163 | {AsyncResult.isSuccess(tools) && !editing && ( |
| 162 | <span className="hidden text-xs tabular-nums text-muted-foreground sm:block"> | 164 | <span className="hidden text-xs tabular-nums text-muted-foreground sm:block"> |
| 163 | {sourceTools.length} {sourceTools.length === 1 ? "tool" : "tools"} | 165 | {sourceTools.length} {sourceTools.length === 1 ? "tool" : "tools"} |
| @@ -411,7 +411,7 @@ function SourceGrid(props: { · 410 unmodified lines | |||
| 411 | <CardStackEntry key={s.id} asChild searchText={`${s.name} ${s.id} ${s.kind}`}> | 411 | <CardStackEntry key={s.id} asChild searchText={`${s.name} ${s.id} ${s.kind}`}> |
| 412 | <Link to="/sources/$namespace" params={{ namespace: s.id }}> | 412 | <Link to="/sources/$namespace" params={{ namespace: s.id }}> |
| 413 | <CardStackEntryMedia> | 413 | <CardStackEntryMedia> |
| 414 | <SourceFavicon url={s.url} size={32} /> | 414 | <SourceFavicon sourceId={s.id} url={s.url} size={32} /> |
| 415 | </CardStackEntryMedia> | 415 | </CardStackEntryMedia> |
| 416 | <CardStackEntryContent> | 416 | <CardStackEntryContent> |
| 417 | <CardStackEntryTitle>{s.name}</CardStackEntryTitle> | 417 | <CardStackEntryTitle>{s.name}</CardStackEntryTitle> |
| @@ -423,8 +423,11 @@ function SourceGrid(props: { · 5 unmodified lines | |||
| 423 | <SummaryComponent sourceId={s.id} /> | 423 | <SummaryComponent sourceId={s.id} /> |
| 424 | </Suspense> | 424 | </Suspense> |
| 425 | )} | 425 | )} |
| 426 | {s.runtime && <Badge className="bg-muted text-muted-foreground">built-in</Badge>} | 426 | {s.runtime ? ( |
| 427 | <Badge variant="secondary">{s.kind}</Badge> | 427 | <Badge className="bg-muted text-muted-foreground">built-in</Badge> |
| 428 | ) : ( | ||
| 429 | <Badge variant="secondary">{s.kind}</Badge> | ||
| 430 | )} | ||
| 428 | </CardStackEntryActions> | 431 | </CardStackEntryActions> |
| 429 | </Link> | 432 | </Link> |
| 430 | </CardStackEntry> | 433 | </CardStackEntry> |