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
4848 ### Via the CLI
4949
5050 ```bash
51−executor call openapi addSource '{
51+executor call executor openapi addSource '{
5252 "spec": "https://petstore3.swagger.io/api/v3/openapi.json",
5353 "namespace": "petstore",
5454 "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
1414 //
1515 // Elicitation coverage uses the openapi plugin: a tiny Effect HttpApi
1616 // 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.
1818 // `requiresApproval: true` fires the executor's approval elicitation, which
1919 // round-trips back to the SDK Client's `ElicitRequestSchema` handler.
2020 // ---------------------------------------------------------------------------
@@ -455,7 +455,7 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) · 434 unmodified lines
455455 method: "POST",
456456 headers: {
457457 accept: "application/json, text/event-stream",
458− authorization: "Bearer bogus",
458+ authorization: "Bearer bogus.bogus.bogus",
459459 "content-type": "application/json",
460460 },
461461 body: JSON.stringify({
@@ -794,7 +794,7 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) · 332 unmodified lines
794794 // `HttpApiGroup` name ("approve") becomes part of the sandbox path,
795795 // so the invocation reads `tools.approveapi.approve.approveThing`.
796796 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" });`,
798798 `return await tools.approveapi.approve.approveThing({});`,
799799 ].join("\n");
800800 const result = yield* Effect.promise(() =>
apps/cloud/src/mcp-session.e2e.node.test.ts+4−6
@@ -15,7 +15,7 @@ · 14 unmodified lines
1515 // before prod does.
1616
1717 import { describe, expect, it } from "@effect/vitest";
18−import { Effect } from "effect";
18+import { Effect, Schema } from "effect";
1919 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2020 import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
2121 import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
@@ -44,11 +44,9 @@ import { DbService } from "./services/db"; · 22 unmodified lines
4444 // eliciting test drive the real engine + sandbox rather than a stub engine.
4545 // ---------------------------------------------------------------------------
4646
47−const EMPTY_INPUT_SCHEMA = {
48− type: "object",
49− properties: {},
50− additionalProperties: false,
51−} as const;
47+const EMPTY_INPUT_SCHEMA = Schema.toStandardSchemaV1(
48+ Schema.toStandardJSONSchemaV1(Schema.Struct({})),
49+);
5250
5351 const elicitingTestPlugin = definePlugin(() => ({
5452 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
676676 it.effect("sources.remove on a static source is rejected", () =>
677677 Effect.gen(function* () {
678678 // `canRemove: false` is reserved for static (plugin-declared)
679− // sources. The openapi plugin declares one with id "openapi"
680− // for its control tools.
679+ // sources. Plugin-owned executor tools are mounted under the
680+ // built-in executor source.
681681 const org = `org_${crypto.randomUUID()}`;
682682
683683 const result = yield* asOrg(org, (client) =>
684684 client.sources
685− .remove({ params: { scopeId: ScopeId.make(org), sourceId: "openapi" } })
685+ .remove({ params: { scopeId: ScopeId.make(org), sourceId: "executor" } })
686686 .pipe(Effect.result),
687687 );
688688 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
298298 : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
299299 ].join(" ")}
300300 >
301− <SourceFavicon url={s.url} />
301+ <SourceFavicon sourceId={s.id} url={s.url} />
302302 <span className="flex-1 truncate">{s.name}</span>
303303 <span className="rounded bg-secondary/50 px-1 py-px text-xs font-medium text-muted-foreground">
304304 {s.kind}
apps/local/vite.config.ts+9−0
@@ -1,4 +1,5 @@
11 import { readFileSync } from "node:fs";
2+import { resolve } from "node:path";
23 import { Readable } from "node:stream";
34 import { defineConfig, type Plugin } from "vite";
45 import react from "@vitejs/plugin-react";
@@ -38,6 +39,11 @@ function executorApiPlugin(): Plugin { · 33 unmodified lines
3839 return {
3940 name: "executor-api",
4041 configureServer(server) {
42+ server.watcher.on("change", (path) => {
43+ if (path.includes("/src/server/") || path.endsWith("/executor.config.ts")) {
44+ handlers = null;
45+ }
46+ });
4147 server.middlewares.use(async (req, res, next) => {
4248 const rawUrl = req.url ?? "/";
4349 const isApi = rawUrl.startsWith("/api/") || rawUrl === "/api";
@@ -121,6 +127,9 @@ export default defineConfig({ · 77 unmodified lines
121127 usePolling: true,
122128 interval: 200,
123129 },
130+ fs: {
131+ allow: [resolve(import.meta.dirname, "../..")],
132+ },
124133 },
125134 plugins: [
126135 tailwindcss(),
bun.lock+1−0
@@ -316,6 +316,7 @@ · 315 unmodified lines
316316 "version": "0.2.0",
317317 "dependencies": {
318318 "@executor-js/storage-core": "workspace:*",
319+ "@standard-schema/spec": "^1.1.0",
319320 "effect": "catalog:",
320321 "fractional-indexing": "^3.2.0",
321322 "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
9090 5. SDK Client advertises `capabilities: { elicitation: { form: {} } }` and
9191 registers `client.setRequestHandler(ElicitRequestSchema, ...)` that returns
9292 `{ 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,
9494 namespace })`, (b) invokes the POST operation.
9595
9696 **Tool id format inside the `execute` sandbox.**
packages/core/execution/src/description.test.ts+4−6
@@ -1,15 +1,13 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { Effect } from "effect";
2+import { Effect, Schema } from "effect";
33
44 import { createExecutor, definePlugin, makeTestConfig } from "@executor-js/sdk";
55
66 import { buildExecuteDescription } from "./description";
77
8−const EmptyInputSchema = {
9− type: "object",
10− properties: {},
11− additionalProperties: false,
12−} as const;
8+const EmptyInputSchema = Schema.toStandardSchemaV1(
9+ Schema.toStandardJSONSchemaV1(Schema.Struct({})),
10+);
1311
1412 // Two plugins registering static sources whose ids are distinct from their
1513 // pluginIds/names. If `buildExecuteDescription` ever renders the wrong field
packages/core/execution/src/tool-invoker.test.ts+12−25
@@ -1,5 +1,5 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { Effect, Fiber } from "effect";
2+import { Effect, Fiber, Schema } from "effect";
33
44 import {
55 ElicitationResponse,
@@ -14,30 +14,17 @@ import { describeTool, makeExecutorToolInvoker, searchTools } from "./tool-invok · 8 unmodified lines
1414
1515 const codeExecutor = makeQuickJsExecutor();
1616
17−const RepoInputSchema = {
18− type: "object",
19− properties: {
20− owner: { type: "string" },
21− repo: { type: "string" },
22− },
23− required: ["owner", "repo"],
24− additionalProperties: false,
25−} as const;
26−
27−const ContactInputSchema = {
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;
17+const RepoInputSchema = Schema.toStandardSchemaV1(
18+ Schema.toStandardJSONSchemaV1(Schema.Struct({ owner: Schema.String, repo: Schema.String })),
19+);
20+
21+const ContactInputSchema = Schema.toStandardSchemaV1(
22+ Schema.toStandardJSONSchemaV1(Schema.Struct({ email: Schema.String })),
23+);
24+
25+const EmptyInputSchema = Schema.toStandardSchemaV1(
26+ Schema.toStandardJSONSchemaV1(Schema.Struct({})),
27+);
4128
4229 const acceptAll = () => Effect.succeed(new ElicitationResponse({ action: "accept" }));
4330
packages/core/sdk/package.json+1−0
@@ -59,6 +59,7 @@ · 58 unmodified lines
5959 },
6060 "dependencies": {
6161 "@executor-js/storage-core": "workspace:*",
62+ "@standard-schema/spec": "^1.1.0",
6263 "effect": "catalog:",
6364 "fractional-indexing": "^3.2.0",
6465 "oauth4webapi": "^3.8.5"
packages/core/sdk/src/executor.test.ts+18−20
@@ -1,5 +1,5 @@
11 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";
33 import { FetchHttpClient } from "effect/unstable/http";
44
55 import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory";
@@ -9,7 +9,7 @@ import { makeInMemoryBlobStore } from "./blob"; · 3 unmodified lines
99 import { CreateConnectionInput, TokenMaterial } from "./connections";
1010 import { collectSchemas, createExecutor } from "./executor";
1111 import { ElicitationResponse, FormElicitation, UrlElicitation } from "./elicitation";
12−import { defineSchema, definePlugin } from "./plugin";
12+import { defineSchema, definePlugin, tool } from "./plugin";
1313 import { RemoveSecretInput, SetSecretInput } from "./secrets";
1414 import { makeTestConfig } from "./testing";
1515 import type { SecretProvider } from "./secrets";
@@ -45,7 +45,7 @@ const recordFindMany = (adapter: DBAdapter, calls: FindManyCall[]): DBAdapter => · 29 unmodified lines
4545 });
4646
4747 // ---------------------------------------------------------------------------
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
4949 // plugin schema for a per-row key/value table, and a dynamic invokeTool
5050 // handler. Exercises everything createExecutor has to wire up.
5151 // ---------------------------------------------------------------------------
@@ -837,21 +837,19 @@ describe("createExecutor", () => { · 785 unmodified lines
837837 kind: "control",
838838 name: "Preview Ctl",
839839 tools: [
840− {
840+ tool({
841841 name: "createContact",
842842 description: "create",
843− inputSchema: {
844− type: "object",
845− properties: {
846− name: { type: "string" },
847− age: { type: "number" },
848− },
849− required: ["name", "age"],
850− additionalProperties: false,
851− },
852− outputSchema: { type: "string" },
853− handler: ({ args }) => Effect.succeed(args),
854− },
843+ inputSchema: Schema.toStandardSchemaV1(
844+ Schema.toStandardJSONSchemaV1(
845+ Schema.Struct({ name: Schema.String, age: Schema.Finite }),
846+ ),
847+ ),
848+ outputSchema: Schema.toStandardSchemaV1(
849+ Schema.toStandardJSONSchemaV1(Schema.String),
850+ ),
851+ execute: (input) => Effect.succeed(input),
852+ }),
855853 ],
856854 },
857855 ],
@@ -1147,12 +1145,12 @@ const tenantPlugin = definePlugin(() => ({ · 289 unmodified lines
11471145 kind: "control" as const,
11481146 name: "Tenant Ctl",
11491147 tools: [
1150− {
1148+ tool({
11511149 name: "noop",
11521150 description: "noop",
1153− inputSchema: { type: "object", additionalProperties: false },
1154− handler: () => Effect.succeed(null),
1155− },
1151+ inputSchema: Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(Schema.Struct({}))),
1152+ execute: () => Effect.succeed(null),
1153+ }),
11561154 ],
11571155 },
11581156 ],
packages/core/sdk/src/executor.ts+57−13
@@ -103,6 +103,7 @@ import type { · 102 unmodified lines
103103 PluginExtensions,
104104 StaticSourceDecl,
105105 StaticToolDecl,
106+ StaticToolSchema,
106107 StorageDeps,
107108 } from "./plugin";
108109 import type { Scope } from "./scope";
@@ -457,11 +458,32 @@ const staticDeclToTool = ( · 348 unmodified lines
457458 pluginId,
458459 name: tool.name,
459460 description: tool.description,
460− inputSchema: tool.inputSchema,
461− outputSchema: tool.outputSchema,
461+ inputSchema: toToolJsonSchema(tool.inputSchema),
462+ outputSchema: toToolJsonSchema(tool.outputSchema, "output"),
462463 annotations: tool.annotations,
463464 });
464465
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+
465487 // ---------------------------------------------------------------------------
466488 // Dynamic-row writers — used by ctx.core.sources.register. Static sources
467489 // never touch these functions.
@@ -2719,18 +2741,40 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 2251 unmodified lines
27192741 }
27202742
27212743 // 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
27222748 const decls = plugin.staticSources ? plugin.staticSources(extension) : [];
27232749 for (const source of decls) {
2724− if (staticSources.has(source.id)) {
2725− return yield* new StorageError({
2726− message: `Duplicate static source id: ${source.id} (plugin ${plugin.id})`,
2727− cause: undefined,
2728− });
2750+ const mountUnderExecutor = source.kind === "executor" && source.id === plugin.id;
2751+ const mountedSource = mountUnderExecutor ? EXECUTOR_SOURCE : source;
2752+
2753+ if (mountUnderExecutor) {
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 });
27292768 }
2730− staticSources.set(source.id, { source, pluginId: plugin.id });
27312769
27322770 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}`;
27342778 if (staticTools.has(fqid)) {
27352779 return yield* new StorageError({
27362780 message: `Duplicate static tool id: ${fqid} (plugin ${plugin.id})`,
@@ -2738,8 +2782,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 1 unmodified line
27382782 });
27392783 }
27402784 staticTools.set(fqid, {
2741− source,
2742− tool,
2785+ source: mountedSource,
2786+ tool: mountedTool,
27432787 pluginId: plugin.id,
27442788 ctx,
27452789 });
@@ -3028,8 +3072,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 282 unmodified lines
30283072 name: staticEntry.tool.name,
30293073 description: staticEntry.tool.description,
30303074 sourceId: undefined,
3031− rawInput: staticEntry.tool.inputSchema,
3032− rawOutput: staticEntry.tool.outputSchema,
3075+ rawInput: toToolJsonSchema(staticEntry.tool.inputSchema),
3076+ rawOutput: toToolJsonSchema(staticEntry.tool.outputSchema, "output"),
30333077 });
30343078 }
30353079 // 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
280280 type StorageDeps,
281281 type StaticSourceDecl,
282282 type StaticToolDecl,
283+ type StaticToolExecuteContext,
283284 type StaticToolHandlerInput,
285+ type StaticToolInput,
284286 type InvokeToolInput,
285287 type SourceLifecycleInput,
286288 type SecretListEntry,
287289 type Elicit,
288290 definePlugin,
289291 defineSchema,
292+ tool,
290293 } from "./plugin";
291294
292295 // Executor
packages/core/sdk/src/plugin.ts+72−5
@@ -1,6 +1,8 @@
1−import type { Context, Effect, Layer } from "effect";
1+import { Effect } from "effect";
2+import type { Context, Layer } from "effect";
23 import type { HttpClient } from "effect/unstable/http";
34 import type { HttpApiGroup } from "effect/unstable/httpapi";
5+import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec";
46 import type { DBSchema, StorageFailure } from "@executor-js/storage-core";
57
68 import type { PluginBlobStore } from "./blob";
@@ -249,11 +251,21 @@ export interface StaticToolHandlerInput<TStore = unknown> { · 242 unmodified lines
249251 readonly elicit: Elicit;
250252 }
251253
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+
252264 export interface StaticToolDecl<TStore = unknown> {
253265 readonly name: string;
254266 readonly description: string;
255− readonly inputSchema?: unknown;
256− readonly outputSchema?: unknown;
267+ readonly inputSchema?: StaticToolSchema;
268+ readonly outputSchema?: StaticToolSchema;
257269 /** Default-policy annotations — `requiresApproval`, `approvalDescription`,
258270 * `mayElicit`. Enforced by the executor before the handler runs.
259271 * Inline because static tools have no plugin storage to resolve from;
@@ -262,13 +274,68 @@ export interface StaticToolDecl<TStore = unknown> { · 2 unmodified lines
262274 readonly handler: (input: StaticToolHandlerInput<TStore>) => Effect.Effect<unknown, unknown>;
263275 }
264276
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+
265332 export interface StaticSourceDecl<TStore = unknown> {
266333 readonly id: string;
267334 readonly kind: string;
268335 readonly name: string;
269336 readonly url?: string;
270− /** Static sources default to `canRemove: false` — they represent
271− * plugin-provided control surfaces and shouldn't be user-removable.
337+ /** Static sources default to `canRemove: false` because they are
338+ * plugin-provided surfaces and shouldn't usually be user-removable.
272339 * Override only if you really want that. */
273340 readonly canRemove?: boolean;
274341 readonly canRefresh?: boolean;
packages/core/sdk/src/promise.test.ts+14−16
@@ -1,8 +1,8 @@
11 import { describe, expect, it } from "@effect/vitest";
22
33 import { createExecutor } from "./promise";
4−import { definePlugin, defineSchema } from "./plugin";
5−import { Effect } from "effect";
4+import { definePlugin, defineSchema, tool } from "./plugin";
5+import { Effect, Schema } from "effect";
66
77 // A minimal static-tool plugin built on the Effect surface, consumed
88 // through the Promise façade. Exercises the proxy's ability to promisify
@@ -17,18 +17,14 @@ const echoPlugin = definePlugin(() => ({ · 8 unmodified lines
1717 kind: "control" as const,
1818 name: "Echo Ctl",
1919 tools: [
20− {
20+ tool({
2121 name: "say",
2222 description: "Echo the input",
23− inputSchema: {
24− type: "object",
25− properties: { message: { type: "string" } },
26− required: ["message"],
27− additionalProperties: false,
28− },
29− handler: ({ args }: { args: unknown }) =>
30− Effect.succeed((args as { message: string }).message),
31− },
23+ inputSchema: Schema.toStandardSchemaV1(
24+ Schema.toStandardJSONSchemaV1(Schema.Struct({ message: Schema.String })),
25+ ),
26+ execute: (input) => Effect.succeed(input.message),
27+ }),
3228 ],
3329 },
3430 ],
@@ -80,13 +76,15 @@ describe("promise/createExecutor", () => { · 45 unmodified lines
8076 kind: "control" as const,
8177 name: "Ap Ctl",
8278 tools: [
83− {
79+ tool({
8480 name: "go",
8581 description: "Requires approval",
8682 annotations: { requiresApproval: true } as const,
87− inputSchema: { type: "object", additionalProperties: false },
88− handler: () => Effect.succeed("ran"),
89− },
83+ inputSchema: Schema.toStandardSchemaV1(
84+ Schema.toStandardJSONSchemaV1(Schema.Struct({})),
85+ ),
86+ execute: () => Effect.succeed("ran"),
87+ }),
9088 ],
9189 },
9290 ],
packages/plugins/graphql/src/sdk/plugin.test.ts+25−8
@@ -283,8 +283,8 @@ describe("graphqlPlugin", () => { · 282 unmodified lines
283283 const ids = tools.map((t) => t.id);
284284 expect(ids).toContain("test_api.query.hello");
285285 expect(ids).toContain("test_api.mutation.setGreeting");
286− // static control tool also present
287− expect(ids).toContain("graphql.addSource");
286+ // static executor tool also present under the executor namespace
287+ expect(ids).toContain("executor.graphql.addSource");
288288
289289 const queryTool = tools.find((t) => t.id === "test_api.query.hello");
290290 expect(queryTool?.description).toBe("Say hello");
@@ -320,7 +320,7 @@ describe("graphqlPlugin", () => { · 29 unmodified lines
320320 }),
321321 );
322322
323− it.effect("lists sources with the static control source", () =>
323+ it.effect("lists sources with the executor built-in source", () =>
324324 Effect.gen(function* () {
325325 const executor = yield* createExecutor(
326326 makeTestConfig({ plugins: [graphqlPlugin()] as const }),
@@ -341,7 +341,8 @@ describe("graphqlPlugin", () => { · 14 unmodified lines
341341 expect(dynamic!.canEdit).toBe(true);
342342 expect(dynamic!.runtime).toBe(false);
343343
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");
345346 expect(control).toBeDefined();
346347 expect(control!.runtime).toBe(true);
347348 }),
@@ -401,7 +402,7 @@ describe("graphqlPlugin", () => { · 53 unmodified lines
401402 }),
402403 );
403404
404− it.effect("static graphql.addSource delegates to extension", () =>
405+ it.effect("static executor.graphql.addSource delegates to extension", () =>
405406 Effect.gen(function* () {
406407 const userScope = ScopeId.make("static-user");
407408 const orgScope = ScopeId.make("static-org");
@@ -416,7 +417,7 @@ describe("graphqlPlugin", () => { · 8 unmodified lines
416417 );
417418
418419 const result = yield* executor.tools.invoke(
419− "graphql.addSource",
420+ "executor.graphql.addSource",
420421 {
421422 scope: String(orgScope),
422423 endpoint: "http://localhost:4000/graphql",
@@ -436,6 +437,22 @@ describe("graphqlPlugin", () => { · 13 unmodified lines
436437 }),
437438 );
438439
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+
439456 it.effect("requires approval before a runtime-added query sends prior tool output", () =>
440457 Effect.gen(function* () {
441458 const server = yield* serveGreetingServer;
@@ -451,7 +468,7 @@ describe("graphqlPlugin", () => { · 9 unmodified lines
451468 expect(trusted).toBe("sample-value");
452469 const declined = yield* executor.tools
453470 .invoke(
454− "graphql.addSource",
471+ "executor.graphql.addSource",
455472 {
456473 endpoint: server.endpoint,
457474 scope: TEST_SCOPE,
@@ -478,7 +495,7 @@ describe("graphqlPlugin", () => { · 20 unmodified lines
478495 );
479496
480497 yield* executor.tools.invoke(
481− "graphql.addSource",
498+ "executor.graphql.addSource",
482499 {
483500 endpoint: server.endpoint,
484501 scope: TEST_SCOPE,
packages/plugins/graphql/src/sdk/plugin.ts+14−30
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 ConfiguredCredentialBinding,
88 type CredentialBindingRef,
99 definePlugin,
10+ tool,
1011 ScopeId,
1112 SecretId,
1213 SourceDetectionResult,
@@ -108,6 +109,13 @@ const StaticAddSourceInputSchema = Schema.Struct({ · 95 unmodified lines
108109 auth: Schema.optional(GraphqlSourceAuthInputSchema),
109110 });
110111
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+
111119 // ---------------------------------------------------------------------------
112120 // Plugin extension
113121 // ---------------------------------------------------------------------------
@@ -974,44 +982,20 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 860 unmodified lines
974982 staticSources: (self) => [
975983 {
976984 id: "graphql",
977− kind: "control",
985+ kind: "executor",
978986 name: "GraphQL",
979987 tools: [
980− {
988+ tool({
981989 name: "addSource",
982990 description: "Add a GraphQL endpoint and register its operations as tools",
983991 annotations: {
984992 requiresApproval: true,
985993 approvalDescription: "Add a GraphQL source",
986994 },
987− inputSchema: {
988− type: "object",
989− properties: {
990− scope: { type: "string" },
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− },
995+ inputSchema: StaticAddSourceInputStandardSchema,
996+ outputSchema: StaticAddSourceOutputStandardSchema,
997+ execute: (input) => self.addSource(input),
998+ }),
1015999 ],
10161000 },
10171001 ],
packages/plugins/openapi/src/sdk/plugin.test.ts+30−9
@@ -212,7 +212,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 211 unmodified lines
212212 }),
213213 );
214214
215− it.effect("registers static openapi control tools", () =>
215+ it.effect("registers static openapi executor tools", () =>
216216 Effect.gen(function* () {
217217 const httpClient = yield* HttpClient.HttpClient;
218218 const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
@@ -228,12 +228,12 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 9 unmodified lines
228228
229229 const tools = yield* executor.tools.list();
230230 const ids = tools.map((t) => t.id);
231− expect(ids).toContain("openapi.previewSpec");
232− expect(ids).toContain("openapi.addSource");
231+ expect(ids).toContain("executor.openapi.previewSpec");
232+ expect(ids).toContain("executor.openapi.addSource");
233233 }),
234234 );
235235
236− it.effect("lists openapi as a static runtime source", () =>
236+ it.effect("lists executor as the static runtime source", () =>
237237 Effect.gen(function* () {
238238 const httpClient = yield* HttpClient.HttpClient;
239239 const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
@@ -248,7 +248,8 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 8 unmodified lines
248248 );
249249
250250 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");
252253 expect(control).toBeDefined();
253254 expect(control!.runtime).toBe(true);
254255 expect(control!.canRemove).toBe(false);
@@ -270,7 +271,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 15 unmodified lines
270271 );
271272
272273 const result = (yield* executor.tools.invoke(
273− "openapi.previewSpec",
274+ "executor.openapi.previewSpec",
274275 { spec: specJson },
275276 autoApprove,
276277 )) as { operationCount: number };
@@ -279,6 +280,26 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 2 unmodified lines
279280 }),
280281 );
281282
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+
282303 it.effect("invokes static addSource through executor.tools.invoke", () =>
283304 Effect.gen(function* () {
284305 const httpClient = yield* HttpClient.HttpClient;
@@ -300,7 +321,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 15 unmodified lines
300321 );
301322
302323 const result = (yield* executor.tools.invoke(
303− "openapi.addSource",
324+ "executor.openapi.addSource",
304325 { scope: String(orgScope), spec: specJson, namespace: "runtime" },
305326 autoApprove,
306327 )) as { sourceId: string; toolCount: number };
@@ -322,7 +343,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 15 unmodified lines
322343
323344 const declined = yield* executor.tools
324345 .invoke(
325− "openapi.addSource",
346+ "executor.openapi.addSource",
326347 { scope: TEST_SCOPE, spec: specJson, namespace: "runtime_declined" },
327348 { onElicitation: () => Effect.succeed({ action: "decline" as const }) },
328349 )
@@ -825,7 +846,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 496 unmodified lines
825846
826847 const remaining = yield* executor.tools.list();
827848 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"]);
829850 }),
830851 );
831852
packages/plugins/openapi/src/sdk/plugin.ts+26−46
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 SourceDetectionResult,
99 StorageError,
1010 definePlugin,
11+ tool,
1112 resolveSecretBackedMap,
1213 type CredentialBindingRef,
1314 type PluginCtx,
@@ -169,7 +170,6 @@ const PreviewSpecInputSchema = Schema.Struct({ · 155 unmodified lines
169170 }),
170171 ),
171172 });
172−type PreviewSpecInput = typeof PreviewSpecInputSchema.Type;
173173
174174 const OpenApiHeaderInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]);
175175 const OpenApiOAuthInputSchema = OAuth2SourceConfig;
@@ -191,7 +191,21 @@ const AddSourceInputSchema = Schema.Struct({ · 15 unmodified lines
191191 }),
192192 ),
193193 });
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+);
195209
196210 // ---------------------------------------------------------------------------
197211 // Helpers
@@ -1172,60 +1186,26 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 974 unmodified lines
11721186 staticSources: (self) => [
11731187 {
11741188 id: "openapi",
1175− kind: "control",
1189+ kind: "executor",
11761190 name: "OpenAPI",
11771191 tools: [
1178− {
1192+ tool({
11791193 name: "previewSpec",
11801194 description: "Preview an OpenAPI document before adding it as a source",
1181− inputSchema: {
1182− type: "object",
1183− properties: {
1184− spec: { type: "string" },
1185− specFetchCredentials: { type: "object" },
1186− },
1187− required: ["spec"],
1188− },
1189− handler: ({ args }) => self.previewSpec(args as PreviewSpecInput),
1190− },
1191− {
1195+ inputSchema: PreviewSpecInputStandardSchema,
1196+ execute: (input) => self.previewSpec(input),
1197+ }),
1198+ tool({
11921199 name: "addSource",
11931200 description: "Add an OpenAPI source and register its operations as tools",
11941201 annotations: {
11951202 requiresApproval: true,
11961203 approvalDescription: "Add an OpenAPI source",
11971204 },
1198− inputSchema: {
1199− type: "object",
1200− properties: {
1201− scope: { type: "string" },
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− },
1205+ inputSchema: AddSourceInputStandardSchema,
1206+ outputSchema: AddSourceOutputStandardSchema,
1207+ execute: (input) => self.addSpec(input),
1208+ }),
12291209 ],
12301210 },
12311211 ],
packages/react/src/components/source-favicon.test.tsx+6−1
@@ -1,6 +1,6 @@
11 import { describe, expect, it } from "@effect/vitest";
22
3−import { sourceFaviconUrl } from "./source-favicon";
3+import { sourceFaviconUrl, sourceLocalIconUrl } from "./source-favicon";
44
55 describe("SourceFavicon", () => {
66 it("uses the favicon service that handles provider-specific icon locations", () => {
@@ -19,4 +19,9 @@ describe("SourceFavicon", () => { · 12 unmodified lines
1919 "https://www.google.com/s2/favicons?domain=github.com&sz=40",
2020 );
2121 });
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+ });
2227 });
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
1414 return `https://www.google.com/s2/favicons?domain=${domain}&sz=${size * 2}`;
1515 }
1616
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+}) {
1831 const [failed, setFailed] = useState(false);
19− const src = failed ? null : sourceFaviconUrl(url, size);
32+ const src = failed ? null : (sourceLocalIconUrl(sourceId) ?? sourceFaviconUrl(url, size));
2033
2134 if (!src) {
2235 return (
packages/react/src/pages/source-detail.tsx+3−1
@@ -157,7 +157,9 @@ export function SourceDetailPage(props: { namespace: string }) { · 156 unmodified lines
157157 {sourceData?.runtime && (
158158 <Badge className="bg-muted text-muted-foreground">built-in</Badge>
159159 )}
160− <Badge variant="secondary">{sourceData?.kind ?? "source"}</Badge>
160+ {(!sourceData?.runtime || sourceData.kind !== "built-in") && (
161+ <Badge variant="secondary">{sourceData?.kind ?? "source"}</Badge>
162+ )}
161163 {AsyncResult.isSuccess(tools) && !editing && (
162164 <span className="hidden text-xs tabular-nums text-muted-foreground sm:block">
163165 {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
411411 <CardStackEntry key={s.id} asChild searchText={`${s.name} ${s.id} ${s.kind}`}>
412412 <Link to="/sources/$namespace" params={{ namespace: s.id }}>
413413 <CardStackEntryMedia>
414− <SourceFavicon url={s.url} size={32} />
414+ <SourceFavicon sourceId={s.id} url={s.url} size={32} />
415415 </CardStackEntryMedia>
416416 <CardStackEntryContent>
417417 <CardStackEntryTitle>{s.name}</CardStackEntryTitle>
@@ -423,8 +423,11 @@ function SourceGrid(props: { · 5 unmodified lines
423423 <SummaryComponent sourceId={s.id} />
424424 </Suspense>
425425 )}
426− {s.runtime && <Badge className="bg-muted text-muted-foreground">built-in</Badge>}
427− <Badge variant="secondary">{s.kind}</Badge>
426+ {s.runtime ? (
427+ <Badge className="bg-muted text-muted-foreground">built-in</Badge>
428+ ) : (
429+ <Badge variant="secondary">{s.kind}</Badge>
430+ )}
428431 </CardStackEntryActions>
429432 </Link>
430433 </CardStackEntry>