History

maybe: mcp elicitation + openapi approvals

Rhys Sullivan committed 7 months ago

Commit 38d84c81 parent 8ece7d217 files · +192 −106Browse files

Unified · Split

17 files
  1. apps/server/src/handlers/tools.ts +0 −6
  2. apps/web/vite.config.ts +59 −39
  3. packages/clients/react/src/atoms.ts +1 −1
  4. packages/clients/react/src/index.ts +0 −1
  5. packages/core/api/src/tools/api.ts +0 −31
  6. packages/core/execution/src/engine.ts +3 −3
  7. packages/core/execution/src/tool-invoker.ts +2 −2
  8. packages/core/sdk/src/executor.ts +34 −3
  9. packages/core/sdk/src/in-memory/tool-registry.ts +10 −1
  10. packages/core/sdk/src/index.test.ts +10 −7
  11. packages/core/sdk/src/index.ts +1 −0
  12. packages/core/sdk/src/plugins/in-memory-tools.ts +7 −2
  13. packages/core/sdk/src/tools.ts +23 −4
  14. packages/core/storage-file/src/tool-registry.ts +10 −1
  15. packages/plugins/openapi/src/sdk/invoke.ts +24 −1
  16. packages/plugins/openapi/src/sdk/plugin.test.ts +7 −4
  17. packages/plugins/openapi/src/sdk/plugin.ts +1 −0
apps/server/src/handlers/tools.ts+0−6
@@ -28,11 +28,5 @@ export const ToolsHandlers = HttpApiBuilder.group( · 27 unmodified lines
28 const executor = yield* ExecutorService;28 const executor = yield* ExecutorService;
29 return yield* executor.tools.schema(path.toolId);29 return yield* executor.tools.schema(path.toolId);
30 }),30 }),
31 )
32 .handle("invoke", ({ path, payload }) =>
33 Effect.gen(function* () {
34 const executor = yield* ExecutorService;
35 return yield* executor.tools.invoke(path.toolId, payload.args);
36 }),
37 ),31 ),
38);32);
apps/web/vite.config.ts+59−39
@@ -5,6 +5,63 @@ import { createServerHandlers } from "@executor/server"; · 4 unmodified lines
5 5
6const handlersPromise = createServerHandlers();6const handlersPromise = createServerHandlers();
7 7
8// Build a Web Request from a Node IncomingMessage
9const toWebRequest = async (
10 req: import("http").IncomingMessage,
11): Promise<Request> => {
12 const headers = new Headers();
13 for (const [key, value] of Object.entries(req.headers)) {
14 if (value === undefined) continue;
15 if (Array.isArray(value)) {
16 for (const v of value) headers.append(key, v);
17 } else {
18 headers.set(key, value);
19 }
20 }
21
22 const host = req.headers.host ?? "localhost";
23 const url = `http://${host}${req.url}`;
24
25 return new Request(url, {
26 method: req.method,
27 headers,
28 body:
29 req.method !== "GET" && req.method !== "HEAD"
30 ? (await new Promise<Buffer>((resolve) => {
31 const chunks: Buffer[] = [];
32 req.on("data", (c: Buffer) => chunks.push(c));
33 req.on("end", () => resolve(Buffer.concat(chunks)));
34 }) as unknown as BodyInit)
35 : undefined,
36 duplex: "half" as const,
37 });
38};
39
40// Pipe a Web Response back to a Node ServerResponse, streaming if needed
41const sendWebResponse = async (
42 webRes: Response,
43 nodeRes: import("http").ServerResponse,
44) => {
45 nodeRes.statusCode = webRes.status;
46 webRes.headers.forEach((value, key) => nodeRes.setHeader(key, value));
47
48 if (!webRes.body) {
49 nodeRes.end();
50 return;
51 }
52
53 const reader = webRes.body.getReader();
54 try {
55 for (;;) {
56 const { done, value } = await reader.read();
57 if (done) break;
58 nodeRes.write(value);
59 }
60 } finally {
61 nodeRes.end();
62 }
63};
64
8export default defineConfig({65export default defineConfig({
9 plugins: [66 plugins: [
10 react(),67 react(),
@@ -24,50 +81,13 @@ export default defineConfig({ · 13 unmodified lines
24 if (!isApi && !isMcp) return next();81 if (!isApi && !isMcp) return next();
25 82
26 const handlers = await handlersPromise;83 const handlers = await handlersPromise;
27 84 const request = await toWebRequest(req);
28 // Build a standard Request from the Node HTTP request
29 const headers = new Headers();
30 for (const [key, value] of Object.entries(req.headers)) {
31 if (value === undefined) continue;
32 if (Array.isArray(value)) {
33 for (const v of value) headers.append(key, v);
34 } else {
35 headers.set(key, value);
36 }
37 }
38
39 const host = req.headers.host ?? "localhost";
40 const request = new Request(`http://${host}${url}`, {
41 method: req.method,
42 headers,
43 body:
44 req.method !== "GET" && req.method !== "HEAD"
45 ? (await new Promise<Uint8Array>((resolve) => {
46 const chunks: Uint8Array[] = [];
47 req.on("data", (c: Uint8Array) => chunks.push(c));
48 req.on("end", () => {
49 const total = chunks.reduce((n, c) => n + c.length, 0);
50 const buf = new Uint8Array(total);
51 let offset = 0;
52 for (const c of chunks) {
53 buf.set(c, offset);
54 offset += c.length;
55 }
56 resolve(buf);
57 });
58 }) as unknown as BodyInit)
59 : undefined,
60 });
61 85
62 const response = isMcp86 const response = isMcp
63 ? await handlers.mcp.handleRequest(request)87 ? await handlers.mcp.handleRequest(request)
64 : await handlers.api.handler(request);88 : await handlers.api.handler(request);
65 89
66 res.statusCode = response.status;90 await sendWebResponse(response, res);
67 response.headers.forEach((value, key) => res.setHeader(key, value));
68
69 const body = await response.arrayBuffer();
70 res.end(Buffer.from(body));
71 });91 });
72 },92 },
73 },93 },
packages/clients/react/src/atoms.ts+1−1
@@ -58,7 +58,7 @@ export const secretStatusAtom = (scopeId: ScopeId, secretId: SecretId) => · 57 unmodified lines
58// Mutation atoms58// Mutation atoms
59// ---------------------------------------------------------------------------59// ---------------------------------------------------------------------------
60 60
61export const invokeTool = ExecutorApiClient.mutation("tools", "invoke");61
62 62
63export const setSecret = ExecutorApiClient.mutation("secrets", "set");63export const setSecret = ExecutorApiClient.mutation("secrets", "set");
64 64
packages/clients/react/src/index.ts+0−1
@@ -28,7 +28,6 @@ export { · 27 unmodified lines
28 sourceAtom,28 sourceAtom,
29 secretsAtom,29 secretsAtom,
30 secretStatusAtom,30 secretStatusAtom,
31 invokeTool,
32 setSecret,31 setSecret,
33 resolveSecret,32 resolveSecret,
34 removeSecret,33 removeSecret,
packages/core/api/src/tools/api.ts+0−31
@@ -4,9 +4,6 @@ import { · 3 unmodified lines
4 ScopeId,4 ScopeId,
5 ToolId,5 ToolId,
6 ToolNotFoundError,6 ToolNotFoundError,
7 ToolInvocationError,
8 PolicyDeniedError,
9 ElicitationDeclinedError,
10} from "@executor/sdk";7} from "@executor/sdk";
11 8
12// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
@@ -35,16 +32,6 @@ const ToolSchemaResponse = Schema.Struct({ · 22 unmodified lines
35 outputSchema: Schema.optional(Schema.Unknown),32 outputSchema: Schema.optional(Schema.Unknown),
36});33});
37 34
38const ToolInvokePayload = Schema.Struct({
39 args: Schema.Unknown,
40});
41
42const ToolInvokeResponse = Schema.Struct({
43 data: Schema.Unknown,
44 error: Schema.NullOr(Schema.Unknown),
45 status: Schema.optional(Schema.Number),
46});
47
48// ---------------------------------------------------------------------------35// ---------------------------------------------------------------------------
49// Error schemas with HTTP status annotations36// Error schemas with HTTP status annotations
50// ---------------------------------------------------------------------------37// ---------------------------------------------------------------------------
@@ -52,15 +39,6 @@ const ToolInvokeResponse = Schema.Struct({ · 1 unmodified line
52const ToolNotFound = ToolNotFoundError.annotations(39const ToolNotFound = ToolNotFoundError.annotations(
53 HttpApiSchema.annotations({ status: 404 }),40 HttpApiSchema.annotations({ status: 404 }),
54);41);
55const ToolInvocation = ToolInvocationError.annotations(
56 HttpApiSchema.annotations({ status: 500 }),
57);
58const PolicyDenied = PolicyDeniedError.annotations(
59 HttpApiSchema.annotations({ status: 403 }),
60);
61const ElicitationDeclined = ElicitationDeclinedError.annotations(
62 HttpApiSchema.annotations({ status: 422 }),
63);
64 42
65// ---------------------------------------------------------------------------43// ---------------------------------------------------------------------------
66// Group44// Group
@@ -76,13 +54,4 @@ export class ToolsApi extends HttpApiGroup.make("tools") · 9 unmodified lines
76 .addSuccess(ToolSchemaResponse)54 .addSuccess(ToolSchemaResponse)
77 .addError(ToolNotFound),55 .addError(ToolNotFound),
78 )56 )
79 .add(
80 HttpApiEndpoint.post("invoke")`/scopes/${scopeIdParam}/tools/${toolIdParam}/invoke`
81 .setPayload(ToolInvokePayload)
82 .addSuccess(ToolInvokeResponse)
83 .addError(ToolNotFound)
84 .addError(ToolInvocation)
85 .addError(PolicyDenied)
86 .addError(ElicitationDeclined),
87 )
88 .prefix("/v1") {}57 .prefix("/v1") {}
packages/core/execution/src/engine.ts+3−3
@@ -125,7 +125,7 @@ export const formatPausedExecution = (paused: PausedExecution): { · 124 unmodified lines
125 125
126const makeFullInvoker = (126const makeFullInvoker = (
127 executor: Executor,127 executor: Executor,
128 invokeOptions?: InvokeOptions,128 invokeOptions: InvokeOptions,
129): SandboxToolInvoker => {129): SandboxToolInvoker => {
130 const base = makeExecutorToolInvoker(executor, { invokeOptions });130 const base = makeExecutorToolInvoker(executor, { invokeOptions });
131 return {131 return {
@@ -155,7 +155,7 @@ export type ExecutionEngine = { · 23 unmodified lines
155 */155 */
156 readonly execute: (156 readonly execute: (
157 code: string,157 code: string,
158 options?: { readonly onElicitation?: ElicitationHandler },158 options: { readonly onElicitation: ElicitationHandler },
159 ) => Promise<ExecuteResult>;159 ) => Promise<ExecuteResult>;
160 160
161 /**161 /**
@@ -188,7 +188,7 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 26 unmodified lines
188 return {188 return {
189 execute: async (code, options) => {189 execute: async (code, options) => {
190 const invoker = makeFullInvoker(executor, {190 const invoker = makeFullInvoker(executor, {
191 onElicitation: options?.onElicitation,191 onElicitation: options.onElicitation,
192 });192 });
193 return runEffect(codeExecutor.execute(code, invoker));193 return runEffect(codeExecutor.execute(code, invoker));
194 },194 },
packages/core/execution/src/tool-invoker.ts+2−2
@@ -8,14 +8,14 @@ import type { SandboxToolInvoker } from "@executor/codemode-core"; · 7 unmodified lines
8 */8 */
9export const makeExecutorToolInvoker = (9export const makeExecutorToolInvoker = (
10 executor: Executor,10 executor: Executor,
11 options?: { readonly invokeOptions?: InvokeOptions },11 options: { readonly invokeOptions: InvokeOptions },
12): SandboxToolInvoker => ({12): SandboxToolInvoker => ({
13 invoke: ({ path, args }) =>13 invoke: ({ path, args }) =>
14 Effect.gen(function* () {14 Effect.gen(function* () {
15 const result = yield* executor.tools.invoke(15 const result = yield* executor.tools.invoke(
16 path as ToolId,16 path as ToolId,
17 args,17 args,
18 options?.invokeOptions,18 options.invokeOptions,
19 );19 );
20 if (result.error !== null && result.error !== undefined) {20 if (result.error !== null && result.error !== undefined) {
21 return yield* Effect.fail(result.error);21 return yield* Effect.fail(result.error);
packages/core/sdk/src/executor.ts+34−3
@@ -25,7 +25,17 @@ import type { · 24 unmodified lines
25 SecretResolutionError,25 SecretResolutionError,
26 PolicyDeniedError,26 PolicyDeniedError,
27} from "./errors";27} from "./errors";
28import type { ElicitationDeclinedError } from "./elicitation";28import {
29 FormElicitation,
30 ElicitationDeclinedError,
31 ElicitationResponse,
32 type ElicitationHandler,
33} from "./elicitation";
34
35const resolveElicitationHandler = (options: InvokeOptions): ElicitationHandler =>
36 options.onElicitation === "accept-all"
37 ? () => Effect.succeed(new ElicitationResponse({ action: "accept" }))
38 : options.onElicitation;
29 39
30// ---------------------------------------------------------------------------40// ---------------------------------------------------------------------------
31// Executor — the main public API, expands with plugins41// Executor — the main public API, expands with plugins
@@ -46,7 +56,7 @@ export type Executor< · 14 unmodified lines
46 readonly invoke: (56 readonly invoke: (
47 toolId: string,57 toolId: string,
48 args: unknown,58 args: unknown,
49 options?: InvokeOptions,59 options: InvokeOptions,
50 ) => Effect.Effect<60 ) => Effect.Effect<
51 ToolInvocationResult,61 ToolInvocationResult,
52 | ToolNotFoundError62 | ToolNotFoundError
@@ -149,10 +159,31 @@ export const createExecutor = < · 96 unmodified lines
149 list: (filter?: ToolListFilter) => tools.list(filter),159 list: (filter?: ToolListFilter) => tools.list(filter),
150 schema: (toolId: string) => tools.schema(toolId as ToolId),160 schema: (toolId: string) => tools.schema(toolId as ToolId),
151 definitions: () => tools.definitions(),161 definitions: () => tools.definitions(),
152 invoke: (toolId: string, args: unknown, options?: InvokeOptions) => {162 invoke: (toolId: string, args: unknown, options: InvokeOptions) => {
153 const tid = toolId as ToolId;163 const tid = toolId as ToolId;
154 return Effect.gen(function* () {164 return Effect.gen(function* () {
155 yield* policies.check({ scopeId: scope.id, toolId: tid });165 yield* policies.check({ scopeId: scope.id, toolId: tid });
166
167 // Dynamically resolve annotations from the plugin
168 const annotations = yield* tools.resolveAnnotations(tid);
169 if (annotations?.requiresApproval) {
170 const handler = resolveElicitationHandler(options);
171 const response = yield* handler({
172 toolId: tid,
173 args,
174 request: new FormElicitation({
175 message: annotations.approvalDescription ?? `Approve ${toolId}?`,
176 requestedSchema: {},
177 }),
178 });
179 if (response.action !== "accept") {
180 return yield* new ElicitationDeclinedError({
181 toolId: tid,
182 action: response.action,
183 });
184 }
185 }
186
156 return yield* tools.invoke(tid, args, options);187 return yield* tools.invoke(tid, args, options);
157 });188 });
158 },189 },
packages/core/sdk/src/in-memory/tool-registry.ts+10−1
@@ -66,7 +66,16 @@ export const makeInMemoryToolRegistry = () => { · 65 unmodified lines
66 invokers.set(pluginKey, invoker);66 invokers.set(pluginKey, invoker);
67 }),67 }),
68 68
69 invoke: (toolId: ToolId, args: unknown, options?: InvokeOptions) =>69 resolveAnnotations: (toolId: ToolId) =>
70 Effect.gen(function* () {
71 const tool = tools.get(toolId);
72 if (!tool) return undefined;
73 const invoker = invokers.get(tool.pluginKey);
74 if (!invoker?.resolveAnnotations) return undefined;
75 return yield* invoker.resolveAnnotations(toolId);
76 }),
77
78 invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) =>
70 Effect.gen(function* () {79 Effect.gen(function* () {
71 const tool = yield* Effect.fromNullable(tools.get(toolId)).pipe(80 const tool = yield* Effect.fromNullable(tools.get(toolId)).pipe(
72 Effect.mapError(() => new ToolNotFoundError({ toolId })),81 Effect.mapError(() => new ToolNotFoundError({ toolId })),
packages/core/sdk/src/index.test.ts+10−7
@@ -11,9 +11,12 @@ import { · 10 unmodified lines
11 ElicitationResponse,11 ElicitationResponse,
12 type MemoryToolContext,12 type MemoryToolContext,
13 type ToolId,13 type ToolId,
14 type InvokeOptions,
14 SecretId,15 SecretId,
15} from "./index";16} from "./index";
16 17
18const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
19
17// ---------------------------------------------------------------------------20// ---------------------------------------------------------------------------
18// Schemas21// Schemas
19// ---------------------------------------------------------------------------22// ---------------------------------------------------------------------------
@@ -95,7 +98,7 @@ describe("SDK Executor", () => { · 75 unmodified lines
95 }),98 }),
96 );99 );
97 100
98 const result = yield* executor.tools.invoke("inventory.getItem", { itemId: 42 });101 const result = yield* executor.tools.invoke("inventory.getItem", { itemId: 42 }, autoApprove);
99 expect(result.data).toEqual({ id: 42, name: "Widget" });102 expect(result.data).toEqual({ id: 42, name: "Widget" });
100 expect(result.error).toBeNull();103 expect(result.error).toBeNull();
101 }),104 }),
@@ -121,12 +124,12 @@ describe("SDK Executor", () => { · 19 unmodified lines
121 );124 );
122 125
123 const exit = yield* executor.tools126 const exit = yield* executor.tools
124 .invoke("inventory.getItem", { itemId: "not-a-number" })127 .invoke("inventory.getItem", { itemId: "not-a-number" }, autoApprove)
125 .pipe(Effect.exit);128 .pipe(Effect.exit);
126 129
127 expect(Exit.isFailure(exit)).toBe(true);130 expect(Exit.isFailure(exit)).toBe(true);
128 const error = yield* Effect.flip(131 const error = yield* Effect.flip(
129 executor.tools.invoke("inventory.getItem", { itemId: "not-a-number" }),132 executor.tools.invoke("inventory.getItem", { itemId: "not-a-number" }, autoApprove),
130 );133 );
131 expect(error._tag).toBe("ToolInvocationError");134 expect(error._tag).toBe("ToolInvocationError");
132 }),135 }),
@@ -136,7 +139,7 @@ describe("SDK Executor", () => { · 3 unmodified lines
136 Effect.gen(function* () {139 Effect.gen(function* () {
137 const executor = yield* createExecutor(makeTestConfig());140 const executor = yield* createExecutor(makeTestConfig());
138 const error = yield* Effect.flip(141 const error = yield* Effect.flip(
139 executor.tools.invoke("nonexistent", {}),142 executor.tools.invoke("nonexistent", {}, autoApprove),
140 );143 );
141 expect(error._tag).toBe("ToolNotFoundError");144 expect(error._tag).toBe("ToolNotFoundError");
142 }),145 }),
@@ -204,7 +207,7 @@ describe("SDK Executor", () => { · 61 unmodified lines
204 expect(tools).toHaveLength(1);207 expect(tools).toHaveLength(1);
205 expect(tools[0]!.name).toBe("dynamicTool");208 expect(tools[0]!.name).toBe("dynamicTool");
206 209
207 const result = yield* executor.tools.invoke("runtime.dynamicTool", {});210 const result = yield* executor.tools.invoke("runtime.dynamicTool", {}, autoApprove);
208 expect(result.data).toBe("dynamic result");211 expect(result.data).toBe("dynamic result");
209 }),212 }),
210 );213 );
@@ -352,7 +355,7 @@ describe("SDK Executor", () => { · 141 unmodified lines
352 );355 );
353 356
354 const error = yield* Effect.flip(357 const error = yield* Effect.flip(
355 executor.tools.invoke("auth.login", {}),358 executor.tools.invoke("auth.login", {}, autoApprove),
356 );359 );
357 expect(error._tag).toBe("ElicitationDeclinedError");360 expect(error._tag).toBe("ElicitationDeclinedError");
358 }),361 }),
@@ -471,7 +474,7 @@ describe("SDK Executor", () => { · 112 unmodified lines
471 const result = yield* executor.tools.invoke("vault.rotateKey", {474 const result = yield* executor.tools.invoke("vault.rotateKey", {
472 secretName: "DB_PASSWORD",475 secretName: "DB_PASSWORD",
473 newValue: "correct-horse-battery-staple",476 newValue: "correct-horse-battery-staple",
474 });477 }, autoApprove);
475 478
476 // 4. Verify the tool returned old and new values479 // 4. Verify the tool returned old and new values
477 expect(result.data).toEqual({480 expect(result.data).toEqual({
packages/core/sdk/src/index.ts+1−0
@@ -17,6 +17,7 @@ export { · 16 unmodified lines
17 ToolInvocationResult,17 ToolInvocationResult,
18 ToolRegistry,18 ToolRegistry,
19 ToolRegistration,19 ToolRegistration,
20 ToolAnnotations,
20 ToolListFilter,21 ToolListFilter,
21 type ToolInvoker,22 type ToolInvoker,
22 type InvokeOptions,23 type InvokeOptions,
packages/core/sdk/src/plugins/in-memory-tools.ts+7−2
@@ -12,6 +12,8 @@ import { · 11 unmodified lines
12} from "../tools";12} from "../tools";
13import {13import {
14 ElicitationDeclinedError,14 ElicitationDeclinedError,
15 ElicitationResponse,
16 type ElicitationHandler,
15 type ElicitationRequest,17 type ElicitationRequest,
16} from "../elicitation";18} from "../elicitation";
17import { definePlugin, type PluginContext } from "../plugin";19import { definePlugin, type PluginContext } from "../plugin";
@@ -192,13 +194,16 @@ const makeInvoker = ( · 174 unmodified lines
192 },194 },
193 elicit: (request) =>195 elicit: (request) =>
194 Effect.gen(function* () {196 Effect.gen(function* () {
195 const handler = options?.onElicitation;197 const raw = options?.onElicitation;
196 if (!handler) {198 if (!raw) {
197 return yield* new ElicitationDeclinedError({199 return yield* new ElicitationDeclinedError({
198 toolId,200 toolId,
199 action: "decline",201 action: "decline",
200 });202 });
201 }203 }
204 const handler: ElicitationHandler = raw === "accept-all"
205 ? () => Effect.succeed(new ElicitationResponse({ action: "accept" }))
206 : raw;
202 const response = yield* handler({207 const response = yield* handler({
203 toolId,208 toolId,
204 args,209 args,
packages/core/sdk/src/tools.ts+23−4
@@ -11,6 +11,13 @@ import type { · 10 unmodified lines
11// Tool models11// Tool models
12// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
13 13
14export class ToolAnnotations extends Schema.Class<ToolAnnotations>("ToolAnnotations")({
15 /** Whether this tool requires user approval before execution */
16 requiresApproval: Schema.optional(Schema.Boolean),
17 /** Human-readable description shown in the approval prompt */
18 approvalDescription: Schema.optional(Schema.String),
19}) {}
20
14export class ToolMetadata extends Schema.Class<ToolMetadata>("ToolMetadata")({21export class ToolMetadata extends Schema.Class<ToolMetadata>("ToolMetadata")({
15 id: ToolId,22 id: ToolId,
16 pluginKey: Schema.String,23 pluginKey: Schema.String,
@@ -51,8 +58,8 @@ export class ToolListFilter extends Schema.Class<ToolListFilter>("ToolListFilter · 34 unmodified lines
51// ---------------------------------------------------------------------------58// ---------------------------------------------------------------------------
52 59
53export interface InvokeOptions {60export interface InvokeOptions {
54 /** Handler for elicitation requests. If not provided, elicitations are auto-declined. */61 /** Handler for elicitation requests, or "accept-all" to auto-approve everything. */
55 readonly onElicitation?: ElicitationHandler;62 readonly onElicitation: ElicitationHandler | "accept-all";
56}63}
57 64
58// ---------------------------------------------------------------------------65// ---------------------------------------------------------------------------
@@ -71,7 +78,7 @@ export class ToolRegistry extends Context.Tag("@executor/sdk/ToolRegistry")< · 12 unmodified lines
71 readonly invoke: (78 readonly invoke: (
72 toolId: ToolId,79 toolId: ToolId,
73 args: unknown,80 args: unknown,
74 options?: InvokeOptions,81 options: InvokeOptions,
75 ) => Effect.Effect<82 ) => Effect.Effect<
76 ToolInvocationResult,83 ToolInvocationResult,
77 ToolNotFoundError | ToolInvocationError | ElicitationDeclinedError84 ToolNotFoundError | ToolInvocationError | ElicitationDeclinedError
@@ -100,6 +107,13 @@ export class ToolRegistry extends Context.Tag("@executor/sdk/ToolRegistry")< · 22 unmodified lines
100 invoker: ToolInvoker,107 invoker: ToolInvoker,
101 ) => Effect.Effect<void>;108 ) => Effect.Effect<void>;
102 109
110 /**
111 * Resolve annotations for a tool by delegating to the plugin's invoker.
112 */
113 readonly resolveAnnotations: (
114 toolId: ToolId,
115 ) => Effect.Effect<ToolAnnotations | undefined>;
116
103 /** Register tools (used by plugins to push tools into the registry) */117 /** Register tools (used by plugins to push tools into the registry) */
104 readonly register: (118 readonly register: (
105 tools: readonly ToolRegistration[],119 tools: readonly ToolRegistration[],
@@ -125,11 +139,16 @@ export interface ToolInvoker { · 19 unmodified lines
125 readonly invoke: (139 readonly invoke: (
126 toolId: ToolId,140 toolId: ToolId,
127 args: unknown,141 args: unknown,
128 options?: InvokeOptions,142 options: InvokeOptions,
129 ) => Effect.Effect<143 ) => Effect.Effect<
130 ToolInvocationResult,144 ToolInvocationResult,
131 ToolInvocationError | ElicitationDeclinedError145 ToolInvocationError | ElicitationDeclinedError
132 >;146 >;
147
148 /** Dynamically compute annotations for a tool (e.g. approval requirements). */
149 readonly resolveAnnotations?: (
150 toolId: ToolId,
151 ) => Effect.Effect<ToolAnnotations | undefined>;
133}152}
134 153
135// ---------------------------------------------------------------------------154// ---------------------------------------------------------------------------
packages/core/storage-file/src/tool-registry.ts+10−1
@@ -99,7 +99,16 @@ export const makeKvToolRegistry = ( · 98 unmodified lines
99 registerInvoker: (pluginKey: string, invoker: ToolInvoker) =>99 registerInvoker: (pluginKey: string, invoker: ToolInvoker) =>
100 Effect.sync(() => { invokers.set(pluginKey, invoker); }),100 Effect.sync(() => { invokers.set(pluginKey, invoker); }),
101 101
102 invoke: (toolId: ToolId, args: unknown, options?: InvokeOptions) =>102 resolveAnnotations: (toolId: ToolId) =>
103 Effect.gen(function* () {
104 const tool = yield* getTool(toolId);
105 if (!tool) return undefined;
106 const invoker = invokers.get(tool.pluginKey);
107 if (!invoker?.resolveAnnotations) return undefined;
108 return yield* invoker.resolveAnnotations(toolId);
109 }),
110
111 invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) =>
103 Effect.gen(function* () {112 Effect.gen(function* () {
104 const tool = yield* getTool(toolId);113 const tool = yield* getTool(toolId);
105 if (!tool) return yield* new ToolNotFoundError({ toolId });114 if (!tool) return yield* new ToolNotFoundError({ toolId });
packages/plugins/openapi/src/sdk/invoke.ts+24−1
@@ -278,13 +278,36 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 277 unmodified lines
278// ToolInvoker — bridges operation store + HTTP client into SDK invoker278// ToolInvoker — bridges operation store + HTTP client into SDK invoker
279// ---------------------------------------------------------------------------279// ---------------------------------------------------------------------------
280 280
281const SAFE_METHODS = new Set(["get", "head", "options"]);
282
283/**
284 * Derive tool annotations from the HTTP method and path.
285 */
286export const annotationsForOperation = (method: string, pathTemplate: string): {
287 requiresApproval?: boolean;
288 approvalDescription?: string;
289} => {
290 if (SAFE_METHODS.has(method.toLowerCase())) return {};
291 return {
292 requiresApproval: true,
293 approvalDescription: `${method.toUpperCase()} ${pathTemplate}`,
294 };
295};
296
281export const makeOpenApiInvoker = (opts: {297export const makeOpenApiInvoker = (opts: {
282 readonly operationStore: OpenApiOperationStore;298 readonly operationStore: OpenApiOperationStore;
283 readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>;299 readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>;
284 readonly secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect<string, unknown> };300 readonly secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect<string, unknown> };
285 readonly scopeId: ScopeId;301 readonly scopeId: ScopeId;
286}): ToolInvoker => ({302}): ToolInvoker => ({
287 invoke: (toolId: ToolId, args: unknown, _options) =>303 resolveAnnotations: (toolId: ToolId) =>
304 Effect.gen(function* () {
305 const entry = yield* opts.operationStore.get(toolId);
306 if (!entry) return undefined;
307 return annotationsForOperation(entry.binding.method, entry.binding.pathTemplate);
308 }),
309
310 invoke: (toolId: ToolId, args: unknown) =>
288 Effect.gen(function* () {311 Effect.gen(function* () {
289 const entry = yield* opts.operationStore.get(toolId);312 const entry = yield* opts.operationStore.get(toolId);
290 if (!entry) {313 if (!entry) {
packages/plugins/openapi/src/sdk/plugin.test.ts+7−4
@@ -11,9 +11,11 @@ import { · 10 unmodified lines
11} from "@effect/platform";11} from "@effect/platform";
12import { NodeHttpServer } from "@effect/platform-node";12import { NodeHttpServer } from "@effect/platform-node";
13 13
14import { createExecutor, makeTestConfig, SecretId } from "@executor/sdk";14import { createExecutor, makeTestConfig, SecretId, type InvokeOptions } from "@executor/sdk";
15import { openApiPlugin } from "./plugin";15import { openApiPlugin } from "./plugin";
16 16
17const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
18
17// ---------------------------------------------------------------------------19// ---------------------------------------------------------------------------
18// Define a test API with Effect HttpApi20// Define a test API with Effect HttpApi
19// ---------------------------------------------------------------------------21// ---------------------------------------------------------------------------
@@ -151,6 +153,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 131 unmodified lines
151 const result = yield* executor.tools.invoke(153 const result = yield* executor.tools.invoke(
152 "authed.items.echoHeaders",154 "authed.items.echoHeaders",
153 {},155 {},
156 autoApprove,
154 );157 );
155 158
156 expect(result.error).toBeNull();159 expect(result.error).toBeNull();
@@ -185,7 +188,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 28 unmodified lines
185 188
186 // Invoke — should fail with a clear error about the missing secret189 // Invoke — should fail with a clear error about the missing secret
187 const error = yield* Effect.flip(190 const error = yield* Effect.flip(
188 executor.tools.invoke("noauth.items.listItems", {}),191 executor.tools.invoke("noauth.items.listItems", {}, autoApprove),
189 );192 );
190 193
191 expect(error._tag).toBe("ToolInvocationError");194 expect(error._tag).toBe("ToolInvocationError");
@@ -240,7 +243,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 48 unmodified lines
240 baseUrl: "",243 baseUrl: "",
241 });244 });
242 245
243 const result = yield* executor.tools.invoke("test.items.listItems", {});246 const result = yield* executor.tools.invoke("test.items.listItems", {}, autoApprove);
244 expect(result.error).toBeNull();247 expect(result.error).toBeNull();
245 expect(result.data).toEqual(ITEMS);248 expect(result.data).toEqual(ITEMS);
246 }),249 }),
@@ -267,7 +270,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 20 unmodified lines
267 270
268 const result = yield* executor.tools.invoke("test.items.getItem", {271 const result = yield* executor.tools.invoke("test.items.getItem", {
269 itemId: "2",272 itemId: "2",
270 });273 }, autoApprove);
271 expect(result.error).toBeNull();274 expect(result.error).toBeNull();
272 expect(result.data).toEqual({ id: 2, name: "Gadget" });275 expect(result.data).toEqual({ id: 2, name: "Gadget" });
273 }),276 }),
packages/plugins/openapi/src/sdk/plugin.ts+1−0
@@ -26,6 +26,7 @@ import { · 25 unmodified lines
26 OperationBinding,26 OperationBinding,
27} from "./types";27} from "./types";
28 28
29
29// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
30// Plugin config31// Plugin config
31// ---------------------------------------------------------------------------32// ---------------------------------------------------------------------------