mcpbeat

Just Bash Executor

vercel-labs/just-bash-executor

Convert a GraphQL endpoint, OpenAPI spec, or MCP server into bash CLI commands and a `tools.*` JS API runnable inside `just-bash`'s `js-exec` sandbox. Use when the user wants to expose a remote API to a sandboxed bash agent, build a tool-calling agent on top of just-bash, or generate CLI commands from an existing API spec.

58k tokens
context cost
the whole folder, loaded on every use
32
files
ships runnable scripts
0
copies elsewhere
how many repositories repackaged it
4036
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/vercel-labs/just-bash --skill just-bash-executor

What comes with it

211 173 bytes besides the instruction
CHANGELOG.md
README.md
dist/create-executor.d.ts
dist/create-executor.js
dist/executor-discovery-plugin.d.ts
dist/executor-discovery-plugin.js
dist/executor-init.d.ts
dist/executor-init.js
dist/index.d.ts
dist/index.js
dist/parse-tool-args.d.ts
dist/parse-tool-args.js
dist/tool-command.d.ts
dist/tool-command.js
dist/types.d.ts
dist/types.js
package.json
src/create-executor.ts
src/docs.test.ts
src/executor-discovery-plugin.ts
src/executor-examples.test.ts
src/executor-init.ts
src/fixtures/countries-introspection.json
src/index.ts
src/node-esm-smoke.test.ts
src/parse-tool-args.ts
src/tool-command.test.ts
src/tool-command.ts
src/types.ts
tsconfig.json
vitest.config.ts

What it tells the agent to use

found in the instruction text
Bash runs shell commands — read the instruction before connecting
Write writes files

The instruction itself

14 sections, as written by the author

@just-bash/executor — agent guide

This file is for an AI agent writing code that uses @just-bash/executor. It

maps each input (an OpenAPI spec, a GraphQL endpoint, an MCP server, or your

own JS functions) to the exact code to write and the exact surfaces (JS API +

bash CLI) the user gets back.

Read top to bottom on the first task; jump by section number on later tasks.

§1. Decide which source kind you have

Have an OpenAPI spec / Swagger doc?     → §3 OpenAPI
Have a GraphQL endpoint or SDL?         → §4 GraphQL
Have an MCP server (URL or stdio)?      → §5 MCP
Defining tools yourself in code?        → §2 Inline
Mixing several of the above?            → call sources.add() once per source
                                          inside the same setup(); paths stay
                                          namespaced by `name`

For all four, the consuming code is identical (§6, §7) — what changes is the

createExecutor config.

§2. Inline tools

Use when there's no upstream spec — the user wants to expose specific JS

functions to the sandbox.

import { Bash } from "just-bash";
import { createExecutor } from "@just-bash/executor";

const executor = await createExecutor({
  tools: {
    "ns.action": {
      description: "What it does",
      execute: async (args: { /* shape */ }) => ({ /* JSON-serializable */ }),
    },
  },
});

const bash = new Bash({
  customCommands: executor.commands,
  javascript: { invokeTool: executor.invokeTool },
});

Conversion (single rule):

key in tools: {…}      JS                          bash
"ns.action"      →     await tools.ns.action(args) ns action key=value
                                                    ns action --key value
                                                    ns action --json '{"key":1}'

The first dot-segment is the namespace command; the rest is the subcommand

(kebab-cased, with the original form as an alias when different).

§3. OpenAPI → tools

Ask the user for: spec source, base endpoint, namespace name, and optional

auth headers.

The spec field accepts three forms:

spec: "https://petstore3.swagger.io/api/v3/openapi.json"  // URL — fetched at setup
spec: fs.readFileSync("./openapi.yaml", "utf8")          // YAML text
spec: JSON.stringify(specObject)                          // JSON text

For authenticated APIs, pass headers (and optionally queryParams):

await sdk.sources.add({
  kind: "openapi",
  spec: "https://api.github.com/openapi.json",
  endpoint: "https://api.github.com",
  name: "github",
  headers: {
    Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
    Accept: "application/vnd.github+json",
  },
});

