Skip to content

Instantly share code, notes, and snippets.

@RhysSullivan
Last active October 4, 2026 18:45
Show Gist options
  • Select an option

  • Save RhysSullivan/9664620c5cdad3ee5de42598c5f7ce76 to your computer and use it in GitHub Desktop.

Select an option

Save RhysSullivan/9664620c5cdad3ee5de42598c5f7ce76 to your computer and use it in GitHub Desktop.
Build on Executor: hackathon guide (SDK, Eve agent, MCP server)

Build on Executor: hackathon guide

Executor runs tools for your users. You deploy an app: a small TypeScript file that declares tools, such as "list my GitHub issues". Your users connect their own accounts. Your code, an agent or an MCP client then calls the tools on a user's behalf through Executor.

This gist has three guides:

File What it covers
0-README.md (this file) Getting a key, installing the SDK, deploying an app, calling it
1-eve-agent.md An Eve agent that uses a user's Executor tools
2-mcp-server.md Executor as an MCP server for Claude Code, Cursor, Claude Desktop or Eve

The other files in the gist are the two examples' source. Each guide says where to save them.

Links:

This is a beta. Expect rough edges, and pin exact versions.

1. Get a project key

  1. Open https://platform.executor.sh/platform and sign in with an email code, Google or GitHub.
  2. Create an organization if the console asks for one. Its first project, Default, is created with it.
  3. Open Project keys and select Create key. Copy the key when the console shows it. It starts with exk_, and the console never shows it again.

A project key can do everything in its project. Keep it on your server or your own machine. Do not put it in browser code or commit it.

2. Install the SDK

npm install --save-exact @executor-js/sdk@2.1.0-beta.0

The SDK is a Promise API over fetch. It runs in Node 20 or newer, Bun, Deno and edge runtimes.

export EXECUTOR_BASE_URL="https://platform.executor.sh/platform"
export EXECUTOR_API_KEY="exk_..."

Always pass baseUrl. The SDK's built-in default is a production address that does not serve the platform yet.

3. Owners

Every call names an owner: your own ID for one of your users, such as user_a. Apps, accounts and profiles belong to an owner. There is nothing to create first; an owner exists once a call names it. Owners are per project, so user_a in one project is not user_a in another.

4. Deploy an app and call a tool

Save this app as hello/index.ts. It needs no account, so its tools work at once. Executor builds and runs it; you don't install apps yourself.

import { defineApp, object, query, router, string } from "apps";

export default defineApp(
  { accounts: {} },
  {
    tools: router({
      greet: query(
        { description: "Greet someone by name.", input: object({ name: string() }) },
        async (_ctx, { name }) => ({ message: `Hello, ${name}!` }),
      ),
    }),
  },
);

Then save this as quickstart.ts next to it, in a project with "type": "module":

import { readFileSync } from "node:fs";
import { createRemoteExecutor } from "@executor-js/sdk";

const executor = await createRemoteExecutor({
  baseUrl: process.env.EXECUTOR_BASE_URL ?? "https://platform.executor.sh/platform",
  apiKey: process.env.EXECUTOR_API_KEY!,
});
const owner = "user_a";

// Deploy the app's source for this owner, or a new version of it. Executor builds it.
const name = "Hello";
const [existing] = await executor.apps.list({ owner, name });
const files = [
    { path: "index.ts", content: readFileSync("hello/index.ts", "utf8") },
    {
      path: "package.json",
      // The apps version must be one the platform runs.
      content: JSON.stringify({ name: "hello", dependencies: { apps: "0.0.1-beta.22" } }),
    },
];
const { app } = await executor.apps.deploy(
  existing === undefined ? { owner, name, files } : { owner, app: existing.id, files },
);

// A profile holds the accounts an app's tools run with. This app needs none. The idempotency key
// makes a second run return the same profile.
const profile = await executor.apps.profiles.create({
  owner,
  app: app.id,
  subject: "default",
  idempotencyKey: "hello-default",
  accounts: {},
});

const tools = await executor.tools.list({ app: app.id, profile: profile.id });
console.log(tools.items.map((tool) => tool.name)); // [ "greet" ]

const result = await executor.tools.call({
  app: app.id,
  profile: profile.id,
  tool: "greet",
  kind: "query", // "query" for read-only tools, "mutation" for the rest
  input: { name: "Ada" },
});
console.log(result); // { status: "completed", value: { message: "Hello, Ada!" } }

Run it again after editing the app: it deploys the new version to the same app.

Node 22.18 and newer run TypeScript directly: node quickstart.ts. On Node 20, use npx tsx quickstart.ts.

See Author an app for mutations, approvals, accounts and everything else an app can declare.

There is no app catalog on this deployment yet. Write the apps you need, or copy one already in your project with executor.apps.copy.

5. Connect a user's account

An app can declare accounts, such as a GitHub sign-in. Until the owner's profile selects one, the app's tools fail with AccountRequired. The provider an app needs is app.requirements.accounts.<slot>.provider, from executor.apps.list({ owner }).

API key providers. Add the account, then select it in the profile:

const account = await executor.accounts.add({
  owner,
  provider: app.requirements.accounts.demo.provider,
  method: "apiKey", // the auth method's name in the provider's declaration
  fields: { token: "the user's API token" },
});
await executor.apps.profiles.update({
  app: app.id,
  profile: profile.id,
  expectedRevision: profile.revision,
  accounts: { demo: account.id },
});

Sign-in (OAuth) providers, with a connect link from the console. No code of your own:

  1. GitHub and Google need your own OAuth app. Create one with the service and set its callback URL to https://platform.executor.sh/platform/connect/callback. In the console, open OAuth clients, select Register client, pick the owner and enter the client ID and secret.
  2. Open Owners, select New auth link, enter the owner ID, choose the provider and client, and create the link.
  3. Send the link to your user. They sign in with the service; they don't need an Executor account.
  4. The link adds the account to the owner, not to an app. Find it with executor.accounts.list({ owner }) and select it with executor.apps.profiles.update as above.

Sign-in (OAuth) providers, with only the key. Your own app hosts the OAuth callback:

  1. executor.accountConnections.create({ owner, target: { app, profile, requirement: slot } })
  2. executor.accountConnections.startOAuth({ connection, owner, method: "oauth", redirectUri, client }), where redirectUri is your own callback URL and client is { clientId, clientSecret } from your OAuth app, or { id } of a client registered with executor.oauthClients.register.
  3. Send the user to the returned authorizationUrl.
  4. In your callback route, call executor.accountConnections.completeOAuth({ connection, owner, callbackUrl }) with the full callback URL. Because the connection named a target, the account fills that profile slot.

Handling errors

A failed call rejects with an instance of an exported error class. Check it with instanceof:

import { ProjectKeyInvalid, TransportError } from "@executor-js/sdk";

try {
  await executor.accounts.list({ owner: "user_a" });
} catch (error) {
  if (error instanceof ProjectKeyInvalid) console.error("Check EXECUTOR_API_KEY");
  else if (error instanceof TransportError) console.error(error.reason);
  else throw error;
}
Error Status Meaning
ProjectKeyInvalid 401 The key is missing, unknown or revoked.
OwnerRequired 400 The call did not name an owner.
ProjectResourceNotFound 404 A referenced app or account is not in the key's project.
ProjectOperationUnavailable 403 The operation works on data no project owns.
AccountRequired 409 The app needs an account for a slot. Nothing ran.
TransportError - No valid answer arrived. The call may still have run.

Each operation's own errors, such as AppNotFound or ToolNotFound, are exported too. The API reference lists them per operation.

Approvals

A tool with an approval policy does not run until someone approves it. The call returns { status: "approval-required", requestId, elicitation } and nothing has run yet. Show elicitation.message to the user, then answer with executor.tools.resume, naming the owner:

if (result.status === "approval-required") {
  console.log(result.elicitation.message);
  const resumed = await executor.tools.resume({
    owner,
    requestId: result.requestId,
    response: { action: "accept" }, // or { action: "decline" }
  });
  if (resumed.status === "completed") console.log(resumed.value);
}

Questions a tool asks while it runs

A running tool can ask its user a question, such as a name or a choice (ctx.elicit in the app). Pass an elicitation handler to answer it during the call. Without one, the call fails with ToolElicitationFailed when the tool asks, and anything it did before asking has already happened.

const asked = await executor.tools.call(
  { app: app.id, profile: profile.id, tool: "greetMe", kind: "query", input: {} },
  {
    elicitation: async (request) => {
      console.log(request.message); // request.requestedSchema describes the form
      return { action: "accept", content: { name: "Ada" } };
    },
  },
);

The handler answers questions only. An approval still returns approval-required, with or without a handler.

TypeScript

The package ships its own declarations and has no dependencies. Use moduleResolution bundler, node16 or nodenext, and include the DOM library or @types/node. baseUrl is required, and so is owner on every operation that takes one, so the compiler catches a missing one. The full SDK README is on npm.

