claude-octopus 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,263 @@
1
+ <p align="center">
2
+ <img src="assets/claude-octopus-icon.svg" alt="Claude Octopus" width="200" />
3
+ </p>
4
+
5
+ # Claude Octopus
6
+
7
+ One brain, many arms.
8
+
9
+ An MCP server that wraps the [Claude Agent SDK](https://docs.anthropic.com/en/docs/claude-code/sdk), letting you run multiple specialized Claude Code agents — each with its own model, tools, system prompt, and personality — from any MCP client.
10
+
11
+ ## Why
12
+
13
+ Claude Code is powerful. But one instance does everything the same way. Sometimes you want a **strict code reviewer** that only reads files. A **test writer** that defaults to TDD. A **cheap quick helper** on Haiku. A **deep thinker** on Opus.
14
+
15
+ Claude Octopus lets you spin up as many of these as you need. Same binary, different configurations. Each one shows up as a separate tool in your MCP client.
16
+
17
+ ## Prerequisites
18
+
19
+ - **Node.js** >= 18
20
+ - **Claude Code** — the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) is bundled as a dependency, but it spawns Claude Code under the hood, so you need a working `claude` CLI installation
21
+ - **Anthropic API key** (`ANTHROPIC_API_KEY` env var) or an active Claude Code OAuth session
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ npm install claude-octopus
27
+ ```
28
+
29
+ Or skip the install entirely — use `npx` directly in your `.mcp.json` (see Quick Start below).
30
+
31
+ ## Quick Start
32
+
33
+ Add to your `.mcp.json`:
34
+
35
+ ```json
36
+ {
37
+ "mcpServers": {
38
+ "claude": {
39
+ "command": "npx",
40
+ "args": ["claude-octopus"],
41
+ "env": {
42
+ "CLAUDE_PERMISSION_MODE": "bypassPermissions"
43
+ }
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ This gives you two tools: `claude_code` and `claude_code_reply`. That's it — you have Claude Code as a tool.
50
+
51
+ ## Multiple Agents
52
+
53
+ The real power is running several instances with different configurations:
54
+
55
+ ```json
56
+ {
57
+ "mcpServers": {
58
+ "code-reviewer": {
59
+ "command": "npx",
60
+ "args": ["claude-octopus"],
61
+ "env": {
62
+ "CLAUDE_TOOL_NAME": "code_reviewer",
63
+ "CLAUDE_SERVER_NAME": "code-reviewer",
64
+ "CLAUDE_DESCRIPTION": "Strict code reviewer. Finds bugs and security issues. Read-only.",
65
+ "CLAUDE_MODEL": "opus",
66
+ "CLAUDE_ALLOWED_TOOLS": "Read,Grep,Glob",
67
+ "CLAUDE_APPEND_PROMPT": "You are a strict code reviewer. Report real bugs, not style preferences.",
68
+ "CLAUDE_EFFORT": "high"
69
+ }
70
+ },
71
+ "test-writer": {
72
+ "command": "npx",
73
+ "args": ["claude-octopus"],
74
+ "env": {
75
+ "CLAUDE_TOOL_NAME": "test_writer",
76
+ "CLAUDE_SERVER_NAME": "test-writer",
77
+ "CLAUDE_DESCRIPTION": "Writes thorough tests with edge case coverage.",
78
+ "CLAUDE_MODEL": "sonnet",
79
+ "CLAUDE_APPEND_PROMPT": "Write tests first. Cover edge cases. TDD."
80
+ }
81
+ },
82
+ "quick-qa": {
83
+ "command": "npx",
84
+ "args": ["claude-octopus"],
85
+ "env": {
86
+ "CLAUDE_TOOL_NAME": "quick_qa",
87
+ "CLAUDE_SERVER_NAME": "quick-qa",
88
+ "CLAUDE_DESCRIPTION": "Fast answers to quick coding questions.",
89
+ "CLAUDE_MODEL": "haiku",
90
+ "CLAUDE_MAX_BUDGET_USD": "0.02",
91
+ "CLAUDE_EFFORT": "low"
92
+ }
93
+ }
94
+ }
95
+ }
96
+ ```
97
+
98
+ Your MCP client now sees three distinct tools — `code_reviewer`, `test_writer`, `quick_qa` — each purpose-built.
99
+
100
+ ## Agent Factory
101
+
102
+ Don't want to write configs by hand? Add a factory instance:
103
+
104
+ ```json
105
+ {
106
+ "mcpServers": {
107
+ "agent-factory": {
108
+ "command": "npx",
109
+ "args": ["claude-octopus"],
110
+ "env": {
111
+ "CLAUDE_FACTORY_ONLY": "true",
112
+ "CLAUDE_SERVER_NAME": "agent-factory"
113
+ }
114
+ }
115
+ }
116
+ }
117
+ ```
118
+
119
+ This exposes a single `create_claude_code_mcp` tool — an interactive wizard. Tell it what you want ("a strict code reviewer that only reads files") and it generates the `.mcp.json` entry for you, listing all available options you can customize.
120
+
121
+ In factory-only mode, no query tools are registered — just the wizard. This keeps routing clean: the factory creates agents, the agents do work.
122
+
123
+ ## Tools
124
+
125
+ Each non-factory instance exposes:
126
+
127
+ | Tool | Purpose |
128
+ | -------------- | ------------------------------------------------------- |
129
+ | `<name>` | Send a task to the agent, get a response + `session_id` |
130
+ | `<name>_reply` | Continue a previous conversation by `session_id` |
131
+
132
+ Per-invocation parameters (override server defaults):
133
+
134
+ | Parameter | Description |
135
+ | ----------------- | ----------------------------------------------- |
136
+ | `prompt` | The task or question (required) |
137
+ | `cwd` | Working directory override |
138
+ | `model` | Model override |
139
+ | `tools` | Restrict available tools (intersects with server restriction) |
140
+ | `disallowedTools` | Block additional tools (unions with server blacklist) |
141
+ | `additionalDirs` | Extra directories the agent can access |
142
+ | `plugins` | Additional plugin paths to load |
143
+ | `effort` | Thinking effort (`low`, `medium`, `high`, `max`) |
144
+ | `permissionMode` | Permission mode (can only tighten, never loosen) |
145
+ | `maxTurns` | Max conversation turns |
146
+ | `maxBudgetUsd` | Max spend in USD |
147
+ | `systemPrompt` | Additional prompt (appended to server default) |
148
+
149
+ ## Configuration
150
+
151
+ All configuration is via environment variables in `.mcp.json`. Every env var is optional.
152
+
153
+ ### Identity
154
+
155
+ | Env Var | Description | Default |
156
+ | --------------------- | ---------------------------------------------- | ---------------- |
157
+ | `CLAUDE_TOOL_NAME` | Tool name prefix (`<name>` and `<name>_reply`) | `claude_code` |
158
+ | `CLAUDE_DESCRIPTION` | Tool description shown to the host AI | generic |
159
+ | `CLAUDE_SERVER_NAME` | MCP server name in protocol handshake | `claude-octopus` |
160
+ | `CLAUDE_FACTORY_ONLY` | Only expose the factory wizard tool | `false` |
161
+
162
+ ### Agent
163
+
164
+ | Env Var | Description | Default |
165
+ | ------------------------- | ----------------------------------------------------- | --------------- |
166
+ | `CLAUDE_MODEL` | Model (`sonnet`, `opus`, `haiku`, or full ID) | SDK default |
167
+ | `CLAUDE_CWD` | Working directory | `process.cwd()` |
168
+ | `CLAUDE_PERMISSION_MODE` | `default`, `acceptEdits`, `bypassPermissions`, `plan` | `default` |
169
+ | `CLAUDE_ALLOWED_TOOLS` | Comma-separated tool restriction (available tools) | all |
170
+ | `CLAUDE_DISALLOWED_TOOLS` | Comma-separated tool blacklist | none |
171
+ | `CLAUDE_MAX_TURNS` | Max conversation turns | unlimited |
172
+ | `CLAUDE_MAX_BUDGET_USD` | Max spend per invocation | unlimited |
173
+ | `CLAUDE_EFFORT` | `low`, `medium`, `high`, `max` | SDK default |
174
+
175
+ ### Prompts
176
+
177
+ | Env Var | Description |
178
+ | ---------------------- | ------------------------------------------------------ |
179
+ | `CLAUDE_SYSTEM_PROMPT` | Replaces the default Claude Code system prompt |
180
+ | `CLAUDE_APPEND_PROMPT` | Appended to the default prompt (usually what you want) |
181
+
182
+ ### Advanced
183
+
184
+ | Env Var | Description |
185
+ | ------------------------ | -------------------------------------------------------- |
186
+ | `CLAUDE_ADDITIONAL_DIRS` | Extra directories to grant access (comma-separated) |
187
+ | `CLAUDE_PLUGINS` | Local plugin paths (comma-separated) |
188
+ | `CLAUDE_MCP_SERVERS` | MCP servers for the inner agent (JSON) |
189
+ | `CLAUDE_PERSIST_SESSION` | `true`/`false` — enable session resume (default: `true`) |
190
+ | `CLAUDE_SETTING_SOURCES` | Settings to load: `user`, `project`, `local` |
191
+ | `CLAUDE_SETTINGS` | Path to settings JSON or inline JSON |
192
+ | `CLAUDE_BETAS` | Beta features (comma-separated) |
193
+
194
+ ### Authentication
195
+
196
+ | Env Var | Description | Default |
197
+ | ------------------------- | -------------------------------------- | --------------------- |
198
+ | `ANTHROPIC_API_KEY` | Anthropic API key for this agent | inherited from parent |
199
+ | `CLAUDE_CODE_OAUTH_TOKEN` | Claude Code OAuth token for this agent | inherited from parent |
200
+
201
+ Leave both unset to inherit auth from the parent process. Set one per agent to use a different account or billing source.
202
+
203
+ Lists accept JSON arrays when values contain commas: `["path,with,comma", "/normal"]`
204
+
205
+ ## Security
206
+
207
+ - **Permission mode defaults to ****`default`** — tool executions prompt for approval unless you explicitly set `bypassPermissions`.
208
+ - **`cwd` overrides preserve agent knowledge** — when the host overrides `cwd`, the agent's configured base directory is automatically added to `additionalDirectories` so it retains access to its own context.
209
+ - **Tool restrictions narrow, never widen** — per-invocation `tools` intersects with the server restriction (can only remove tools, not add). `disallowedTools` unions (can only block more).
210
+ - **`_reply`**** tool respects persistence** — not registered when `CLAUDE_PERSIST_SESSION=false`.
211
+
212
+ ## Architecture
213
+
214
+ ```
215
+ ┌─────────────────────────────────┐
216
+ │ MCP Client │
217
+ │ (Claude Desktop, Cursor, etc.) │
218
+ │ │
219
+ │ Sees: code_reviewer, │
220
+ │ test_writer, quick_qa │
221
+ └──────────┬──────────────────────┘
222
+ │ JSON-RPC / stdio
223
+ ┌──────────▼──────────────────────┐
224
+ │ Claude Octopus (per instance) │
225
+ │ │
226
+ │ Env: CLAUDE_MODEL=opus │
227
+ │ CLAUDE_ALLOWED_TOOLS=... │
228
+ │ CLAUDE_APPEND_PROMPT=... │
229
+ │ │
230
+ │ Calls: Agent SDK query() │
231
+ └──────────┬──────────────────────┘
232
+ │ in-process
233
+ ┌──────────▼──────────────────────┐
234
+ │ Claude Agent SDK │
235
+ │ Runs autonomously: reads files,│
236
+ │ writes code, runs commands │
237
+ │ Returns result + session_id │
238
+ └─────────────────────────────────┘
239
+ ```
240
+
241
+ ## How It Compares
242
+
243
+ | Feature | `` | [claude-code-mcp](https://github.com/steipete/claude-code-mcp) | **Claude Octopus** |
244
+ | ------------------- | ------------ | -------------------------------------------------------------- | ------------------ |
245
+ | Approach | Built-in | CLI wrapping | Agent SDK |
246
+ | Exposes | 16 raw tools | 1 prompt tool | 1 prompt + reply |
247
+ | Multi-instance | No | No | Yes |
248
+ | Per-instance config | No | No | Yes (18 env vars) |
249
+ | Factory wizard | No | No | Yes |
250
+ | Session continuity | No | No | Yes |
251
+
252
+ ## Development
253
+
254
+ ```bash
255
+ pnpm install
256
+ pnpm build # compile TypeScript
257
+ pnpm test # run tests (vitest)
258
+ pnpm test:coverage # coverage report
259
+ ```
260
+
261
+ ## License
262
+
263
+ [ISC](https://github.com/xiaolai/claude-octopus/blob/main/LICENSE) - Xiaolai Li
Binary file
@@ -0,0 +1,50 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" shape-rendering="crispEdges">
2
+ <rect x="7" y="6" width="18" height="1" fill="#C56F52"/>
3
+ <rect x="7" y="7" width="18" height="1" fill="#C56F52"/>
4
+ <rect x="7" y="8" width="18" height="1" fill="#C56F52"/>
5
+ <rect x="7" y="9" width="18" height="1" fill="#C56F52"/>
6
+ <rect x="7" y="10" width="4" height="1" fill="#C56F52"/>
7
+ <rect x="13" y="10" width="6" height="1" fill="#C56F52"/>
8
+ <rect x="21" y="10" width="4" height="1" fill="#C56F52"/>
9
+ <rect x="7" y="11" width="4" height="1" fill="#C56F52"/>
10
+ <rect x="13" y="11" width="6" height="1" fill="#C56F52"/>
11
+ <rect x="21" y="11" width="4" height="1" fill="#C56F52"/>
12
+ <rect x="5" y="12" width="22" height="1" fill="#C56F52"/>
13
+ <rect x="5" y="13" width="22" height="1" fill="#C56F52"/>
14
+ <rect x="3" y="14" width="26" height="1" fill="#C56F52"/>
15
+ <rect x="3" y="15" width="26" height="1" fill="#C56F52"/>
16
+ <rect x="3" y="16" width="26" height="1" fill="#C56F52"/>
17
+ <rect x="3" y="17" width="26" height="1" fill="#C56F52"/>
18
+ <rect x="3" y="18" width="2" height="1" fill="#C56F52"/>
19
+ <rect x="7" y="18" width="18" height="1" fill="#C56F52"/>
20
+ <rect x="27" y="18" width="2" height="1" fill="#C56F52"/>
21
+ <rect x="3" y="19" width="2" height="1" fill="#C56F52"/>
22
+ <rect x="7" y="19" width="18" height="1" fill="#C56F52"/>
23
+ <rect x="27" y="19" width="2" height="1" fill="#C56F52"/>
24
+ <rect x="3" y="20" width="2" height="1" fill="#C56F52"/>
25
+ <rect x="7" y="20" width="18" height="1" fill="#C56F52"/>
26
+ <rect x="27" y="20" width="2" height="1" fill="#C56F52"/>
27
+ <rect x="3" y="21" width="2" height="1" fill="#C56F52"/>
28
+ <rect x="7" y="21" width="18" height="1" fill="#C56F52"/>
29
+ <rect x="27" y="21" width="2" height="1" fill="#C56F52"/>
30
+ <rect x="7" y="22" width="2" height="1" fill="#C56F52"/>
31
+ <rect x="11" y="22" width="2" height="1" fill="#C56F52"/>
32
+ <rect x="15" y="22" width="2" height="1" fill="#C56F52"/>
33
+ <rect x="19" y="22" width="2" height="1" fill="#C56F52"/>
34
+ <rect x="23" y="22" width="2" height="1" fill="#C56F52"/>
35
+ <rect x="7" y="23" width="2" height="1" fill="#C56F52"/>
36
+ <rect x="11" y="23" width="2" height="1" fill="#C56F52"/>
37
+ <rect x="15" y="23" width="2" height="1" fill="#C56F52"/>
38
+ <rect x="19" y="23" width="2" height="1" fill="#C56F52"/>
39
+ <rect x="23" y="23" width="2" height="1" fill="#C56F52"/>
40
+ <rect x="7" y="24" width="2" height="1" fill="#C56F52"/>
41
+ <rect x="11" y="24" width="2" height="1" fill="#C56F52"/>
42
+ <rect x="15" y="24" width="2" height="1" fill="#C56F52"/>
43
+ <rect x="19" y="24" width="2" height="1" fill="#C56F52"/>
44
+ <rect x="23" y="24" width="2" height="1" fill="#C56F52"/>
45
+ <rect x="7" y="25" width="2" height="1" fill="#C56F52"/>
46
+ <rect x="11" y="25" width="2" height="1" fill="#C56F52"/>
47
+ <rect x="15" y="25" width="2" height="1" fill="#C56F52"/>
48
+ <rect x="19" y="25" width="2" height="1" fill="#C56F52"/>
49
+ <rect x="23" y="25" width="2" height="1" fill="#C56F52"/>
50
+ </svg>
package/dist/config.js CHANGED
@@ -16,9 +16,9 @@ export function buildBaseOptions() {
16
16
  const model = envStr("CLAUDE_MODEL");
17
17
  if (model)
18
18
  opts.model = model;
19
- const allowed = envList("CLAUDE_ALLOWED_TOOLS");
20
- if (allowed)
21
- opts.allowedTools = allowed;
19
+ const tools = envList("CLAUDE_ALLOWED_TOOLS");
20
+ if (tools)
21
+ opts.tools = tools;
22
22
  const disallowed = envList("CLAUDE_DISALLOWED_TOOLS");
23
23
  if (disallowed)
24
24
  opts.disallowedTools = disallowed;
@@ -62,16 +62,17 @@ export function buildBaseOptions() {
62
62
  }
63
63
  const settings = envStr("CLAUDE_SETTINGS");
64
64
  if (settings) {
65
- if (settings.startsWith("{")) {
65
+ const trimmed = settings.trim();
66
+ if (trimmed.startsWith("{")) {
66
67
  try {
67
- opts.settings = JSON.parse(settings);
68
+ opts.settings = JSON.parse(trimmed);
68
69
  }
69
- catch {
70
- opts.settings = settings;
70
+ catch (e) {
71
+ console.error(`claude-octopus: invalid CLAUDE_SETTINGS JSON: ${e instanceof Error ? e.message : e}`);
71
72
  }
72
73
  }
73
74
  else {
74
- opts.settings = settings;
75
+ opts.settings = trimmed;
75
76
  }
76
77
  }
77
78
  const betas = envList("CLAUDE_BETAS");
package/dist/constants.js CHANGED
@@ -23,8 +23,8 @@ export const OPTION_CATALOG = [
23
23
  {
24
24
  key: "allowedTools",
25
25
  envVar: "CLAUDE_ALLOWED_TOOLS",
26
- label: "Allowed tools",
27
- hint: "Only these tools are available (comma-separated)",
26
+ label: "Available tools",
27
+ hint: "Restrict agent to only these tools (comma-separated)",
28
28
  example: '"Read,Grep,Glob" for read-only; "Bash,Read,Write,Edit,Grep,Glob" for full access',
29
29
  },
30
30
  {
@@ -111,4 +111,18 @@ export const OPTION_CATALOG = [
111
111
  hint: "Enable beta capabilities (comma-separated)",
112
112
  example: '"context-1m-2025-08-07" for 1M context window',
113
113
  },
114
+ {
115
+ key: "apiKey",
116
+ envVar: "ANTHROPIC_API_KEY",
117
+ label: "API key",
118
+ hint: "Anthropic API key for this agent (overrides inherited auth)",
119
+ example: '"sk-ant-api03-..." — leave unset to inherit from parent',
120
+ },
121
+ {
122
+ key: "oauthToken",
123
+ envVar: "CLAUDE_CODE_OAUTH_TOKEN",
124
+ label: "OAuth token",
125
+ hint: "Claude Code OAuth token for this agent (overrides inherited auth)",
126
+ example: '"sk-ant-oat01-..." — leave unset to inherit from parent',
127
+ },
114
128
  ];
package/dist/index.js CHANGED
@@ -10,15 +10,13 @@
10
10
  */
11
11
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
12
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
- import { fileURLToPath } from "node:url";
14
- import { dirname, resolve } from "node:path";
13
+ import { createRequire } from "node:module";
15
14
  import { envStr, envBool, sanitizeToolName } from "./lib.js";
16
15
  import { buildBaseOptions } from "./config.js";
17
16
  import { registerQueryTools } from "./tools/query.js";
18
17
  import { registerFactoryTool } from "./tools/factory.js";
19
- const __filename = fileURLToPath(import.meta.url);
20
- const __dirname = dirname(__filename);
21
- const SERVER_ENTRY = resolve(__dirname, "index.js");
18
+ const require = createRequire(import.meta.url);
19
+ const { version: PKG_VERSION } = require("../package.json");
22
20
  // ── Configuration ──────────────────────────────────────────────────
23
21
  const BASE_OPTIONS = buildBaseOptions();
24
22
  const TOOL_NAME = sanitizeToolName(envStr("CLAUDE_TOOL_NAME") || "claude_code");
@@ -33,12 +31,12 @@ const DEFAULT_DESCRIPTION = [
33
31
  ].join(" ");
34
32
  const TOOL_DESCRIPTION = envStr("CLAUDE_DESCRIPTION") || DEFAULT_DESCRIPTION;
35
33
  // ── Server ─────────────────────────────────────────────────────────
36
- const server = new McpServer({ name: SERVER_NAME, version: "1.0.0" });
34
+ const server = new McpServer({ name: SERVER_NAME, version: PKG_VERSION });
37
35
  if (!FACTORY_ONLY) {
38
36
  registerQueryTools(server, BASE_OPTIONS, TOOL_NAME, TOOL_DESCRIPTION);
39
37
  }
40
38
  if (FACTORY_ONLY) {
41
- registerFactoryTool(server, SERVER_ENTRY);
39
+ registerFactoryTool(server);
42
40
  }
43
41
  // ── Start ──────────────────────────────────────────────────────────
44
42
  async function main() {
package/dist/lib.d.ts CHANGED
@@ -9,10 +9,16 @@ export declare function envJson<T>(key: string, env?: Record<string, string | un
9
9
  export declare const MAX_TOOL_NAME_LEN: number;
10
10
  export declare function sanitizeToolName(raw: string): string;
11
11
  export declare function isDescendantPath(requested: string, baseCwd: string): boolean;
12
- export declare function mergeAllowedTools(serverList: string[] | undefined, callList: string[]): string[];
12
+ export declare function mergeTools(serverList: string[] | undefined, callList: string[]): string[];
13
13
  export declare function mergeDisallowedTools(serverList: string[] | undefined, callList: string[]): string[];
14
14
  export declare const VALID_PERM_MODES: Set<string>;
15
15
  export declare function validatePermissionMode(mode: string): string;
16
+ /**
17
+ * Narrow permission mode: returns the stricter of base and override.
18
+ * Callers can tighten permissions but never loosen them.
19
+ * Returns base unchanged if override is invalid or less strict.
20
+ */
21
+ export declare function narrowPermissionMode(base: string, override: string): string;
16
22
  export declare function deriveServerName(description: string): string;
17
23
  export declare function deriveToolName(name: string): string;
18
24
  export declare function serializeArrayEnv(val: unknown[]): string;
package/dist/lib.js CHANGED
@@ -69,7 +69,7 @@ export function isDescendantPath(requested, baseCwd) {
69
69
  return normalReq.startsWith(baseWithSep);
70
70
  }
71
71
  // ── Tool restriction merging ───────────────────────────────────────
72
- export function mergeAllowedTools(serverList, callList) {
72
+ export function mergeTools(serverList, callList) {
73
73
  if (serverList?.length) {
74
74
  const serverSet = new Set(serverList);
75
75
  return callList.filter((t) => serverSet.has(t));
@@ -87,11 +87,30 @@ export const VALID_PERM_MODES = new Set([
87
87
  "bypassPermissions",
88
88
  "plan",
89
89
  "dontAsk",
90
- "auto",
91
90
  ]);
92
91
  export function validatePermissionMode(mode) {
93
92
  return VALID_PERM_MODES.has(mode) ? mode : "default";
94
93
  }
94
+ // Strictness order: most permissive → most restrictive
95
+ const PERM_STRICTNESS = {
96
+ bypassPermissions: 0,
97
+ acceptEdits: 1,
98
+ default: 2,
99
+ plan: 3,
100
+ dontAsk: 4,
101
+ };
102
+ /**
103
+ * Narrow permission mode: returns the stricter of base and override.
104
+ * Callers can tighten permissions but never loosen them.
105
+ * Returns base unchanged if override is invalid or less strict.
106
+ */
107
+ export function narrowPermissionMode(base, override) {
108
+ if (!VALID_PERM_MODES.has(override))
109
+ return base;
110
+ const baseLevel = PERM_STRICTNESS[base] ?? 2;
111
+ const overrideLevel = PERM_STRICTNESS[override] ?? 2;
112
+ return overrideLevel >= baseLevel ? override : base;
113
+ }
95
114
  // ── Factory name derivation ────────────────────────────────────────
96
115
  export function deriveServerName(description) {
97
116
  const slug = description
@@ -1,2 +1,2 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- export declare function registerFactoryTool(server: McpServer, serverEntry: string): void;
2
+ export declare function registerFactoryTool(server: McpServer): void;
@@ -23,14 +23,14 @@ function buildEnvFromParams(params) {
23
23
  }
24
24
  return env;
25
25
  }
26
- export function registerFactoryTool(server, serverEntry) {
26
+ export function registerFactoryTool(server) {
27
27
  server.registerTool("create_claude_code_mcp", {
28
28
  description: [
29
- "Create a new specialized Claude Code agent as an MCP server.",
30
- "WHEN TO USE: user says 'create agent', 'new agent', 'set up agent',",
31
- "'configure agent', 'add a reviewer', 'add a test writer',",
32
- "'make me a code reviewer', 'I need a specialized agent', etc.",
33
- "DO NOT USE when user just wants to run a task — use the main tool for that.",
29
+ "Generate a .mcp.json config entry for a new Claude Octopus MCP server instance.",
30
+ "WHEN TO USE: user says 'octopus agent', 'octopus mcp',",
31
+ "'new octopus', 'add octopus', 'create octopus',",
32
+ "'octopus instance', 'octopus config', 'octopus server',",
33
+ "or any phrase combining 'octopus' with agent/mcp/new/add/create/config/server/setup.",
34
34
  "This is a wizard: only a description is required.",
35
35
  "Returns a ready-to-use .mcp.json config and lists all customization options.",
36
36
  "Call again with more parameters to refine.",
@@ -55,6 +55,8 @@ export function registerFactoryTool(server, serverEntry) {
55
55
  settingSources: z.array(z.string()).optional(),
56
56
  mcpServers: z.record(z.string(), z.unknown()).optional(),
57
57
  betas: z.array(z.string()).optional(),
58
+ apiKey: z.string().optional().describe("Anthropic API key for this agent (leave unset to inherit)"),
59
+ oauthToken: z.string().optional().describe("Claude Code OAuth token for this agent (leave unset to inherit)"),
58
60
  }),
59
61
  }, async (params) => {
60
62
  const { description, name: nameParam, toolName: toolNameParam } = params;
@@ -71,11 +73,17 @@ export function registerFactoryTool(server, serverEntry) {
71
73
  Object.assign(env, optionEnv);
72
74
  const configured = Object.keys(optionEnv);
73
75
  const notConfigured = OPTION_CATALOG.filter((o) => !configured.includes(o.envVar));
76
+ const SENSITIVE_KEYS = new Set(["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]);
77
+ // Redact secrets in rendered output, keep real values in config
78
+ const displayEnv = {};
79
+ for (const [k, v] of Object.entries(env)) {
80
+ displayEnv[k] = SENSITIVE_KEYS.has(k) ? "<REDACTED>" : v;
81
+ }
74
82
  const mcpEntry = {
75
83
  [name]: {
76
- command: "node",
77
- args: [serverEntry],
78
- env,
84
+ command: "npx",
85
+ args: ["claude-octopus"],
86
+ env: displayEnv,
79
87
  },
80
88
  };
81
89
  const sections = [];
@@ -87,7 +95,8 @@ export function registerFactoryTool(server, serverEntry) {
87
95
  for (const key of configured) {
88
96
  const opt = OPTION_CATALOG.find((o) => o.envVar === key);
89
97
  if (opt) {
90
- sections.push(`| ${opt.label} | \`${env[key]}\` |`);
98
+ const val = SENSITIVE_KEYS.has(key) ? "<REDACTED>" : env[key];
99
+ sections.push(`| ${opt.label} | \`${val}\` |`);
91
100
  }
92
101
  }
93
102
  if (configured.length === 0) {
@@ -1,17 +1,53 @@
1
1
  import { z } from "zod/v4";
2
2
  import { resolve } from "node:path";
3
3
  import { query, } from "@anthropic-ai/claude-agent-sdk";
4
- import { isDescendantPath, mergeAllowedTools, mergeDisallowedTools, buildResultPayload, formatErrorMessage, } from "../lib.js";
4
+ import { mergeTools, mergeDisallowedTools, narrowPermissionMode, buildResultPayload, formatErrorMessage, } from "../lib.js";
5
5
  async function runQuery(prompt, overrides, baseOptions) {
6
6
  const options = { ...baseOptions };
7
+ // Handle cwd override — accept any path, preserve agent's base access
7
8
  if (overrides.cwd) {
8
9
  const baseCwd = baseOptions.cwd || process.cwd();
9
- if (isDescendantPath(overrides.cwd, baseCwd)) {
10
- options.cwd = resolve(baseCwd, overrides.cwd);
10
+ const resolvedCwd = resolve(baseCwd, overrides.cwd);
11
+ if (resolvedCwd !== baseCwd) {
12
+ options.cwd = resolvedCwd;
13
+ // Agent's base dir becomes an additional dir so it keeps its knowledge
14
+ const dirs = new Set(options.additionalDirectories || []);
15
+ dirs.add(baseCwd);
16
+ options.additionalDirectories = [...dirs];
11
17
  }
12
18
  }
19
+ // Per-invocation additionalDirs — unions with server-level + auto-added dirs
20
+ if (overrides.additionalDirs?.length) {
21
+ const dirs = new Set(options.additionalDirectories || []);
22
+ for (const dir of overrides.additionalDirs) {
23
+ dirs.add(dir);
24
+ }
25
+ options.additionalDirectories = [...dirs];
26
+ }
27
+ // Per-invocation plugins — unions with server-level plugins
28
+ if (overrides.plugins?.length) {
29
+ const base = baseOptions.plugins || [];
30
+ const overridePaths = new Set(base.map((p) => p.path));
31
+ const merged = [...base];
32
+ for (const path of overrides.plugins) {
33
+ if (!overridePaths.has(path)) {
34
+ merged.push({ type: "local", path });
35
+ overridePaths.add(path);
36
+ }
37
+ }
38
+ options.plugins = merged;
39
+ }
13
40
  if (overrides.model)
14
41
  options.model = overrides.model;
42
+ if (overrides.effort)
43
+ options.effort = overrides.effort;
44
+ // Permission mode can only tighten, never loosen
45
+ if (overrides.permissionMode) {
46
+ const base = baseOptions.permissionMode || "default";
47
+ const narrowed = narrowPermissionMode(base, overrides.permissionMode);
48
+ options.permissionMode = narrowed;
49
+ options.allowDangerouslySkipPermissions = narrowed === "bypassPermissions";
50
+ }
15
51
  if (overrides.maxTurns !== undefined && overrides.maxTurns > 0) {
16
52
  options.maxTurns = overrides.maxTurns;
17
53
  }
@@ -19,8 +55,9 @@ async function runQuery(prompt, overrides, baseOptions) {
19
55
  options.maxBudgetUsd = overrides.maxBudgetUsd;
20
56
  if (overrides.resumeSessionId)
21
57
  options.resume = overrides.resumeSessionId;
22
- if (overrides.allowedTools?.length) {
23
- options.allowedTools = mergeAllowedTools(baseOptions.allowedTools, overrides.allowedTools);
58
+ if (overrides.tools?.length) {
59
+ const baseTools = Array.isArray(baseOptions.tools) ? baseOptions.tools : undefined;
60
+ options.tools = mergeTools(baseTools, overrides.tools);
24
61
  }
25
62
  if (overrides.disallowedTools?.length) {
26
63
  options.disallowedTools = mergeDisallowedTools(baseOptions.disallowedTools, overrides.disallowedTools);
@@ -87,16 +124,20 @@ export function registerQueryTools(server, baseOptions, toolName, toolDescriptio
87
124
  prompt: z.string().describe("Task or question for Claude Code"),
88
125
  cwd: z.string().optional().describe("Working directory (overrides CLAUDE_CWD)"),
89
126
  model: z.string().optional().describe('Model override (e.g. "sonnet", "opus", "haiku")'),
90
- allowedTools: z.array(z.string()).optional().describe("Tool whitelist override"),
91
- disallowedTools: z.array(z.string()).optional().describe("Tool blacklist override"),
127
+ tools: z.array(z.string()).optional().describe("Restrict available tools to this list (intersects with server-level restriction)"),
128
+ disallowedTools: z.array(z.string()).optional().describe("Additional tools to block (unions with server-level blacklist)"),
129
+ additionalDirs: z.array(z.string()).optional().describe("Extra directories the agent can access for this invocation"),
130
+ plugins: z.array(z.string()).optional().describe("Additional plugin paths to load for this invocation (unions with server-level plugins)"),
131
+ effort: z.enum(["low", "medium", "high", "max"]).optional().describe("Thinking effort override"),
132
+ permissionMode: z.enum(["default", "acceptEdits", "plan"]).optional().describe("Permission mode override (can only tighten, never loosen)"),
92
133
  maxTurns: z.number().int().positive().optional().describe("Max conversation turns"),
93
- maxBudgetUsd: z.number().optional().describe("Max spend in USD"),
134
+ maxBudgetUsd: z.number().positive().optional().describe("Max spend in USD"),
94
135
  systemPrompt: z.string().optional().describe("Additional system prompt (appended to server default)"),
95
136
  }),
96
- }, async ({ prompt, cwd, model, allowedTools, disallowedTools, maxTurns, maxBudgetUsd, systemPrompt }) => {
137
+ }, async ({ prompt, cwd, model, tools, disallowedTools, additionalDirs, plugins, effort, permissionMode, maxTurns, maxBudgetUsd, systemPrompt }) => {
97
138
  try {
98
139
  const result = await runQuery(prompt, {
99
- cwd, model, allowedTools, disallowedTools, maxTurns, maxBudgetUsd, systemPrompt,
140
+ cwd, model, tools, disallowedTools, additionalDirs, plugins, effort, permissionMode, maxTurns, maxBudgetUsd, systemPrompt,
100
141
  }, baseOptions);
101
142
  return formatResult(result);
102
143
  }
@@ -117,7 +158,7 @@ export function registerQueryTools(server, baseOptions, toolName, toolDescriptio
117
158
  cwd: z.string().optional().describe("Working directory override"),
118
159
  model: z.string().optional().describe("Model override"),
119
160
  maxTurns: z.number().int().positive().optional().describe("Max conversation turns"),
120
- maxBudgetUsd: z.number().optional().describe("Max spend in USD"),
161
+ maxBudgetUsd: z.number().positive().optional().describe("Max spend in USD"),
121
162
  }),
122
163
  }, async ({ session_id, prompt, cwd, model, maxTurns, maxBudgetUsd }) => {
123
164
  try {
package/dist/types.d.ts CHANGED
@@ -2,8 +2,12 @@ import type { Options } from "@anthropic-ai/claude-agent-sdk";
2
2
  export interface InvocationOverrides {
3
3
  cwd?: string;
4
4
  model?: string;
5
- allowedTools?: string[];
5
+ tools?: string[];
6
6
  disallowedTools?: string[];
7
+ additionalDirs?: string[];
8
+ plugins?: string[];
9
+ effort?: string;
10
+ permissionMode?: string;
7
11
  maxTurns?: number;
8
12
  maxBudgetUsd?: number;
9
13
  systemPrompt?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-octopus",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "One brain, many arms — spawn multiple specialized Claude Code agents as MCP servers",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.ts CHANGED
@@ -29,8 +29,8 @@ export function buildBaseOptions(): Options {
29
29
  const model = envStr("CLAUDE_MODEL");
30
30
  if (model) opts.model = model;
31
31
 
32
- const allowed = envList("CLAUDE_ALLOWED_TOOLS");
33
- if (allowed) opts.allowedTools = allowed;
32
+ const tools = envList("CLAUDE_ALLOWED_TOOLS");
33
+ if (tools) opts.tools = tools;
34
34
  const disallowed = envList("CLAUDE_DISALLOWED_TOOLS");
35
35
  if (disallowed) opts.disallowedTools = disallowed;
36
36
 
@@ -78,14 +78,15 @@ export function buildBaseOptions(): Options {
78
78
 
79
79
  const settings = envStr("CLAUDE_SETTINGS");
80
80
  if (settings) {
81
- if (settings.startsWith("{")) {
81
+ const trimmed = settings.trim();
82
+ if (trimmed.startsWith("{")) {
82
83
  try {
83
- opts.settings = JSON.parse(settings);
84
- } catch {
85
- opts.settings = settings;
84
+ opts.settings = JSON.parse(trimmed);
85
+ } catch (e) {
86
+ console.error(`claude-octopus: invalid CLAUDE_SETTINGS JSON: ${e instanceof Error ? e.message : e}`);
86
87
  }
87
88
  } else {
88
- opts.settings = settings;
89
+ opts.settings = trimmed;
89
90
  }
90
91
  }
91
92
 
package/src/constants.ts CHANGED
@@ -25,8 +25,8 @@ export const OPTION_CATALOG: OptionCatalogEntry[] = [
25
25
  {
26
26
  key: "allowedTools",
27
27
  envVar: "CLAUDE_ALLOWED_TOOLS",
28
- label: "Allowed tools",
29
- hint: "Only these tools are available (comma-separated)",
28
+ label: "Available tools",
29
+ hint: "Restrict agent to only these tools (comma-separated)",
30
30
  example: '"Read,Grep,Glob" for read-only; "Bash,Read,Write,Edit,Grep,Glob" for full access',
31
31
  },
32
32
  {
@@ -113,4 +113,18 @@ export const OPTION_CATALOG: OptionCatalogEntry[] = [
113
113
  hint: "Enable beta capabilities (comma-separated)",
114
114
  example: '"context-1m-2025-08-07" for 1M context window',
115
115
  },
116
+ {
117
+ key: "apiKey",
118
+ envVar: "ANTHROPIC_API_KEY",
119
+ label: "API key",
120
+ hint: "Anthropic API key for this agent (overrides inherited auth)",
121
+ example: '"sk-ant-api03-..." — leave unset to inherit from parent',
122
+ },
123
+ {
124
+ key: "oauthToken",
125
+ envVar: "CLAUDE_CODE_OAUTH_TOKEN",
126
+ label: "OAuth token",
127
+ hint: "Claude Code OAuth token for this agent (overrides inherited auth)",
128
+ example: '"sk-ant-oat01-..." — leave unset to inherit from parent',
129
+ },
116
130
  ];
package/src/index.ts CHANGED
@@ -12,16 +12,14 @@
12
12
 
13
13
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
14
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
- import { fileURLToPath } from "node:url";
16
- import { dirname, resolve } from "node:path";
15
+ import { createRequire } from "node:module";
17
16
  import { envStr, envBool, sanitizeToolName } from "./lib.js";
18
17
  import { buildBaseOptions } from "./config.js";
19
18
  import { registerQueryTools } from "./tools/query.js";
20
19
  import { registerFactoryTool } from "./tools/factory.js";
21
20
 
22
- const __filename = fileURLToPath(import.meta.url);
23
- const __dirname = dirname(__filename);
24
- const SERVER_ENTRY = resolve(__dirname, "index.js");
21
+ const require = createRequire(import.meta.url);
22
+ const { version: PKG_VERSION } = require("../package.json");
25
23
 
26
24
  // ── Configuration ──────────────────────────────────────────────────
27
25
 
@@ -43,14 +41,14 @@ const TOOL_DESCRIPTION = envStr("CLAUDE_DESCRIPTION") || DEFAULT_DESCRIPTION;
43
41
 
44
42
  // ── Server ─────────────────────────────────────────────────────────
45
43
 
46
- const server = new McpServer({ name: SERVER_NAME, version: "1.0.0" });
44
+ const server = new McpServer({ name: SERVER_NAME, version: PKG_VERSION });
47
45
 
48
46
  if (!FACTORY_ONLY) {
49
47
  registerQueryTools(server, BASE_OPTIONS, TOOL_NAME, TOOL_DESCRIPTION);
50
48
  }
51
49
 
52
50
  if (FACTORY_ONLY) {
53
- registerFactoryTool(server, SERVER_ENTRY);
51
+ registerFactoryTool(server);
54
52
  }
55
53
 
56
54
  // ── Start ──────────────────────────────────────────────────────────
package/src/lib.test.ts CHANGED
@@ -8,9 +8,10 @@ import {
8
8
  sanitizeToolName,
9
9
  MAX_TOOL_NAME_LEN,
10
10
  isDescendantPath,
11
- mergeAllowedTools,
11
+ mergeTools,
12
12
  mergeDisallowedTools,
13
13
  validatePermissionMode,
14
+ narrowPermissionMode,
14
15
  VALID_PERM_MODES,
15
16
  deriveServerName,
16
17
  deriveToolName,
@@ -202,24 +203,24 @@ describe("isDescendantPath", () => {
202
203
  });
203
204
  });
204
205
 
205
- // ── mergeAllowedTools ──────────────────────────────────────────────
206
+ // ── mergeTools ───────────────────────────────────────────────────
206
207
 
207
- describe("mergeAllowedTools", () => {
208
+ describe("mergeTools", () => {
208
209
  it("intersects when server has a list", () => {
209
210
  expect(
210
- mergeAllowedTools(["Read", "Grep", "Glob"], ["Read", "Write", "Glob"])
211
+ mergeTools(["Read", "Grep", "Glob"], ["Read", "Write", "Glob"])
211
212
  ).toEqual(["Read", "Glob"]);
212
213
  });
213
214
 
214
215
  it("passes through when server has no list", () => {
215
216
  expect(
216
- mergeAllowedTools(undefined, ["Read", "Write"])
217
+ mergeTools(undefined, ["Read", "Write"])
217
218
  ).toEqual(["Read", "Write"]);
218
219
  });
219
220
 
220
221
  it("returns empty when no overlap", () => {
221
222
  expect(
222
- mergeAllowedTools(["Read"], ["Write"])
223
+ mergeTools(["Read"], ["Write"])
223
224
  ).toEqual([]);
224
225
  });
225
226
  });
@@ -257,6 +258,52 @@ describe("validatePermissionMode", () => {
257
258
  expect(validatePermissionMode("allowEdits")).toBe("default");
258
259
  expect(validatePermissionMode("garbage")).toBe("default");
259
260
  expect(validatePermissionMode("")).toBe("default");
261
+ expect(validatePermissionMode("auto")).toBe("default");
262
+ });
263
+ });
264
+
265
+ // ── narrowPermissionMode ──────────────────────────────────────────
266
+
267
+ describe("narrowPermissionMode", () => {
268
+ it("allows tightening from bypassPermissions to stricter modes", () => {
269
+ expect(narrowPermissionMode("bypassPermissions", "acceptEdits")).toBe("acceptEdits");
270
+ expect(narrowPermissionMode("bypassPermissions", "default")).toBe("default");
271
+ expect(narrowPermissionMode("bypassPermissions", "plan")).toBe("plan");
272
+ expect(narrowPermissionMode("bypassPermissions", "dontAsk")).toBe("dontAsk");
273
+ });
274
+
275
+ it("allows tightening from acceptEdits to stricter modes", () => {
276
+ expect(narrowPermissionMode("acceptEdits", "default")).toBe("default");
277
+ expect(narrowPermissionMode("acceptEdits", "plan")).toBe("plan");
278
+ expect(narrowPermissionMode("acceptEdits", "dontAsk")).toBe("dontAsk");
279
+ });
280
+
281
+ it("dontAsk is the strictest — rejects all loosening", () => {
282
+ expect(narrowPermissionMode("dontAsk", "bypassPermissions")).toBe("dontAsk");
283
+ expect(narrowPermissionMode("dontAsk", "acceptEdits")).toBe("dontAsk");
284
+ expect(narrowPermissionMode("dontAsk", "default")).toBe("dontAsk");
285
+ expect(narrowPermissionMode("dontAsk", "plan")).toBe("dontAsk");
286
+ expect(narrowPermissionMode("dontAsk", "dontAsk")).toBe("dontAsk");
287
+ });
288
+
289
+ it("rejects loosening — returns base unchanged", () => {
290
+ expect(narrowPermissionMode("default", "bypassPermissions")).toBe("default");
291
+ expect(narrowPermissionMode("default", "acceptEdits")).toBe("default");
292
+ expect(narrowPermissionMode("plan", "bypassPermissions")).toBe("plan");
293
+ expect(narrowPermissionMode("plan", "acceptEdits")).toBe("plan");
294
+ expect(narrowPermissionMode("plan", "default")).toBe("plan");
295
+ });
296
+
297
+ it("same mode returns same mode", () => {
298
+ expect(narrowPermissionMode("default", "default")).toBe("default");
299
+ expect(narrowPermissionMode("plan", "plan")).toBe("plan");
300
+ expect(narrowPermissionMode("bypassPermissions", "bypassPermissions")).toBe("bypassPermissions");
301
+ });
302
+
303
+ it("invalid override returns base unchanged", () => {
304
+ expect(narrowPermissionMode("default", "garbage")).toBe("default");
305
+ expect(narrowPermissionMode("bypassPermissions", "")).toBe("bypassPermissions");
306
+ expect(narrowPermissionMode("default", "auto")).toBe("default");
260
307
  });
261
308
  });
262
309
 
package/src/lib.ts CHANGED
@@ -94,7 +94,7 @@ export function isDescendantPath(
94
94
 
95
95
  // ── Tool restriction merging ───────────────────────────────────────
96
96
 
97
- export function mergeAllowedTools(
97
+ export function mergeTools(
98
98
  serverList: string[] | undefined,
99
99
  callList: string[]
100
100
  ): string[] {
@@ -121,13 +121,33 @@ export const VALID_PERM_MODES = new Set([
121
121
  "bypassPermissions",
122
122
  "plan",
123
123
  "dontAsk",
124
- "auto",
125
124
  ]);
126
125
 
127
126
  export function validatePermissionMode(mode: string): string {
128
127
  return VALID_PERM_MODES.has(mode) ? mode : "default";
129
128
  }
130
129
 
130
+ // Strictness order: most permissive → most restrictive
131
+ const PERM_STRICTNESS: Record<string, number> = {
132
+ bypassPermissions: 0,
133
+ acceptEdits: 1,
134
+ default: 2,
135
+ plan: 3,
136
+ dontAsk: 4,
137
+ };
138
+
139
+ /**
140
+ * Narrow permission mode: returns the stricter of base and override.
141
+ * Callers can tighten permissions but never loosen them.
142
+ * Returns base unchanged if override is invalid or less strict.
143
+ */
144
+ export function narrowPermissionMode(base: string, override: string): string {
145
+ if (!VALID_PERM_MODES.has(override)) return base;
146
+ const baseLevel = PERM_STRICTNESS[base] ?? 2;
147
+ const overrideLevel = PERM_STRICTNESS[override] ?? 2;
148
+ return overrideLevel >= baseLevel ? override : base;
149
+ }
150
+
131
151
  // ── Factory name derivation ────────────────────────────────────────
132
152
 
133
153
  export function deriveServerName(description: string): string {
@@ -33,15 +33,14 @@ function buildEnvFromParams(
33
33
 
34
34
  export function registerFactoryTool(
35
35
  server: McpServer,
36
- serverEntry: string
37
36
  ) {
38
37
  server.registerTool("create_claude_code_mcp", {
39
38
  description: [
40
- "Create a new specialized Claude Code agent as an MCP server.",
41
- "WHEN TO USE: user says 'create agent', 'new agent', 'set up agent',",
42
- "'configure agent', 'add a reviewer', 'add a test writer',",
43
- "'make me a code reviewer', 'I need a specialized agent', etc.",
44
- "DO NOT USE when user just wants to run a task — use the main tool for that.",
39
+ "Generate a .mcp.json config entry for a new Claude Octopus MCP server instance.",
40
+ "WHEN TO USE: user says 'octopus agent', 'octopus mcp',",
41
+ "'new octopus', 'add octopus', 'create octopus',",
42
+ "'octopus instance', 'octopus config', 'octopus server',",
43
+ "or any phrase combining 'octopus' with agent/mcp/new/add/create/config/server/setup.",
45
44
  "This is a wizard: only a description is required.",
46
45
  "Returns a ready-to-use .mcp.json config and lists all customization options.",
47
46
  "Call again with more parameters to refine.",
@@ -68,6 +67,8 @@ export function registerFactoryTool(
68
67
  settingSources: z.array(z.string()).optional(),
69
68
  mcpServers: z.record(z.string(), z.unknown()).optional(),
70
69
  betas: z.array(z.string()).optional(),
70
+ apiKey: z.string().optional().describe("Anthropic API key for this agent (leave unset to inherit)"),
71
+ oauthToken: z.string().optional().describe("Claude Code OAuth token for this agent (leave unset to inherit)"),
71
72
  }),
72
73
  }, async (params) => {
73
74
  const { description, name: nameParam, toolName: toolNameParam } = params;
@@ -91,11 +92,19 @@ export function registerFactoryTool(
91
92
  (o) => !configured.includes(o.envVar)
92
93
  );
93
94
 
95
+ const SENSITIVE_KEYS = new Set(["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]);
96
+
97
+ // Redact secrets in rendered output, keep real values in config
98
+ const displayEnv: Record<string, string> = {};
99
+ for (const [k, v] of Object.entries(env)) {
100
+ displayEnv[k] = SENSITIVE_KEYS.has(k) ? "<REDACTED>" : v;
101
+ }
102
+
94
103
  const mcpEntry = {
95
104
  [name]: {
96
- command: "node",
97
- args: [serverEntry],
98
- env,
105
+ command: "npx",
106
+ args: ["claude-octopus"],
107
+ env: displayEnv,
99
108
  },
100
109
  };
101
110
 
@@ -118,7 +127,8 @@ export function registerFactoryTool(
118
127
  for (const key of configured) {
119
128
  const opt = OPTION_CATALOG.find((o) => o.envVar === key);
120
129
  if (opt) {
121
- sections.push(`| ${opt.label} | \`${env[key]}\` |`);
130
+ const val = SENSITIVE_KEYS.has(key) ? "<REDACTED>" : env[key];
131
+ sections.push(`| ${opt.label} | \`${val}\` |`);
122
132
  }
123
133
  }
124
134
  if (configured.length === 0) {
@@ -7,9 +7,9 @@ import {
7
7
  } from "@anthropic-ai/claude-agent-sdk";
8
8
  import type { Options, InvocationOverrides } from "../types.js";
9
9
  import {
10
- isDescendantPath,
11
- mergeAllowedTools,
10
+ mergeTools,
12
11
  mergeDisallowedTools,
12
+ narrowPermissionMode,
13
13
  buildResultPayload,
14
14
  formatErrorMessage,
15
15
  } from "../lib.js";
@@ -21,13 +21,53 @@ async function runQuery(
21
21
  ): Promise<SDKResultMessage> {
22
22
  const options: Options = { ...baseOptions };
23
23
 
24
+ // Handle cwd override — accept any path, preserve agent's base access
24
25
  if (overrides.cwd) {
25
26
  const baseCwd = baseOptions.cwd || process.cwd();
26
- if (isDescendantPath(overrides.cwd, baseCwd)) {
27
- options.cwd = resolve(baseCwd, overrides.cwd);
27
+ const resolvedCwd = resolve(baseCwd, overrides.cwd);
28
+ if (resolvedCwd !== baseCwd) {
29
+ options.cwd = resolvedCwd;
30
+ // Agent's base dir becomes an additional dir so it keeps its knowledge
31
+ const dirs = new Set(options.additionalDirectories || []);
32
+ dirs.add(baseCwd);
33
+ options.additionalDirectories = [...dirs];
28
34
  }
29
35
  }
36
+
37
+ // Per-invocation additionalDirs — unions with server-level + auto-added dirs
38
+ if (overrides.additionalDirs?.length) {
39
+ const dirs = new Set(options.additionalDirectories || []);
40
+ for (const dir of overrides.additionalDirs) {
41
+ dirs.add(dir);
42
+ }
43
+ options.additionalDirectories = [...dirs];
44
+ }
45
+
46
+ // Per-invocation plugins — unions with server-level plugins
47
+ if (overrides.plugins?.length) {
48
+ const base = baseOptions.plugins || [];
49
+ const overridePaths = new Set(base.map((p) => p.path));
50
+ const merged = [...base];
51
+ for (const path of overrides.plugins) {
52
+ if (!overridePaths.has(path)) {
53
+ merged.push({ type: "local" as const, path });
54
+ overridePaths.add(path);
55
+ }
56
+ }
57
+ options.plugins = merged;
58
+ }
59
+
30
60
  if (overrides.model) options.model = overrides.model;
61
+ if (overrides.effort) options.effort = overrides.effort as Options["effort"];
62
+
63
+ // Permission mode can only tighten, never loosen
64
+ if (overrides.permissionMode) {
65
+ const base = (baseOptions.permissionMode as string) || "default";
66
+ const narrowed = narrowPermissionMode(base, overrides.permissionMode);
67
+ options.permissionMode = narrowed as Options["permissionMode"];
68
+ options.allowDangerouslySkipPermissions = narrowed === "bypassPermissions";
69
+ }
70
+
31
71
  if (overrides.maxTurns !== undefined && overrides.maxTurns > 0) {
32
72
  options.maxTurns = overrides.maxTurns;
33
73
  }
@@ -35,11 +75,9 @@ async function runQuery(
35
75
  options.maxBudgetUsd = overrides.maxBudgetUsd;
36
76
  if (overrides.resumeSessionId) options.resume = overrides.resumeSessionId;
37
77
 
38
- if (overrides.allowedTools?.length) {
39
- options.allowedTools = mergeAllowedTools(
40
- baseOptions.allowedTools,
41
- overrides.allowedTools
42
- );
78
+ if (overrides.tools?.length) {
79
+ const baseTools = Array.isArray(baseOptions.tools) ? baseOptions.tools : undefined;
80
+ options.tools = mergeTools(baseTools, overrides.tools);
43
81
  }
44
82
  if (overrides.disallowedTools?.length) {
45
83
  options.disallowedTools = mergeDisallowedTools(
@@ -125,16 +163,20 @@ export function registerQueryTools(
125
163
  prompt: z.string().describe("Task or question for Claude Code"),
126
164
  cwd: z.string().optional().describe("Working directory (overrides CLAUDE_CWD)"),
127
165
  model: z.string().optional().describe('Model override (e.g. "sonnet", "opus", "haiku")'),
128
- allowedTools: z.array(z.string()).optional().describe("Tool whitelist override"),
129
- disallowedTools: z.array(z.string()).optional().describe("Tool blacklist override"),
166
+ tools: z.array(z.string()).optional().describe("Restrict available tools to this list (intersects with server-level restriction)"),
167
+ disallowedTools: z.array(z.string()).optional().describe("Additional tools to block (unions with server-level blacklist)"),
168
+ additionalDirs: z.array(z.string()).optional().describe("Extra directories the agent can access for this invocation"),
169
+ plugins: z.array(z.string()).optional().describe("Additional plugin paths to load for this invocation (unions with server-level plugins)"),
170
+ effort: z.enum(["low", "medium", "high", "max"]).optional().describe("Thinking effort override"),
171
+ permissionMode: z.enum(["default", "acceptEdits", "plan"]).optional().describe("Permission mode override (can only tighten, never loosen)"),
130
172
  maxTurns: z.number().int().positive().optional().describe("Max conversation turns"),
131
- maxBudgetUsd: z.number().optional().describe("Max spend in USD"),
173
+ maxBudgetUsd: z.number().positive().optional().describe("Max spend in USD"),
132
174
  systemPrompt: z.string().optional().describe("Additional system prompt (appended to server default)"),
133
175
  }),
134
- }, async ({ prompt, cwd, model, allowedTools, disallowedTools, maxTurns, maxBudgetUsd, systemPrompt }) => {
176
+ }, async ({ prompt, cwd, model, tools, disallowedTools, additionalDirs, plugins, effort, permissionMode, maxTurns, maxBudgetUsd, systemPrompt }) => {
135
177
  try {
136
178
  const result = await runQuery(prompt, {
137
- cwd, model, allowedTools, disallowedTools, maxTurns, maxBudgetUsd, systemPrompt,
179
+ cwd, model, tools, disallowedTools, additionalDirs, plugins, effort, permissionMode, maxTurns, maxBudgetUsd, systemPrompt,
138
180
  }, baseOptions);
139
181
  return formatResult(result);
140
182
  } catch (error) {
@@ -155,7 +197,7 @@ export function registerQueryTools(
155
197
  cwd: z.string().optional().describe("Working directory override"),
156
198
  model: z.string().optional().describe("Model override"),
157
199
  maxTurns: z.number().int().positive().optional().describe("Max conversation turns"),
158
- maxBudgetUsd: z.number().optional().describe("Max spend in USD"),
200
+ maxBudgetUsd: z.number().positive().optional().describe("Max spend in USD"),
159
201
  }),
160
202
  }, async ({ session_id, prompt, cwd, model, maxTurns, maxBudgetUsd }) => {
161
203
  try {
package/src/types.ts CHANGED
@@ -3,8 +3,12 @@ import type { Options } from "@anthropic-ai/claude-agent-sdk";
3
3
  export interface InvocationOverrides {
4
4
  cwd?: string;
5
5
  model?: string;
6
- allowedTools?: string[];
6
+ tools?: string[];
7
7
  disallowedTools?: string[];
8
+ additionalDirs?: string[];
9
+ plugins?: string[];
10
+ effort?: string;
11
+ permissionMode?: string;
8
12
  maxTurns?: number;
9
13
  maxBudgetUsd?: number;
10
14
  systemPrompt?: string;