Full inline example:

import { createExecutor } from "@just-bash/executor";
import { Bash } from "just-bash";

// `spec` is the raw OpenAPI document as a STRING (JSON or YAML text),
// not a parsed object. Read it from disk if you have a file.
const PETSTORE_SPEC = JSON.stringify({
  openapi: "3.0.0",
  info: { title: "Petstore", version: "1.0.0" },
  paths: {
    "/pets": {
      get: {
        operationId: "listPets",
        parameters: [
          { name: "status", in: "query", schema: { type: "string" } },
        ],
        responses: { "200": { description: "ok" } },
      },
      post: {
        operationId: "createPet",
        requestBody: {
          content: {
            "application/json": {
              schema: {
                type: "object",
                properties: { name: { type: "string" } },
              },
            },
          },
        },
        responses: { "201": { description: "ok" } },
      },
    },
    "/pets/{petId}": {
      get: {
        operationId: "getPetById",
        parameters: [
          { name: "petId", in: "path", required: true, schema: { type: "string" } },
        ],
        responses: { "200": { description: "ok" } },
      },
    },
  },
});

const executor = await createExecutor({
  setup: async (sdk) => {
    await sdk.sources.add({
      kind: "openapi",
      spec: PETSTORE_SPEC,
      endpoint: "https://petstore.example.com",
      name: "pets",            // becomes the namespace
    });
  },
  onToolApproval: "allow-all",
});

const bash = new Bash({
  customCommands: executor.commands,
  javascript: { invokeTool: executor.invokeTool },
});

Conversion rules:

  • One tool per operation in the spec
  • Tool path: <name>.<firstUrlSegment>.<operationId> — the first URL path

segment is included as a grouping prefix (camelCase preserved on the operationId)

  • Args object = path params + query params + requestBody fields, flattened
  • Bash subcommand: kebab-case of <firstUrlSegment>.<operationId>; original

camelCase form is kept as an alias

Example (from the spec above — all under /pets/*):

operationId      → tool path                 JS call                                                  bash
listPets         → pets.pets.listPets        await tools.pets.pets.listPets({ status })               pets pets.list-pets --status open
createPet        → pets.pets.createPet       await tools.pets.pets.createPet({ name })                pets pets.create-pet --name Fido
getPetById       → pets.pets.getPetById      await tools.pets.pets.getPetById({ petId })              pets pets.get-pet-by-id --pet-id 42

(The double pets.pets looks awkward but is deterministic: the first pets

is your name, the second is the URL's first path segment.)

Pitfalls:

  • spec must be a string (URL, JSON text, or YAML text), not a parsed object
  • Operations missing operationId are skipped; if the user's spec lacks them,

add them or fall back to inline tools

  • All param locations (path, query, body) flatten into one args object — name

collisions across locations are the user's problem to resolve

  • headers are sent on every invocation — fine for static tokens, but for

per-request auth wire an inline tool that adds the header dynamically

  • The plugin is loaded lazily via setup; install @executor-js/plugin-openapi

alongside @executor-js/sdk or createExecutor will throw

§4. GraphQL → tools

Ask the user for: endpoint URL, optional introspection JSON, a namespace name,

and optional auth headers.

const executor = await createExecutor({
  setup: async (sdk) => {
    await sdk.sources.add({
      kind: "graphql",
      endpoint: "https://api.github.com/graphql",
      name: "github",
      headers: {
        Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
      },
      // Optional: pre-fetched schema; skips the introspection round-trip and
      // lets discovery work offline. Recommended for unstable upstreams.
      // introspectionJson: INTROSPECTION_JSON,
    });
  },
  onToolApproval: "allow-all",
});

Conversion rules:

  • One tool per top-level Query and Mutation field
  • Tool path: <name>.query.<fieldName> for queries and

<name>.mutation.<fieldName> for mutations (camelCase preserved)

  • Args object = the field's argument definitions
  • Result is the raw GraphQL response envelope: { status, data, errors }.

Scripts must check errors and read data themselves — there is no

auto-unwrap.

  • The plugin auto-generates a shallow selection set. Queries whose return

types contain nested object fields will fail server-side validation

("Field X of type Y must have a selection of subfields"). For these,

wrap the call in an inline tool that posts a hand-written GraphQL query

via fetch instead of going through the SDK plugin.

  • Subscriptions are not currently exposed as callable tools

Example, from the public Countries schema (all queries):

Query field      → tool path                JS call                                                            bash
country(code)    → geo.query.country        await tools.geo.query.country({ code: "JP" })                      geo query.country code=JP
countries(filter)→ geo.query.countries      await tools.geo.query.countries({ filter: { ... } })               geo query.countries --json '{"filter":{...}}'
continent(code)  → geo.query.continent      await tools.geo.query.continent({ code: "EU" })                    geo query.continent code=EU
continents       → geo.query.continents     await tools.geo.query.continents({})                               geo query.continents
language(code)   → geo.query.language       await tools.geo.query.language({ code: "en" })                     geo query.language code=en
languages        → geo.query.languages      await tools.geo.query.languages({})                                geo query.languages

Reading the response in a script:

const r = await tools.geo.query.country({ code: "JP" });
if (r.errors && r.errors.length) {
  throw new Error(r.errors.map((e) => e.message).join("; "));
}
const country = r.data.country;
console.log(country.name);

Pitfalls:

  • Required GraphQL args (String!, ID!) must be passed; the SDK surfaces

validation errors as thrown exceptions inside scripts — wrap calls in

try/catch if the agent might call with empty args

  • For complex filter/input-object args, prefer --json over key=value
  • headers apply to introspection AND every tool call — useful for tokens,

but don't put per-user identity here

  • Install @executor-js/plugin-graphql alongside @executor-js/sdk

§5. MCP → tools

Ask the user for: transport ("remote" or "stdio"), endpoint URL or

command+args, a namespace name.

const executor = await createExecutor({
  setup: async (sdk) => {
    // Remote (SSE / HTTP)
    await sdk.sources.add({
      kind: "mcp",
      transport: "remote",
      endpoint: "https://mcp.example.com/sse",
      name: "docs",
    });

    // Remote with auth headers
    await sdk.sources.add({
      kind: "mcp",
      transport: "remote",
      endpoint: "https://mcp.context7.com/mcp",
      name: "context7",
      headers: {
        Authorization: `Bearer ${process.env.CONTEXT7_TOKEN}`,
      },
    });

    // Stdio (local process) — env vars and cwd are passed to the child
    await sdk.sources.add({
      kind: "mcp",
      transport: "stdio",
      command: "npx",
      args: ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
      env: { LOG_LEVEL: "info" },
      cwd: "/work",
      name: "fs",
    });
  },
  onToolApproval: async (req) => {
    // MCP servers can do destructive things — gate by tool path
    if (req.toolPath.endsWith(".write_file")) {
      return { approved: false, reason: "writes need review" };
    }
    return { approved: true };
  },
  onElicitation: async (ctx) => {
    // MCP servers may request user input mid-tool (forms, OAuth URLs).
    // Decline by default; implement a real handler for interactive flows.
    return { action: "decline" };
  },
});

Conversion rules:

  • One tool per tool advertised by the MCP server's tools/list capability
  • Tool path: <name>.<server-tool-name> — server tool names are preserved

verbatim (often snake_case like read_file)

  • Args object = the MCP tool's input schema
  • Subcommand: server tool name → kebab-case; original (snake_case or otherwise)

is kept as an alias when different

Example (filesystem-style MCP server with read_file, list_dir):

server tool   → tool path        JS call                                       bash kebab                        bash snake alias
read_file     → fs.read_file     await tools.fs.read_file({ path: "/x.md" })   fs read-file path=/x.md           fs read_file path=/x.md
list_dir      → fs.list_dir      await tools.fs.list_dir({ path: "/" })        fs list-dir path=/                fs list_dir path=/

Pitfalls:

  • transport: "remote" requires endpoint; transport: "stdio" requires

command + args

  • MCP servers with elicitation flows need an onElicitation handler other

than the default decline-all, otherwise interactive tools will fail

  • Install @executor-js/plugin-mcp alongside @executor-js/sdk

§5b. Combining multiple sources in one executor

Real agents usually need more than one upstream. Add as many sources.add()

calls as you want inside the same setup; each registers its own namespace and

tools land in a single unified tools proxy / bash command set.

const executor = await createExecutor({
  setup: async (sdk) => {
    // OpenAPI from a URL (no auth)
    await sdk.sources.add({
      kind: "openapi",
      spec: "https://petstore3.swagger.io/api/v3/openapi.json",
      name: "petstore",
    });

    // GraphQL with bearer auth
    await sdk.sources.add({
      kind: "graphql",
      endpoint: "https://api.github.com/graphql",
      name: "github",
      headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` },
    });

    // Remote MCP for context lookups
    await sdk.sources.add({
      kind: "mcp",
      transport: "remote",
      endpoint: "https://mcp.example.com/sse",
      name: "context",
    });
  },
  // Inline tools coexist with discovered ones; inline wins on path conflict.
  tools: {
    "util.now": {
      description: "Wall-clock ISO timestamp",
      execute: () => ({ ts: new Date().toISOString() }),
    },
  },
  onToolApproval: async (req) => {
    // Different policy per source
    if (req.sourceId === "github" && req.toolPath.includes("delete")) {
      return { approved: false, reason: "github deletes need review" };
    }
    return { approved: true };
  },
});

A js-exec script can then call across all sources in one turn. Remember the

shape per source kind: GraphQL paths are <name>.query.<field>, OpenAPI paths

are <name>.<firstUrlSegment>.<operationId>, MCP paths are

<name>.<server-tool-name>, inline paths are exactly your key.

const repos = await tools.github.query.search({ query: "stars:>10000", type: "REPOSITORY" });
const pet   = await tools.petstore.pet.findPetById({ petId: 1 });
const ctx   = await tools.context.lookup({ name: "react" });
const ts    = await tools.util.now();
// GraphQL responses are wrapped — unwrap before reading
console.log({
  repos: repos.data?.search?.repositoryCount ?? null,
  pet, ctx, ts,
});

Use distinct name values per source — collisions silently overwrite tool

paths within the namespace.

§6. Calling generated tools — the rules to internalize

These two tables are the only things you need to memorize. They apply to all

four source kinds — the conversion is uniform.

JS API (inside js-exec scripts)

| Want | Write |

| ------------------------- | ---------------------------------------------- |

| Call any tool | await tools.<namespace>.<name>(args) |

| Pass no args | await tools.ns.name() or ({}) |

| Catch tool errors | try { ... } catch (e) { e.message } |

| Snake-case server tool | await tools.docs"read_file" |

| Deeply nested path | await tools.a.b.c.d(args) — works as written |

undefined returns reach the script as undefined; everything else is

JSON-serialized and parsed back into a JS value.

Bash CLI (inside bash.exec(...) scripts)

| Want | Write |

| ------------------- | --------------------------------------- |

| key=value | ns name a=1 b=2 |

| flags | ns name --a 1 --b 2 |

| --key=value | ns name --a=1 |

| Bool flag | ns name --verbose{verbose: true} |

| Inline JSON | ns name --json '{"a":1,"b":2}' |

| Piped JSON | echo '{"a":1}' \| ns name |

| Compose with jq | ns name a=1 \| jq -r .field |

| Show help | ns --help or ns name --help |

Mode precedence when more than one is used: flags > --json > stdin.

Values are coerced via JSON.parse first (a=2 → number 2,

ok=true → boolean true, xs=[1,2] → array), falling back to string when

parsing fails.

Tool errors land on stderr with format <namespace>: <subcommand>: <message>

and exit code 1.

§7. Skeleton an agent can copy and adapt

Self-contained — pick a source kind, fill in the spec, run with tsx.

import { Bash } from "just-bash";
import { createExecutor } from "@just-bash/executor";

const executor = await createExecutor({
  // Pick ONE of: `tools` (inline) or `setup` (SDK), or both.
  tools: {
    "math.add": {
      description: "Add two numbers",
      execute: ({ a, b }: { a: number; b: number }) => ({ sum: a + b }),
    },
  },
  // setup: async (sdk) => {
  //   await sdk.sources.add({ kind: "openapi", spec, endpoint, name });
  // },
  onToolApproval: "allow-all",
});

const bash = new Bash({
  customCommands: executor.commands,
  javascript: { invokeTool: executor.invokeTool },
  executionLimits: { maxJsTimeoutMs: 30_000 },
});

// 1. JS API
const r1 = await bash.exec(`js-exec -c '
  try {
    const r = await tools.math.add({ a: 2, b: 3 });
    console.log("sum=" + r.sum);
  } catch (e) {
    console.error("tool failed:", e.message);
  }
'`);
process.stdout.write(r1.stdout);
if (r1.stderr) process.stderr.write(r1.stderr);

// 2. Bash CLI — three input modes, all equivalent
for (const cmd of [
  "math add a=2 b=3",
  "math add --a 2 --b 3",
  `echo '{"a":2,"b":3}' | math add`,
]) {
  const r = await bash.exec(cmd);
  console.log(`${cmd}  →  ${r.stdout.trim()}  (exit=${r.exitCode})`);
}

// 3. Help text
process.stdout.write((await bash.exec("math --help")).stdout);

§8. Verification before reporting "done"

Run these checks in order. Stop at the first failure.

  • Exec works. A simple call returns exit 0 with parseable JSON on stdout:
   const r = await bash.exec(`<ns> <subcommand> <args>`);
   JSON.parse(r.stdout);  // should not throw
  • Wrong path errors clearly. await tools.ns.nope({}) throws with

Unknown tool in the message — confirms dispatch is wired.

  • Help reflects discovery. bash.exec("<ns> --help") lists every tool

the user expected. If a tool's missing, the source registration didn't pick

it up (most often: missing operationId for OpenAPI; subscription field

for GraphQL; capability not advertised for MCP).

  • Inspect via SDK handle (when setup was used):
   // List everything
   const all = await executor.sdk!.tools.list();
   console.log(all.map(t => t.id));

   // Filter by source
   const ghOnly = await executor.sdk!.tools.list({ sourceId: "github" });

   // Search descriptions/names
   const writes = await executor.sdk!.tools.list({ query: "create" });
  • Approval gates work. If you wired onToolApproval, deny one path and

confirm the call throws inside js-exec rather than silently succeeding.

§9. Anti-patterns

  • Don't pass parsed objects to kind: "openapi". spec is a string

(JSON or YAML text). Use JSON.stringify(...) or fs.readFileSync(path, "utf8").

  • Don't put tool logic inside the js-exec script. execute runs on the

host; the script just calls it. Putting fetches or DB calls in the script

defeats the sandbox.

  • Don't rely on await doing real async work. Tool calls are synchronous

via Atomics.wait from the script's perspective; await is for portability

with other runtimes.

  • Don't expose host-FS or shell tools without an onToolApproval gate.

The default "allow-all" is fine for read-only or pure-compute tools; for

anything destructive, gate by toolPath.

  • Don't reuse a namespace across sources. Two sources.add calls with the

same name will collide. Use distinct names per source.

  • Don't skip installing the plugin package. @executor-js/sdk alone is not

enough — each source kind requires its plugin (@executor-js/plugin-openapi,

…-graphql, …-mcp).

§10. Cross-references

  • README.md — conceptual overview, configuration reference
  • examples/executor-tools/ — runnable

end-to-end examples (inline-tools.ts, multi-turn-discovery.ts)

upstream SDK whose plugins drive discovery

How to use it

Copy the folder

Take vercel-labs/just-bash-executor from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.