Executor tools in an Eve agent

An Eve agent that uses one Executor user's apps through the Executor SDK's remote client, @executor-js/sdk.

At the start of every turn, the agent reads the user's apps from Executor. Each Executor tool becomes its own Eve tool, with the tool's own input schema. A greet tool in the app hackathon-hello, for example, becomes the Eve tool hackathon_hello__greet. Two more tools work with any app:

  • list_executor_tools lists the apps and tools, and says why an app cannot be used yet, such as a missing account.
  • call_executor_tool calls any listed tool by app, name and JSON input.

Prerequisites

  • Node.js 24 or newer. Eve requires it.

  • An Executor project key:

    1. Open the platform console at https://platform.executor.sh/platform and sign in.
    2. Create an organization if the console asks for one. Its first project, Default, is created with it.
    3. Open Project keys and select Create key. Copy the key when the console shows it. It starts with exk_.

    A project key can do everything in its project. Keep it on your server.

  • An owner with at least one app. An owner is your own id for one of your users, such as user_a. See Give the owner an app.

  • A model credential: an OpenAI API key, or a Vercel AI Gateway key. See Choose the model.

Install

Download the example's files from this gist into a new folder, then install:

mkdir -p executor-eve-agent && cd executor-eve-agent
mkdir -p agent agent/lib agent/tools
G="https://gist.githubusercontent.com/RhysSullivan/9664620c5cdad3ee5de42598c5f7ce76/raw"
curl -fsSL "$G/eve-package.json" -o package.json
curl -fsSL "$G/eve-tsconfig.json" -o tsconfig.json
curl -fsSL "$G/eve.env.example" -o .env.example
curl -fsSL "$G/eve-agent.ts" -o agent/agent.ts
curl -fsSL "$G/eve-instructions.md" -o agent/instructions.md
curl -fsSL "$G/eve-lib-executor.ts" -o agent/lib/executor.ts
curl -fsSL "$G/eve-tools-executor.ts" -o agent/tools/executor.ts
curl -fsSL "$G/eve-tools-list_executor_tools.ts" -o agent/tools/list_executor_tools.ts
curl -fsSL "$G/eve-tools-call_executor_tool.ts" -o agent/tools/call_executor_tool.ts
npm install

This installs @executor-js/sdk@2.1.0-beta.0, Eve 0.70.0, ai and zod. The SDK is a beta, so its version is pinned.

The files, if you would rather copy them by hand:

Gist file Save as
eve-package.json package.json
eve-tsconfig.json tsconfig.json
eve.env.example .env.example
eve-agent.ts agent/agent.ts
eve-instructions.md agent/instructions.md
eve-lib-executor.ts agent/lib/executor.ts
eve-tools-executor.ts agent/tools/executor.ts
eve-tools-list_executor_tools.ts agent/tools/list_executor_tools.ts
eve-tools-call_executor_tool.ts agent/tools/call_executor_tool.ts

Configure

cp .env.example .env.local

Then edit .env.local. eve dev loads it automatically.

Variable Required Value
EXECUTOR_API_KEY yes Your project key, exk_....
EXECUTOR_OWNER yes The owner the agent acts for, such as user_a.
EXECUTOR_BASE_URL no https://platform.executor.sh/platform, which is also the default.
OPENAI_API_KEY yes* Your OpenAI key. *Or AI_GATEWAY_API_KEY; see below.
OPENAI_MODEL no Any OpenAI model your key can use. Defaults to gpt-5.4-mini.

The SDK has no default address, so the agent always passes this URL to createRemoteExecutor.

Run

npm run dev

This opens Eve's terminal UI. Try:

  • "What can you do with my Executor apps?"
  • "Greet Ada." (with the hello app below)
  • "Which of my apps can't I use yet?"

To run a turn without the terminal UI, start the server in one terminal and send a message from another:

npx eve dev --no-ui
npx eve remote invoke --url http://127.0.0.1:2000 "Greet Ada and show me what the tool returned."

npm run typecheck checks the TypeScript. npm run build builds the agent for deployment with eve build.

Give the owner an app

An owner's apps live in your Executor project, and you add them with your project key. There is nothing to create for the owner first: each call names it. This app needs no account, so its tools work at once. Save its source as hello/index.ts:

import { defineApp, object, query, router, string } from "apps";

export default defineApp(
  { accounts: {} },
  {
    tools: router({
      greet: query(
        { description: "Greet someone by name.", input: object({ name: string() }) },
        async (_ctx, { name }) => ({ message: `Hello, ${name}!` }),
      ),
      now: query(
        { description: "Return the current server time.", input: object({}) },
        async () => ({ now: new Date().toISOString() }),
      ),
    }),
  },
);

Executor builds and runs that code; you do not install apps here. Then save this as deploy-hello.ts and run node --env-file=.env.local deploy-hello.ts. Run it again after you edit hello/index.ts: it deploys the new source to the same app.

import { readFileSync } from "node:fs";
import { createRemoteExecutor } from "@executor-js/sdk";

const executor = await createRemoteExecutor({
  baseUrl: process.env.EXECUTOR_BASE_URL ?? "https://platform.executor.sh/platform",
  apiKey: process.env.EXECUTOR_API_KEY!,
});
const owner = process.env.EXECUTOR_OWNER!;

const name = "Hello";
const files = [
  { path: "index.ts", content: readFileSync("hello/index.ts", "utf8") },
  {
    path: "package.json",
    content: JSON.stringify({ name: "hello", dependencies: { apps: "0.0.1-beta.22" } }),
  },
];
// Deploy a new app, or a new version of the owner's existing one.
const [existing] = await executor.apps.list({ owner, name });
const { app } = await executor.apps.deploy(
  existing === undefined ? { owner, name, files } : { owner, app: existing.id, files },
);

// A profile holds the owner's account choices for one app. This app needs none.
await executor.apps.profiles.create({
  owner,
  app: app.id,
  subject: "default",
  idempotencyKey: "hello-default",
  accounts: {},
});
console.log(`Deployed ${app.name} (${app.id}) for ${owner}`);

On its next message, the agent has the tools hello__greet and hello__now. The platform API reference lists every operation, including apps.copy to copy an app that is already in your project.

Give the owner an account

Some apps need an account, such as GitHub. Until the owner has one selected, the app's tools cannot be listed or called: Executor answers AccountRequired, and the agent tells the user which account is missing. The provider an app needs is in app.requirements.accounts.<slot>.provider, from executor.apps.list({ owner }).

There are three ways to connect one:

  1. An auth link from the console. Signed in to https://platform.executor.sh/platform as an organization owner or admin, open Owners and select New auth link. Enter the owner, choose the provider, then select Create link. Send the link to your user; they do not need an Executor sign-in. An auth link adds the account to the owner, not to an app, so then select it in the app's profile:

    const [profile] = await executor.apps.profiles.list({ app: app.id, owner });
    const [account] = await executor.accounts.list({ owner });
    if (profile === undefined || account === undefined) throw new Error("Nothing to select yet");
    await executor.apps.profiles.update({
      app: app.id,
      profile: profile.id,
      expectedRevision: profile.revision,
      accounts: { github: account.id },
    });

    GitHub and Google sign-in need your own OAuth app. Register its client for the owner in the console or with executor.oauthClients.register, and set the OAuth app's callback URL to https://platform.executor.sh/platform/connect/callback.

  2. Your own OAuth callback, with the key only. Call executor.accountConnections.create({ owner, target: { app, profile, requirement: "github" } }). Then call executor.accountConnections.startOAuth({ connection, owner, method: "oauth", redirectUri, client }) with your own callback URL and OAuth client, and send the user to the authorizationUrl it returns. Your callback route finishes with executor.accountConnections.completeOAuth({ connection, owner, callbackUrl }). This fills the profile's account slot, so no update is needed.

  3. API-key providers. Call executor.accounts.add({ owner, provider, method, fields }), then select the account in the profile as in option 1.

The agent reads the catalog again on every message, so the app's tools appear on the user's next message after the account is selected.

How it works

One Eve tool per Executor tool

agent/tools/executor.ts is an Eve dynamic tool resolver that runs on turn.started. It calls executor.apps.list({ owner }), picks each app's profile with executor.apps.profiles.list, and reads the app's tools with executor.tools.list. Each tool becomes an Eve tool named <app slug>__<tool name>, using only letters, digits and underscores. Its input schema is Executor's JSON Schema, unchanged, so Eve checks the model's input before the call.

A tool whose input is not a JSON object cannot be an Eve tool. It still appears in list_executor_tools, and call_executor_tool can call it.

Approvals

