History

Move control tools under executor namespace (#756)

Rhys Sullivan committed 6 months ago

Commit 65bd2ff1 parent d80571124 files · +329 −200Browse files

Unified · Split

24 files
  1. README.md +1 −1
  2. apps/cloud/src/mcp-miniflare.e2e.node.test.ts +3 −3
  3. apps/cloud/src/mcp-session.e2e.node.test.ts +4 −6
  4. apps/cloud/src/services/sources-api.node.test.ts +3 −3
  5. apps/local/src/web/shell.tsx +1 −1
  6. apps/local/vite.config.ts +9 −0
  7. bun.lock +1 −0
  8. notes/old/mcp-testing.md +1 −1
  9. packages/core/execution/src/description.test.ts +4 −6
  10. packages/core/execution/src/tool-invoker.test.ts +12 −25
  11. packages/core/sdk/package.json +1 −0
  12. packages/core/sdk/src/executor.test.ts +18 −20
  13. packages/core/sdk/src/executor.ts +57 −13
  14. packages/core/sdk/src/index.ts +3 −0
  15. packages/core/sdk/src/plugin.ts +72 −5
  16. packages/core/sdk/src/promise.test.ts +14 −16
  17. packages/plugins/graphql/src/sdk/plugin.test.ts +25 −8
  18. packages/plugins/graphql/src/sdk/plugin.ts +14 −30
  19. packages/plugins/openapi/src/sdk/plugin.test.ts +30 −9
  20. packages/plugins/openapi/src/sdk/plugin.ts +26 −46
  21. packages/react/src/components/source-favicon.test.tsx +6 −1
  22. packages/react/src/components/source-favicon.tsx +15 −2
  23. packages/react/src/pages/source-detail.tsx +3 −1
  24. packages/react/src/pages/sources.tsx +6 −3
README.md+1−1
@@ -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 CLI48### Via the CLI
49 49
50```bash50```bash
51executor call openapi addSource '{51executor 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"
apps/cloud/src/mcp-miniflare.e2e.node.test.ts+3−3
@@ -14,7 +14,7 @@ · 13 unmodified lines
14//14//
15// Elicitation coverage uses the openapi plugin: a tiny Effect HttpApi15// Elicitation coverage uses the openapi plugin: a tiny Effect HttpApi
16// upstream is stood up in-process, its generated spec is handed to16// 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, which18// `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(() =>
apps/cloud/src/mcp-session.e2e.node.test.ts+4−6
@@ -15,7 +15,7 @@ · 14 unmodified lines
15// before prod does.15// before prod does.
16 16
17import { describe, expect, it } from "@effect/vitest";17import { describe, expect, it } from "@effect/vitest";
18import { Effect } from "effect";18import { Effect, Schema } from "effect";
19import { Client } from "@modelcontextprotocol/sdk/client/index.js";19import { Client } from "@modelcontextprotocol/sdk/client/index.js";
20import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";20import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
21import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";21import { 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
47const EMPTY_INPUT_SCHEMA = {47const EMPTY_INPUT_SCHEMA = Schema.toStandardSchemaV1(
48 type: "object",48 Schema.toStandardJSONSchemaV1(Schema.Struct({})),
49 properties: {},49);
50 additionalProperties: false,
51} as const;
52 50
53const elicitingTestPlugin = definePlugin(() => ({51const elicitingTestPlugin = definePlugin(() => ({
54 id: "eliciting-test" as const,52 id: "eliciting-test" as const,
apps/cloud/src/services/sources-api.node.test.ts+3−3
@@ -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.sources684 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);
apps/local/src/web/shell.tsx+1−1
@@ -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}
apps/local/vite.config.ts+9−0
@@ -1,4 +1,5 @@
1import { readFileSync } from "node:fs";1import { readFileSync } from "node:fs";
2import { resolve } from "node:path";
2import { Readable } from "node:stream";3import { Readable } from "node:stream";
3import { defineConfig, type Plugin } from "vite";4import { defineConfig, type Plugin } from "vite";
4import react from "@vitejs/plugin-react";5import 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(),
bun.lock+1−0
@@ -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",
notes/old/mcp-testing.md+1−1
@@ -90,7 +90,7 @@ Pattern used in `mcp-miniflare.e2e.node.test.ts`: · 89 unmodified lines
905. SDK Client advertises `capabilities: { elicitation: { form: {} } }` and905. SDK Client advertises `capabilities: { elicitation: { form: {} } }` and
91 registers `client.setRequestHandler(ElicitRequestSchema, ...)` that returns91 registers `client.setRequestHandler(ElicitRequestSchema, ...)` that returns
92 `{ action: "accept", content: {} }`.92 `{ action: "accept", content: {} }`.
936. Call `execute` with code that (a) calls `tools.openapi.addSource({ spec,936. Call `execute` with code that (a) calls `tools.executor.openapi.addSource({ spec,
94namespace })`, (b) invokes the POST operation.94namespace })`, (b) invokes the POST operation.
95 95
96**Tool id format inside the `execute` sandbox.**96**Tool id format inside the `execute` sandbox.**
packages/core/execution/src/description.test.ts+4−6
@@ -1,15 +1,13 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";2import { Effect, Schema } from "effect";
3 3
4import { createExecutor, definePlugin, makeTestConfig } from "@executor-js/sdk";4import { createExecutor, definePlugin, makeTestConfig } from "@executor-js/sdk";
5 5
6import { buildExecuteDescription } from "./description";6import { buildExecuteDescription } from "./description";
7 7
8const EmptyInputSchema = {8const 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 their12// Two plugins registering static sources whose ids are distinct from their
15// pluginIds/names. If `buildExecuteDescription` ever renders the wrong field13// pluginIds/names. If `buildExecuteDescription` ever renders the wrong field
packages/core/execution/src/tool-invoker.test.ts+12−25
@@ -1,5 +1,5 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Fiber } from "effect";2import { Effect, Fiber, Schema } from "effect";
3 3
4import {4import {
5 ElicitationResponse,5 ElicitationResponse,
@@ -14,30 +14,17 @@ import { describeTool, makeExecutorToolInvoker, searchTools } from "./tool-invok · 8 unmodified lines
14 14
15const codeExecutor = makeQuickJsExecutor();15const codeExecutor = makeQuickJsExecutor();
16 16
17const RepoInputSchema = {17const 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" },21const ContactInputSchema = Schema.toStandardSchemaV1(
22 },22 Schema.toStandardJSONSchemaV1(Schema.Struct({ email: Schema.String })),
23 required: ["owner", "repo"],23);
24 additionalProperties: false,24
25} as const;25const EmptyInputSchema = Schema.toStandardSchemaV1(
26 26 Schema.toStandardJSONSchemaV1(Schema.Struct({})),
27const ContactInputSchema = {27);
28 type: "object",
29 properties: {
30 email: { type: "string" },
31 },
32 required: ["email"],
33 additionalProperties: false,
34} as const;
35
36const EmptyInputSchema = {
37 type: "object",
38 properties: {},
39 additionalProperties: false,
40} as const;
41 28
42const acceptAll = () => Effect.succeed(new ElicitationResponse({ action: "accept" }));29const acceptAll = () => Effect.succeed(new ElicitationResponse({ action: "accept" }));
43 30
packages/core/sdk/package.json+1−0
@@ -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"
packages/core/sdk/src/executor.test.ts+18−20
@@ -1,5 +1,5 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Data, Effect, Exit, Predicate, Result } from "effect";2import { Data, Effect, Exit, Predicate, Result, Schema } from "effect";
3import { FetchHttpClient } from "effect/unstable/http";3import { FetchHttpClient } from "effect/unstable/http";
4 4
5import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory";5import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory";
@@ -9,7 +9,7 @@ import { makeInMemoryBlobStore } from "./blob"; · 3 unmodified lines
9import { CreateConnectionInput, TokenMaterial } from "./connections";9import { CreateConnectionInput, TokenMaterial } from "./connections";
10import { collectSchemas, createExecutor } from "./executor";10import { collectSchemas, createExecutor } from "./executor";
11import { ElicitationResponse, FormElicitation, UrlElicitation } from "./elicitation";11import { ElicitationResponse, FormElicitation, UrlElicitation } from "./elicitation";
12import { defineSchema, definePlugin } from "./plugin";12import { defineSchema, definePlugin, tool } from "./plugin";
13import { RemoveSecretInput, SetSecretInput } from "./secrets";13import { RemoveSecretInput, SetSecretInput } from "./secrets";
14import { makeTestConfig } from "./testing";14import { makeTestConfig } from "./testing";
15import type { SecretProvider } from "./secrets";15import 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, a48// 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 invokeTool49// 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 ],
packages/core/sdk/src/executor.ts+57−13
@@ -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";
108import type { Scope } from "./scope";109import 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
466const 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
476const EXECUTOR_SOURCE_ID = "executor";
477const 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 sources488// 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 rows3079 // Innermost-wins lookup: the scope-wrapped adapter returns rows
packages/core/sdk/src/index.ts+3−0
@@ -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// Executor295// Executor
packages/core/sdk/src/plugin.ts+72−5
@@ -1,6 +1,8 @@
1import type { Context, Effect, Layer } from "effect";1import { Effect } from "effect";
2import type { Context, Layer } from "effect";
2import type { HttpClient } from "effect/unstable/http";3import type { HttpClient } from "effect/unstable/http";
3import type { HttpApiGroup } from "effect/unstable/httpapi";4import type { HttpApiGroup } from "effect/unstable/httpapi";
5import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec";
4import type { DBSchema, StorageFailure } from "@executor-js/storage-core";6import type { DBSchema, StorageFailure } from "@executor-js/storage-core";
5 7
6import type { PluginBlobStore } from "./blob";8import 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
254export 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
261export type StaticToolSchema<Input = unknown, Output = Input> = StandardSchemaV1<Input, Output> &
262 StandardJSONSchemaV1<Input, Output>;
263
252export interface StaticToolDecl<TStore = unknown> {264export 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
277const 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
289export 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
308export 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
265export interface StaticSourceDecl<TStore = unknown> {332export 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 represent337 /** 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;
packages/core/sdk/src/promise.test.ts+14−16
@@ -1,8 +1,8 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import { createExecutor } from "./promise";3import { createExecutor } from "./promise";
4import { definePlugin, defineSchema } from "./plugin";4import { definePlugin, defineSchema, tool } from "./plugin";
5import { Effect } from "effect";5import { Effect, Schema } from "effect";
6 6
7// A minimal static-tool plugin built on the Effect surface, consumed7// A minimal static-tool plugin built on the Effect surface, consumed
8// through the Promise façade. Exercises the proxy's ability to promisify8// 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 ],
packages/plugins/graphql/src/sdk/plugin.test.ts+25−8
@@ -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 present286 // 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.tools469 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,
packages/plugins/graphql/src/sdk/plugin.ts+14−30
@@ -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
112const StaticAddSourceInputStandardSchema = Schema.toStandardSchemaV1(
113 Schema.toStandardJSONSchemaV1(StaticAddSourceInputSchema),
114);
115const StaticAddSourceOutputStandardSchema = Schema.toStandardSchemaV1(
116 Schema.toStandardJSONSchemaV1(Schema.Struct({ toolCount: Schema.Number })),
117);
118
111// ---------------------------------------------------------------------------119// ---------------------------------------------------------------------------
112// Plugin extension120// 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 ],
packages/plugins/openapi/src/sdk/plugin.test.ts+30−9
@@ -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.tools344 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
packages/plugins/openapi/src/sdk/plugin.ts+26−46
@@ -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});
172type PreviewSpecInput = typeof PreviewSpecInputSchema.Type;
173 173
174const OpenApiHeaderInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]);174const OpenApiHeaderInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]);
175const OpenApiOAuthInputSchema = OAuth2SourceConfig;175const OpenApiOAuthInputSchema = OAuth2SourceConfig;
@@ -191,7 +191,21 @@ const AddSourceInputSchema = Schema.Struct({ · 15 unmodified lines
191 }),191 }),
192 ),192 ),
193});193});
194type AddSourceInput = typeof AddSourceInputSchema.Type;194
195const AddSourceOutputSchema = Schema.Struct({
196 sourceId: Schema.String,
197 toolCount: Schema.Number,
198});
199
200const PreviewSpecInputStandardSchema = Schema.toStandardSchemaV1(
201 Schema.toStandardJSONSchemaV1(PreviewSpecInputSchema),
202);
203const AddSourceInputStandardSchema = Schema.toStandardSchemaV1(
204 Schema.toStandardJSONSchemaV1(AddSourceInputSchema),
205);
206const AddSourceOutputStandardSchema = Schema.toStandardSchemaV1(
207 Schema.toStandardJSONSchemaV1(AddSourceOutputSchema),
208);
195 209
196// ---------------------------------------------------------------------------210// ---------------------------------------------------------------------------
197// Helpers211// 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 ],
packages/react/src/components/source-favicon.test.tsx+6−1
@@ -1,6 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import { sourceFaviconUrl } from "./source-favicon";3import { sourceFaviconUrl, sourceLocalIconUrl } from "./source-favicon";
4 4
5describe("SourceFavicon", () => {5describe("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});
packages/react/src/components/source-favicon.tsx+15−2
@@ -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
17export function SourceFavicon({ url, size = 16 }: { url?: string; size?: number }) {17export function sourceLocalIconUrl(sourceId: string | undefined): string | null {
18 if (sourceId !== "executor") return null;
19 return "/favicon-32.png";
20}
21
22export 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 (
packages/react/src/pages/source-detail.tsx+3−1
@@ -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"}
packages/react/src/pages/sources.tsx+6−3
@@ -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>