LLMs Technical Reviews

UsefulSoftwareCo/executor

MCP gateway that turns OpenAPI, GraphQL and MCP integrations into one policy-gated catalog agents call from sandboxed code.

GitHub ↗★ 4.1kTypeScriptMITcommit 27dccb8 · 2026-10-05homepage ↗

Overview

Executor is a self-hostable gateway that sits between AI agents and the APIs they call. You register an integration once (an OpenAPI spec, a GraphQL endpoint, a remote or local MCP server, or a Google Discovery document), attach one or more authenticated connections to it, and set per-tool policies. Every MCP client you point at Executor then shares the same catalog, the same credentials and the same approval rules.

The interesting design decision is how the catalog reaches the model. By default Executor does not expose hundreds of MCP tools. It exposes one execute tool, and the agent writes a short JavaScript or TypeScript program against a tools proxy, for example await tools.github.org.main.issues_create({...}). That program runs in a sandbox, usually QuickJS compiled to WASM, and tool discovery happens from code with tools.search(...). A second mode, ?mode=passthrough, serves a conventional search / invoke pair for clients that cannot or should not write code.

The codebase is a Bun + Turborepo monorepo written almost entirely in Effect-TS. One SDK core is packaged five ways: a CLI with a background daemon, an Electron desktop app, a Docker self-host image, a Cloudflare Worker, and the hosted cloud product, which lives in the same MIT-licensed repository. It is large and heavily engineered. The core executor.ts alone is over 7,000 lines.

Architecture

flowchart LR
  A["MCP client"] -->|"stdio bridge or HTTP /mcp"| H["host-mcp: createExecutorMcpServer"]
  H --> E["ExecutionEngine"]
  E --> K["Sandbox: QuickJS / dynamic worker"]
  K -->|"tools.* proxy"| TI["makeExecutorToolInvoker"]
  TI --> X["SDK Executor.execute"]
  X --> P["Policy + approval"]
  X --> C["Credential providers"]
  X --> PL["Plugin.invokeTool"]
  PL --> O["OpenAPI / GraphQL / MCP upstream"]
  X --> DB["FumaDB: libSQL or Postgres"]
Component Path Role
SDK core packages/core/sdk Executor, plugin contract, integrations, connections, policies, OAuth, secrets
Execution engine packages/core/execution Runs code against a CodeExecutor, pause/resume for approvals, tools.search
MCP host packages/hosts/mcp MCP server: execute, resume, skills, artifacts, passthrough search/invoke
Sandboxes packages/kernel/runtime-* QuickJS WASM (default), Cloudflare dynamic worker (cloud), Deno and workerd subprocess variants
Protocol plugins packages/plugins/{openapi,graphql,mcp} Turn a spec or server into tool rows and invoke them
Secret plugins packages/plugins/{keychain,file-secrets,encrypted-secrets,onepassword,workos-vault} Credential backends
Storage packages/core/fumadb Schema/ORM layer over SQLite/libSQL and Postgres
Product roots apps/{cli,local,desktop,host-selfhost,host-cloudflare,cloud} Compose plugins, storage, auth and a runtime into a deployable

How a request flows

Take an agent calling execute with code that creates a GitHub issue:

  1. Transport. executor mcp is a stdio-to-HTTP bridge into the local daemon’s /mcp endpoint. It owns no database, so many clients share one daemon (main.ts). Hosted and self-hosted servers expose /mcp over Streamable HTTP directly.
  2. Session. createExecutorMcpServer builds the engine and chooses codemode or passthrough from the connection’s query flags (tool-server.ts). It registers execute with the live integration inventory as the tool description (tool-server.ts).
  3. Sandbox. executeCode calls engine.executeWithPause (tool-server.ts). startPausableExecution forks the sandbox fiber with an elicitation handler that parks the run on a Deferred whenever a tool needs approval (engine.ts).
  4. Dispatch. Each tools.<path>(args) call is routed by makeFullInvoker. search and executor.integrations.list are built in, and everything else goes to makeExecutorToolInvoker (engine.ts). That invoker converts the path to a five-part address (integration.owner.connection.tool), calls executor.execute, and turns expected failures into tool results. Unexpected failures become an opaque error with a correlation id (tool-invoker.ts).
  5. Policy and approval. Executor.execute loads the tool row and resolves the owner-ranked policy. block throws ToolBlockedError (executor.ts). If approval is needed, args are validated first, so a call that cannot succeed does not waste a human approval, and then enforceApproval raises a form elicitation (executor.ts, L6641-L6660).
  6. Pause and resume. The engine returns paused with an execution id. Depending on elicitation_mode, the user approves in a browser page, through native MCP elicitation, or the model calls resume itself (tool-server.ts).
  7. Credentials and invoke. After approval, the connection’s credential inputs are resolved, which can include an OAuth refresh. The owning plugin’s invokeTool then renders auth onto the outbound request (executor.ts). The OpenAPI plugin builds the HTTP request and applies separate header and body timeouts (invoke.ts).
  8. Result. The sandbox’s return value, emitted outputs and logs are formatted, with a 30,000-character preview cap, into the MCP tool result.