Executor marks read-only tools as queries. The rest are mutations, which can change data. Every call names its kind, and Executor refuses a wrong kind before anything runs.

  • Eve asks the user to approve every mutation before it runs.
  • An app can also ask for approval itself. Executor then answers approval-required, and nothing has run. When the user already approved that exact call in Eve, the agent gives Executor the same answer and the call runs.
  • For a query, the agent tells the model that the app wants approval. The model can call call_executor_tool with requireApproval: true, so Eve asks the user first.

What the model is told when a call fails

explain in agent/lib/executor.ts turns the SDK's typed errors into sentences the model can act on. Some of them:

SDK error What the model is told
ProjectKeyInvalid The project key was rejected, and retrying will not help.
AccountRequired The app needs an account for a named slot. Nothing ran.
ToolKindMismatch, InputInvalid What was wrong with the call. Nothing ran.
ToolElicitationFailed The tool asked a question mid-call, which this agent cannot answer.
ToolCallFailed The tool failed after it started and may have changed something.
TransportError No valid answer from Executor. Check EXECUTOR_BASE_URL.

Tool questions are the gap. A tool can ask its user a question while it runs. Answering it means keeping the call open with an elicitation handler on executor.tools.call, and an Eve tool cannot wait for a person in the middle of a call. For tools that ask questions, use your own code with an elicitation handler, or an MCP client that supports form elicitation with the MCP server example.

One owner per agent

The agent acts for the single owner in EXECUTOR_OWNER. To serve many users, derive the owner from the signed-in user instead, such as ctx.session.auth.current in Eve. See Eve's authentication guide.

Alternative: connect Eve to the MCP server example

The MCP server guide serves an owner's Executor tools over MCP. Eve can use it through an MCP connection instead of the SDK tools above.

  1. Install and start the server over Streamable HTTP, with the same Executor settings in its .env:

    # In the MCP server example's folder; see 2-mcp-server.md.
    npm install
    node --env-file=.env server.ts --http   # serves http://127.0.0.1:3333/mcp
  2. In this agent, delete agent/tools/ and agent/lib/, and add agent/connections/executor.ts:

    import { defineMcpClientConnection } from "eve/connections";
    
    export default defineMcpClientConnection({
      url: "http://127.0.0.1:3333/mcp",
      description: "The user's Executor apps and tools.",
    });

    If the server sets MCP_HTTP_TOKEN, add auth: { getToken: async () => ({ token: process.env.MCP_HTTP_TOKEN! }) }.

The model then finds the tools with connection_search and calls them with connection_execute. To have Eve ask before some calls, add an approval policy to the connection.

Choose the model

agent/agent.ts calls OpenAI directly through Eve's openai() helper, which reads OPENAI_API_KEY. It names gpt-5.4-mini because Eve's own default OpenAI model is not available to every key.

To use the Vercel AI Gateway, replace the model line with a Gateway model id and set AI_GATEWAY_API_KEY:

model: "openai/gpt-5.4-mini",

Inside npm run dev, /login and /model work too. See Eve's agent configuration for other providers.

Deploy

npm run build runs eve build. Before you deploy, for example to Vercel with npx eve deploy, set the same environment variables on the host. Also configure authentication for the agent's routes: Eve rejects production traffic until you do. See Eve's deployment guide.

Executor as an MCP server

This example runs a small MCP server for one Executor owner. Claude Code, Cursor, Claude Desktop, Eve or any other MCP client can then use that owner's Executor tools.

  • tools/list reads the owner's apps and lists the tools of each one. It does this on every request, so new apps and tools show up the next time your client lists tools.
  • tools/call runs the Executor tool with your project key. The result comes back as JSON text. When the result is an object, it also comes back as structuredContent.
  • Executor failures come back as MCP tool errors (isError: true) with a short explanation, such as "This app needs a "github" account before its tools can run."
  • A tool can ask its user a question while it runs, or ask for approval before it runs. If your client supports MCP elicitation, these questions appear as forms in the client.

The server is a single file, server.ts (mcp-server.ts in this gist). It uses @executor-js/sdk and the official @modelcontextprotocol/sdk.

Prerequisites

  • Node 22.18 or newer, which runs TypeScript files directly. On older Node, write npx tsx where the commands below say node, and run the scripts yourself rather than through npm run, for example npx tsx --env-file=.env scripts/smoke.ts.
  • A project key:
    1. Open the console at https://platform.executor.sh/platform and sign in.
    2. Create an organization if the console asks for one. Its first project, Default, is created with it.
    3. Open Project keys and select Create key. Copy the key when the console shows it. A key starts with exk_.
  • An owner with at least one app. An owner is your own ID for one of your users, such as user_a. If you don't have an app yet, use the demo app: see Give the owner an app.

A project key can do everything in its project. Keep it on your machine or server, and don't commit it.

Install

Download the example's files from this gist into a new folder, then install:

mkdir -p executor-mcp-server && cd executor-mcp-server
mkdir -p demo-app scripts
G="https://gist.githubusercontent.com/RhysSullivan/9664620c5cdad3ee5de42598c5f7ce76/raw"
curl -fsSL "$G/mcp-package.json" -o package.json
curl -fsSL "$G/mcp-tsconfig.json" -o tsconfig.json
curl -fsSL "$G/mcp.env.example" -o .env.example
curl -fsSL "$G/mcp-server.ts" -o server.ts
curl -fsSL "$G/mcp-demo-app.ts" -o demo-app/index.ts
curl -fsSL "$G/mcp-deploy-demo-app.ts" -o scripts/deploy-demo-app.ts
curl -fsSL "$G/mcp-smoke.ts" -o scripts/smoke.ts
npm install

The files, if you would rather copy them by hand:

Gist file Save as
mcp-package.json package.json
mcp-tsconfig.json tsconfig.json
mcp.env.example .env.example
mcp-server.ts server.ts
mcp-demo-app.ts demo-app/index.ts
mcp-deploy-demo-app.ts scripts/deploy-demo-app.ts
mcp-smoke.ts scripts/smoke.ts

Configure

cp .env.example .env

Then edit .env:

Variable Meaning
EXECUTOR_API_KEY Your project key, exk_.... Required.
EXECUTOR_OWNER The owner whose tools the server offers. Required.
EXECUTOR_BASE_URL The platform API, https://platform.executor.sh/platform. The server uses this address when it is not set.
EXECUTOR_SUBJECT Optional. When an app has several profiles for the owner, the server uses the profile with this subject.
PORT, HOST HTTP mode only. Default 3333 and 127.0.0.1.
MCP_HTTP_TOKEN HTTP mode only. When set, clients must send Authorization: Bearer <token>. Required on any non-local HOST.

Run

Give the owner the demo app (optional), then check the server with the smoke test:

npm run deploy-demo-app
npm run smoke

npm run smoke starts server.ts over stdio, lists its tools, and calls the first <app>__greet tool. To call another tool, name it and pass its input as JSON:

npm run smoke -- mcp-demo__shout '{"text":"hello"}'

The smoke client answers the tool's questions for you: it fills each text field with "MCP smoke test" and accepts approvals.

The npm run scripts load .env with node --env-file=.env. You can also run the server yourself:

node --env-file=.env server.ts           # stdio: what desktop clients start
node --env-file=.env server.ts --http    # Streamable HTTP at http://127.0.0.1:3333/mcp
npx tsx --env-file=.env server.ts        # the same, on older Node

The server writes its logs to stderr. On stdio, stdout carries the MCP messages.

To inspect the server with the MCP Inspector, run it with the inspector's CLI. Everything before -- starts the server, and the inspector's own options come after it:

npx @modelcontextprotocol/inspector --cli \
  node --env-file=/absolute/path/to/.env /absolute/path/to/server.ts \
  -- --method tools/list

Connect a client

Each client starts the server itself. Replace /absolute/path/to with the path to this folder. --env-file points Node at your .env, so the key stays in that one ignored file rather than in the client's configuration or your shell history. Some desktop apps don't read your shell's PATH. If yours can't find Node 22.18 or newer, replace node with the output of which node.

Claude Code

claude mcp add executor -- node --env-file=/absolute/path/to/.env /absolute/path/to/server.ts

Check it with claude mcp list. Inside Claude Code, the tools are named mcp__executor__<app slug>__<tool>. Claude Code reads the tool list when it connects. After you deploy a new app, run /mcp and reconnect the server to see its tools.

Cursor

Add the server to .cursor/mcp.json in your project, or to ~/.cursor/mcp.json for every project:

{
  "mcpServers": {
    "executor": {
      "command": "node",
      "args": ["--env-file=/absolute/path/to/.env", "/absolute/path/to/server.ts"]
    }
  }
}

Claude Desktop

Open Settings > Developer > Edit Config. This opens claude_desktop_config.json:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json

Add the same mcpServers entry as for Cursor, then restart Claude Desktop.

Eve

An Eve MCP connection needs a URL that speaks Streamable HTTP or SSE, so run the server in HTTP mode:

