Plugins that publish higher-level tools over existing integrations need three things the seam did not offer: a way to invoke another catalog tool inside the same request context (ctx.execute, keeping policy, approval, and credential resolution in the core path), opaque provider-item storage for values they own that are not connections, and hooks to project tool schemas at read time and clean up on integration removal. Binding failures (a caller naming an unknown or mismatched connection) now surface as a binding_error tool result naming the role and integration instead of an opaque internal tool error.
Unified · Split
| @@ -305,6 +305,27 @@ const unmarkedErrorPlugin = makeTestPlugin({ · 304 unmodified lines | ||
| 305 | 305 | ], |
| 306 | 306 | }); |
| 307 | 307 | |
| 308 | +const bindingErrorPlugin = makeTestPlugin({ | |
| 309 | + pluginId: "binding-error-test", | |
| 310 | + integration: "binding", | |
| 311 | + tools: [ | |
| 312 | + { | |
| 313 | + name: "sync", | |
| 314 | + description: "Sync with a caller-selected connection", | |
| 315 | + inputJsonSchema: EmptyInputJson, | |
| 316 | + validator: EmptyValidator, | |
| 317 | + handler: () => | |
| 318 | + Effect.fail({ | |
| 319 | + _tag: "BindingError", | |
| 320 | + message: 'unknown connection "personal" for role "github" (github)', | |
| 321 | + role: "github", | |
| 322 | + integration: "github", | |
| 323 | + requestedConnection: "personal", | |
| 324 | + }), | |
| 325 | + }, | |
| 326 | + ], | |
| 327 | +}); | |
| 328 | + | |
| 308 | 329 | const oauthErrorPlugin = definePlugin(() => ({ |
| 309 | 330 | id: "oauth-error-test" as const, |
| 310 | 331 | storage: () => ({}), |
| @@ -1215,6 +1236,33 @@ describe("tool discovery", () => { · 904 unmodified lines | ||
| 1215 | 1236 | }), |
| 1216 | 1237 | ); |
| 1217 | 1238 | |
| 1239 | + it.effect("returns binding errors as ToolResult.fail", () => | |
| 1240 | + Effect.gen(function* () { | |
| 1241 | + const executor = yield* makeExecutorWith([bindingErrorPlugin] as const); | |
| 1242 | + yield* provision(executor as never, [ | |
| 1243 | + { pluginId: "binding-error-test", integration: "binding" }, | |
| 1244 | + ]); | |
| 1245 | + const invoker = makeExecutorToolInvoker(executor, { | |
| 1246 | + invokeOptions: { onElicitation: acceptAll }, | |
| 1247 | + }); | |
| 1248 | + | |
| 1249 | + const result = yield* invoker.invoke({ path: "binding.org.main.sync", args: {} }); | |
| 1250 | + | |
| 1251 | + expect(result).toEqual({ | |
| 1252 | + ok: false, | |
| 1253 | + error: { | |
| 1254 | + code: "binding_error", | |
| 1255 | + message: 'unknown connection "personal" for role "github" (github)', | |
| 1256 | + details: { | |
| 1257 | + role: "github", | |
| 1258 | + integration: "github", | |
| 1259 | + requestedConnection: "personal", | |
| 1260 | + }, | |
| 1261 | + }, | |
| 1262 | + }); | |
| 1263 | + }), | |
| 1264 | + ); | |
| 1265 | + | |
| 1218 | 1266 | it.effect("preserves nested upstream error bodies through ToolResult.fail", () => |
| 1219 | 1267 | Effect.gen(function* () { |
| 1220 | 1268 | const executor = yield* makeExecutorWith([structuredFailurePlugin] as const); |
| @@ -189,6 +189,28 @@ const credentialResolutionToolFailure = (input: { · 188 unmodified lines | ||
| 189 | 189 | }, |
| 190 | 190 | }); |
| 191 | 191 | |
| 192 | +const bindingToolFailure = (value: unknown): ToolError | null => { | |
| 193 | + if (!Predicate.isTagged(value, "BindingError")) return null; | |
| 194 | + const maybeBinding = value as { | |
| 195 | + readonly message?: unknown; | |
| 196 | + readonly role?: unknown; | |
| 197 | + readonly integration?: unknown; | |
| 198 | + readonly requestedConnection?: unknown; | |
| 199 | + }; | |
| 200 | + const details: Record<string, string> = {}; | |
| 201 | + if (typeof maybeBinding.role === "string") details.role = maybeBinding.role; | |
| 202 | + if (typeof maybeBinding.integration === "string") details.integration = maybeBinding.integration; | |
| 203 | + if (typeof maybeBinding.requestedConnection === "string") { | |
| 204 | + details.requestedConnection = maybeBinding.requestedConnection; | |
| 205 | + } | |
| 206 | + return { | |
| 207 | + code: "binding_error", | |
| 208 | + message: | |
| 209 | + typeof maybeBinding.message === "string" ? maybeBinding.message : "Tool binding failed.", | |
| 210 | + ...(Object.keys(details).length > 0 ? { details } : {}), | |
| 211 | + }; | |
| 212 | +}; | |
| 213 | + | |
| 192 | 214 | const expectedToolFailure = (value: unknown): ToolError | null => { |
| 193 | 215 | if (isUserActionableError(value)) { |
| 194 | 216 | return { |
| @@ -223,6 +245,8 @@ const expectedToolFailure = (value: unknown): ToolError | null => { · 28 unmodified lines | ||
| 223 | 245 | message: cause.userMessage, |
| 224 | 246 | }; |
| 225 | 247 | } |
| 248 | + const binding = bindingToolFailure(cause); | |
| 249 | + if (binding) return binding; | |
| 226 | 250 | const issues = validationIssues(cause); |
| 227 | 251 | if (issues) { |
| 228 | 252 | return { |
| @@ -1944,6 +1944,19 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1943 unmodified lines | ||
| 1944 | 1944 | if (!existing.can_remove) { |
| 1945 | 1945 | return yield* new IntegrationRemovalNotAllowedError({ slug }); |
| 1946 | 1946 | } |
| 1947 | + const runtime = runtimes.get(existing.plugin_id); | |
| 1948 | + if (runtime?.plugin.removeIntegration) { | |
| 1949 | + yield* runtime.plugin | |
| 1950 | + .removeIntegration({ | |
| 1951 | + ctx: runtime.ctx, | |
| 1952 | + integration: rowToIntegrationRecord(existing, describeAuthMethodsForRow(existing)), | |
| 1953 | + }) | |
| 1954 | + .pipe( | |
| 1955 | + Effect.mapError((cause) => | |
| 1956 | + pluginStorageFailure(existing.plugin_id, "removeIntegration", cause), | |
| 1957 | + ), | |
| 1958 | + ); | |
| 1959 | + } | |
| 1947 | 1960 | // Drop owned connections / tools / definitions for this integration. |
| 1948 | 1961 | const where = (b: AnyCb) => b("integration", "=", String(slug)); |
| 1949 | 1962 | yield* core.deleteMany("tool", { where }); |
| @@ -2121,6 +2134,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 171 unmodified lines | ||
| 2121 | 2134 | |
| 2122 | 2135 | const result: ResolveToolsResult = yield* runtime.plugin |
| 2123 | 2136 | .resolveTools({ |
| 2137 | + ctx: runtime.ctx, | |
| 2124 | 2138 | integration: rowToIntegration(integrationRow), |
| 2125 | 2139 | config: decodeJsonColumn(integrationRow.config), |
| 2126 | 2140 | httpClientLayer: runtime.ctx.httpClientLayer, |
| @@ -3212,6 +3226,30 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1085 unmodified lines | ||
| 3212 | 3226 | ); |
| 3213 | 3227 | if (effective.action === "block") return null; |
| 3214 | 3228 | |
| 3229 | + const runtime = runtimes.get(row.plugin_id); | |
| 3230 | + const projected = runtime?.plugin.projectToolSchema | |
| 3231 | + ? yield* runtime.plugin | |
| 3232 | + .projectToolSchema({ | |
| 3233 | + ctx: runtime.ctx, | |
| 3234 | + toolRow: row, | |
| 3235 | + inputSchema: tool.inputSchema, | |
| 3236 | + outputSchema: tool.outputSchema, | |
| 3237 | + }) | |
| 3238 | + .pipe( | |
| 3239 | + Effect.mapError((cause) => | |
| 3240 | + pluginStorageFailure(row.plugin_id, "projectToolSchema", cause), | |
| 3241 | + ), | |
| 3242 | + ) | |
| 3243 | + : null; | |
| 3244 | + const inputSchema = | |
| 3245 | + projected && Object.prototype.hasOwnProperty.call(projected, "inputSchema") | |
| 3246 | + ? projected.inputSchema | |
| 3247 | + : tool.inputSchema; | |
| 3248 | + const outputSchema = | |
| 3249 | + projected && Object.prototype.hasOwnProperty.call(projected, "outputSchema") | |
| 3250 | + ? projected.outputSchema | |
| 3251 | + : tool.outputSchema; | |
| 3252 | + | |
| 3215 | 3253 | const definitionRows = yield* core.findMany("definition", { |
| 3216 | 3254 | where: (b: AnyCb) => |
| 3217 | 3255 | b.and( |
| @@ -3223,15 +3261,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines | ||
| 3223 | 3261 | const defs = new Map<string, unknown>(); |
| 3224 | 3262 | for (const def of definitionRows) defs.set(def.name, decodeJsonColumn(def.schema)); |
| 3225 | 3263 | |
| 3226 | − const referenced = collectReferencedDefinitions( | |
| 3227 | − [tool.inputSchema, tool.outputSchema], | |
| 3228 | − defs, | |
| 3229 | − ); | |
| 3264 | + const referenced = collectReferencedDefinitions([inputSchema, outputSchema], defs); | |
| 3230 | 3265 | const preview = yield* Effect.tryPromise({ |
| 3231 | 3266 | try: () => |
| 3232 | 3267 | buildToolTypeScriptPreview({ |
| 3233 | − inputSchema: tool.inputSchema, | |
| 3234 | − outputSchema: tool.outputSchema, | |
| 3268 | + inputSchema, | |
| 3269 | + outputSchema, | |
| 3235 | 3270 | defs, |
| 3236 | 3271 | }), |
| 3237 | 3272 | catch: (cause) => |
| @@ -3243,8 +3278,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines | ||
| 3243 | 3278 | address, |
| 3244 | 3279 | name: tool.name, |
| 3245 | 3280 | description: tool.description, |
| 3246 | − inputSchema: tool.inputSchema, | |
| 3247 | − outputSchema: tool.outputSchema, | |
| 3281 | + inputSchema, | |
| 3282 | + outputSchema, | |
| 3248 | 3283 | schemaDefinitions: |
| 3249 | 3284 | Object.keys(referenced).length > 0 |
| 3250 | 3285 | ? (referenced as Record<string, unknown>) |
| @@ -3271,6 +3306,53 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 20 unmodified lines | ||
| 3271 | 3306 | return yield* provider.list(); |
| 3272 | 3307 | }); |
| 3273 | 3308 | |
| 3309 | + const providersGet = ( | |
| 3310 | + key: ProviderKey, | |
| 3311 | + id: ProviderItemId, | |
| 3312 | + ): Effect.Effect<string | null, StorageFailure> => | |
| 3313 | + Effect.gen(function* () { | |
| 3314 | + const provider = credentialProviders.get(String(key)); | |
| 3315 | + if (!provider) return null; | |
| 3316 | + return yield* provider.get(id); | |
| 3317 | + }); | |
| 3318 | + | |
| 3319 | + const providersHas = ( | |
| 3320 | + key: ProviderKey, | |
| 3321 | + id: ProviderItemId, | |
| 3322 | + ): Effect.Effect<boolean, StorageFailure> => | |
| 3323 | + Effect.gen(function* () { | |
| 3324 | + const provider = credentialProviders.get(String(key)); | |
| 3325 | + if (!provider) return false; | |
| 3326 | + if (provider.has) return yield* provider.has(id); | |
| 3327 | + const value = yield* provider.get(id); | |
| 3328 | + return value !== null; | |
| 3329 | + }); | |
| 3330 | + | |
| 3331 | + const providersSetDefault = ( | |
| 3332 | + id: ProviderItemId, | |
| 3333 | + value: string, | |
| 3334 | + ): Effect.Effect<ProviderKey, CredentialProviderNotRegisteredError | StorageFailure> => | |
| 3335 | + Effect.gen(function* () { | |
| 3336 | + const provider = defaultWritableProvider(); | |
| 3337 | + if (!provider || !provider.set) { | |
| 3338 | + return yield* new CredentialProviderNotRegisteredError({ | |
| 3339 | + provider: ProviderKey.make("default"), | |
| 3340 | + }); | |
| 3341 | + } | |
| 3342 | + yield* provider.set(id, value); | |
| 3343 | + return provider.key; | |
| 3344 | + }); | |
| 3345 | + | |
| 3346 | + const providersRemove = ( | |
| 3347 | + key: ProviderKey, | |
| 3348 | + id: ProviderItemId, | |
| 3349 | + ): Effect.Effect<void, StorageFailure> => | |
| 3350 | + Effect.gen(function* () { | |
| 3351 | + const provider = credentialProviders.get(String(key)); | |
| 3352 | + if (!provider || !provider.delete) return; | |
| 3353 | + yield* provider.delete(id); | |
| 3354 | + }); | |
| 3355 | + | |
| 3274 | 3356 | // ------------------------------------------------------------------ |
| 3275 | 3357 | // Policies — owner-ranked (user=0 inner, org=1 outer). |
| 3276 | 3358 | // ------------------------------------------------------------------ |
| @@ -3700,6 +3782,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 423 unmodified lines | ||
| 3700 | 3782 | credential, |
| 3701 | 3783 | args, |
| 3702 | 3784 | elicit: buildElicit(address, args, handler), |
| 3785 | + invokeOptions: options, | |
| 3703 | 3786 | }), |
| 3704 | 3787 | ); |
| 3705 | 3788 | }).pipe( |
| @@ -3854,8 +3937,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 148 unmodified lines | ||
| 3854 | 3937 | providers: { |
| 3855 | 3938 | list: () => providersList(), |
| 3856 | 3939 | items: (key) => providersItems(key), |
| 3940 | + get: (key, id) => providersGet(key, id), | |
| 3941 | + has: (key, id) => providersHas(key, id), | |
| 3942 | + setDefault: (id, value) => providersSetDefault(id, value), | |
| 3943 | + remove: (key, id) => providersRemove(key, id), | |
| 3857 | 3944 | }, |
| 3858 | 3945 | oauth, |
| 3946 | + execute: (address, args, options) => execute(address, args, options), | |
| 3859 | 3947 | transaction: <A, E>(effect: Effect.Effect<A, E>) => transaction(effect), |
| 3860 | 3948 | }; |
| 3861 | 3949 | |
| @@ -26,9 +26,11 @@ import type { · 25 unmodified lines | ||
| 26 | 26 | ConnectionName, |
| 27 | 27 | IntegrationSlug, |
| 28 | 28 | Owner, |
| 29 | + ProviderItemId, | |
| 29 | 30 | ProviderKey, |
| 30 | 31 | Subject, |
| 31 | 32 | Tenant, |
| 33 | + ToolAddress, | |
| 32 | 34 | } from "./ids"; |
| 33 | 35 | import type { IntegrationDetectionResult } from "./types"; |
| 34 | 36 | import type { |
| @@ -36,8 +38,10 @@ import type { · 1 unmodified line | ||
| 36 | 38 | ElicitationHandler, |
| 37 | 39 | ElicitationRequest, |
| 38 | 40 | ElicitationResponse, |
| 41 | + InvokeOptions, | |
| 39 | 42 | } from "./elicitation"; |
| 40 | 43 | import type { |
| 44 | + ExecuteError, | |
| 41 | 45 | ConnectionNotFoundError, |
| 42 | 46 | CredentialProviderNotRegisteredError, |
| 43 | 47 | IntegrationNotFoundError, |
| @@ -244,11 +248,41 @@ export interface PluginCtx<TStore = unknown> { · 200 unmodified lines | ||
| 244 | 248 | readonly items: ( |
| 245 | 249 | provider: ProviderKey, |
| 246 | 250 | ) => Effect.Effect<readonly ProviderEntry[], StorageFailure>; |
| 251 | + /** Read an opaque item from a provider. Plugins use this for secret values | |
| 252 | + * they own that are not modeled as connections. */ | |
| 253 | + readonly get: ( | |
| 254 | + provider: ProviderKey, | |
| 255 | + id: ProviderItemId, | |
| 256 | + ) => Effect.Effect<string | null, StorageFailure>; | |
| 257 | + readonly has: ( | |
| 258 | + provider: ProviderKey, | |
| 259 | + id: ProviderItemId, | |
| 260 | + ) => Effect.Effect<boolean, StorageFailure>; | |
| 261 | + /** Write through the executor's default writable provider and return the | |
| 262 | + * provider key that owns the item. */ | |
| 263 | + readonly setDefault: ( | |
| 264 | + id: ProviderItemId, | |
| 265 | + value: string, | |
| 266 | + ) => Effect.Effect<ProviderKey, CredentialProviderNotRegisteredError | StorageFailure>; | |
| 267 | + readonly remove: ( | |
| 268 | + provider: ProviderKey, | |
| 269 | + id: ProviderItemId, | |
| 270 | + ) => Effect.Effect<void, StorageFailure>; | |
| 247 | 271 | }; |
| 248 | 272 | |
| 249 | 273 | /** Shared OAuth service. */ |
| 250 | 274 | readonly oauth: OAuthService; |
| 251 | 275 | |
| 276 | + /** Invoke another catalog tool through the same executor request context: | |
| 277 | + * policy, approval, credential resolution, and plugin dispatch all stay in | |
| 278 | + * the core path. Intended for plugin sandboxes that expose higher-level | |
| 279 | + * virtual tools over existing integration tools. */ | |
| 280 | + readonly execute: ( | |
| 281 | + address: ToolAddress, | |
| 282 | + args: unknown, | |
| 283 | + options?: InvokeOptions, | |
| 284 | + ) => Effect.Effect<unknown, ExecuteError>; | |
| 285 | + | |
| 252 | 286 | /** Run `effect` inside a FumaDB transaction (atomic across plugin storage + |
| 253 | 287 | * core integration/tool writes). */ |
| 254 | 288 | readonly transaction: <A, E>(effect: Effect.Effect<A, E>) => Effect.Effect<A, E | StorageFailure>; |
| @@ -261,6 +295,7 @@ export interface PluginCtx<TStore = unknown> { · 6 unmodified lines | ||
| 261 | 295 | // --------------------------------------------------------------------------- |
| 262 | 296 | |
| 263 | 297 | export interface ResolveToolsInput<TStore = unknown> { |
| 298 | + readonly ctx?: PluginCtx<TStore>; | |
| 264 | 299 | /** The catalog record (public projection) whose connection is being resolved. */ |
| 265 | 300 | readonly integration: Integration; |
| 266 | 301 | /** The plugin's stored opaque config for that integration. */ |
| @@ -302,6 +337,18 @@ export interface ResolveToolsResult { · 35 unmodified lines | ||
| 302 | 337 | readonly incompleteReason?: string; |
| 303 | 338 | } |
| 304 | 339 | |
| 340 | +export interface ProjectToolSchemaInput<TStore = unknown> { | |
| 341 | + readonly ctx: PluginCtx<TStore>; | |
| 342 | + readonly toolRow: ToolInvocationRow; | |
| 343 | + readonly inputSchema?: unknown; | |
| 344 | + readonly outputSchema?: unknown; | |
| 345 | +} | |
| 346 | + | |
| 347 | +export interface ProjectToolSchemaResult { | |
| 348 | + readonly inputSchema?: unknown; | |
| 349 | + readonly outputSchema?: unknown; | |
| 350 | +} | |
| 351 | + | |
| 305 | 352 | // --------------------------------------------------------------------------- |
| 306 | 353 | // Resolved credential handed to `invokeTool` so the plugin renders auth onto |
| 307 | 354 | // the request (D11: "auth state derived into the auth-template format"). |
| @@ -457,6 +504,8 @@ export interface InvokeToolInput<TStore = unknown> { · 149 unmodified lines | ||
| 457 | 504 | readonly credential: ToolInvocationCredential; |
| 458 | 505 | readonly args: unknown; |
| 459 | 506 | readonly elicit: Elicit; |
| 507 | + /** Original caller options for nested same-request tool calls. */ | |
| 508 | + readonly invokeOptions?: InvokeOptions; | |
| 460 | 509 | } |
| 461 | 510 | |
| 462 | 511 | /** Input for `validateToolArgs` — no credential/elicit: validation runs |
| @@ -477,6 +526,11 @@ export interface ConnectionLifecycleInput<TStore = unknown> { · 14 unmodified lines | ||
| 477 | 526 | readonly connection: ConnectionRef; |
| 478 | 527 | } |
| 479 | 528 | |
| 529 | +export interface IntegrationLifecycleInput<TStore = unknown> { | |
| 530 | + readonly ctx: PluginCtx<TStore>; | |
| 531 | + readonly integration: IntegrationRecord; | |
| 532 | +} | |
| 533 | + | |
| 480 | 534 | export interface ConfigureIntegrationHandlerInput<TStore = unknown> { |
| 481 | 535 | readonly ctx: PluginCtx<TStore>; |
| 482 | 536 | readonly integration: IntegrationSlug; |
| @@ -615,6 +669,13 @@ export interface PluginSpec< · 132 unmodified lines | ||
| 615 | 669 | * for catalogs derived purely from stored state (specs, static config). */ |
| 616 | 670 | readonly remoteToolCatalog?: boolean; |
| 617 | 671 | |
| 672 | + /** Project a persisted tool row's stable schemas into the request-visible | |
| 673 | + * schema view. Use this only for volatile presentation data that should be | |
| 674 | + * current at read time, not persisted at catalog-refresh time. */ | |
| 675 | + readonly projectToolSchema?: ( | |
| 676 | + input: ProjectToolSchemaInput<TStore>, | |
| 677 | + ) => Effect.Effect<ProjectToolSchemaResult, unknown>; | |
| 678 | + | |
| 618 | 679 | /** Invoke a dynamic tool. Called when the static-handler map doesn't have the |
| 619 | 680 | * address. The plugin applies `input.credential` to the outbound request. */ |
| 620 | 681 | readonly invokeTool?: (input: InvokeToolInput<TStore>) => Effect.Effect<unknown, unknown>; |
| @@ -642,6 +703,12 @@ export interface PluginSpec< · 21 unmodified lines | ||
| 642 | 703 | input: ConnectionLifecycleInput<TStore>, |
| 643 | 704 | ) => Effect.Effect<void, unknown>; |
| 644 | 705 | |
| 706 | + /** Plugin-side cleanup when a removable integration is removed. Core still | |
| 707 | + * owns deleting the integration, connection, tool, and definition rows. */ | |
| 708 | + readonly removeIntegration?: ( | |
| 709 | + input: IntegrationLifecycleInput<TStore>, | |
| 710 | + ) => Effect.Effect<void, unknown>; | |
| 711 | + | |
| 645 | 712 | /** Core-dispatched integration configuration (beyond auth). */ |
| 646 | 713 | readonly integrationConfigure?: IntegrationConfigureDecl<TStore>; |
| 647 | 714 | |