Key components

Plugin contract

A plugin is a PluginSpec. It owns its storage, and it can contribute static integrations, HTTP API routes, a policy provider, and the four hooks that matter for tools: resolveTools, invokeTool, validateToolArgs and resolveAnnotations (plugin.ts). resolveTools runs when a connection is created or refreshed, and its output is persisted as tool rows. A plugin that sets remoteToolCatalog is re-listed once its rows exceed a TTL. The MCP plugin does this, because a remote server’s tools can change underneath Executor. The OpenAPI plugin wires all four hooks onto spec-derived operation bindings (plugin.ts).

Policies

Policies are glob patterns over tool ids with approve, require_approval or block. They are ranked by owner, and when several owners match, the most restrictive action wins. With no matching rule, the plugin’s default annotation decides (policies.ts). For OpenAPI that default comes from the HTTP verb: POST, PUT, PATCH and DELETE require approval (invoke.ts).

Sandbox

The QuickJS executor defaults to a 5-minute budget, 64 MB of memory and a 1 MB stack, and enforces them with an interrupt handler (index.ts, L438-L454). fetch is replaced with a function that throws, so the only way out of the sandbox is the tools proxy (index.ts). The cloud product swaps in Cloudflare’s dynamic worker loader (execution-stack.ts). The Deno and workerd subprocess runtimes are in the repository, but no app at this commit depends on them.

Auth and credentials

Connections bind to one auth template from the integration. Templates can be API keys, headers or bearer tokens, OAuth 2.1 with PKCE built on oauth4webapi, or enterprise-managed authorization with ID-JAG. Values come from pluggable CredentialProviders. External backends such as 1Password, the OS keychain and WorkOS Vault resolve an opaque reference at call time, so the secret never sits in Executor’s own tables. Credential resolution happens only after approval succeeds, so a declined call never triggers a token refresh.

Extending it

  • Plugins. Write a definePlugin spec with resolveTools and invokeTool for a new protocol, or a credential-provider plugin for a new vault. packages/plugins/example is the template. Local installs also load plugins listed in executor.jsonc.
  • Per-deployment plugin lists. Each product has an executor.config.ts. The self-host image runs OpenAPI (with Google and Microsoft presets), MCP, GraphQL, toolkits and encrypted DB secrets (executor.config.ts).
  • Toolkits. These are named, scoped subsets of the catalog with their own policies, served at /mcp/toolkits/<slug>.
  • Client surface. Query flags on /mcp switch between passthrough and codemode, enable per-integration search_<integration> tools, disable artifacts, and pick the approval mode.

Running it

  • Local. npm i -g executor, then executor install registers a background service, and executor web opens the console. executor mcp is the stdio entry point for agents. Storage is libSQL under the data directory. Stdio MCP servers are allowed here (dangerouslyAllowStdioMCP: true), unlike the multi-user hosts.
  • Docker. A distroless Bun image listens on 4788 with a /data volume and a /api/health check (Dockerfile). Better Auth handles sign-in. The session secret is generated on first run if you do not supply one. Spawning stdio MCP servers requires EXECUTOR_ALLOW_STDIO_MCP=true.
  • Cloudflare. apps/host-cloudflare deploys as a Worker. The hosted cloud uses Postgres through Hyperdrive, WorkOS and the dynamic-worker sandbox.

Strengths and caveats

  • Strength: code mode keeps context small. One execute tool plus on-demand tools.search scales to large catalogs without flooding the model’s tool list, and multi-step work runs in one round trip.
  • Strength: governance is real. Owner-ranked policies, verb-derived approval defaults, validation before approval, durable pause/resume with idempotent retries, and credentials resolved only after approval.
  • Strength: one core, many shapes. CLI, desktop, Docker, Workers and cloud share the SDK and plugins. Self-host needs no external database or services.
  • Caveat: the agent must write code. Codemode assumes a model that writes correct async JS against a discovered schema. Passthrough exists for clients that cannot, but in passthrough every invoke is advertised as destructive. Policy approvals are auto-accepted on the theory that the client’s own tool approval covers them (tool-server.ts). Weak client-side approval UX weakens the gate.
  • Caveat: no data sync or triggers. Executor is request/response only. There are no webhooks, scheduled syncs or cursors for agent data.
  • Caveat: complexity. It is Effect-TS end to end, with a 7,000-line core executor, several migration generations (v1 to v2) and three subprocess or worker runtimes beyond the default. Contributing has a steep entry cost.
  • Caveat: QuickJS limits. A WASM interpreter with no fetch and a 64 MB default is fine for orchestration glue, not for heavy data processing inside the sandbox.

Sources: code at 27dccb8, deepwiki-open wiki (18 pages), OpenDeepWiki wiki (22 pages), verified Q&A.

How it answers the API layer & connectors questions

Each answer was drafted by a code-reading agent at commit 27dccb8. Its citations were checked mechanically. Compare with the other api layer & connectors →

How is third-party authentication implemented?

answered

Executor implements OAuth 2.1 (authorization code + PKCE) and static credential (API key, header, bearer token) auth methods.

OAuth 2.1 flow lives in oauth-helpers.ts and delegates the RFC work to the oauth4webapi library (stateless, pure Web Crypto + fetch, no deps). A flow starts by creating a PKCE code verifier + S256 challenge, building an authorization URL with buildAuthorizationUrl() (line 219), and persisting an OAuth session (15-min TTL). The callback completes the code exchange inside oauth-client.ts's types. The OAuth client is self-contained (carries its own endpoints) and integration-independent, so the same app can back connections on any integration sharing that provider.

Token refresh uses skew-aware logic: OAUTH2_REFRESH_SKEW_MS = 60_000 (line 111) triggers a refresh before actual expiry. The shouldRefreshToken utility checks stored expiresAt minus skew. Refresh tokens are stored per-connection on the connection row (the expiresAt and oauthScope fields on the Connection interface in connection.ts, lines 41-59). Two concurrent refreshers racing on the same rotated token are handled via persisted refresh-token-based dedup.

Enterprise-Managed Authorization (EMA, id-jag draft) in oauth-ema.ts supports the MCP enterprise profile: an identity assertion from SSO is exchanged for an ID-JAG at the IdP, then redeemed at the Resource Authorization Server for an access token.

Credentials use the CredentialProvider interface in provider.ts (line 24): the default writable store holds pasted values, while external backends (1Password, keychain, WorkOS vault) resolve an opaque ID on demand — the value never lands in core storage. A connection binds to ONE auth method via template and resolves variable→value inputs. Placements are rendered onto HTTP requests by renderAuthPlacements() in http-auth/auth-method.ts (line 95), which maps credential variables to headers/query params with optional prefixes ("Bearer ").

Multi-tenant routing is handled by the OAuth callback state encoding: encodeOAuthCallbackState() in oauth.ts (line 59) wraps the raw random state with the org slug so the callback can route to the correct organization before completing the flow.

How is an integration / connector defined?

answered

An integration is defined by a slug, a kind (which plugin owns it), a description, an opaque config blob (never parsed by core), and a list of declared authMethods. The Integration interface in integration.ts (line 91) is the public projection — it carries no credentials and no plugin-internal config.

OpenAPI integrations (packages/plugins/openapi/src/sdk/plugin.ts): the user provides a spec URL or spec text, which is parsed by parse.ts, tools extracted via extract.ts, then compiled and stored as StoredOperation bindings in the OpenapiStore (store.ts). The integration config holds the spec reference, auth templates, and health-check config. Auth methods are derived from the spec's securitySchemes via deriveAuthenticationTemplateFromPreview().

GraphQL integrations (packages/plugins/graphql/src/sdk/plugin.ts): the user provides an endpoint URL, which is introspected to get the schema, then tools are generated per query/mutation.

MCP integrations (packages/plugins/mcp/src/sdk/plugin.ts): the user provides a server URL (streamable HTTP) or a stdio command. The server's capabilities are probed at connect time via probeMcpEndpointShape(), tools are discovered, and an MCP client connection is created through createMcpConnector().

Versioning: Specs are stored as immutable blobs with hash-addressed entries. Config migration is handled by per-plugin migrate-config.ts files that define schema transforms.

Auth template merge is shared across plugins via mergeAuthTemplates() in integration.ts (line 138): incoming entries with matching slugs replace existing ones; slug-less entries get a fresh custom_<id> slug.

Register count: The repo ships 3 first-party protocol plugins (openapi, graphql, mcp), plus provider extensions for Google and Microsoft APIs in the openapi plugin, and credential-provider plugins (1password, keychain, file-secrets, workos-vault, encrypted-secrets).

How are integrations exposed to LLM agents?

answered

Integrations are exposed to LLM agents exclusively through the Model Context Protocol (MCP). The MCP host surface lives in packages/hosts/mcp/.

Serving envelope (envelope.ts): The McpServingRoutes HTTP router (line 432) serves the MCP streamable-HTTP endpoint at /mcp. It handles auth via a pluggable McpAuthProvider, CORS preflight, session lifecycle (create via POST, send per mcp-session-id header), and routes JSON-RPC requests to the session store. Errors use jsonRpcErrorBody() (line 103) for byte-identical response bodies across all hosts.

Tool surface (tool-server.ts): The createExecutorMcpServer() factory builds the full MCP server. It registers tools including execute (runs code against the sandbox with access to all tools), resume (resumes a paused execution for approval gates), skills (instruction inventory), search (tool discovery), and five artifact tools. Elicitation supports three modes: browser (pop-up approval page), model (inline confirmations), and native (MCP native approvals).

Tool addressing: The executor presents a tool catalog where each tool is addressable as tools.<integration>.<owner>.<connection>.<tool>. This is described via describe.tool which returns TypeScript schemas. Tools can be discovered dynamically via the search tool, which returns paginated, ranked results.

Passthrough mode: An alternative ?mode=passthrough serves search + invoke directly (no sandbox), for agents that don't need code execution but want to call tools directly.

SDK embeddings: The TypeScript SDK (packages/core/sdk/src/promise.ts) exposes createExecutor() which accepts an array of plugins and returns a Promise-based Executor handle. From there you call executor.tools.list(), executor.tools.schema(), or executor.call() with raw tool addresses. The Effect-native SDK is also available for Effect-based consumers.

Code execution runtime: The ExecutionEngine in engine.ts provides both inline execution and pause/resume execution. It discovers tools through a ToolDiscoveryProvider that wraps executor.tools. Tool calls inside code dispatch through makeFullInvoker() which handles search, integration listing, describe, and the base invoker for connected tools.

Editor's note. Correction: passthrough mode does not bypass the sandbox. Each invoke is turned into a one-line generated program and run through engine.execute; policy approvals are auto-accepted because every passthrough invoke is advertised to the client as destructive.

How is a tool call executed?

answered

A tool call is executed through a layered pipeline: code sandbox → tool invoker → plugin invoke → HTTP/transport.

Sandbox → executor → plugin: The ExecutionEngine (packages/core/execution/src/engine.ts) runs code inside a sandboxed runtime (QuickJS WASM, Deno subprocess, or Cloudflare dynamic worker). Code calling tools.<integration>.<owner>.<connection>.<tool>(args) is intercepted by makeFullInvoker() (line 342) which strips the tools. prefix and dispatches to executor.execute(). Inside makeExecutorToolInvoker() (tool-invoker.ts, line 314), the call is dispatched to the owning plugin via executor.execute(). Errors are caught: CredentialResolutionError → auth-failure tool result, ToolNotFoundError → tool_not_found, ToolBlockedError → tool_blocked, ElicitationDeclinedError → fail with clear message, and opaque defects → sanitized with a correlation ID (line 359).