# Once: add a token to .env, such as MCP_HTTP_TOKEN=<output of openssl rand -hex 32>
node --env-file=.env server.ts --http

Then add a connection file to your Eve agent. The filename is the connection name:

// agent/connections/executor.ts
import { defineMcpClientConnection } from "eve/connections";

export default defineMcpClientConnection({
  url: process.env.EXECUTOR_MCP_URL ?? "http://127.0.0.1:3333/mcp",
  description: "Executor apps: the tools of this user's connected apps.",
  auth: {
    getToken: async () => ({ token: process.env.MCP_HTTP_TOKEN! }),
  },
});

Eve sends the token as Authorization: Bearer <token>. The model finds the tools with connection_search and runs them with connection_execute. Eve's events name them executor__<app slug>__<tool>.

Eve first sends server/discover. This server does not answer it, so Eve falls back to the initialize handshake. To skip that first request, add protocolVersionDiscovery: false to the connection.

Eve's documentation does not cover MCP elicitation for connections. Expect tools that ask a question or need approval to return an error there. To gate tools in Eve, use the connection's own approval option.

An agent you deploy can't reach 127.0.0.1. Run the server where the agent can reach it, with HOST=0.0.0.0 and a MCP_HTTP_TOKEN, and set EXECUTOR_MCP_URL to its address. The server refuses to listen on a non-local address without a token.

Give the owner an app

The quickest way is the demo app in demo-app/index.ts. It needs no account and has three tools:

  • greet takes { name } and returns a greeting.
  • greetMe asks the user for their name while it runs.
  • shout asks for approval before it runs.
npm run deploy-demo-app

scripts/deploy-demo-app.ts does this with the project key:

  1. executor.apps.deploy({ owner, name, files }) deploys the source. A later run deploys a new version with { owner, app, files }.
  2. executor.apps.profiles.create({ owner, app, subject, idempotencyKey, accounts: {} }) creates the profile. A profile holds the accounts the app's tools run with.

Edit demo-app/index.ts and run the script again to change the tools. The apps version in the deployed package.json must be one the platform runs; the script uses 0.0.1-beta.22.

Give the owner an account

An app that declares an account, such as a GitHub sign-in, can't list or run its tools until the owner's profile selects an account. Until then, the server leaves the app out of tools/list and logs the reason. A call to one of its tools returns an error like this:

This app needs a "github" account before its tools can run. Connect one for owner "user_a" and select it in the app's profile, then try again.

Each account an app needs has a slot name. app.requirements.accounts.<slot>.provider, from executor.apps.list({ owner }), is the provider to connect.

API key accounts. Add the account, then select it in the app's profile:

const [app] = await executor.apps.list({ owner, slug: "my-app" });
if (app === undefined) throw new Error(`${owner} has no app my-app`);
const slot = "demo"; // a key of app.requirements.accounts
const requirement = app.requirements.accounts[slot];
const [profile] = await executor.apps.profiles.list({ app: app.id, owner });
if (requirement === undefined || profile === undefined) throw new Error("Nothing to connect");

const account = await executor.accounts.add({
  owner,
  provider: requirement.provider,
  method: "apiKey", // the auth method's name in the provider's declaration
  fields: { token: "the user's API token" }, // the method's fields
});
await executor.apps.profiles.update({
  app: app.id,
  profile: profile.id,
  expectedRevision: profile.revision,
  accounts: { [slot]: account.id },
});

Sign-in (OAuth) accounts, from the console. This needs no code of your own:

  1. GitHub and Google need an OAuth client of your own. Create an OAuth app with the service, and set its redirect URL to https://platform.executor.sh/platform/connect/callback. Then, in the console, open OAuth clients and select Register client. Pick the owner and enter the client ID and secret.
  2. Open Owners and select New auth link. Enter the owner ID, choose the provider and the OAuth client, then select Create link.
  3. Send the link to the user. They sign in with the service. They don't need an Executor account.
  4. The link adds the account but does not select it in any profile. Find it with executor.accounts.list({ owner }), then select it with executor.apps.profiles.update as shown above.

Sign-in (OAuth) accounts, with only the key. Your own app hosts the OAuth callback:

  1. Call executor.accountConnections.create({ owner, target: { app, profile, requirement: slot } }).
  2. Call executor.accountConnections.startOAuth({ connection, owner, method: "oauth", redirectUri, client }). method is the sign-in method's name in the provider's declaration. redirectUri is your own callback URL. client is { clientId, clientSecret } from your OAuth app, or { id } of a client you registered with executor.oauthClients.register.
  3. Send the user to the returned authorizationUrl.
  4. In your callback route, call executor.accountConnections.completeOAuth({ connection, owner, callbackUrl }) with the full callback URL. A connection created with a target selects the new account in that profile slot when it completes.

The API reference lists every operation.

Questions and approvals

A running tool can ask its user a question (ctx.elicit in the app). A tool with an approval policy asks for approval before it runs. The server handles both with MCP form elicitation:

  • If the client supports elicitation, the client shows the question as a form and the server sends back the answer. For an approval, the server shows the approval form, then resumes the saved call with executor.tools.resume.
  • If the client doesn't support it, the call returns an MCP tool error. A tool that needs approval does not run. A tool that asks a question fails at that point, after any earlier steps it took.

Whether a client supports elicitation depends on the client and its version. The smoke client supports it.

Limitations

  • One server serves one owner. To serve several users, run one server per owner, or extend server.ts to read the owner from each session.
  • Cancelling a call in the client does not stop the Executor tool. The SDK has no way to cancel a running tools.call.
  • The server does not push tool list changes. Clients see new tools the next time they list them.
  • Tool output schemas are not forwarded.
  • MCP tool names are <app slug>__<tool>. Characters other than letters, digits, _ and - become _, so a nested tool issues.list is <app slug>__issues_list. Some clients limit the length of tool names, so long app slugs may not fit.
import { defineAgent } from "eve";
import { openai } from "eve/models/openai";
export default defineAgent({
// Calls OpenAI directly with OPENAI_API_KEY. Set OPENAI_MODEL to use another OpenAI model. To
// route through the Vercel AI Gateway instead, replace this line with a model id string, such
// as model: "openai/gpt-5.4-mini", and set AI_GATEWAY_API_KEY.
model: openai(process.env.OPENAI_MODEL ?? "gpt-5.4-mini"),
// Keep the agent to its Executor tools. eve's optional defaults add a shell sandbox, file and
// web tools, which this example does not need.
defaultTools: false,
});

Executor assistant

You help the user get things done with the apps they have in Executor. Each Executor app offers tools, such as reading a calendar or sending a message, that run with the user's own connected accounts.

Your tools

  • Each Executor tool the user can call is also one of your tools. Its name is the app's slug and the tool's name joined by two underscores, for example hackathon_hello__greet. Its description names the app and says whether it is a query (reads data) or a mutation (changes something).
  • list_executor_tools lists the user's apps, their tools and any app that cannot be used yet, with the reason. Use it when a tool you expect is missing, or after the user says they connected an account.
  • call_executor_tool calls any listed tool by app, tool name and JSON input. Prefer the dedicated tool when there is one.

A tool call returns { "status": "completed", "value": ... }. When it also has "toolError": true, the tool reported a problem in value; read it and tell the user.

Approvals

The user approves every mutation before it runs; eve asks them for you. Do not ask for permission in your reply as well.

Some apps also ask for approval before a query. That call returns "status": "approval-required" and nothing has run. Explain what the call would do and, if the user wants it, call call_executor_tool with requireApproval: true so they can approve it.

When something fails

Tool errors explain what happened and what to do next. Follow them:

  • When an app needs an account, nothing ran. Tell the user which app and account it needs. Do not retry until they say it is connected.
  • When the project key is rejected, stop calling Executor tools and tell the user the agent's operator must fix its configuration.
  • When a tool asks a question while it runs, this agent cannot answer it. Tell the user what the tool needed.
  • When a tool fails after it started, it may already have changed something. Say so, and do not repeat a mutation without asking.

Never invent tool results, accounts or ids. Keep replies short.

