# @goke/mcp

> Dynamically generate CLI commands from MCP server tools

Latest version **0.1.2** (published 2026-09-16) · MIT license · 0 weekly downloads

## Install

```sh
npm install @goke/mcp
pnpm add @goke/mcp
yarn add @goke/mcp
bun add @goke/mcp
```

## Health

**Score 70/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; recently updated; high maintenance score; high quality score.

Warnings: low downloads; pre 1.0.

## Facts

| | |
|---|---|
| Version | 0.1.2 |
| Published | 2026-09-16 |
| First published | 2026-02-08 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | bundled |
| Module format | ESM + CommonJS |
| Dependencies | 2 |
| Unpacked size | 259.7 KB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Author | Tommaso De Rossi, morse |
| Maintainers | xmorse |
| Keywords | mcp, cli, goke, model-context-protocol |

## Links

- npm: https://www.npmjs.com/package/@goke/mcp
- Repository: https://github.com/remorses/goke
- Homepage: https://github.com/remorses/goke/tree/main/mcp
- Issues: https://github.com/remorses/goke/issues
- npm.io page: https://npm.io/package/@goke/mcp

## Dependencies (2)

- [js-yaml](https://npm.io/package/js-yaml.md) ^4.1.1
- [@modelcontextprotocol/sdk](https://npm.io/package/@modelcontextprotocol/sdk.md) ^1.30.0

## Alternatives

- [@salesforce/cli](https://npm.io/package/@salesforce/cli.md) — 389.7K weekly downloads
- [@mintlify/cli](https://npm.io/package/@mintlify/cli.md) — 208.9K weekly downloads
- [@grafana/e2e-selectors](https://npm.io/package/@grafana/e2e-selectors.md) — 128.7K weekly downloads
- [mintlify](https://npm.io/package/mintlify.md) — 112.0K weekly downloads
- [@intlayer/cli](https://npm.io/package/@intlayer/cli.md) — 22.8K weekly downloads

## Recent versions

- 0.1.2 (latest) — 2026-09-16
- 0.1.1 — 2026-09-16
- 0.1.0 — 2026-09-16
- 0.0.13 — 2026-08-15
- 0.0.12 — 2026-08-15
- 0.0.11 — 2026-06-27
- 0.0.10 — 2026-04-11
- 0.0.9 — 2026-03-08
- 0.0.8 — 2026-03-06
- 0.0.7 — 2026-03-06
- 0.0.6 — 2026-03-06
- 0.0.5 — 2026-02-09
- 0.0.4 — 2026-02-08

## README

# @goke/mcp

Turn any [MCP](https://modelcontextprotocol.io) server into a CLI. Connects to the server, discovers tools, and generates CLI commands with typed arguments — automatically.

## Install

```bash
npm install @goke/mcp goke
```

`goke` is a peer dependency (the CLI framework that commands are registered on).

## How it works

```
MCP server                        Your CLI
┌──────────────────┐              ┌──────────────────────────┐
│  tools/list      │──discover──▸ │  mycli notion-search     │
│  - notion-search │              │  mycli notion-get-page   │
│  - notion-get-…  │              │  mycli notion-create-…   │
│  (JSON Schema)   │──coerce───▸ │  --query <string>        │
│                  │              │  --pageId <string>       │
└──────────────────┘              └──────────────────────────┘
```

1. **Discover** — calls `tools/list` on the MCP server to get every tool + its JSON Schema
2. **Register** — creates a CLI command per tool with `--options` derived from the schema
3. **Cache** — tool schemas are cached for 1 hour (no network on subsequent runs). Expired cache is still used when a live fetch is impossible (no token, 401 on `--help`)
4. **Execute** — on invocation, connects to the server and calls the tool with coerced arguments
5. **OAuth** — if the server returns 401, automatically opens the browser for OAuth, then retries. `--help`, `--version`, `completions`, and no-args never start OAuth

## Quick start

```ts
import { goke } from 'goke'
import { addMcpCommands } from '@goke/mcp'
import type { McpOAuthState, CachedMcpTools } from '@goke/mcp'

const cli = goke('notion-mcp-cli')

await addMcpCommands({
  cli,
  getMcpUrl: () => 'https://mcp.notion.com/mcp',
  oauth: {
    clientName: 'Notion CLI',
    load: () => loadConfig().oauthState,
    save: (state) => saveConfig({ oauthState: state }),
  },
  loadCache: () => loadConfig().cache,
  saveCache: (cache) => saveConfig({ cache }),
})

cli.help()
cli.completions()
cli.parse()
```

If the MCP server is HTTP, pass `getMcpUrl` even when the user has no token. `--help` and no-args must still run so the CLI can show `config` / login instructions. Use `getMcpTransport` when you need stdio or a custom transport.

For an HTTP Bearer token, pass `getHeaders`:

```ts
await addMcpCommands({
  cli,
  getMcpUrl: () => 'https://api.example.com/mcp',
  getHeaders: () => {
    const token = process.env.EXAMPLE_TOKEN || loadConfig().token
    if (!token) return
    return { Authorization: `Bearer ${token}` }
  },
  loadCache: () => loadConfig().cache,
  saveCache: (cache) => saveConfig({ cache }),
})
```

That's it. Every tool the MCP server exposes becomes a CLI command:

```bash
notion-mcp-cli notion-search --query "meeting notes"
notion-mcp-cli notion-retrieve-page --page_id "abc123"
notion-mcp-cli notion-list-users
```

## Expose a CLI as an MCP server

`createMcpAction()` turns your entire CLI into a stdio MCP server with one line. Every CLI command becomes an MCP tool automatically. The command you attach it to is excluded from the tool list.

```ts
import { goke } from "goke"
import { z } from "zod"
import { createMcpAction } from "@goke/mcp"

const cli = goke("my-cli")

cli
  .command("search", "Search pages")
  .option("--query <query>", z.string().describe("Search query"))
  .required()
  .option("--limit [limit]", z.number().default(10).describe("Max results"))
  .action((options) => {
    return { results: findPages(options.query, options.limit) }
  })

cli
  .command("deploy <env>", "Deploy to environment")
  .option("--dry-run", z.boolean().default(false).describe("Simulate"))
  .action((env, options) => {
    return options.dryRun ? `would deploy to ${env}` : deploy(env)
  })

// Add MCP support — runs a stdio MCP server when the user invokes `my-cli mcp`
cli.command("mcp", "Start MCP server over stdio")
  .action(createMcpAction({ cli }))

cli.help()
cli.completions()
cli.parse()
```

Now users can use your CLI directly **or** connect it as an MCP server:

```bash
# Use as a normal CLI
my-cli search --query "meeting notes"
my-cli deploy staging --dry-run

# Use as an MCP server (e.g. from Claude Desktop, Cursor, etc.)
my-cli mcp
```

When running as MCP, the server exposes `search` and `deploy` as tools. The `mcp` command itself is excluded. Options with Zod schemas (or any Standard Schema) become typed `inputSchema` properties in the MCP tool definition.

**`inputSchema.required` is not the CLI `<value>` syntax.** `--query <query>` means the flag needs a value if it is present. The flag itself is still optional unless you chain `.required()`. Required **positionals** like `<env>` always go in `required`. `.required()` flags go in `required` too.

### Installing the MCP server in clients

Users can install your CLI as an MCP server in any client using [`@playwriter/install-mcp`](https://github.com/nicepkg/install-mcp) — a cross-platform tool that handles config file locations for every major MCP client:

```bash
# Install in Claude Desktop
npx @playwriter/install-mcp my-cli --client claude-desktop

# Install in Cursor
npx @playwriter/install-mcp my-cli --client cursor

# Install in VS Code
npx @playwriter/install-mcp my-cli --client vscode
```

This works with any client: `claude-desktop`, `cursor`, `vscode`, `windsurf`, `claude-code`, `opencode`, `zed`, `goose`, `cline`, `codex`, `gemini-cli`, and [more](https://github.com/supermemoryai/install-mcp#supported-clients). If the command needs custom arguments, pass the full command string:

```bash
npx @playwriter/install-mcp 'npx my-cli mcp' --client cursor
```

`createMcpAction` accepts the same filtering options as `addCliToolsToMcp`:

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `cli` | `Goke` | **required** | The CLI instance to expose |
| `commandFilter` | `(name) => boolean` | — | Additional filter (MCP command is always excluded) |
| `sanitizeToolName` | `(name) => string` | — | Custom tool name sanitizer |
| `serverName` | `string` | CLI name | MCP server name |
| `serverVersion` | `string` | `'1.0.0'` | MCP server version |
| `createTransport` | `() => Transport` | stdio | Custom transport factory |

### Advanced: `addCliToolsToMcp`

For more control (composing with existing MCP tools, using a custom server), use `addCliToolsToMcp()` directly:

```ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { addCliToolsToMcp } from "@goke/mcp"

const server = new Server(
  { name: "my-cli-mcp", version: "1.0.0" },
  { capabilities: {} },
)

// Mount CLI commands as tools alongside your own
addCliToolsToMcp({ cli, server })

const transport = new StdioServerTransport()
await server.connect(transport)
```

Also works with the high-level `McpServer`:

```ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"

const mcp = new McpServer({ name: "my-cli-mcp", version: "1.0.0" })
mcp.tool("custom-tool", "A tool defined directly", async () => ({ ... }))
addCliToolsToMcp({ cli, server: mcp })
```

## Multi-tenant remote MCP over HTTP

When you expose a cli as a **remote** MCP over HTTP, one process handles many concurrent users. Each request must run against that user's own filesystem, working directory, environment, and stdin — otherwise tenants see each other's state and stdio writes trample the JSON-RPC channel.

Do **not** keep MCP session IDs. Streamable HTTP can run **stateless**: `sessionIdGenerator: undefined`. Each POST builds a fresh `Server` and transport, then closes them. That works on Cloudflare Workers and any other isolate that does not keep process memory.

The recipe is:

1. Define the cli **once**.
2. On **every POST**, resolve the tenant from the request (JWT, cookie, header), **clone** the cli with `{ cwd, env, fs }`, and mount it on a fresh `Server` via `addCliToolsToMcp({ cli: tenantClone, server })`.
3. Inside command actions, always use the **injected** `ctx` — `ctx.fs`, `ctx.process.cwd`, `ctx.process.env`, `ctx.console.log` — instead of the Node globals. `@goke/mcp` wires each tool call into the tenant's cloned context, but only code that goes through `ctx` participates in that isolation.

### Write commands against `ctx`

```ts
import { goke } from "goke"
import { z } from "zod"
import path from "node:path"

const cli = goke("notes-app")

cli
  .command("save <filename>", "Save content into the user's workspace")
  .option("--content <content>", z.string().describe("File content"))
  .action(async (filename, options, ctx) => {
    const full = path.posix.join(ctx.process.cwd, filename)
    await ctx.fs.writeFile(full, options.content)
    return { saved: full, tenant: ctx.process.env.TENANT_ID }
  })

cli
  .command("load <filename>", "Load a file from the user's workspace")
  .action(async (filename, _options, ctx) => {
    const full = path.posix.join(ctx.process.cwd, filename)
    const text = await ctx.fs.readFile(full, "utf8")
    return { path: full, text }
  })
```

`ctx.fs` satisfies the `GokeFs` interface — a Node-compatible async filesystem API. You can point it at a real directory, a virtual in-memory store, an S3 bucket adapter, a `memfs`, or anything else you can wrap behind that interface.

### Clone the cli per request

The MCP SDK ships `WebStandardStreamableHTTPServerTransport`, which accepts a Web-Standard `Request` and returns a `Response`. That one shape plugs directly into **any** web framework that speaks web-standard: [Spiceflow](https://github.com/remorses/spiceflow), Cloudflare Workers, Deno, Bun, Next.js route handlers, SvelteKit endpoints, or a raw `fetch`-based handler. No Express, no `node:http` wiring, no framework lock-in.

Pass `sessionIdGenerator: undefined` so the transport does not emit or expect `mcp-session-id`. Do not store transports in a `Map`. JSON-RPC `initialize`, `tools/list`, and `tools/call` are separate POSTs. Each one clones the cli from the request's tenant identity.

This is MCP SDK v1 Streamable HTTP **without optional transport sessions** (protocol revisions through `2025-11-25`). It is not the later `2026-07-28` protocol, which drops `initialize`. Session IDs are optional in `2025-11-25`. Stateless servers omit them.

The Streamable HTTP spec requires **Origin** checks on every request (DNS rebinding). Reject a present, disallowed `Origin` with **403**. Missing `Origin` is normal for non-browser MCP clients.

```
POST /mcp  (JWT / x-tenant-id)
        │
        v
resolveTenant(id) ──> baseCli.clone({ cwd, env, fs })
                           │
                           v
                    fresh Server + transport
                    sessionIdGenerator: undefined
                           │
                           v
addCliToolsToMcp ──> handleRequest
                           │
                           v
                        Response
                           │
                           v
             close transport + server ──> nothing stored
```

You build one `handleMcpRequest(request: Request): Promise<Response>` function and mount it wherever you route HTTP:

```ts
import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import { addCliToolsToMcp } from "@goke/mcp"
import type { GokeFs } from "goke"

declare function resolveTenant(tenantId: string): {
  cwd: string
  env: Record<string, string>
  fs: GokeFs
}

declare function isAllowedOrigin(origin: string): boolean

export async function handleMcpRequest(request: Request): Promise<Response> {
  const origin = request.headers.get("origin")
  if (origin && !isAllowedOrigin(origin)) {
    return new Response(null, { status: 403 })
  }

  if (request.method !== "POST") {
    return new Response(null, { status: 405, headers: { Allow: "POST" } })
  }

  const parsedBody = await request.clone().json().catch(() => undefined)

  const tenantId = request.headers.get("x-tenant-id")
  if (!tenantId) return new Response("missing x-tenant-id", { status: 401 })
  const tenant = resolveTenant(tenantId)

  const tenantCli = baseCli.clone({
    cwd: tenant.cwd,
    env: { ...tenant.env, TENANT_ID: tenantId },
    fs: tenant.fs,
  })

  const mcpServer = new McpServer(
    { name: "notes-app-mcp", version: "1.0.0" },
    { capabilities: {} },
  )
  addCliToolsToMcp({ cli: tenantCli, server: mcpServer })

  const transport = new WebStandardStreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true,
  })
  await mcpServer.connect(transport)
  try {
    return await transport.handleRequest(request, { parsedBody })
  } finally {
    await transport.close()
    await mcpServer.close()
  }
}
```

Now plug `handleMcpRequest` into whichever web runtime you use:

```ts
// Spiceflow — runs on Node, Bun, and Cloudflare Workers with the same code
import { Spiceflow } from "spiceflow"

export const app = new Spiceflow()
  .route({
    method: "*",
    path: "/mcp",
    handler: ({ request }) => handleMcpRequest(request),
  })

app.listen(3000)

// Cloudflare Workers / Deno / Bun
export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url)
    if (url.pathname === "/mcp") return handleMcpRequest(request)
    return new Response("not found", { status: 404 })
  },
}

// Next.js app router
export async function POST(request: Request) {
  return handleMcpRequest(request)
}
```

**Key guarantees**

- Every request runs against `tenantCli`'s `cwd` / `env` / `fs` — not the base cli's and not another tenant's.
- `ctx.console.log` / `ctx.console.error` / `ctx.process.stdout.write` / `ctx.process.stderr.write` are captured into the `CallToolResult.content`. They never reach the host process stdio, so they can't corrupt the JSON-RPC channel or leak between users.
- `ctx.process.exit(code)` throws `GokeProcessExit` instead of killing the server. The tool call resolves as `{ isError: code !== 0, content: [captured output] }` and the next request keeps running.
- Actions that `throw` are caught and returned as `{ isError: true, content: [message, stderr] }`.

**Bypass hazards**

Only code that flows through `ctx` participates in the isolation. The following **bypass** it and will leak across tenants or kill the host process:

- `import fs from "node:fs"` inside a command action — reads/writes the real disk.
- `console.log(...)` / `console.error(...)` — writes to the real server stdio.
- `process.exit(1)` — terminates the entire host process.
- `process.cwd()` / `process.env.X` at module load time — snapshots the server's values, not the tenant's.

Port those to `ctx.fs`, `ctx.console`, `ctx.process.*` and you're multi-tenant-safe.

For a runnable end-to-end example (including two concurrent in-memory-fs tenants writing separate files, with a new server per POST), see [`src/__test__/http-multi-tenant.test.ts`](./src/__test__/http-multi-tenant.test.ts).

### Lower-level primitive: `cli.createExecutionContext(override)`

If you're building a custom transport or a non-HTTP multi-tenant adapter, the underlying goke primitive is:

```ts
import { GokeProcessExit, type GokeExecutionContextOverride } from "goke"

const override: GokeExecutionContextOverride = {
  cwd: tenant.cwd,
  env: tenant.env,
  fs: tenant.fs,
  stdin: tenant.stdin,
  stdout: captureStdoutStream,
  stderr: captureStderrStream,
  exit: () => {}, // throw-only: the wrapper still throws GokeProcessExit
}

const ctx = cli.createExecutionContext(override)

try {
  await action(...positionalArgs, options, ctx)
} catch (err) {
  if (err instanceof GokeProcessExit) {
    // handle the exit code — the host process is untouched
  } else {
    throw err
  }
}
```

`addCliToolsToMcp` builds this context for you on every tool call. Call it yourself if you need finer control (e.g. per-request capture streams for custom routing, synthetic `argv`, or serving MCP from a non-Node runtime that implements `GokeFs` differently).

## Full example (with config persistence)

This is the pattern used by [notion-mcp-cli](../notion-mcp-cli):

```ts
import { goke } from 'goke'
import { addMcpCommands } from '@goke/mcp'
import type { McpOAuthState, CachedMcpTools } from '@goke/mcp'
import fs from 'node:fs'
import path from 'node:path'
import os from 'node:os'

// --- Config persistence (JSON file in ~/.myapp/) ---

const CONFIG_DIR = path.join(os.homedir(), '.myapp')
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')

interface AppConfig {
  mcpUrl: string
  oauthState?: McpOAuthState
  cache?: CachedMcpTools
}

function loadConfig(): AppConfig {
  try {
    return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'))
  } catch {
    return { mcpUrl: 'https://mcp.notion.com/mcp' }
  }
}

function saveConfig(partial: Partial<AppConfig>): void {
  const merged = { ...loadConfig(), ...partial }
  fs.mkdirSync(CONFIG_DIR, { recursive: true })
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2))
}

// --- CLI setup ---

const cli = goke('myapp')

await addMcpCommands({
  cli,
  clientName: 'myapp',
  getMcpUrl: () => loadConfig().mcpUrl,
  oauth: {
    clientName: 'My App',
    load: () => loadConfig().oauthState,
    save: (state) => saveConfig({ oauthState: state }),
  },
  loadCache: () => loadConfig().cache,
  saveCache: (cache) => saveConfig({ cache }),
})

// Custom commands alongside auto-generated MCP commands
cli
  .command('login', 'Save MCP URL')
  .option('--url <url>', 'MCP server URL')
  .action((options) => {
    saveConfig({ mcpUrl: options.url })
    console.log(`Saved: ${options.url}`)
  })

cli.command('logout', 'Clear tokens').action(() => {
  saveConfig({ oauthState: undefined, cache: undefined })
  console.log('Logged out')
})

cli.help()
cli.completions()
cli.parse()
```

## API

### `addMcpCommands(options)`

Registers MCP tool commands on a goke CLI instance.

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `cli` | `Goke` | **required** | The goke CLI instance to add commands to |
| `getMcpUrl` | `() => string \| undefined` | — | Returns the MCP server URL. Return the URL even when the user is not logged in so `--help` still works |
| `getMcpTransport` | `() => Transport \| null` | — | Custom transport. Use for stdio or anything `getMcpUrl` cannot express |
| `getHeaders` | `() => Record<string, string> \| undefined` | — | Extra HTTP headers (for example `Authorization`). Used with `getMcpUrl` |
| `argv` | `string[]` | `process.argv.slice(2)` | Args used to skip live discovery on help and already registered commands |
| `commandPrefix` | `string` | `''` | Prefix for commands (e.g. `'mcp'` makes `mcp notion-search`) |
| `clientName` | `string` | `'mcp-cli-client'` | Name sent to the MCP server during connection |
| `oauth` | `McpOAuthConfig` | — | OAuth config for servers that require authentication |
| `loadCache` | `() => CachedMcpTools \| undefined` | **required** | Load cached tools from storage |
| `saveCache` | `(cache) => void` | **required** | Save cached tools to storage |

### `McpOAuthConfig`

| Field | Type | Description |
|-------|------|-------------|
| `clientName` | `string` | Name shown on the OAuth consent screen |
| `load` | `() => McpOAuthState \| undefined` | Load persisted OAuth state |
| `save` | `(state) => void` | Save OAuth state after auth or token refresh |
| `onAuthUrl` | `(url: string) => void` | Custom handler for auth URL (default: opens browser) |
| `onAuthSuccess` | `() => void` | Called after successful authentication |
| `onAuthError` | `(error: string) => void` | Called on authentication failure |

### Exports

```ts
// MCP server → CLI (consume MCP tools as CLI commands)
export { addMcpCommands } from '@goke/mcp'
export type { AddMcpCommandsOptions, CachedMcpTools } from '@goke/mcp'
export type { McpOAuthConfig, McpOAuthState } from '@goke/mcp'

// CLI → MCP server (expose CLI commands as MCP tools)
export { createMcpAction, addCliToolsToMcp } from '@goke/mcp'
export type { CreateMcpActionOptions, AddCliToolsToMcpOptions } from '@goke/mcp'
```

## OAuth flow

OAuth is **lazy** — no auth check happens on startup. The flow is:

```
User runs command
       │
       ▼
  Call MCP tool ───── success ──▸ Print result
       │
    401 error
       │
       ▼
  Start local server (random port)
       │
       ▼
  Open browser ──▸ User authorizes
       │
       ▼
  Receive callback with auth code
       │
       ▼
  Exchange code for tokens
       │
       ▼
  Save tokens via oauth.save()
       │
       ▼
  Retry the original tool call
```

Tokens are persisted via the `oauth.save()` callback you provide, so subsequent runs skip auth entirely.

## Caching

Tool schemas are cached for **1 hour** so `--help` and command registration do not hit the network on every invocation. The cache is managed through the `loadCache`/`saveCache` callbacks. You control where it is stored (file, database, env, etc.). Each tool call still opens a new MCP connection. There is no `Mcp-Session-Id` reuse.

When the cache expires or a tool call fails, the cache is cleared and tools are re-fetched on the next run.

If a live `tools/list` cannot run (no transport, 401 during `--help`), `addMcpCommands` still registers tools from an expired cache so help stays useful.

## License

MIT

---
_Source: https://npm.io/package/@goke/mcp · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