OpenAPI invocation (packages/plugins/openapi/src/sdk/invoke.ts): invoke() (line 1198) builds an HTTP request via buildRequest() (line 960) which resolves path parameters, serializes query/header/cookie params per OAS3 style rules, applies auth headers, and constructs the request body (JSON, form-urlencoded, multipart, octet-stream via 26KB of encoding logic). The HTTP call uses HttpClient with two independent timeouts: RESPONSE_HEADERS_TIMEOUT_MS = 110_000 (line 202) and RESPONSE_BODY_TIMEOUT_MS = 60_000 (line 197). Streaming responses (NDJSON, SSE) are collected with byte and time caps (1MB/10s default). Transport failures are classified: DNS errors, connection refused, TLS failures get structured error fields (transportFailureFields, line 1185).

MCP invocation (packages/plugins/mcp/src/sdk/invoke.ts): calls the remote MCP server's tools/call over HTTP or stdio.

Pagination is not on the transport layer but on tool inventory: searchTools returns PagedResult<T> with items, total, hasMore, nextOffset (tool-invoker.ts, line 458). The paginate() helper (line 465) slices the result set.

Rate limiting: Not built into core. The cloud product adds it in apps/cloud/src/engine/execution-rate-limit.ts.

Retries: The engine caches settled outcomes for idempotent resume retries (settledOutcomes map, line 595, bounded to 64 entries). For MCP client retries of resume that arrive while the first is still in flight, a pendingResumes map (line 601) deduplicates concurrent resumes.

Approval gating: Tools annotated with requiresApproval trigger an elicitation before execution. The engine supports pause/resume via ElicitationHandler + Queue pattern (line 690).

How are data sync, webhooks and triggers implemented?

not applicable

Executor is not a data sync platform and does not implement scheduled syncs, incremental cursors, webhook ingestion, or event triggers for agent tools. It is a synchronous tool catalog and execution proxy: an agent calls a tool, the plugin makes the API request, and the result returns immediately. The cloud product handles WorkOS webhooks for user/auth lifecycle events (apps/cloud/src/auth/workos-webhook.ts), but these are for platform operation (user provisioning, auth, billing), not for syncing data through integrations. If an agent needs to poll for changes or receive events, it calls tools explicitly as part of its code execution loop.

How is it self-hosted and what is open vs proprietary?

answered

The entire repository is MIT-licensed (LICENSE). The repo contains all components needed to run the full application.

Docker self-host (apps/host-selfhost/Dockerfile): Single-stage multi-phase build producing a distroless container (~120MB) with no external service dependencies. At startup it needs BETTER_AUTH_SECRET (for session encryption), EXECUTOR_BOOTSTRAP_ADMIN_EMAIL/PASSWORD, and EXECUTOR_WEB_BASE_URL. Data persists in a volume at /data. The container exposes port 4788 and has a built-in health check via /api/health. Uses Better Auth for authentication (username/password, SSO) and SQLite/Postgres for storage.

Cloudflare self-host (apps/host-cloudflare/): Deployed as a Cloudflare Worker with D1 database binding. Suitable for edge deployment.

Local CLI (apps/local/): Shared local runtime powering both the CLI (npm install -g executor) and the desktop app. Uses SQLite via libSQL, runs Better Auth, and can be run foreground or as a background daemon.

Desktop app (apps/desktop/): An Electron-based native app wrapping the local runtime with a system-tray daemon, auto-updater, and native keychain integration.

What's open vs proprietary: All code in this repo is open (MIT). The apps/cloud/ directory contains the hosted cloud product — it's in the same repo (not a separate closed repo) but requires proprietary services: WorkOS for auth, billing, and team management; PlanetScale for PostgreSQL hosting; Axiom for tracing; PostHog for analytics. The self-host path replaces those with Better Auth + SQLite/Postgres and has no required external services.

Required services: None for self-host. The Docker container is fully self-contained. The npm CLI uses SQLite locally. The only dependency is Node.js 20+ (or Bun for development).

Editor's note. Correction: the Docker image boots with zero config (the Better Auth secret is generated and persisted in /data; bootstrap-admin env vars are optional), and self-host storage is libSQL only. Postgres is used by the hosted cloud app.