diff --git a/packages/plugins/google-discovery/src/api/group.ts b/packages/plugins/google-discovery/src/api/group.ts index 5d2509314..0b3f685bb 100644 --- a/packages/plugins/google-discovery/src/api/group.ts +++ b/packages/plugins/google-discovery/src/api/group.ts @@ -3,6 +3,7 @@ import { Schema } from "effect"; import { InternalError, ScopeId, SecretBackedValue } from "@executor-js/sdk/shared"; import { GoogleDiscoveryParseError, GoogleDiscoverySourceError } from "../sdk/errors"; import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source"; +import { GoogleDiscoveryAnnotationPolicy } from "../sdk/types"; export { HttpApiSchema }; @@ -52,6 +53,7 @@ const AddSourcePayload = Schema.Struct({ credentials: Schema.optional(DiscoveryCredentialsPayload), namespace: Schema.optional(Schema.String), auth: AuthPayload, + annotationPolicy: Schema.optional(GoogleDiscoveryAnnotationPolicy), }); const AddSourceResponse = Schema.Struct({ @@ -62,6 +64,7 @@ const AddSourceResponse = Schema.Struct({ const UpdateSourcePayload = Schema.Struct({ name: Schema.optional(Schema.String), auth: Schema.optional(AuthPayload), + annotationPolicy: Schema.optional(Schema.NullOr(GoogleDiscoveryAnnotationPolicy)), }); const UpdateSourceResponse = Schema.Struct({ diff --git a/packages/plugins/google-discovery/src/api/handlers.ts b/packages/plugins/google-discovery/src/api/handlers.ts index 04450afd4..224f204a7 100644 --- a/packages/plugins/google-discovery/src/api/handlers.ts +++ b/packages/plugins/google-discovery/src/api/handlers.ts @@ -92,6 +92,7 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( yield* ext.updateSource(path.namespace, path.scopeId, { name: payload.name, auth: payload.auth, + annotationPolicy: payload.annotationPolicy, }); return { updated: true }; }), diff --git a/packages/plugins/google-discovery/src/sdk/binding-store.ts b/packages/plugins/google-discovery/src/sdk/binding-store.ts index f9e69d27b..e770ba7fb 100644 --- a/packages/plugins/google-discovery/src/sdk/binding-store.ts +++ b/packages/plugins/google-discovery/src/sdk/binding-store.ts @@ -28,6 +28,7 @@ import { import { GoogleDiscoveryMethodBinding, GoogleDiscoveryStoredSourceData, + type GoogleDiscoveryAnnotationPolicy, type GoogleDiscoveryAuth, type GoogleDiscoveryCredentialValue, type GoogleDiscoveryFetchCredentials, @@ -272,6 +273,7 @@ export interface GoogleDiscoveryStore { update: { readonly name?: string; readonly auth?: import("./types").GoogleDiscoveryAuth; + readonly annotationPolicy?: GoogleDiscoveryAnnotationPolicy | null; }, ) => Effect.Effect; readonly removeSource: (sourceId: string, scope: string) => Effect.Effect; @@ -435,11 +437,21 @@ export const makeGoogleDiscoveryStore = ( ); if (!row) return; const auth = update.auth ?? columnsToAuth(row); + const existingConfig = yield* hydrateStoredSourceData(row, sourceId, scope); + const nextConfig = { + ...existingConfig, + auth, + ...(update.annotationPolicy !== undefined + ? { annotationPolicy: update.annotationPolicy ?? undefined } + : {}), + }; + const encoded = stripExtractedFields(decodeJsonObject(encodeStoredSourceData(nextConfig))); yield* fuma.use("google_discovery_source.updateManyByScopedId", (db) => db.updateMany("google_discovery_source", { where: (b) => b.and(b("id", "=", sourceId), b("scope_id", "=", scope)), set: { name: update.name ?? decodeString(row.name), + config: toJsonRecord(encoded), updated_at: new Date(), ...authToColumns(auth), }, diff --git a/packages/plugins/google-discovery/src/sdk/invoke.test.ts b/packages/plugins/google-discovery/src/sdk/invoke.test.ts new file mode 100644 index 000000000..fb9693c11 --- /dev/null +++ b/packages/plugins/google-discovery/src/sdk/invoke.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { annotationsForOperation } from "./invoke"; + +// --------------------------------------------------------------------------- +// Pure tests for the Google Discovery annotation helper. Exercises the +// default (POST / PUT / PATCH / DELETE) policy plus per-source overrides +// supplied via `GoogleDiscoveryAnnotationPolicy.requireApprovalFor`. +// --------------------------------------------------------------------------- + +describe("annotationsForOperation", () => { + it("applies the default policy when no override is supplied", () => { + const post = annotationsForOperation("post", "/foo", undefined); + expect(post.requiresApproval).toBe(true); + expect(post.approvalDescription).toBe("POST /foo"); + + const get = annotationsForOperation("get", "/foo", undefined); + expect(get.requiresApproval).toBeUndefined(); + expect(get.approvalDescription).toBeUndefined(); + }); + + it("honors overrides that include GET", () => { + const result = annotationsForOperation("get", "/foo", { + requireApprovalFor: ["get"], + }); + expect(result.requiresApproval).toBe(true); + expect(result.approvalDescription).toBe("GET /foo"); + }); + + it("honors overrides that exclude POST", () => { + const result = annotationsForOperation("post", "/foo", { + requireApprovalFor: ["get"], + }); + expect(result.requiresApproval).toBeUndefined(); + expect(result.approvalDescription).toBeUndefined(); + }); + + it("treats an empty override as approval-for-nothing", () => { + const result = annotationsForOperation("delete", "/foo", { + requireApprovalFor: [], + }); + expect(result.requiresApproval).toBeUndefined(); + expect(result.approvalDescription).toBeUndefined(); + }); + + it("treats a null policy the same as undefined (fall back to defaults)", () => { + const post = annotationsForOperation("post", "/foo", null); + expect(post.requiresApproval).toBe(true); + const get = annotationsForOperation("get", "/foo", null); + expect(get.requiresApproval).toBeUndefined(); + }); +}); diff --git a/packages/plugins/google-discovery/src/sdk/invoke.ts b/packages/plugins/google-discovery/src/sdk/invoke.ts index fd52de59e..ab59114a9 100644 --- a/packages/plugins/google-discovery/src/sdk/invoke.ts +++ b/packages/plugins/google-discovery/src/sdk/invoke.ts @@ -26,8 +26,11 @@ const errorMessageFromUnknown = (cause: unknown): string => { export const annotationsForOperation = ( method: string, pathTemplate: string, + policy?: { readonly requireApprovalFor?: readonly string[] } | null, ): { requiresApproval?: boolean; approvalDescription?: string } => { - if (SAFE_METHODS.has(method.toLowerCase())) return {}; + const m = method.toLowerCase(); + const requiresApproval = policy?.requireApprovalFor?.includes(m) ?? !SAFE_METHODS.has(m); + if (!requiresApproval) return {}; return { requiresApproval: true, approvalDescription: `${method.toUpperCase()} ${pathTemplate}`, diff --git a/packages/plugins/google-discovery/src/sdk/plugin.ts b/packages/plugins/google-discovery/src/sdk/plugin.ts index bc8bf119a..ff2b620d0 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.ts @@ -25,6 +25,7 @@ import { extractGoogleDiscoveryManifest } from "./document"; import { annotationsForOperation, invokeGoogleDiscoveryTool } from "./invoke"; import { GoogleDiscoveryParseError, GoogleDiscoverySourceError } from "./errors"; import { + GoogleDiscoveryAnnotationPolicy, GoogleDiscoveryAuth, GoogleDiscoveryFetchCredentials, GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema, @@ -123,6 +124,7 @@ export interface GoogleDiscoveryUpdateSourceInput { /** Rewrite the source's auth — typically after a successful * re-authenticate, to point at a freshly minted Connection. */ readonly auth?: GoogleDiscoveryAuth; + readonly annotationPolicy?: GoogleDiscoveryAnnotationPolicy | null; } const GoogleDiscoveryProbeInputSchema = Schema.Struct({ @@ -154,6 +156,7 @@ const GoogleDiscoveryAddSourceInputSchema = Schema.Struct({ credentials: Schema.optional(GoogleDiscoveryFetchCredentials), namespace: Schema.optional(Schema.String), auth: GoogleDiscoveryAuth, + annotationPolicy: Schema.optional(GoogleDiscoveryAnnotationPolicy), }); const GoogleDiscoveryStaticAddSourceInputSchema = Schema.Struct({ name: Schema.String, @@ -161,6 +164,7 @@ const GoogleDiscoveryStaticAddSourceInputSchema = Schema.Struct({ credentials: Schema.optional(GoogleDiscoveryFetchCredentials), namespace: Schema.optional(Schema.String), auth: GoogleDiscoveryAuth, + annotationPolicy: Schema.optional(GoogleDiscoveryAnnotationPolicy), }); export type GoogleDiscoveryProbeInput = typeof GoogleDiscoveryProbeInputSchema.Type; export type GoogleDiscoveryAddSourceInput = typeof GoogleDiscoveryAddSourceInputSchema.Type; @@ -186,6 +190,7 @@ const GoogleDiscoveryGetSourceOutputSchema = Schema.Struct({ const GoogleDiscoveryConfigureInputSchema = Schema.Struct({ name: Schema.optional(Schema.String), auth: Schema.optional(GoogleDiscoveryAuth), + annotationPolicy: Schema.optional(Schema.NullOr(GoogleDiscoveryAnnotationPolicy)), }); const GoogleDiscoveryConfigureSourceInputSchema = Schema.Struct({ source: Schema.Struct({ @@ -495,6 +500,7 @@ const makeGoogleDiscoveryPluginExtension = (ctx: PluginCtx rootUrl: manifest.rootUrl, servicePath: manifest.servicePath, auth: input.auth, + annotationPolicy: input.annotationPolicy, }); const toolCount = yield* registerManifest( ctx, @@ -529,6 +535,7 @@ const makeGoogleDiscoveryPluginExtension = (ctx: PluginCtx ctx.storage.updateSourceMeta(namespace, scope, { name: input.name?.trim() || undefined, auth: input.auth, + annotationPolicy: input.annotationPolicy, }), }); @@ -670,15 +677,23 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ const scopes = new Set(); for (const row of toolRows) scopes.add(decodeString(row.scope_id)); const byScope = new Map>(); + const policyByScope = new Map(); for (const scope of scopes) { const bindings = yield* typedCtx.storage.getBindingsForSource(sourceId, scope); byScope.set(scope, bindings); + const source = yield* typedCtx.storage.getSource(sourceId, scope); + policyByScope.set(scope, source?.config.annotationPolicy); } const out: Record = {}; for (const row of toolRows) { - const binding = byScope.get(decodeString(row.scope_id))?.get(row.id); + const scope = decodeString(row.scope_id); + const binding = byScope.get(scope)?.get(row.id); if (binding) { - out[row.id] = annotationsForOperation(binding.method, binding.pathTemplate); + out[row.id] = annotationsForOperation( + binding.method, + binding.pathTemplate, + policyByScope.get(scope), + ); } } return out; diff --git a/packages/plugins/google-discovery/src/sdk/types.ts b/packages/plugins/google-discovery/src/sdk/types.ts index 41825bdc5..2f83d6168 100644 --- a/packages/plugins/google-discovery/src/sdk/types.ts +++ b/packages/plugins/google-discovery/src/sdk/types.ts @@ -12,6 +12,11 @@ export const GoogleDiscoveryHttpMethod = Schema.Literals([ ]); export type GoogleDiscoveryHttpMethod = typeof GoogleDiscoveryHttpMethod.Type; +export const GoogleDiscoveryAnnotationPolicy = Schema.Struct({ + requireApprovalFor: Schema.optional(Schema.Array(GoogleDiscoveryHttpMethod)), +}).annotate({ identifier: "GoogleDiscoveryAnnotationPolicy" }); +export type GoogleDiscoveryAnnotationPolicy = typeof GoogleDiscoveryAnnotationPolicy.Type; + export const GoogleDiscoveryParameterLocation = Schema.Literals(["path", "query", "header"]); export type GoogleDiscoveryParameterLocation = typeof GoogleDiscoveryParameterLocation.Type; @@ -98,6 +103,7 @@ export const GoogleDiscoveryStoredSourceData = Schema.Struct({ name: Schema.String, discoveryUrl: Schema.String, credentials: Schema.optional(GoogleDiscoveryFetchCredentials), + annotationPolicy: Schema.optional(GoogleDiscoveryAnnotationPolicy), service: Schema.String, version: Schema.String, rootUrl: Schema.String, diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index 90798858a..b89290d35 100644 --- a/packages/plugins/graphql/src/api/group.ts +++ b/packages/plugins/graphql/src/api/group.ts @@ -4,6 +4,7 @@ import { InternalError, ScopeId } from "@executor-js/sdk/shared"; import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors"; import { + AnnotationPolicy, GraphqlConfiguredValueInput, ConfiguredGraphqlCredentialValue, GraphqlCredentialInput, @@ -22,6 +23,7 @@ export const StoredSourceSchema = Schema.Struct({ headers: Schema.Record(Schema.String, ConfiguredGraphqlCredentialValue), queryParams: Schema.Record(Schema.String, ConfiguredGraphqlCredentialValue), auth: GraphqlSourceAuth, + annotationPolicy: Schema.optional(AnnotationPolicy), }); // --------------------------------------------------------------------------- @@ -49,6 +51,7 @@ const AddSourcePayload = Schema.Struct({ headers: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInput)), queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInput)), oauth2: Schema.optional(OAuth2SourceConfig), + annotationPolicy: Schema.optional(AnnotationPolicy), credentials: Schema.optional( Schema.Struct({ scope: ScopeId, diff --git a/packages/plugins/graphql/src/api/handlers.ts b/packages/plugins/graphql/src/api/handlers.ts index 38c1a4cfb..505360273 100644 --- a/packages/plugins/graphql/src/api/handlers.ts +++ b/packages/plugins/graphql/src/api/handlers.ts @@ -53,6 +53,7 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra headers: payload.headers, queryParams: payload.queryParams, oauth2: payload.oauth2, + annotationPolicy: payload.annotationPolicy, credentials: payload.credentials, }); return { diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 4d571ddb4..840ca04b6 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -55,6 +55,7 @@ import { type StoredOperation, } from "./store"; import { + AnnotationPolicy, ExtractedField, GraphqlConfiguredValueInput as GraphqlConfiguredValueInputSchema, GRAPHQL_OAUTH_CONNECTION_SLOT, @@ -114,6 +115,7 @@ export interface GraphqlSourceConfig { readonly queryParams?: Record; /** Optional OAuth2 credential used as a Bearer token for every request. */ readonly oauth2?: OAuth2SourceConfig; + readonly annotationPolicy?: AnnotationPolicy; /** Initial credential bindings used while adding and introspecting this source. */ readonly credentials?: GraphqlInitialCredentialsInput; } @@ -134,6 +136,7 @@ const StaticAddSourceInputSchema = Schema.Struct({ headers: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInputSchema)), queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInputSchema)), oauth2: Schema.optional(OAuth2SourceConfig), + annotationPolicy: Schema.optional(AnnotationPolicy), credentials: Schema.optional(GraphqlInitialCredentialsInputSchema), }); const SourceConfigureInputSchema = Schema.Struct({ @@ -142,6 +145,7 @@ const SourceConfigureInputSchema = Schema.Struct({ headers: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)), queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)), auth: Schema.optional(GraphqlSourceAuthInputSchema), + annotationPolicy: Schema.optional(Schema.NullOr(AnnotationPolicy)), }); const StaticConfigureSourceInputSchema = Schema.Struct({ source: Schema.Struct({ @@ -245,6 +249,7 @@ export interface GraphqlConfigureSourceInput { readonly headers?: Record; readonly queryParams?: Record; readonly auth?: GraphqlSourceAuthInput; + readonly annotationPolicy?: AnnotationPolicy | null; } // --------------------------------------------------------------------------- @@ -399,11 +404,16 @@ const prepareOperations = ( }); }; -const annotationsFor = (binding: OperationBinding): ToolAnnotations => { - if (binding.kind === "mutation") { +const annotationsFor = ( + binding: OperationBinding, + policy: AnnotationPolicy | undefined, +): ToolAnnotations => { + const requiresApproval = + policy?.requireApprovalFor?.includes(binding.kind) ?? binding.kind === "mutation"; + if (requiresApproval) { return { requiresApproval: true, - approvalDescription: `mutation ${binding.fieldName}`, + approvalDescription: `${binding.kind} ${binding.fieldName}`, }; } return {}; @@ -907,6 +917,7 @@ const makeGraphqlExtension = ( headers: canonicalHeaders, queryParams: canonicalQueryParams, auth, + annotationPolicy: config.annotationPolicy, }; const storedOps: StoredOperation[] = prepared.map((p) => ({ @@ -1004,6 +1015,7 @@ const makeGraphqlExtension = ( headers: canonicalHeaders?.values, queryParams: canonicalQueryParams?.values, auth: canonicalAuth?.auth, + annotationPolicy: input.annotationPolicy, }); if (affectedPrefixes.length > 0 || directBindings.length > 0) { yield* ctx.credentialBindings.replaceForSource({ @@ -1294,16 +1306,18 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { const ops = yield* ctx.storage.listOperationsBySource(sourceId, scope); const byId = new Map(); for (const op of ops) byId.set(op.toolId, op.binding); - return [scope, byId] as const; + const source = yield* ctx.storage.getSource(sourceId, scope); + return [scope, { bindings: byId, policy: source?.annotationPolicy }] as const; }), { concurrency: "unbounded" }, ); - const byScope = new Map>(entries); + const byScope = new Map(entries); const out: Record = {}; for (const row of toolRows as readonly ToolRow[]) { - const binding = byScope.get(row.scope_id)?.get(row.id); - if (binding) out[row.id] = annotationsFor(binding); + const entry = byScope.get(row.scope_id); + const binding = entry?.bindings.get(row.id); + if (binding) out[row.id] = annotationsFor(binding, entry?.policy); } return out; }), diff --git a/packages/plugins/graphql/src/sdk/store.ts b/packages/plugins/graphql/src/sdk/store.ts index 5b6d96dee..d20ca4018 100644 --- a/packages/plugins/graphql/src/sdk/store.ts +++ b/packages/plugins/graphql/src/sdk/store.ts @@ -9,6 +9,7 @@ import { } from "@executor-js/sdk/core"; import { + AnnotationPolicy, GraphqlSourceAuth, OperationBinding, type ConfiguredGraphqlCredentialValue, @@ -25,6 +26,7 @@ export interface StoredGraphqlSource { readonly headers: Record; readonly queryParams: Record; readonly auth: GraphqlSourceAuth; + readonly annotationPolicy?: AnnotationPolicy; } export interface StoredOperation { @@ -69,6 +71,7 @@ const SourceStorage = Schema.Struct({ headers: Schema.optional(CredentialMapStorage), queryParams: Schema.optional(CredentialMapStorage), auth: GraphqlSourceAuth, + annotationPolicy: Schema.optional(AnnotationPolicy), }); const OperationStorage = Schema.Struct({ toolId: Schema.String, @@ -111,6 +114,7 @@ const sourceData = (source: StoredGraphqlSource) => ({ headers: source.headers, queryParams: source.queryParams, auth: source.auth, + ...(source.annotationPolicy ? { annotationPolicy: source.annotationPolicy } : {}), }); const operationData = (operation: StoredOperation) => ({ @@ -131,6 +135,7 @@ const rowToSource = (row: PluginStorageEntry): StoredGraphqlSource | null => { headers: normalizeCredentialMap(source.headers), queryParams: normalizeCredentialMap(source.queryParams), auth: source.auth, + annotationPolicy: source.annotationPolicy, }; }; @@ -159,6 +164,7 @@ export interface GraphqlStore { readonly headers?: Record; readonly queryParams?: Record; readonly auth?: GraphqlSourceAuth; + readonly annotationPolicy?: AnnotationPolicy | null; }, ) => Effect.Effect; readonly getSource: ( @@ -258,6 +264,10 @@ export const makeDefaultGraphqlStore = ({ headers: patch.headers ?? source.headers, queryParams: patch.queryParams ?? source.queryParams, auth: patch.auth ?? source.auth, + annotationPolicy: + patch.annotationPolicy !== undefined + ? (patch.annotationPolicy ?? undefined) + : source.annotationPolicy, }), }); }), diff --git a/packages/plugins/graphql/src/sdk/types.ts b/packages/plugins/graphql/src/sdk/types.ts index a4ea95c69..b28e28484 100644 --- a/packages/plugins/graphql/src/sdk/types.ts +++ b/packages/plugins/graphql/src/sdk/types.ts @@ -13,6 +13,11 @@ import { HttpConfiguredValueInput, HttpCredentialInput } from "@executor-js/sdk/ export const GraphqlOperationKind = Schema.Literals(["query", "mutation"]); export type GraphqlOperationKind = typeof GraphqlOperationKind.Type; +export const AnnotationPolicy = Schema.Struct({ + requireApprovalFor: Schema.optional(Schema.Array(GraphqlOperationKind)), +}).annotate({ identifier: "GraphqlAnnotationPolicy" }); +export type AnnotationPolicy = typeof AnnotationPolicy.Type; + // --------------------------------------------------------------------------- // Extracted field (becomes a tool) // --------------------------------------------------------------------------- diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 8c03efe9b..75ad2cf53 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -4,7 +4,12 @@ import { InternalError, ScopeId, SecretBackedMap } from "@executor-js/sdk/shared import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors"; import { McpStoredSourceSchema } from "../sdk/stored-source"; -import { McpConfiguredValueInput, McpConnectionAuthInput, McpCredentialInput } from "../sdk/types"; +import { + AnnotationPolicy, + McpConfiguredValueInput, + McpConnectionAuthInput, + McpCredentialInput, +} from "../sdk/types"; import { OAuth2SourceConfig } from "@executor-js/sdk/http-source"; // --------------------------------------------------------------------------- @@ -28,6 +33,7 @@ const AddRemoteSourcePayload = Schema.Struct({ queryParams: Schema.optional(Schema.Record(Schema.String, McpConfiguredValueInput)), headers: Schema.optional(Schema.Record(Schema.String, McpConfiguredValueInput)), oauth2: Schema.optional(OAuth2SourceConfig), + annotationPolicy: Schema.optional(AnnotationPolicy), credentials: Schema.optional( Schema.Struct({ scope: ScopeId, @@ -46,6 +52,7 @@ const AddStdioSourcePayload = Schema.Struct({ env: Schema.optional(StringMap), cwd: Schema.optional(Schema.String), namespace: Schema.optional(Schema.String), + annotationPolicy: Schema.optional(AnnotationPolicy), }); const AddSourcePayload = Schema.Union([AddRemoteSourcePayload, AddStdioSourcePayload]); diff --git a/packages/plugins/mcp/src/api/handlers.ts b/packages/plugins/mcp/src/api/handlers.ts index b6cea24e0..6a039ce20 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -44,6 +44,10 @@ const toSourceConfig = ( env?: Record; cwd?: string; namespace?: string; + annotationPolicy?: Extract< + McpSourceConfig, + { readonly transport: "stdio" } + >["annotationPolicy"]; }; return { transport: "stdio", @@ -54,6 +58,7 @@ const toSourceConfig = ( env: p.env, cwd: p.cwd, namespace: p.namespace, + annotationPolicy: p.annotationPolicy, }; } @@ -66,6 +71,10 @@ const toSourceConfig = ( headers?: Record; namespace?: string; oauth2?: OAuth2SourceConfigType; + annotationPolicy?: Extract< + McpSourceConfig, + { readonly transport: "remote" } + >["annotationPolicy"]; credentials?: Extract["credentials"]; }; @@ -79,6 +88,7 @@ const toSourceConfig = ( headers: p.headers, namespace: p.namespace, oauth2: p.oauth2, + annotationPolicy: p.annotationPolicy, credentials: p.credentials, }; }; @@ -144,6 +154,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand scope: ScopeId.make(source.scope), name: source.name, config: source.config, + annotationPolicy: source.annotationPolicy, }) : null; }), diff --git a/packages/plugins/mcp/src/sdk/binding-store.ts b/packages/plugins/mcp/src/sdk/binding-store.ts index adc6c40e1..efe4d7f1f 100644 --- a/packages/plugins/mcp/src/sdk/binding-store.ts +++ b/packages/plugins/mcp/src/sdk/binding-store.ts @@ -7,7 +7,7 @@ import { type StorageFailure, } from "@executor-js/sdk/core"; -import { McpStoredSourceData, McpToolBinding } from "./types"; +import { AnnotationPolicy, McpStoredSourceData, McpToolBinding } from "./types"; export const mcpSchema = {} satisfies FumaTables; export type McpSchema = typeof mcpSchema; @@ -17,6 +17,7 @@ const BINDING_COLLECTION = "binding"; const decodeSourceData = Schema.decodeUnknownSync(McpStoredSourceData); const encodeSourceData = Schema.encodeSync(McpStoredSourceData); +const decodeAnnotationPolicy = Schema.decodeUnknownSync(AnnotationPolicy); const decodeBinding = Schema.decodeUnknownSync(McpToolBinding); const encodeBinding = Schema.encodeSync(McpToolBinding); const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); @@ -31,6 +32,7 @@ export interface McpStoredSource { readonly scope: string; readonly name: string; readonly config: McpStoredSourceData; + readonly annotationPolicy?: AnnotationPolicy; } export interface McpBindingStore { @@ -72,6 +74,11 @@ export interface McpBindingStore { scope: string, ) => Effect.Effect; readonly putSource: (source: McpStoredSource) => Effect.Effect; + readonly updateSourceMeta: ( + namespace: string, + scope: string, + patch: { readonly name?: string; readonly annotationPolicy?: AnnotationPolicy | null }, + ) => Effect.Effect; readonly removeSource: (namespace: string, scope: string) => Effect.Effect; } @@ -80,6 +87,7 @@ const sourceData = (source: McpStoredSource) => ({ scope: source.scope, name: source.name, config: encodeSourceData(source.config), + ...(source.annotationPolicy ? { annotationPolicy: source.annotationPolicy } : {}), }); const bindingData = ( @@ -110,6 +118,10 @@ const rowToSource = (row: PluginStorageEntry): McpStoredSource | null => { scope: record.scope, name: record.name, config: decodeSourceData(coerceJson(record.config)), + annotationPolicy: + record.annotationPolicy === undefined + ? undefined + : decodeAnnotationPolicy(coerceJson(record.annotationPolicy)), }; }; @@ -215,6 +227,30 @@ export const makeMcpStore = ({ pluginStorage }: StorageDeps): McpBind }) .pipe(Effect.asVoid), + updateSourceMeta: (namespace, scope, patch) => + Effect.gen(function* () { + const existing = yield* pluginStorage.getAtScope({ + scope, + collection: SOURCE_COLLECTION, + key: namespace, + }); + const source = existing ? rowToSource(existing) : null; + if (!source) return; + yield* pluginStorage.put({ + scope, + collection: SOURCE_COLLECTION, + key: namespace, + data: sourceData({ + ...source, + name: patch.name ?? source.name, + annotationPolicy: + patch.annotationPolicy !== undefined + ? (patch.annotationPolicy ?? undefined) + : source.annotationPolicy, + }), + }); + }), + removeSource: (namespace, scope) => Effect.gen(function* () { yield* removeBindingsForSourceScope(namespace, scope); diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index f1c44e490..9f57a612f 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -58,6 +58,7 @@ import { deriveMcpNamespace, type McpToolManifest, type McpToolManifestEntry } f import { mcpPresets } from "./presets"; import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape"; import { + AnnotationPolicy, MCP_OAUTH_CLIENT_ID_SLOT, MCP_OAUTH_CLIENT_SECRET_SLOT, MCP_OAUTH_CONNECTION_SLOT, @@ -106,6 +107,7 @@ export interface McpRemoteSourceConfig extends McpSourceScopeField { readonly headers?: Record; readonly namespace?: string; readonly oauth2?: OAuth2SourceConfig; + readonly annotationPolicy?: AnnotationPolicy; readonly credentials?: McpInitialCredentialsInput; } @@ -117,6 +119,7 @@ export interface McpStdioSourceConfig extends McpSourceScopeField { readonly env?: Record; readonly cwd?: string; readonly namespace?: string; + readonly annotationPolicy?: AnnotationPolicy; } export type McpSourceConfig = McpRemoteSourceConfig | McpStdioSourceConfig; @@ -154,6 +157,7 @@ const McpConfigureSourcePayloadSchema = Schema.Struct({ headers: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), queryParams: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), auth: Schema.optional(McpConnectionAuthInput), + annotationPolicy: Schema.optional(Schema.NullOr(AnnotationPolicy)), }); const McpConfigureSourceInputSchema = Schema.Struct({ scope: Schema.String, @@ -178,6 +182,7 @@ const McpRemoteAddSourceInputSchema = Schema.Struct({ headers: Schema.optional(Schema.Record(Schema.String, McpConfiguredValueInput)), namespace: Schema.optional(Schema.String), oauth2: Schema.optional(OAuth2SourceConfig), + annotationPolicy: Schema.optional(AnnotationPolicy), credentials: Schema.optional(McpInitialCredentialsInputSchema), }); @@ -189,6 +194,7 @@ const McpStdioAddSourceInputSchema = Schema.Struct({ env: Schema.optional(Schema.Record(Schema.String, Schema.String)), cwd: Schema.optional(Schema.String), namespace: Schema.optional(Schema.String), + annotationPolicy: Schema.optional(AnnotationPolicy), }); const McpAddSourceInputSchema = Schema.Union([ @@ -1567,6 +1573,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { scope: config.scope, name: sourceName, config: sd, + annotationPolicy: config.annotationPolicy, }); yield* ctx.storage.putBindings( @@ -1830,6 +1837,10 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { scope: sourceScope, name: sourceName, config: updatedConfig, + annotationPolicy: + input.annotationPolicy !== undefined + ? (input.annotationPolicy ?? undefined) + : existing.annotationPolicy, }); if (affectedPrefixes.length > 0 || directBindings.length > 0) { yield* ctx.credentialBindings.replaceForSource({ @@ -2226,10 +2237,28 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { { concurrency: "unbounded" }, ); const byScope = new Map(entries); + const sources = yield* Effect.forEach( + [...scopes], + (scope) => + Effect.gen(function* () { + const source = yield* ctx.storage.getSource(sourceId, scope); + return [scope, source] as const; + }), + { concurrency: "unbounded" }, + ); + const sourceByScope = new Map(sources); const out: Record = {}; for (const row of toolRows) { const binding = byScope.get(row.scope_id)?.get(row.id); + const source = sourceByScope.get(row.scope_id); + if (source?.annotationPolicy?.requireApprovalForAll === true) { + out[row.id] = { + requiresApproval: true, + approvalDescription: `Source "${source.name}" requires approval for every MCP tool call`, + }; + continue; + } const ann = binding?.annotations; if (ann?.destructiveHint === true) { out[row.id] = { diff --git a/packages/plugins/mcp/src/sdk/stored-source.ts b/packages/plugins/mcp/src/sdk/stored-source.ts index f6d30dcbf..5ab4f4116 100644 --- a/packages/plugins/mcp/src/sdk/stored-source.ts +++ b/packages/plugins/mcp/src/sdk/stored-source.ts @@ -1,7 +1,7 @@ import { Schema } from "effect"; import { ScopeId } from "@executor-js/sdk/shared"; -import { McpStoredSourceData } from "./types"; +import { AnnotationPolicy, McpStoredSourceData } from "./types"; // --------------------------------------------------------------------------- // Stored source — the shape persisted by the binding store and exposed @@ -13,6 +13,7 @@ export const McpStoredSourceSchema = Schema.Struct({ scope: ScopeId, name: Schema.String, config: McpStoredSourceData, + annotationPolicy: Schema.optional(AnnotationPolicy), }).annotate({ identifier: "McpStoredSource" }); export type McpStoredSourceSchema = typeof McpStoredSourceSchema.Type; diff --git a/packages/plugins/mcp/src/sdk/types.ts b/packages/plugins/mcp/src/sdk/types.ts index 870e034a4..75f8e1aa3 100644 --- a/packages/plugins/mcp/src/sdk/types.ts +++ b/packages/plugins/mcp/src/sdk/types.ts @@ -119,6 +119,11 @@ export type McpStdioSourceData = typeof McpStdioSourceData.Type; export const McpStoredSourceData = Schema.Union([McpRemoteSourceData, McpStdioSourceData]); export type McpStoredSourceData = typeof McpStoredSourceData.Type; +export const AnnotationPolicy = Schema.Struct({ + requireApprovalForAll: Schema.optional(Schema.Boolean), +}).annotate({ identifier: "McpAnnotationPolicy" }); +export type AnnotationPolicy = typeof AnnotationPolicy.Type; + // --------------------------------------------------------------------------- // Tool binding — maps a registered ToolId back to the MCP tool name // --------------------------------------------------------------------------- diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index 62bf31c77..97eeda877 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -11,7 +11,7 @@ import { import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors"; import { SpecPreview } from "../sdk/preview"; import { StoredSourceSchema } from "../sdk/source-contracts"; -import { OAuth2SourceConfig } from "../sdk/types"; +import { AnnotationPolicy, OAuth2SourceConfig } from "../sdk/types"; // --------------------------------------------------------------------------- // Params @@ -68,6 +68,7 @@ const AddSpecPayload = Schema.Struct({ headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), oauth2: Schema.optional(OAuth2SourceConfig), + annotationPolicy: Schema.optional(AnnotationPolicy), }); const PreviewSpecPayload = Schema.Struct({ @@ -118,6 +119,7 @@ const ConfigurePayload = Schema.Struct({ }), ), oauth2Source: Schema.optional(OAuth2SourceConfig), + annotationPolicy: Schema.optional(Schema.NullOr(AnnotationPolicy)), }); // --------------------------------------------------------------------------- diff --git a/packages/plugins/openapi/src/api/handlers.ts b/packages/plugins/openapi/src/api/handlers.ts index 3be0cd284..0f9f9dae8 100644 --- a/packages/plugins/openapi/src/api/handlers.ts +++ b/packages/plugins/openapi/src/api/handlers.ts @@ -77,6 +77,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope | Record | undefined, oauth2: payload.oauth2, + annotationPolicy: payload.annotationPolicy, }); return { toolCount: result.toolCount, @@ -103,6 +104,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope queryParams: source.config.queryParams, specFetchCredentials: source.config.specFetchCredentials, oauth2: source.config.oauth2, + annotationPolicy: source.config.annotationPolicy, }, }) : null; diff --git a/packages/plugins/openapi/src/sdk/annotation-policy.test.ts b/packages/plugins/openapi/src/sdk/annotation-policy.test.ts new file mode 100644 index 000000000..5e67c8982 --- /dev/null +++ b/packages/plugins/openapi/src/sdk/annotation-policy.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { annotationsForOperation } from "./invoke"; + +describe("annotationsForOperation", () => { + it("uses default approval policy when no override is supplied", () => { + expect(annotationsForOperation("get", "/items")).toEqual({}); + expect(annotationsForOperation("post", "/items")).toEqual({ + requiresApproval: true, + approvalDescription: "POST /items", + }); + }); + + it("lets source policy replace the default method set", () => { + const policy = { requireApprovalFor: ["get"] }; + + expect(annotationsForOperation("get", "/items", policy)).toEqual({ + requiresApproval: true, + approvalDescription: "GET /items", + }); + expect(annotationsForOperation("post", "/items", policy)).toEqual({}); + }); +}); diff --git a/packages/plugins/openapi/src/sdk/invoke.ts b/packages/plugins/openapi/src/sdk/invoke.ts index 2b8b0f788..9b0e8e2ea 100644 --- a/packages/plugins/openapi/src/sdk/invoke.ts +++ b/packages/plugins/openapi/src/sdk/invoke.ts @@ -657,9 +657,11 @@ const REQUIRE_APPROVAL = new Set(["post", "put", "patch", "delete"]); export const annotationsForOperation = ( method: string, pathTemplate: string, + policy?: { readonly requireApprovalFor?: readonly string[] } | null, ): { requiresApproval?: boolean; approvalDescription?: string } => { const m = method.toLowerCase(); - if (!REQUIRE_APPROVAL.has(m)) return {}; + const requiresApproval = policy?.requireApprovalFor?.includes(m) ?? REQUIRE_APPROVAL.has(m); + if (!requiresApproval) return {}; return { requiresApproval: true, approvalDescription: `${method.toUpperCase()} ${pathTemplate}`, diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 26743509e..c5738449a 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -49,6 +49,7 @@ import { type StoredSource, } from "./store"; import { + AnnotationPolicy, HeaderValue as HeaderValueSchema, ConfiguredHeaderBinding, OAuth2SourceConfig, @@ -176,6 +177,7 @@ export interface OpenApiSpecConfig { readonly headers?: Record; readonly queryParams?: Record; readonly oauth2?: OpenApiOAuthInput; + readonly annotationPolicy?: AnnotationPolicy; } export interface OpenApiSourceRef { @@ -371,6 +373,7 @@ const OpenApiConfigureInputSchema = Schema.Struct({ }), ), oauth2Source: Schema.optional(OAuth2SourceConfig), + annotationPolicy: Schema.optional(Schema.NullOr(AnnotationPolicy)), }); export type OpenApiConfigureInput = typeof OpenApiConfigureInputSchema.Type; const OpenApiConfigureSourceInputSchema = Schema.Struct({ @@ -390,6 +393,7 @@ const AddSourceInputSchema = Schema.Struct({ headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)), queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)), oauth2: Schema.optional(OpenApiOAuthInputSchema), + annotationPolicy: Schema.optional(AnnotationPolicy), specFetchCredentials: Schema.optional( Schema.Struct({ headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)), @@ -1203,6 +1207,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { readonly queryParams?: Record; }; readonly oauth2?: OpenApiOAuthInput; + readonly annotationPolicy?: AnnotationPolicy; }; // ctx comes from the plugin runtime — the same instance is passed to @@ -1250,6 +1255,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { queryParams: canonicalQueryParams.values, specFetchCredentials: canonicalSpecFetchCredentials.credentials, oauth2: canonicalOAuth2.oauth2, + annotationPolicy: input.annotationPolicy, }; const storedSource: StoredSource = { @@ -1366,6 +1372,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { queryParams: config.queryParams, specFetchCredentials: config.specFetchCredentials, oauth2: config.oauth2, + annotationPolicy: config.annotationPolicy, }); }); @@ -1440,7 +1447,8 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { if ( input.name !== undefined || input.baseUrl !== undefined || - input.oauth2Source !== undefined + input.oauth2Source !== undefined || + input.annotationPolicy !== undefined ) { if (input.baseUrl !== undefined && input.baseUrl.trim() !== "") { const outerSource = yield* findOuterSource(ctx, sourceId, sourceScope); @@ -1454,6 +1462,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { name: input.name?.trim() || undefined, baseUrl: input.baseUrl, oauth2: input.oauth2Source, + annotationPolicy: input.annotationPolicy, }); } return yield* configureOpenApiSource(ctx, { id: sourceId, scope: sourceScope }, input); @@ -1773,17 +1782,23 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { const ops = yield* ctx.storage.listOperationsBySource(sourceId, scope); const byId = new Map(); for (const op of ops) byId.set(op.toolId, op.binding); - return [scope, byId] as const; + const source = yield* ctx.storage.getSource(sourceId, scope); + return [scope, { bindings: byId, policy: source?.config.annotationPolicy }] as const; }), { concurrency: "unbounded" }, ); - const byScope = new Map>(entries); + const byScope = new Map(entries); const out: Record = {}; for (const row of toolRows as readonly ToolRow[]) { - const binding = byScope.get(row.scope_id)?.get(row.id); + const entry = byScope.get(row.scope_id); + const binding = entry?.bindings.get(row.id); if (binding) { - out[row.id] = annotationsForOperation(binding.method, binding.pathTemplate); + out[row.id] = annotationsForOperation( + binding.method, + binding.pathTemplate, + entry?.policy ?? null, + ); } } return out; diff --git a/packages/plugins/openapi/src/sdk/source-contracts.ts b/packages/plugins/openapi/src/sdk/source-contracts.ts index c1fa4fbc3..5739229b3 100644 --- a/packages/plugins/openapi/src/sdk/source-contracts.ts +++ b/packages/plugins/openapi/src/sdk/source-contracts.ts @@ -1,6 +1,6 @@ import { Schema } from "effect"; -import { ConfiguredHeaderValue, OAuth2SourceConfig } from "./types"; +import { AnnotationPolicy, ConfiguredHeaderValue, OAuth2SourceConfig } from "./types"; export const StoredSourceSchema = Schema.Struct({ namespace: Schema.String, @@ -22,6 +22,7 @@ export const StoredSourceSchema = Schema.Struct({ // Canonical source-owned OAuth config. Concrete client credentials // and connection ids live in OpenAPI-owned scoped binding rows. oauth2: Schema.optional(OAuth2SourceConfig), + annotationPolicy: Schema.optional(AnnotationPolicy), }), }).annotate({ identifier: "OpenApiStoredSource" }); export type StoredSourceSchema = typeof StoredSourceSchema.Type; diff --git a/packages/plugins/openapi/src/sdk/store.ts b/packages/plugins/openapi/src/sdk/store.ts index eb1d6e4eb..914c53c30 100644 --- a/packages/plugins/openapi/src/sdk/store.ts +++ b/packages/plugins/openapi/src/sdk/store.ts @@ -8,6 +8,7 @@ import { } from "@executor-js/sdk/core"; import { + AnnotationPolicy, ConfiguredHeaderBinding, OAuth2SourceConfig, OperationBinding, @@ -35,6 +36,7 @@ export interface SourceConfig { readonly queryParams?: Record; readonly specFetchCredentials?: OpenApiSpecFetchCredentials; readonly oauth2?: OAuth2SourceConfig; + readonly annotationPolicy?: AnnotationPolicy; } export interface OpenApiSpecFetchCredentials { @@ -90,6 +92,7 @@ const SourceConfigStorage = Schema.Struct({ queryParams: Schema.optional(ConfiguredHeaderMapStorage), specFetchCredentials: Schema.optional(SpecFetchCredentialsStorage), oauth2: Schema.optional(Schema.Unknown), + annotationPolicy: Schema.optional(AnnotationPolicy), }); const SourceStorage = Schema.Struct({ namespace: Schema.String, @@ -151,6 +154,7 @@ const encodeSourceConfig = (config: SourceConfig): Record => ({ ...(config.queryParams ? { queryParams: config.queryParams } : {}), ...(config.specFetchCredentials ? { specFetchCredentials: config.specFetchCredentials } : {}), ...(config.oauth2 ? { oauth2: toJsonRecord(encodeOAuth2SourceConfig(config.oauth2)) } : {}), + ...(config.annotationPolicy ? { annotationPolicy: config.annotationPolicy } : {}), }); const rowToSource = (row: PluginStorageEntry): StoredSource | null => { @@ -176,6 +180,7 @@ const rowToSource = (row: PluginStorageEntry): StoredSource | null => { } : undefined, oauth2, + annotationPolicy: stored.config.annotationPolicy, }, }; }; @@ -210,6 +215,7 @@ export interface OpenapiStore { readonly queryParams?: Record; readonly specFetchCredentials?: OpenApiSpecFetchCredentials; readonly oauth2?: OAuth2SourceConfig; + readonly annotationPolicy?: AnnotationPolicy | null; }, ) => Effect.Effect; readonly getSource: ( @@ -322,6 +328,9 @@ export const makeDefaultOpenapiStore = ({ ? { specFetchCredentials: patch.specFetchCredentials } : {}), ...(patch.oauth2 !== undefined ? { oauth2: patch.oauth2 } : {}), + ...(patch.annotationPolicy !== undefined + ? { annotationPolicy: patch.annotationPolicy ?? undefined } + : {}), }, }; yield* pluginStorage.put({ diff --git a/packages/plugins/openapi/src/sdk/types.ts b/packages/plugins/openapi/src/sdk/types.ts index 822af6553..6d5a95108 100644 --- a/packages/plugins/openapi/src/sdk/types.ts +++ b/packages/plugins/openapi/src/sdk/types.ts @@ -35,6 +35,11 @@ export const HttpMethod = Schema.Literals([ ]); export type HttpMethod = typeof HttpMethod.Type; +export const AnnotationPolicy = Schema.Struct({ + requireApprovalFor: Schema.optional(Schema.Array(HttpMethod)), +}).annotate({ identifier: "OpenApiAnnotationPolicy" }); +export type AnnotationPolicy = typeof AnnotationPolicy.Type; + export const ParameterLocation = Schema.Literals(["path", "query", "header", "cookie"]); export type ParameterLocation = typeof ParameterLocation.Type; diff --git a/packages/react/src/plugins/approval-policy-field.tsx b/packages/react/src/plugins/approval-policy-field.tsx new file mode 100644 index 000000000..001c46bd1 --- /dev/null +++ b/packages/react/src/plugins/approval-policy-field.tsx @@ -0,0 +1,317 @@ +import * as React from "react"; +import { ShieldCheckIcon, RotateCcwIcon } from "lucide-react"; + +import { CardStack, CardStackContent } from "../components/card-stack"; +import { Button } from "../components/button"; +import { FieldLabel } from "../components/field"; +import { Switch } from "../components/switch"; +import { cn } from "../lib/utils"; + +// --------------------------------------------------------------------------- +// Shared UX primitive — lets each source plugin render a per-source override +// for which tool invocations require approval before running. +// +// The component is presentational only: it takes a typed list of togglable +// tokens (HTTP methods for OpenAPI/Google Discovery, operation kinds for +// GraphQL, anything for future plugins) plus a per-token "default approval +// required" hint, and stores the user's explicit list in a local set. Pass +// `value === undefined` to render "using defaults"; the first interaction +// materializes a concrete list and emits it via onChange. +// +// The switch variant renders a single toggle for plugins whose policy is a +// simple on/off override (e.g. MCP, which defaults to no tool-level approval +// because servers handle elicitation mid-invocation). +// --------------------------------------------------------------------------- + +export interface ApprovalPolicyToken { + readonly value: string; + readonly label: React.ReactNode; + /** Short description shown under the label when rendered in "detail" mode. */ + readonly description?: React.ReactNode; + /** Whether this token requires approval in the plugin's DEFAULT policy. */ + readonly defaultRequiresApproval: boolean; + /** Visual tone. `safe` → green tint; `write` → amber/red tint; `neutral` → muted. */ + readonly tone?: "safe" | "write" | "neutral"; +} + +// Shared shell — title, description, reset button, children. +interface PolicyShellProps { + readonly title?: React.ReactNode; + readonly description?: React.ReactNode; + readonly isOverridden: boolean; + readonly onReset: () => void; + readonly children: React.ReactNode; +} + +function PolicyShell({ + title = "Approval policy", + description, + isOverridden, + onReset, + children, +}: PolicyShellProps) { + return ( +
+
+ + + {title} + + {isOverridden && ( + + )} +
+ + +
+ {description &&

{description}

} + {children} + {!isOverridden && ( +

+ Using plugin defaults. Click any option to start overriding. +

+ )} +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Toggles variant — one togglable chip per token. OpenAPI, Google Discovery, +// GraphQL all use this. +// --------------------------------------------------------------------------- + +export interface ApprovalPolicyTogglesProps { + readonly title?: React.ReactNode; + readonly description?: React.ReactNode; + readonly tokens: readonly ApprovalPolicyToken[]; + /** + * Current explicit override — the set of token `value`s that require + * approval. `undefined` means "use defaults" (the plugin's derived + * behaviour). When `null` is emitted, the override has been cleared. + */ + readonly value: readonly string[] | undefined; + readonly onChange: (next: readonly string[] | undefined) => void; + /** + * Optional layout. `grid` (default) wraps chips in a responsive grid; + * `list` stacks them vertically with room for descriptions. + */ + readonly layout?: "grid" | "list"; +} + +const toneClasses: Record<"safe" | "write" | "neutral", string> = { + safe: "data-[active=true]:bg-emerald-500/15 data-[active=true]:text-emerald-700 data-[active=true]:border-emerald-500/40 data-[active=true]:ring-emerald-500/20 dark:data-[active=true]:text-emerald-300", + write: + "data-[active=true]:bg-amber-500/15 data-[active=true]:text-amber-800 data-[active=true]:border-amber-500/50 data-[active=true]:ring-amber-500/20 dark:data-[active=true]:text-amber-200", + neutral: + "data-[active=true]:bg-primary/10 data-[active=true]:text-foreground data-[active=true]:border-primary/40 data-[active=true]:ring-primary/20", +}; + +export function ApprovalPolicyToggles({ + title, + description, + tokens, + value, + onChange, + layout = "grid", +}: ApprovalPolicyTogglesProps) { + // `value === undefined` means "using defaults". Derive the effective + // selection set from defaults in that case so we can render chip state. + const effective = React.useMemo(() => { + if (value !== undefined) { + return new Set(value.map((v) => v.toLowerCase())); + } + return new Set( + tokens.filter((t) => t.defaultRequiresApproval).map((t) => t.value.toLowerCase()), + ); + }, [value, tokens]); + + const isOverridden = value !== undefined; + + const toggle = (tokenValue: string) => { + const next = new Set(effective); + const key = tokenValue.toLowerCase(); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + // Preserve the canonical (original-cased) token values in the emitted + // array so the backend receives the same literal strings the caller + // declared. + const canonical = tokens.filter((t) => next.has(t.value.toLowerCase())).map((t) => t.value); + onChange(canonical); + }; + + return ( + onChange(undefined)} + > +
+ {tokens.map((token) => { + const active = effective.has(token.value.toLowerCase()); + const tone = token.tone ?? "neutral"; + return ( + + ); + })} +
+

+ Highlighted options require approval before a tool call runs. Click to toggle. +

+
+ ); +} + +// --------------------------------------------------------------------------- +// Switch variant — single on/off override. MCP uses this. +// --------------------------------------------------------------------------- + +export interface ApprovalPolicySwitchProps { + readonly title?: React.ReactNode; + readonly description?: React.ReactNode; + readonly switchLabel: React.ReactNode; + readonly switchDescription?: React.ReactNode; + readonly defaultValue: boolean; + /** + * Current explicit override. `undefined` means "use defaults"; a boolean + * means the user has pinned the override. + */ + readonly value: boolean | undefined; + readonly onChange: (next: boolean | undefined) => void; +} + +export function ApprovalPolicySwitch({ + title, + description, + switchLabel, + switchDescription, + defaultValue, + value, + onChange, +}: ApprovalPolicySwitchProps) { + const effective = value ?? defaultValue; + const isOverridden = value !== undefined; + return ( + onChange(undefined)} + > +
+
+ {switchLabel} + {switchDescription && ( + {switchDescription} + )} +
+ { + // Allow toggling back to "default" by matching the default value. + if (checked === defaultValue) { + onChange(undefined); + } else { + onChange(checked); + } + }} + /> +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Token presets — keep the shape of "default requires approval" out of each +// plugin's UI by exporting canonical lists. +// --------------------------------------------------------------------------- + +export const HTTP_METHOD_TOKENS: readonly ApprovalPolicyToken[] = [ + { value: "GET", label: "GET", tone: "safe", defaultRequiresApproval: false }, + { value: "HEAD", label: "HEAD", tone: "safe", defaultRequiresApproval: false }, + { value: "OPTIONS", label: "OPTIONS", tone: "safe", defaultRequiresApproval: false }, + { value: "POST", label: "POST", tone: "write", defaultRequiresApproval: true }, + { value: "PUT", label: "PUT", tone: "write", defaultRequiresApproval: true }, + { value: "PATCH", label: "PATCH", tone: "write", defaultRequiresApproval: true }, + { value: "DELETE", label: "DELETE", tone: "write", defaultRequiresApproval: true }, +]; + +export const GRAPHQL_OPERATION_TOKENS: readonly ApprovalPolicyToken[] = [ + { + value: "query", + label: "query", + tone: "safe", + defaultRequiresApproval: false, + description: "Read-only operations", + }, + { + value: "mutation", + label: "mutation", + tone: "write", + defaultRequiresApproval: true, + description: "Operations that change server state", + }, +];