/**
* The agent's connection to Executor: one remote client, the owner's tool catalog, and one way to
* call a tool. Executor's failures become plain sentences the model can act on.
*/
import {
AccountNotFound,
AccountRequired,
AccountSelectionInvalid,
AppNotDeployed,
AppNotFound,
createRemoteExecutor,
InputInvalid,
OAuthReconnectRequired,
OAuthRenewalFailed,
OwnerRequired,
ProjectKeyInvalid,
ProjectKeyUnavailable,
ProjectResourceNotFound,
RequestInvalid,
ToolBlocked,
ToolCallFailed,
ToolElicitationFailed,
ToolKindMismatch,
ToolNotFound,
ToolPolicyFailed,
TransportError,
type Executor,
} from "@executor-js/sdk";
import { z } from "zod";
/** The Executor platform API, used unless EXECUTOR_BASE_URL names another. */
const PLATFORM_URL = "https://platform.executor.sh/platform";
function required(name: string): string {
const value = process.env[name]?.trim();
if (value === undefined || value === "") {
throw new Error(`${name} is not set. Add it to .env.local (see .env.example), then restart.`);
}
return value;
}
/** The Executor owner this agent acts for: your own id for one of your users. */
export const owner = () => required("EXECUTOR_OWNER");
let client: Promise<Executor> | undefined;
/** One Executor client for the whole process. Creating it sends no request. */
export function connect(): Promise<Executor> {
client ??= createRemoteExecutor({
baseUrl: process.env.EXECUTOR_BASE_URL?.trim() || PLATFORM_URL,
apiKey: required("EXECUTOR_API_KEY"),
});
return client;
}
export type ToolKind = "query" | "mutation";
/** JSON, as tool schemas and inputs travel between the model, eve and Executor. */
export type Json = null | boolean | number | string | readonly Json[] | JsonObject;
export type JsonObject = { readonly [key: string]: Json };
/** A tool's input: a JSON object. eve has checked it against the tool's schema already. */
const ToolInput = z.record(z.string(), z.json());
/** Where a call goes. Plain strings, so eve can store it with the tool that makes the call. */
export interface CallTarget {
readonly app: string;
/** The owner's saved account choices for the app, or null when the app has none yet. */
readonly profile: string | null;
readonly tool: string;
readonly kind: ToolKind;
}
/** One Executor tool, as this agent describes it to the model. */
export interface CatalogTool extends CallTarget {
/** The tool's name in eve, or null when its input is not an object and eve cannot expose it. */
readonly name: string | null;
readonly appName: string;
readonly description: string;
readonly inputSchema: JsonObject;
}
/** One of the owner's apps. `problem` says why its tools could not be listed. */
export interface CatalogApp {
readonly id: string;
readonly slug: string;
readonly name: string;
readonly profile: string | null;
readonly tools: readonly CatalogTool[];
readonly problem?: string;
}
/** Reject with a message written for the model, keeping the original error as the cause. */
function fail(error: unknown): never {
throw new Error(explain(error), { cause: error });
}
/**
* The profile to call an app's tools with. A profile holds the owner's account choices for one
* app. Prefer a ready one; otherwise take any, so Executor can say what is still missing.
*/
async function profileFor(executor: Executor, app: string): Promise<string | null> {
const profiles = await executor.apps.profiles.list({ app, owner: owner() });
const usable = profiles.filter(
(profile) => profile.enabled && profile.status !== "removing" && profile.status !== "removed",
);
return (usable.find((profile) => profile.status === "ready") ?? usable[0])?.id ?? null;
}
/** Every page of an app's live tool list. */
async function toolsOf(executor: Executor, app: string, profile: string | null) {
const tools = [];
let cursor: string | undefined;
do {
const page = await executor.tools.list({
app,
...(profile === null ? {} : { profile }),
...(cursor === undefined ? {} : { cursor }),
});
tools.push(...page.items);
cursor = page.next;
} while (cursor !== undefined);
return tools;
}
/**
* Models accept tool names made of letters, digits and underscores, up to 64 characters, and
* only take an object as a tool's input.
*/
function eveToolName(slug: string, tool: string, inputSchema: JsonObject, taken: Set<string>) {
if (inputSchema.type !== "object") return null;
const base = `${slug}__${tool}`.replace(/[^A-Za-z0-9_]/g, "_").slice(0, 60);
let name = base;
for (let suffix = 2; taken.has(name); suffix++) name = `${base}_${suffix}`;
taken.add(name);
return name;
}
/** The owner's apps and their tools, read live from Executor. */
export async function loadCatalog(): Promise<CatalogApp[]> {
const executor = await connect();
const apps = await executor.apps.list({ owner: owner() }).catch(fail);
const listed = await Promise.all(
apps.map(async (app) => {
let profile: string | null = null;
try {
profile = await profileFor(executor, app.id);
return { app, profile, tools: await toolsOf(executor, app.id, profile) };
} catch (error) {
// One app that cannot be used yet, for example because it needs an account, does not
// hide the others.
return { app, profile, tools: [], problem: explain(error) };
}
}),
);
const taken = new Set<string>();
return listed.map(({ app, profile, tools, problem }) => ({
id: app.id,
slug: app.slug,
name: app.name,
profile,
tools: tools.map((tool) => ({
name: eveToolName(app.slug, tool.name, tool.inputSchema, taken),
app: app.id,
appName: app.name,
profile,
tool: tool.name,
// Executor marks read-only tools; everything else may change data.
kind: tool.readOnly === true ? "query" : "mutation",
description: tool.description,
inputSchema: tool.inputSchema,
})),
...(problem === undefined ? {} : { problem }),
}));
}
/**
* Find one of the owner's apps by id or slug, and the profile its tools run with. Slugs use only
* a-z, 0-9 and "-", and eve tool names turn "-" into "_", so a model that copies the slug from a
* tool name still finds the one app it means.
*/
export async function targetFor(app: string, tool: string, kind: ToolKind): Promise<CallTarget> {
const executor = await connect();
const apps = await executor.apps.list({ owner: owner() }).catch(fail);
const found = apps.find(
(candidate) =>
candidate.id === app || candidate.slug === app || candidate.slug.replaceAll("-", "_") === app,
);
if (found === undefined) {
throw new Error(`There is no app "${app}". Call list_executor_tools for the user's apps.`);
}
const profile = await profileFor(executor, found.id).catch(fail);
return { app: found.id, profile, tool, kind };
}
/**
* Call one Executor tool. `approved` says a person approved this exact call in eve. Only then does
* the agent answer Executor's own approval request for it, with the same decision.
*/
export async function callTool(target: CallTarget, input: unknown, approved: boolean) {
const executor = await connect();
const result = await executor.tools
.call({
app: target.app,
...(target.profile === null ? {} : { profile: target.profile }),
tool: target.tool,
// Executor refuses a call whose kind is wrong before anything runs, so a mutation can never
// slip through as a query without the approval eve asks for.
kind: target.kind,
input: ToolInput.parse(input),
})
.catch(fail);
if (result.status === "completed") return result;
// The app's approval policy held the call. Nothing has run yet.
if (!approved) {
return {
status: "approval-required" as const,
message:
"The app wants a person to approve this call. Nothing ran. If the user wants it, " +
"call call_executor_tool with requireApproval: true so eve can ask them.",
};
}
const resumed = await executor.tools
.resume({ requestId: result.requestId, owner: owner(), response: { action: "accept" } })
.catch(fail);
switch (resumed.status) {
case "completed":
return resumed;
case "failed":
throw new Error(
resumed.reason === "execution-failed"
? "The approved call failed while it ran. It may have changed something first; check before trying again."
: `The approval could not be used (${resumed.reason}). Nothing ran; call the tool again.`,
);
case "already-consumed":
throw new Error("This approval was already used, so the call did not run again.");
case "denied":
case "cancelled":
throw new Error(`Executor recorded the approval as ${resumed.status}. Nothing ran.`);
}
}
/** Turn an Executor failure into a sentence the model can act on. */
export function explain(error: unknown): string {
// The platform checks the project key before every operation.
if (error instanceof ProjectKeyInvalid) {
return (
"Executor rejected this agent's project key (EXECUTOR_API_KEY is missing, unknown or " +
"revoked). Retrying will not help. The agent's operator must create a key at " +
"https://platform.executor.sh/platform and restart the agent."
);
}
if (error instanceof ProjectKeyUnavailable) {
return "Executor could not check the project key just now. Nothing ran. Try again shortly.";
}
if (error instanceof OwnerRequired) {
return "Executor needs an owner for this call. The agent's operator must set EXECUTOR_OWNER.";
}
if (error instanceof ProjectResourceNotFound) {
return "That app or account is not in this Executor project. Call list_executor_tools.";
}
// Accounts. Nothing ran in any of these cases.
if (error instanceof AccountRequired) {
return (
`This app needs an account for its "${error.slot}" requirement, and none is connected ` +
"yet. Nothing ran. Tell the user which app needs which account. Accounts are connected " +
"outside this chat, with an auth link from the Executor console. Try again once they " +
"say it is connected."
);
}
if (
error instanceof AccountSelectionInvalid ||
error instanceof AccountNotFound ||
error instanceof OAuthReconnectRequired ||
error instanceof OAuthRenewalFailed ||
error instanceof AppNotFound ||
error instanceof AppNotDeployed
) {
// These errors carry Executor's own explanation and next step.
return `${error.title}. ${error.description} ${error.recovery.action} Nothing ran.`;
}
// The tool call itself.
if (error instanceof ToolNotFound) {
return `The app has no tool "${error.tool}". Call list_executor_tools for current names.`;
}
if (error instanceof ToolKindMismatch) {
return `"${error.tool}" is a ${error.actual}, not a ${error.requested}. Nothing ran. Call it again as a ${error.actual}.`;
}
if (error instanceof InputInvalid) {
return `The input does not match the tool's schema: ${error.problems.join("; ")}. Nothing ran.`;
}
if (error instanceof ToolBlocked) {
return "The app's approval policy refused this call. Nothing ran.";
}
if (error instanceof ToolPolicyFailed) {
return "The app's approval policy could not decide, so nothing ran. Try again later.";
}
if (error instanceof ToolElicitationFailed) {
return (
`The tool asked the user a question while it ran (${error.reason}), and this agent cannot ` +
"answer questions in the middle of a call. The call stopped there; anything it did before " +
"asking may already have happened. Tell the user the tool needs input this agent cannot collect."
);
}
if (error instanceof ToolCallFailed) {
return `The tool failed after it started: ${error.reason}. It may have changed something first; check before trying again.`;
}
if (error instanceof RequestInvalid) {
return "Executor could not read this request. Check the app, the tool name, and that the input is a JSON object.";
}
if (error instanceof TransportError) {
return (
`No valid answer from Executor (${error.reason}). Check EXECUTOR_BASE_URL. The call may ` +
"still have run, so check before repeating anything that changes data."
);
}
if (error instanceof Error) return error.message === "" ? error.name : error.message;
return String(error);
}
{
"name": "executor-eve-agent",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "eve dev",
"build": "eve build",
"start": "eve start",
"info": "eve info",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@executor-js/sdk": "2.1.0-beta.0",
"ai": "7.0.127",
"eve": "0.70.0",
"zod": "4.6.5"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=24"
}
}
import { defineTool } from "eve/tools";
import { z } from "zod";
import { callTool, targetFor } from "../lib/executor";
export default defineTool({
description:
"Call one of the user's Executor tools by name with JSON input. Prefer the tool's own eve " +
"tool when it has one. Use this for a tool list_executor_tools shows without one, or with " +
"requireApproval when Executor says a call needs approval.",
inputSchema: z.object({
app: z
.string()
.describe(
"The app's id (app_...) or its exact slug, such as my-app, as list_executor_tools shows them.",
),
tool: z.string().describe("The Executor tool name, such as greet."),
kind: z
.enum(["query", "mutation"])
.describe("The tool's kind from list_executor_tools. Executor refuses a wrong kind."),
input: z
.record(z.string(), z.unknown())
.default({})
.describe("The tool's input, matching its input schema."),
requireApproval: z
.boolean()
.optional()
.describe("Ask the user to approve the call first. Mutations always ask."),
}),
// How the call reads in activity views, such as the eve dev terminal.
label: { start: ({ app, tool, kind }) => `${app}: ${tool} (${kind})` },
// A person approves every mutation, and any call made with requireApproval, before it runs.
approval: ({ toolInput }) =>
toolInput?.kind === "mutation" || toolInput?.requireApproval === true,
async execute({ app, tool, kind, input, requireApproval }) {
const target = await targetFor(app, tool, kind);
return callTool(target, input, kind === "mutation" || requireApproval === true);
},
});
/**
* Every Executor tool the owner can call, as its own eve tool with that tool's input schema. eve
* runs this at the start of each turn, so an app deployed or an account connected during the
* conversation shows up on the user's next message.
*/
import { defineDynamic, defineTool, type DynamicToolEntry } from "eve/tools";
import { callTool, loadCatalog } from "../lib/executor";
export default defineDynamic({
events: {
"turn.started": async () => {
// When the catalog cannot be read, the turn still runs: list_executor_tools reports why.
const catalog = await loadCatalog().catch((error: unknown) => {
console.warn("Executor tools unavailable:", error instanceof Error ? error.message : error);
return [];
});
const tools: Record<string, DynamicToolEntry> = {};
for (const tool of catalog.flatMap((app) => app.tools)) {
if (tool.name === null) continue;
tools[tool.name] = defineTool({
description: `${tool.description}\n\nExecutor app "${tool.appName}", tool "${tool.tool}" (${tool.kind}).`,
inputSchema: tool.inputSchema,
label: { start: () => `${tool.appName}: ${tool.tool}` },
// eve asks a person before every mutation. Their answer also settles Executor's own
// approval request for the same call.
approval: () => tool.kind === "mutation",
execute: (input) => callTool(tool, input, tool.kind === "mutation"),
});
}
return Object.keys(tools).length === 0 ? null : tools;
},
},
});
import { defineTool } from "eve/tools";
import { z } from "zod";
import { loadCatalog } from "../lib/executor";
export default defineTool({
description:
"List the user's Executor apps and their tools: each tool's eve tool name, kind (query or " +
"mutation), description and input schema. An app that cannot be used yet says why.",
inputSchema: z.object({}),
async execute() {
const catalog = await loadCatalog();
return catalog.map((app) => ({
app: app.id,
slug: app.slug,
name: app.name,
...(app.problem === undefined ? {} : { problem: app.problem }),
tools: app.tools.map((tool) => ({
eveTool: tool.name,
tool: tool.tool,
kind: tool.kind,
description: tool.description,
inputSchema: tool.inputSchema,
})),
}));
},
});
{
"compilerOptions": {
"target": "ES2022",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["ES2023", "DOM"],
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["agent/**/*.ts"]
}
# Copy this file to .env.local and fill in your own values. eve dev loads .env.local.
# Your Executor project key, from https://platform.executor.sh/platform. It starts with exk_.
# Keep it on the server; it can do everything in its project.
EXECUTOR_API_KEY=exk_your_project_key
# The Executor owner this agent acts for: your own id for one of your users.
EXECUTOR_OWNER=user_a
# Where the Executor platform API lives. The agent uses this address when it
# is not set.
EXECUTOR_BASE_URL=https://platform.executor.sh/platform
# The model. agent/agent.ts calls OpenAI directly with this key.
OPENAI_API_KEY=sk-your_openai_key
# Optional. Any OpenAI model id your key can use. The example defaults to gpt-5.4-mini.
# OPENAI_MODEL=gpt-5.4-mini
# Using the Vercel AI Gateway instead? Change the model line in agent/agent.ts
# (see the README) and set this instead of OPENAI_API_KEY.
# AI_GATEWAY_API_KEY=your_ai_gateway_key
/**
* A small Executor app to try the MCP server with. It needs no account. scripts/deploy-demo-app.ts
* deploys this file as it is; Executor builds and runs it, not your machine.
*/
import { defineApp, mutation, object, query, router, string } from "apps";
import { always } from "apps/operations/approval";
export default defineApp(
{ accounts: {} },
{
tools: router({
greet: query(
{ description: "Greet someone by name.", input: object({ name: string() }) },
async (_ctx, { name }) => ({ message: `Hello, ${name}!` }),
),
// Asks the person at the MCP client a question while it runs.
greetMe: query(
{ description: "Ask the user for their name, then greet them.", input: object({}) },
async (ctx) => {
const answer = await ctx.elicit({
mode: "form",
message: "What is your name?",
requestedSchema: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
},
});
if (answer.action !== "accept") return { message: "No name given." };
return { message: `Hello, ${String(answer.content?.name)}!` };
},
),
// Needs the user's approval every time, before it runs.
shout: mutation(
{
description: "Repeat a message in capitals. Asks for approval first.",
input: object({ text: string() }),
approval: always(),
},
async (_ctx, { text }) => ({ shouted: text.toUpperCase() }),
),
}),
},
);
/**
* Give EXECUTOR_OWNER the demo app in demo-app/, so the MCP server has tools to offer. Run it
* again after you edit the app: it deploys the new source to the same app and keeps its profile.
*
* node scripts/deploy-demo-app.ts
*/
import { readFile } from "node:fs/promises";
import { createRemoteExecutor } from "@executor-js/sdk";
const apiKey = process.env.EXECUTOR_API_KEY;
const owner = process.env.EXECUTOR_OWNER;
if (!apiKey || !owner)
throw new Error("Set EXECUTOR_API_KEY and EXECUTOR_OWNER. See .env.example.");
const baseUrl = process.env.EXECUTOR_BASE_URL || "https://platform.executor.sh/platform";
const executor = await createRemoteExecutor({ baseUrl, apiKey });
const name = "MCP Demo";
const files = [
{
path: "index.ts",
content: await readFile(new URL("../demo-app/index.ts", import.meta.url), "utf8"),
},
// The `apps` version must be one the platform runs.
{
path: "package.json",
content: JSON.stringify({ name: "mcp-demo", dependencies: { apps: "0.0.1-beta.22" } }),
},
];
// Deploy a new app, or a new version of the owner's existing one.
const [existing] = await executor.apps.list({ owner, name });
const { app } = await executor.apps.deploy(
existing === undefined ? { owner, name, files } : { owner, app: existing.id, files },
);
// A profile holds the accounts the app's tools run with. This app needs none. The idempotency
// key makes a second run return the same profile.
const profile = await executor.apps.profiles.create({
owner,
app: app.id,
subject: "demo",
idempotencyKey: "mcp-demo",
accounts: {},
});
const tools = await executor.tools.list({ app: app.id, profile: profile.id });
console.log(`Deployed "${app.name}" (${app.slug}) for owner "${owner}".`);
console.log(
`App ${app.id}, deployment ${app.activeDeployment}, profile ${profile.id} (${profile.status}).`,
);
console.log(`Tools: ${tools.items.map((tool) => `${app.slug}__${tool.name}`).join(", ")}`);
{
"name": "executor-mcp-server-example",
"version": "0.1.0",
"private": true,
"description": "Serve an Executor owner's tools to any MCP client.",
"type": "module",
"scripts": {
"deploy-demo-app": "node --env-file=.env scripts/deploy-demo-app.ts",
"smoke": "node --env-file=.env scripts/smoke.ts",
"http": "node --env-file=.env server.ts --http",
"typecheck": "tsc"
},
"dependencies": {
"@executor-js/sdk": "2.1.0-beta.0",
"@modelcontextprotocol/sdk": "^1.30.0"
},
"devDependencies": {
"@types/node": "^22.18.0",
"typescript": "^5.9.0"
},
"engines": {
"node": ">=22.18"
}
}
/**
* An MCP server for one Executor owner. Every tool of every app the owner has becomes an MCP
* tool, and each MCP tool call runs the Executor tool with your project key.
*
* node server.ts stdio, for Claude Code, Cursor and Claude Desktop
* node server.ts --http Streamable HTTP at http://127.0.0.1:3333/mcp, for Eve and remote clients
*
* Environment:
* EXECUTOR_API_KEY your project key (exk_...). Required.
* EXECUTOR_OWNER the owner whose tools to serve, such as one of your users. Required.
* EXECUTOR_BASE_URL the platform API. Defaults to https://platform.executor.sh/platform.
* EXECUTOR_SUBJECT optional. When an app has several profiles for the owner, use this one.
* PORT, HOST --http only. Defaults to 3333 and 127.0.0.1.
* MCP_HTTP_TOKEN --http only. Clients must send "Authorization: Bearer <token>".
*
* Logs go to stderr: on stdio, stdout carries the MCP messages.
*/
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
type CallToolResult,
type ElicitRequestFormParams,
type Tool,
} from "@modelcontextprotocol/sdk/types.js";
import {
AccountRequired,
createRemoteExecutor,
InputInvalid,
OAuthReconnectRequired,
ProjectKeyInvalid,
ToolBlocked,
ToolCallFailed,
ToolElicitationFailed,
ToolNotFound,
TransportError,
type Executor,
type FormElicitation,
type ToolCallOptions,
} from "@executor-js/sdk";
/** The JSON a tool takes. */
type ToolInput = Parameters<Executor["tools"]["call"]>[0]["input"];
/** A required setting. The server cannot start without it. */
function setting(name: string): string {
const value = process.env[name];
if (!value) {
console.error(`Set ${name}. See .env.example.`);
process.exit(1);
}
return value;
}
const apiKey = setting("EXECUTOR_API_KEY");
const owner = setting("EXECUTOR_OWNER");
const baseUrl = process.env.EXECUTOR_BASE_URL || "https://platform.executor.sh/platform";
const subject = process.env.EXECUTOR_SUBJECT || undefined;
const executor = await createRemoteExecutor({ baseUrl, apiKey });
const log = (message: string) => console.error(`[executor-mcp] ${message}`);
/** Where one MCP tool goes in Executor. */
interface Target {
readonly app: string;
readonly profile: string | undefined;
readonly tool: string;
/** Executor checks the kind before anything runs; undefined lets it read the kind itself. */
readonly kind: "query" | "mutation" | undefined;
}
/** The targets of the latest tools/list, by MCP tool name. */
let targets = new Map<string, Target>();
/**
* MCP tool names are `<app slug>__<tool>`. Slugs use only a-z, 0-9 and "-", so the first "__"
* ends the slug. Nested Executor tools have dotted names ("issues.list"), and some MCP clients
* refuse dots, so other characters become "_".
*/
const mcpName = (slug: string, tool: string) => `${slug}__${tool.replace(/[^A-Za-z0-9_-]/g, "_")}`;
/** The owner's profile for an app: the account choices its tools run with. */
async function profileFor(app: string) {
const profiles = await executor.apps.profiles.list({ app, owner, subject });
const usable = profiles.filter(
(profile) => profile.enabled && profile.status !== "removing" && profile.status !== "removed",
);
return (usable.find((profile) => profile.status === "ready") ?? usable[0])?.id;
}
/** List the owner's tools again, app by app. An app that cannot list its tools is skipped. */
async function listTools(): Promise<Tool[]> {
const apps = await executor.apps.list({ owner });
const listed = new Map<string, Target>();
const tools: Tool[] = [];
await Promise.all(
apps.map(async (app) => {
if (app.activeDeployment === null) return log(`Skipping "${app.name}": not deployed yet.`);
try {
const profile = await profileFor(app.id);
let cursor: string | undefined;
do {
const page = await executor.tools.list({ app: app.id, profile, cursor });
for (const router of page.routers)
if (router.error !== undefined)
log(`"${app.name}" could not list the tools under "${router.path || "/"}".`);
for (const tool of page.items) {
const name = mcpName(app.slug, tool.name);
listed.set(name, {
app: app.id,
profile,
tool: tool.name,
kind: tool.readOnly === true ? "query" : "mutation",
});
tools.push({
name,
title: tool.title,
description: `${app.name}: ${tool.description}`,
// Executor tools take a JSON object, described by a JSON Schema: the shape MCP uses.
inputSchema: { ...tool.inputSchema, type: "object" },
annotations: { ...tool.annotations, readOnlyHint: tool.readOnly === true },
});
}
cursor = page.next;
} while (cursor !== undefined);
} catch (error) {
log(`Skipping "${app.name}" (${app.slug}): ${explain(error)}`);
}
}),
);
targets = listed;
log(`Listed ${tools.length} tools from ${apps.length} apps for owner "${owner}".`);
return tools.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Find a tool by its MCP name. A name missing from the latest list still reaches its app, so
* Executor can say what is wrong, for example that the app needs an account first.
*/
async function resolve(name: string): Promise<Target | undefined> {
const listed = targets.get(name);
if (listed !== undefined) return listed;
const split = name.indexOf("__");
if (split <= 0) return undefined;
const [app] = await executor.apps.list({ owner, slug: name.slice(0, split) });
if (app === undefined) return undefined;
return {
app: app.id,
profile: await profileFor(app.id),
tool: name.slice(split + 2),
kind: undefined,
};
}
/** Say what went wrong, in words an agent can act on. Executor failures are typed error classes. */
function explain(error: unknown): string {
if (error instanceof AccountRequired)
return `This app needs a "${error.slot}" account before its tools can run. Connect one for owner "${owner}" and select it in the app's profile, then try again.`;
if (error instanceof OAuthReconnectRequired)
return `The account's sign-in has expired or was revoked. Reconnect it for owner "${owner}", then try again.`;
if (error instanceof InputInvalid)
return `The input does not match the tool's schema: ${error.problems.join("; ")}`;
if (error instanceof ToolCallFailed)
return `The tool failed after it started, so it may have made changes: ${error.reason}`;
if (error instanceof ToolElicitationFailed)
return error.reason === "unavailable"
? "The tool asked a question, and this MCP client cannot show forms (MCP elicitation). Earlier steps may have run."
: `The tool asked a question that could not be answered (${error.reason}). Earlier steps may have run.`;
if (error instanceof ToolBlocked) return "The tool's approval policy refused this call.";
if (error instanceof ToolNotFound)
return `The app has no tool "${error.tool}". List the tools again.`;
if (error instanceof ProjectKeyInvalid)
return "Executor refused the project key. Check EXECUTOR_API_KEY.";
if (error instanceof TransportError)
return `No valid answer from ${baseUrl}: ${error.reason}. The call may still have run.`;
if (error instanceof Error) return error.message ? `${error.name}: ${error.message}` : error.name;
return String(error);
}
/** MCP content for a tool's value: JSON text, plus structured content for objects. */
function completed(value: unknown, toolError: boolean): CallToolResult {
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
const result: CallToolResult = { content: [{ type: "text", text }], isError: toolError };
if (typeof value === "object" && value !== null && !Array.isArray(value))
result.structuredContent = { ...value };
return result;
}
const failed = (text: string): CallToolResult => ({
content: [{ type: "text", text }],
isError: true,
});
/** One MCP session. Over HTTP each client session gets its own. */
function createMcpServer() {
const server = new Server(
{ name: "executor", version: "0.1.0" },
{
capabilities: { tools: {} },
instructions: `Tools from the Executor apps of owner "${owner}". Each tool is named <app slug>__<tool>.`,
},
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
try {
return { tools: await listTools() };
} catch (error) {
throw new Error(explain(error));
}
});
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
const name = request.params.name;
// MCP arguments arrive as a JSON object: the input an Executor tool takes.
const input = (request.params.arguments ?? {}) as ToolInput;
try {
const target = await resolve(name);
if (target === undefined) return failed(`Unknown tool "${name}". List the tools again.`);
// Show a tool's question to the person at the MCP client as a form (MCP elicitation).
const ask = async (question: FormElicitation, signal: AbortSignal) => {
const answer = await server.elicitInput(
{
mode: "form",
message: question.message,
// The same MCP form schema; Executor's type is only read-only.
requestedSchema: question.requestedSchema as ElicitRequestFormParams["requestedSchema"],
},
// relatedRequestId keeps the form on this call's HTTP stream.
{ signal, relatedRequestId: extra.requestId, timeout: 15 * 60_000 },
);
return answer.action === "accept"
? { action: "accept" as const, content: answer.content }
: { action: answer.action };
};
// Only clients that can show forms get the tool's questions while it runs.
const canAsk = server.getClientCapabilities()?.elicitation?.form !== undefined;
const options: ToolCallOptions | undefined = canAsk ? { elicitation: ask } : undefined;
const result = await executor.tools.call({ ...target, input }, options);
if (result.status === "completed") return completed(result.value, result.toolError === true);
// The tool's approval policy saved the call instead of running it. Ask for approval, then
// resume the saved call with the answer.
if (!canAsk)
return failed(
`"${name}" needs approval before it runs, and this MCP client cannot show approval forms (MCP elicitation). Nothing ran.\n\n${result.elicitation.message}`,
);
const answer = await ask(result.elicitation, extra.signal);
const resumed = await executor.tools.resume(
{
owner,
requestId: result.requestId,
// An approval form has no fields, so an accept carries no content.
response: answer.action === "accept" ? { action: "accept" } : answer,
},
options,
);
if (resumed.status === "completed")
return completed(resumed.value, resumed.toolError === true);
if (resumed.status === "failed")
return failed(`"${name}" did not complete after approval: ${resumed.reason}.`);
return failed(`"${name}" did not run: the approval was ${resumed.status.replace("-", " ")}.`);
} catch (error) {
return failed(explain(error));
}
});
return server;
}
if (!process.argv.includes("--http")) {
await createMcpServer().connect(new StdioServerTransport());
log(`Serving owner "${owner}" over stdio.`);
} else {
const port = Number(process.env.PORT || 3333);
const host = process.env.HOST || "127.0.0.1";
const token = process.env.MCP_HTTP_TOKEN || undefined;
const local = ["127.0.0.1", "localhost", "::1"].includes(host);
// Anyone who reaches this port can use the owner's tools, so a public address needs a token.
if (!local && token === undefined) {
console.error(`Set MCP_HTTP_TOKEN to listen on ${host}.`);
process.exit(1);
}
// Each client session keeps its transport, so a tool's question and its answer meet again.
const sessions = new Map<string, StreamableHTTPServerTransport>();
// Compare digests in constant time, so the time a wrong guess takes says nothing about the token.
const digest = (value: string) => createHash("sha256").update(value).digest();
const authorized = (header: string | undefined) =>
token === undefined || timingSafeEqual(digest(header ?? ""), digest(`Bearer ${token}`));
const handle = async (req: IncomingMessage, res: ServerResponse) => {
if (new URL(req.url ?? "/", "http://localhost").pathname !== "/mcp")
return void res.writeHead(404).end();
if (!authorized(req.headers.authorization)) return void res.writeHead(401).end();
// Without a token, accept only local Host names, so a web page cannot reach this server
// through DNS rebinding.
if (
token === undefined &&
!/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(req.headers.host ?? "")
)
return void res.writeHead(403).end();
const id = req.headers["mcp-session-id"];
if (typeof id === "string") {
const transport = sessions.get(id);
// An unknown session tells the client to start a new one.
if (transport === undefined) return void res.writeHead(404).end();
return transport.handleRequest(req, res);
}
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (session) => void sessions.set(session, transport),
});
transport.onclose = () => {
if (transport.sessionId !== undefined) sessions.delete(transport.sessionId);
};
await createMcpServer().connect(transport);
await transport.handleRequest(req, res);
};
createServer((req, res) =>
handle(req, res).catch((error: unknown) => {
log(`HTTP request failed: ${explain(error)}`);
if (!res.headersSent) res.writeHead(500);
res.end();
}),
).listen(port, host, () => log(`Serving owner "${owner}" at http://${host}:${port}/mcp`));
}
/**
* Check the server the way an MCP client uses it: list its tools and call one. The client
* answers any question the tool asks: string fields get "MCP smoke test", approvals are accepted.
*
* node scripts/smoke.ts starts server.ts over stdio, calls "<app>__greet"
* node scripts/smoke.ts <tool> '<json input>' calls another tool
* MCP_URL=http://127.0.0.1:3333/mcp node scripts/smoke.ts
* uses a running `node server.ts --http` instead
*
* Exits with 1 when the call returns an MCP tool error.
*/
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
getDefaultEnvironment,
StdioClientTransport,
} from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
const [toolArg, inputArg] = process.argv.slice(2);
// An MCP client passes the server only the environment it is configured with.
const env: Record<string, string> = getDefaultEnvironment();
const settings = ["EXECUTOR_API_KEY", "EXECUTOR_OWNER", "EXECUTOR_BASE_URL", "EXECUTOR_SUBJECT"];
for (const name of settings) {
const value = process.env[name];
if (value !== undefined) env[name] = value;
}
const url = process.env.MCP_URL;
const token = process.env.MCP_HTTP_TOKEN;
const transport =
url === undefined
? new StdioClientTransport({
// The same Node and flags as this script: `npx tsx scripts/smoke.ts` runs the server with tsx.
command: process.execPath,
args: [...process.execArgv, fileURLToPath(new URL("../server.ts", import.meta.url))],
env,
stderr: "inherit",
})
: new StreamableHTTPClientTransport(new URL(url), {
requestInit: token ? { headers: { Authorization: `Bearer ${token}` } } : undefined,
});
const client = new Client(
{ name: "executor-mcp-smoke", version: "0.1.0" },
{ capabilities: { elicitation: { form: {} } } },
);
client.setRequestHandler(ElicitRequestSchema, async (request) => {
console.log(`\nThe tool asks: ${request.params.message}`);
const content: Record<string, string> = {};
if ("requestedSchema" in request.params)
for (const [field, schema] of Object.entries(request.params.requestedSchema.properties))
if (schema.type === "string") content[field] = "MCP smoke test";
console.log(`Answering: accept ${JSON.stringify(content)}`);
return { action: "accept", content };
});
await client.connect(transport);
const { tools } = await client.listTools();
console.log(`${tools.length} tools:`);
for (const tool of tools) console.log(`- ${tool.name}: ${tool.description}`);
const name = toolArg ?? tools.find((tool) => tool.name.endsWith("__greet"))?.name;
if (name === undefined) {
await client.close();
throw new Error("No <app>__greet tool. Run scripts/deploy-demo-app.ts, or name a tool to call.");
}
const input: Record<string, unknown> =
inputArg === undefined ? { name: "MCP" } : JSON.parse(inputArg);
console.log(`\nCalling ${name} with ${JSON.stringify(input)}`);
const result = await client.callTool({ name, arguments: input });
console.log(JSON.stringify(result, null, 2));
await client.close();
process.exitCode = result.isError === true ? 1 : 0;
{
// Type checking only: Node runs the .ts files directly by stripping their types.
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "nodenext",
"moduleResolution": "nodenext",
"types": ["node"],
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"skipLibCheck": true
},
"include": ["server.ts", "scripts"]
}
# Your project key. Create one in the console: https://platform.executor.sh/platform
EXECUTOR_API_KEY=exk_your_project_key
# The owner whose tools the server offers: your own id for one of your users.
EXECUTOR_OWNER=user_a
# The platform API. The server uses this address when it is not set.
EXECUTOR_BASE_URL=https://platform.executor.sh/platform
# Optional: when an app has several profiles for the owner, use the one with this subject.
# EXECUTOR_SUBJECT=demo
# Streamable HTTP mode only (node server.ts --http).
# PORT=3333
# HOST=127.0.0.1
# A long random token, such as the output of `openssl rand -hex 32`.
# MCP_HTTP_TOKEN=a-long-random-string
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment