@ottocode/sdk 0.1.304 → 0.1.306

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ottocode/sdk",
3
- "version": "0.1.304",
3
+ "version": "0.1.306",
4
4
  "description": "AI agent SDK for building intelligent assistants - tree-shakable and comprehensive",
5
5
  "author": "nitishxyz",
6
6
  "license": "MIT",
@@ -105,7 +105,7 @@
105
105
  "@ai-sdk/provider": "3.0.10",
106
106
  "@ai-sdk/provider-utils": "4.0.27",
107
107
  "@ai-sdk/xai": "3.0.93",
108
- "@ff-labs/fff-bun": "0.9.3",
108
+ "@ff-labs/fff-bun": "0.9.4",
109
109
  "@modelcontextprotocol/sdk": "1.27.1",
110
110
  "@openauthjs/openauth": "0.4.3",
111
111
  "@openrouter/ai-sdk-provider": "1.5.4",
@@ -55,6 +55,7 @@ export { buildTerminalTool } from './tools/builtin/terminal';
55
55
  export {
56
56
  buildLazyToolsRecord,
57
57
  buildLoadFirstPartyToolsTool,
58
+ buildMCPManagerTool,
58
59
  buildSimulatorTool,
59
60
  getLazyToolDefinitions,
60
61
  } from './tools/lazy/index';
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path';
2
- import { spawn, execSync } from 'node:child_process';
2
+ import { spawn, execFileSync } from 'node:child_process';
3
3
  import { homedir } from 'node:os';
4
4
  import {
5
5
  clearCachedBinaries,
@@ -10,7 +10,10 @@ import { fileExists, isExecutable } from './bin-manager/filesystem.ts';
10
10
  import { getAgiBinDir, getBinaryFileName } from './bin-manager/paths.ts';
11
11
  import { extractFromVendor } from './bin-manager/vendor.ts';
12
12
 
13
- let cachedLoginPath: string | null = null;
13
+ let cachedLoginPath: {
14
+ key: string;
15
+ path: string | null;
16
+ } | null = null;
14
17
 
15
18
  export { getAgiBinDir } from './bin-manager/paths.ts';
16
19
 
@@ -64,15 +67,61 @@ export function clearBinaryCache(): void {
64
67
  clearCachedBinaries();
65
68
  }
66
69
 
67
- function getLoginShellPath(): string | null {
68
- if (cachedLoginPath !== null) return cachedLoginPath;
70
+ export function getUserShell(): string {
71
+ if (process.platform === 'win32') return process.env.COMSPEC || 'cmd.exe';
72
+ return process.env.SHELL || '/bin/bash';
73
+ }
74
+
75
+ function getShellRcBootstrap(shell: string): string {
76
+ const shellName = shell.split('/').pop() || '';
77
+ if (shellName.includes('zsh')) {
78
+ return 'if [ -f "$HOME/.zshrc" ]; then . "$HOME/.zshrc"; fi';
79
+ }
80
+ if (shellName.includes('bash')) {
81
+ return 'if [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi';
82
+ }
83
+ return '';
84
+ }
85
+
86
+ function getInteractiveShellFlag(shell: string): string {
87
+ const shellName = shell.split('/').pop() || '';
88
+ if (shellName.includes('bash')) return '-ic';
89
+ return '-ilc';
90
+ }
69
91
 
92
+ export function getShellExecutionConfig(cmd: string): {
93
+ command: string;
94
+ args: string[];
95
+ env: NodeJS.ProcessEnv;
96
+ } {
97
+ const env = { ...process.env, PATH: getAugmentedPath() };
70
98
  if (process.platform === 'win32') {
71
- cachedLoginPath = process.env.PATH || '';
72
- return cachedLoginPath;
99
+ return {
100
+ command: getUserShell(),
101
+ args: ['/d', '/s', '/c', cmd],
102
+ env,
103
+ };
73
104
  }
74
105
 
106
+ const command = getUserShell();
107
+ return {
108
+ command,
109
+ args: [getInteractiveShellFlag(command), 'eval "$OTTO_SHELL_COMMAND"'],
110
+ env: { ...env, OTTO_SHELL_COMMAND: cmd },
111
+ };
112
+ }
113
+
114
+ function getLoginShellPath(): string | null {
75
115
  const home = process.env.HOME || homedir();
116
+ const userShell = getUserShell();
117
+ const cacheKey = [home, userShell, process.env.PATH || ''].join('\0');
118
+ if (cachedLoginPath?.key === cacheKey) return cachedLoginPath.path;
119
+
120
+ if (process.platform === 'win32') {
121
+ cachedLoginPath = { key: cacheKey, path: process.env.PATH || '' };
122
+ return cachedLoginPath.path;
123
+ }
124
+
76
125
  const shellCandidates = [
77
126
  process.env.SHELL,
78
127
  '/bin/zsh',
@@ -82,21 +131,28 @@ function getLoginShellPath(): string | null {
82
131
 
83
132
  for (const shell of shellCandidates) {
84
133
  try {
85
- const result = execSync(`${shell} -ilc 'echo "___PATH___:$PATH"'`, {
134
+ const rcBootstrap = getShellRcBootstrap(shell);
135
+ const pathCommand = `${rcBootstrap ? `${rcBootstrap}\n` : ''}echo "___PATH___:$PATH"`;
136
+ const result = execFileSync(shell, ['-ilc', pathCommand], {
86
137
  timeout: 5000,
87
138
  stdio: ['ignore', 'pipe', 'ignore'],
88
- env: { HOME: home, USER: process.env.USER || '', SHELL: shell },
139
+ env: {
140
+ ...process.env,
141
+ HOME: home,
142
+ USER: process.env.USER || '',
143
+ SHELL: shell,
144
+ },
89
145
  });
90
146
  const output = result.toString();
91
147
  const match = output.match(/___PATH___:(.*)/);
92
148
  if (match?.[1]?.trim()) {
93
- cachedLoginPath = match[1].trim();
94
- return cachedLoginPath;
149
+ cachedLoginPath = { key: cacheKey, path: match[1].trim() };
150
+ return cachedLoginPath.path;
95
151
  }
96
152
  } catch {}
97
153
  }
98
154
 
99
- cachedLoginPath = null;
155
+ cachedLoginPath = { key: cacheKey, path: null };
100
156
  return null;
101
157
  }
102
158
 
@@ -26,7 +26,10 @@ export const IGNORE_PATTERNS: string[] = [
26
26
  ];
27
27
 
28
28
  export function defaultIgnoreGlobs(extra?: string[]): string[] {
29
- const base = IGNORE_PATTERNS.map((p) => `!${p}*`);
29
+ const base = IGNORE_PATTERNS.flatMap((p) => {
30
+ const pattern = p.replace(/\/$/, '');
31
+ return [`${pattern}/**`, `**/${pattern}/**`];
32
+ });
30
33
  if (Array.isArray(extra) && extra.length) return base.concat(extra);
31
34
  return base;
32
35
  }
@@ -3,7 +3,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { z } from 'zod/v3';
5
5
  import DESCRIPTION from './shell.txt' with { type: 'text' };
6
- import { getAugmentedPath } from '../bin-manager.ts';
6
+ import { getShellExecutionConfig } from '../bin-manager.ts';
7
7
  import { createToolError, type ToolResponse } from '../error.ts';
8
8
  import { injectCoAuthorIntoGitCommit } from './git-identity.ts';
9
9
 
@@ -47,17 +47,6 @@ export type ShellOutputMode = 'auto' | 'full' | 'tail';
47
47
  const DEFAULT_TAIL_LINES = 100;
48
48
  const DEFAULT_MAX_OUTPUT_BYTES = 128_000;
49
49
 
50
- function looksLikeRepositorySearchCommand(cmd: string): boolean {
51
- const normalized = cmd.replace(/\s+/g, ' ').trim();
52
- return (
53
- /(^|[;&|()]\s*)(rg|ripgrep)(\s|$)/.test(normalized) ||
54
- /(^|[;&|()]\s*)grep\s+.*\s(-R|-r|--recursive)(\s|$)/.test(normalized) ||
55
- /(^|[;&|()]\s*)find\s+(\.|\.\/|\$PWD|\S*\/).*(-name|-iname|-path|-type)/.test(
56
- normalized,
57
- )
58
- );
59
- }
60
-
61
50
  type CompactTextResult = {
62
51
  text: string;
63
52
  truncated: boolean;
@@ -147,7 +136,9 @@ const shellInputSchema = z
147
136
  .object({
148
137
  cmd: z
149
138
  .string()
150
- .describe('Non-interactive shell command to run (bash -c <cmd>)'),
139
+ .describe(
140
+ 'Non-interactive shell command to run using the user shell with login/interactive startup loaded',
141
+ ),
151
142
  cwd: z
152
143
  .string()
153
144
  .default('.')
@@ -231,18 +222,6 @@ export function buildShellTool(projectRoot: string): {
231
222
  });
232
223
  }
233
224
 
234
- if (looksLikeRepositorySearchCommand(cmd)) {
235
- return createToolError(
236
- 'This looks like repository discovery. Use the search tool for content/code search or glob for filename/path discovery.',
237
- 'validation',
238
- {
239
- cmd,
240
- suggestion:
241
- 'Use search for file contents, or glob for file and path discovery.',
242
- },
243
- );
244
- }
245
-
246
225
  const absCwd = resolveSafePath(projectRoot, cwd || '.');
247
226
  const finalCmd = injectCoAuthorIntoGitCommit(cmd);
248
227
  const shellExecutor = shellExecutorContext.getStore();
@@ -261,11 +240,11 @@ export function buildShellTool(projectRoot: string): {
261
240
  ) as AsyncIterable<ShellStreamChunk> | ShellResult;
262
241
  }
263
242
 
264
- const proc = spawn(finalCmd, {
243
+ const shellConfig = getShellExecutionConfig(finalCmd);
244
+ const proc = spawn(shellConfig.command, shellConfig.args, {
265
245
  cwd: absCwd,
266
- shell: true,
267
246
  stdio: ['ignore', 'pipe', 'pipe'],
268
- env: { ...process.env, PATH: getAugmentedPath() },
247
+ env: shellConfig.env,
269
248
  detached: true,
270
249
  });
271
250
 
@@ -1,4 +1,4 @@
1
- - Execute a non-interactive shell command using `bash -lc`
1
+ - Execute a non-interactive shell command using the user's shell with login/interactive startup loaded
2
2
  - Returns `stdout`, `stderr`, and `exitCode`
3
3
  - `cwd` is relative to the project root and sandboxed within it
4
4
 
@@ -10,3 +10,4 @@ export {
10
10
  type LazyToolDefinition,
11
11
  } from './registry.ts';
12
12
  export { buildSimulatorTool } from './simulator.ts';
13
+ export { buildMCPManagerTool } from './mcp-manager.ts';
@@ -0,0 +1,385 @@
1
+ import { tool, type Tool } from 'ai';
2
+ import { z } from 'zod/v3';
3
+ import { getGlobalConfigDir } from '../../../../config/src/paths.ts';
4
+ import {
5
+ addMCPServerToConfig,
6
+ getMCPManager,
7
+ initializeMCP,
8
+ loadMCPConfig,
9
+ removeMCPServerFromConfig,
10
+ } from '../../mcp/lifecycle.ts';
11
+ import type {
12
+ MCPScope,
13
+ MCPServerConfig,
14
+ MCPServerStatus,
15
+ MCPTransport,
16
+ } from '../../mcp/types.ts';
17
+ import { createToolError } from '../error.ts';
18
+
19
+ const mcpActions = [
20
+ 'list',
21
+ 'add',
22
+ 'update',
23
+ 'remove',
24
+ 'enable',
25
+ 'disable',
26
+ ] as const;
27
+
28
+ type MCPManagerAction = (typeof mcpActions)[number];
29
+
30
+ type ServerSummary = {
31
+ name: string;
32
+ transport: MCPTransport;
33
+ scope: MCPScope;
34
+ disabled: boolean;
35
+ command?: string;
36
+ args?: string[];
37
+ url?: string;
38
+ connected: boolean;
39
+ tools: string[];
40
+ error?: string;
41
+ };
42
+
43
+ function summarizeServer(
44
+ server: MCPServerConfig,
45
+ status?: MCPServerStatus,
46
+ ): ServerSummary {
47
+ return {
48
+ name: server.name,
49
+ transport: server.transport ?? 'stdio',
50
+ scope: server.scope ?? 'global',
51
+ disabled: server.disabled ?? false,
52
+ ...(server.command ? { command: server.command } : {}),
53
+ ...(server.args?.length ? { args: server.args } : {}),
54
+ ...(server.url ? { url: server.url } : {}),
55
+ connected: status?.connected ?? false,
56
+ tools: status?.tools ?? [],
57
+ ...(status?.error ? { error: status.error } : {}),
58
+ };
59
+ }
60
+
61
+ async function getStatuses(): Promise<MCPServerStatus[]> {
62
+ const manager = getMCPManager();
63
+ if (!manager) return [];
64
+ try {
65
+ return await manager.getStatusAsync();
66
+ } catch {
67
+ return [];
68
+ }
69
+ }
70
+
71
+ async function startServer(
72
+ projectRoot: string,
73
+ server: MCPServerConfig,
74
+ ): Promise<MCPServerStatus | undefined> {
75
+ let manager = getMCPManager();
76
+ if (!manager) {
77
+ manager = await initializeMCP({ servers: [] }, projectRoot);
78
+ }
79
+ if (!manager.started) {
80
+ manager.setProjectRoot(projectRoot);
81
+ }
82
+ await manager.restartServer(server);
83
+ const statuses = await manager.getStatusAsync();
84
+ return statuses.find((status) => status.name === server.name);
85
+ }
86
+
87
+ async function stopServer(name: string): Promise<void> {
88
+ const manager = getMCPManager();
89
+ if (!manager) return;
90
+ await manager.stopServer(name);
91
+ }
92
+
93
+ type MCPManagerInput = {
94
+ action: MCPManagerAction;
95
+ name?: string;
96
+ scope?: MCPScope;
97
+ transport?: MCPTransport;
98
+ command?: string;
99
+ args?: string[];
100
+ env?: Record<string, string>;
101
+ url?: string;
102
+ headers?: Record<string, string>;
103
+ start?: boolean;
104
+ };
105
+
106
+ function validateServerInput(
107
+ input: MCPManagerInput,
108
+ existing?: MCPServerConfig,
109
+ ):
110
+ | { ok: true; server: MCPServerConfig }
111
+ | { ok: false; error: ReturnType<typeof createToolError> } {
112
+ const name = input.name?.trim();
113
+ if (!name) {
114
+ return {
115
+ ok: false,
116
+ error: createToolError('name is required', 'validation', {
117
+ parameter: 'name',
118
+ }),
119
+ };
120
+ }
121
+
122
+ const transport: MCPTransport =
123
+ input.transport ?? existing?.transport ?? 'stdio';
124
+ const command = input.command ?? existing?.command;
125
+ const url = input.url ?? existing?.url;
126
+
127
+ if (transport === 'stdio' && !command) {
128
+ return {
129
+ ok: false,
130
+ error: createToolError(
131
+ 'command is required for stdio transport',
132
+ 'validation',
133
+ { parameter: 'command' },
134
+ ),
135
+ };
136
+ }
137
+ if (transport === 'stdio' && command && /^https?:\/\//i.test(command)) {
138
+ return {
139
+ ok: false,
140
+ error: createToolError(
141
+ 'stdio transport requires a local command, not a URL. Use http or sse transport for remote servers.',
142
+ 'validation',
143
+ { parameter: 'command', value: command },
144
+ ),
145
+ };
146
+ }
147
+ if ((transport === 'http' || transport === 'sse') && !url) {
148
+ return {
149
+ ok: false,
150
+ error: createToolError(
151
+ 'url is required for http/sse transport',
152
+ 'validation',
153
+ { parameter: 'url' },
154
+ ),
155
+ };
156
+ }
157
+
158
+ const scope: MCPScope =
159
+ input.scope ?? existing?.scope ?? ('global' as MCPScope);
160
+ const args = input.args ?? existing?.args;
161
+ const env = input.env ?? existing?.env;
162
+ const headers = input.headers ?? existing?.headers;
163
+
164
+ const server: MCPServerConfig = {
165
+ name,
166
+ transport,
167
+ scope,
168
+ ...(command ? { command } : {}),
169
+ ...(args?.length ? { args } : {}),
170
+ ...(env && Object.keys(env).length ? { env } : {}),
171
+ ...(url ? { url } : {}),
172
+ ...(headers && Object.keys(headers).length ? { headers } : {}),
173
+ ...(existing?.oauth ? { oauth: existing.oauth } : {}),
174
+ ...(existing?.cwd ? { cwd: existing.cwd } : {}),
175
+ ...(existing?.disabled ? { disabled: existing.disabled } : {}),
176
+ };
177
+
178
+ return { ok: true, server };
179
+ }
180
+
181
+ /**
182
+ * Build the `mcp_manager` lazy tool which lets an agent list, add, update,
183
+ * remove, enable, or disable MCP servers in the otto project (.otto/config.json)
184
+ * or global (~/.config/otto/config.json) configuration.
185
+ */
186
+ export function buildMCPManagerTool(projectRoot: string): {
187
+ name: string;
188
+ tool: Tool;
189
+ } {
190
+ return {
191
+ name: 'mcp_manager',
192
+ tool: tool({
193
+ description: `Manage otto MCP (Model Context Protocol) servers.
194
+
195
+ Actions:
196
+ - list: list configured MCP servers with scope, disabled flag, and connection status
197
+ - add: add a new MCP server (upserts if the name already exists)
198
+ - update: update an existing MCP server; omitted fields keep their current values
199
+ - remove: stop and delete an MCP server from config
200
+ - enable: clear the disabled flag and start the server
201
+ - disable: set the disabled flag and stop the server
202
+
203
+ Scope:
204
+ - "project" writes to <project>/.otto/config.json (shared with the repo)
205
+ - "global" writes to the user's global otto config (default)
206
+
207
+ Transports:
208
+ - stdio requires "command" (plus optional "args"/"env")
209
+ - http/sse require "url" (plus optional "headers")
210
+
211
+ Set "start": true on add/update to start the server immediately. Newly started servers expose their tools via load_mcp_tools on the next session turn.`,
212
+ inputSchema: z.object({
213
+ action: z.enum(mcpActions).describe('Operation to perform.'),
214
+ name: z
215
+ .string()
216
+ .optional()
217
+ .describe('MCP server name (required for all actions except list).'),
218
+ scope: z
219
+ .enum(['global', 'project'])
220
+ .optional()
221
+ .describe(
222
+ 'Config scope: "project" (.otto/config.json) or "global" (default for new servers).',
223
+ ),
224
+ transport: z
225
+ .enum(['stdio', 'http', 'sse'])
226
+ .optional()
227
+ .describe('Transport type (default stdio).'),
228
+ command: z
229
+ .string()
230
+ .optional()
231
+ .describe('Executable for stdio transport (e.g. "bunx").'),
232
+ args: z
233
+ .array(z.string())
234
+ .optional()
235
+ .describe('Arguments for the stdio command.'),
236
+ env: z
237
+ .record(z.string())
238
+ .optional()
239
+ .describe('Environment variables for the stdio command.'),
240
+ url: z
241
+ .string()
242
+ .optional()
243
+ .describe('Server URL for http/sse transport.'),
244
+ headers: z
245
+ .record(z.string())
246
+ .optional()
247
+ .describe('HTTP headers for http/sse transport.'),
248
+ start: z
249
+ .boolean()
250
+ .optional()
251
+ .describe('Start the server immediately after add/update.'),
252
+ }),
253
+ execute: async (input: MCPManagerInput) => {
254
+ const globalConfigDir = getGlobalConfigDir();
255
+ const action = input.action;
256
+
257
+ if (action === 'list') {
258
+ const config = await loadMCPConfig(projectRoot, globalConfigDir);
259
+ const statuses = await getStatuses();
260
+ return {
261
+ ok: true,
262
+ servers: config.servers.map((server) =>
263
+ summarizeServer(
264
+ server,
265
+ statuses.find((status) => status.name === server.name),
266
+ ),
267
+ ),
268
+ };
269
+ }
270
+
271
+ const name = input.name?.trim();
272
+ if (!name) {
273
+ return createToolError(
274
+ `name is required for action "${action}"`,
275
+ 'validation',
276
+ { parameter: 'name' },
277
+ );
278
+ }
279
+
280
+ const config = await loadMCPConfig(projectRoot, globalConfigDir);
281
+ const existing = config.servers.find((server) => server.name === name);
282
+
283
+ if (action === 'add' || action === 'update') {
284
+ if (action === 'update' && !existing) {
285
+ return createToolError(
286
+ `MCP server "${name}" not found`,
287
+ 'not_found',
288
+ { parameter: 'name', value: name },
289
+ );
290
+ }
291
+ const validated = validateServerInput(input, existing);
292
+ if (!validated.ok) return validated.error;
293
+ const server = validated.server;
294
+
295
+ if (existing && existing.scope !== server.scope) {
296
+ await removeMCPServerFromConfig(projectRoot, name, globalConfigDir);
297
+ }
298
+ await addMCPServerToConfig(projectRoot, server, globalConfigDir);
299
+
300
+ if (input.start) {
301
+ try {
302
+ const status = await startServer(projectRoot, server);
303
+ return {
304
+ ok: true,
305
+ action,
306
+ server: summarizeServer(server, status),
307
+ };
308
+ } catch (err) {
309
+ const msg = err instanceof Error ? err.message : String(err);
310
+ return {
311
+ ok: true,
312
+ action,
313
+ server: summarizeServer(server),
314
+ startError: msg,
315
+ };
316
+ }
317
+ }
318
+ return { ok: true, action, server: summarizeServer(server) };
319
+ }
320
+
321
+ if (!existing) {
322
+ return createToolError(
323
+ `MCP server "${name}" not found`,
324
+ 'not_found',
325
+ { parameter: 'name', value: name },
326
+ );
327
+ }
328
+
329
+ if (action === 'remove') {
330
+ try {
331
+ await stopServer(name);
332
+ } catch {}
333
+ const removed = await removeMCPServerFromConfig(
334
+ projectRoot,
335
+ name,
336
+ globalConfigDir,
337
+ );
338
+ if (!removed) {
339
+ return createToolError(
340
+ `MCP server "${name}" not found in any config file`,
341
+ 'not_found',
342
+ { parameter: 'name', value: name },
343
+ );
344
+ }
345
+ return { ok: true, action, name };
346
+ }
347
+
348
+ if (action === 'enable') {
349
+ const server: MCPServerConfig = { ...existing, disabled: false };
350
+ await addMCPServerToConfig(projectRoot, server, globalConfigDir);
351
+ try {
352
+ const status = await startServer(projectRoot, server);
353
+ return {
354
+ ok: true,
355
+ action,
356
+ server: summarizeServer(server, status),
357
+ };
358
+ } catch (err) {
359
+ const msg = err instanceof Error ? err.message : String(err);
360
+ return {
361
+ ok: true,
362
+ action,
363
+ server: summarizeServer(server),
364
+ startError: msg,
365
+ };
366
+ }
367
+ }
368
+
369
+ if (action === 'disable') {
370
+ const server: MCPServerConfig = { ...existing, disabled: true };
371
+ await addMCPServerToConfig(projectRoot, server, globalConfigDir);
372
+ try {
373
+ await stopServer(name);
374
+ } catch {}
375
+ return { ok: true, action, server: summarizeServer(server) };
376
+ }
377
+
378
+ return createToolError(`Unknown action "${action}"`, 'validation', {
379
+ parameter: 'action',
380
+ value: action,
381
+ });
382
+ },
383
+ }),
384
+ };
385
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Tool } from 'ai';
2
2
  import { buildCopyAttachmentTool } from '../builtin/fs/copy-attachment.ts';
3
3
  import { buildReadImageTool } from '../builtin/fs/read-image.ts';
4
+ import { buildMCPManagerTool } from './mcp-manager.ts';
4
5
  import { buildSimulatorTool } from './simulator.ts';
5
6
  import { buildLoadToolsTool, type LazyToolBrief } from './load-tools.ts';
6
7
 
@@ -28,6 +29,12 @@ export function getLazyToolDefinitions(): LazyToolDefinition[] {
28
29
  'Copy an original uploaded chat attachment into the project without recompression.',
29
30
  build: buildCopyAttachmentTool,
30
31
  },
32
+ {
33
+ name: 'mcp_manager',
34
+ description:
35
+ 'Manage otto MCP servers: list, add, update, remove, enable, or disable servers in project (.otto/config.json) or global config.',
36
+ build: buildMCPManagerTool,
37
+ },
31
38
  ];
32
39
  }
33
40
 
@@ -1,6 +1,5 @@
1
1
  import { tool, type Tool } from 'ai';
2
2
  import { z } from 'zod/v3';
3
- import { finishTool } from './builtin/finish.ts';
4
3
  import { buildFsTools } from './builtin/fs/index.ts';
5
4
  import { buildGitTools } from './builtin/git.ts';
6
5
  import { progressUpdateTool } from './builtin/progress.ts';
@@ -148,7 +147,6 @@ async function discoverStaticProjectTools(
148
147
  for (const { name, tool } of buildGitTools(projectRoot))
149
148
  tools.set(name, tool);
150
149
  // Built-ins
151
- tools.set('finish', finishTool);
152
150
  tools.set('progress_update', progressUpdateTool);
153
151
  const shell = buildShellTool(projectRoot);
154
152
  tools.set(shell.name, shell.tool);
package/src/index.ts CHANGED
@@ -310,6 +310,7 @@ export { buildGitTools } from './core/src/index.ts';
310
310
  export {
311
311
  buildLazyToolsRecord,
312
312
  buildLoadFirstPartyToolsTool,
313
+ buildMCPManagerTool,
313
314
  buildSimulatorTool,
314
315
  getLazyToolDefinitions,
315
316
  } from './core/src/index.ts';
@@ -2,7 +2,7 @@ You help with coding and build tasks.
2
2
 
3
3
  - Be precise and practical. Inspect before editing; prefer small, targeted diffs.
4
4
  - Keep tool inputs short; avoid long prose inside tool parameters.
5
- - Stream a short summary of what you did, then call `finish`.
5
+ - Stream a short summary of what you did, then stop.
6
6
 
7
7
  ## Editing workflow
8
8
 
@@ -26,9 +26,8 @@ When instructions conflict, obey (highest → lowest):
26
26
 
27
27
  ## Finishing your turn
28
28
 
29
- Every response ends with a call to the `finish` tool. The answer/work you already streamed IS your final response — `finish` just signals the turn is over.
29
+ Your turn ends when you stop calling tools. The answer/work you already streamed IS your final response.
30
30
 
31
- - For questions and conversational replies: answer directly, then call `finish`. The answer IS the response; no separate summary.
32
- - For substantive work (edits, multi-tool runs): briefly describe the outcome (what changed, key files, how to verify), then call `finish`.
31
+ - For questions and conversational replies: answer directly. The answer IS the response; no separate summary.
32
+ - For substantive work (edits, multi-tool runs): briefly describe the outcome (what changed, key files, how to verify), then stop.
33
33
  - NEVER label your response with "Summary:" or similar prefixes. NEVER add a recap to trivial replies — the direct answer is sufficient.
34
- - You MUST call `finish` as your last action. Don't call it before your text response finishes streaming.
@@ -50,4 +50,4 @@ Independent operations (multiple reads, multiple searches, `git_status` + `git_d
50
50
 
51
51
  # Finishing
52
52
 
53
- Stream a short summary of what you did, then call the `finish` tool. Never call `finish` without first streaming a summary.
53
+ Stream a short summary of what you did, then stop. Your turn ends when you stop calling tools.
@@ -62,4 +62,4 @@ Independent operations (multiple reads, multiple searches, `git_status` + `git_d
62
62
 
63
63
  # Finishing
64
64
 
65
- Stream a short summary of what you did, then call the `finish` tool. Never call `finish` without first streaming a summary.
65
+ Stream a short summary of what you did, then stop. Your turn ends when you stop calling tools.
@@ -62,4 +62,4 @@ Independent operations (multiple reads, multiple searches, `git_status` + `git_d
62
62
 
63
63
  # Finishing
64
64
 
65
- Stream a short summary of what you did, then call the `finish` tool. Never call `finish` without first streaming a summary.
65
+ Stream a short summary of what you did, then stop. Your turn ends when you stop calling tools.
@@ -25,9 +25,6 @@ type OpenAIOAuthSessionState = {
25
25
  model?: string;
26
26
  status?: string;
27
27
  incompleteReason?: string;
28
- turnState?: string;
29
- installationId?: string;
30
- windowId?: string;
31
28
  };
32
29
 
33
30
  const openAIOAuthSessionState = new Map<string, OpenAIOAuthSessionState>();
@@ -214,17 +211,6 @@ function writeSessionState(sessionId: string, next: OpenAIOAuthSessionState) {
214
211
  openAIOAuthSessionState.set(sessionId, next);
215
212
  }
216
213
 
217
- function mergeSessionState(sessionId: string, next: OpenAIOAuthSessionState) {
218
- writeSessionState(sessionId, {
219
- ...readSessionState(sessionId),
220
- ...next,
221
- });
222
- }
223
-
224
- function getCodexWindowId(sessionId: string) {
225
- return `${sessionId}:0`;
226
- }
227
-
228
214
  function rewriteRequestBody(
229
215
  body: string,
230
216
  sessionId?: string,
@@ -237,17 +223,6 @@ function rewriteRequestBody(
237
223
  return { body: changed ? JSON.stringify(parsed) : body, model };
238
224
  }
239
225
 
240
- const clientMetadata =
241
- parsed.client_metadata && typeof parsed.client_metadata === 'object'
242
- ? (parsed.client_metadata as Record<string, unknown>)
243
- : {};
244
- if (clientMetadata['x-codex-installation-id'] !== CODEX_INSTALLATION_ID) {
245
- parsed.client_metadata = {
246
- ...clientMetadata,
247
- 'x-codex-installation-id': CODEX_INSTALLATION_ID,
248
- };
249
- changed = true;
250
- }
251
226
  if (typeof parsed.prompt_cache_key !== 'string') {
252
227
  parsed.prompt_cache_key = sessionId;
253
228
  changed = true;
@@ -384,9 +359,6 @@ function trackResponseEvent(data: string, sessionId?: string) {
384
359
  model: responseModel ?? prior?.model,
385
360
  status: responseStatus ?? type,
386
361
  incompleteReason,
387
- turnState: prior?.turnState,
388
- installationId: prior?.installationId,
389
- windowId: prior?.windowId,
390
362
  });
391
363
  logOpenAIOAuth(
392
364
  `tracked response event type=${type ?? 'unknown'} responseId=${responseId} session=${sessionId} status=${responseStatus ?? 'unknown'} incompleteReason=${incompleteReason ?? 'none'}`,
@@ -692,10 +664,6 @@ function buildHeaders(
692
664
  sessionId?: string,
693
665
  ): Headers {
694
666
  const headers = new Headers(init?.headers);
695
- const prior = readSessionState(sessionId);
696
- const windowId = sessionId
697
- ? (prior?.windowId ?? getCodexWindowId(sessionId))
698
- : undefined;
699
667
  headers.delete('Authorization');
700
668
  headers.delete('authorization');
701
669
  headers.set('authorization', `Bearer ${accessToken}`);
@@ -710,30 +678,10 @@ function buildHeaders(
710
678
  }
711
679
  if (sessionId) {
712
680
  headers.set('session_id', sessionId);
713
- headers.set('thread_id', sessionId);
714
- headers.set('x-codex-window-id', windowId ?? getCodexWindowId(sessionId));
715
- if (prior?.turnState) {
716
- headers.set('x-codex-turn-state', prior.turnState);
717
- }
718
681
  }
719
682
  return headers;
720
683
  }
721
684
 
722
- function trackCodexResponseHeaders(response: Response, sessionId?: string) {
723
- if (!sessionId) return;
724
- const turnState = response.headers.get('x-codex-turn-state') ?? undefined;
725
- if (!turnState) return;
726
- const windowId = getCodexWindowId(sessionId);
727
- mergeSessionState(sessionId, {
728
- turnState,
729
- installationId: CODEX_INSTALLATION_ID,
730
- windowId,
731
- });
732
- logOpenAIOAuth(
733
- `tracked x-codex-turn-state for session=${sessionId} window=${windowId}`,
734
- );
735
- }
736
-
737
685
  export function createOpenAIOAuthFetch(config: OpenAIOAuthConfig) {
738
686
  let currentOAuth = config.oauth;
739
687
 
@@ -770,9 +718,6 @@ export function createOpenAIOAuthFetch(config: OpenAIOAuthConfig) {
770
718
  model: requestModel,
771
719
  status: prior?.status,
772
720
  incompleteReason: prior?.incompleteReason,
773
- turnState: prior?.turnState,
774
- installationId: prior?.installationId,
775
- windowId: prior?.windowId,
776
721
  });
777
722
  }
778
723
  }
@@ -830,9 +775,6 @@ export function createOpenAIOAuthFetch(config: OpenAIOAuthConfig) {
830
775
  bodyCharsApprox: requestBodySize,
831
776
  model: requestModel,
832
777
  });
833
- if (isResponsesRequest) {
834
- trackCodexResponseHeaders(response, config.sessionId);
835
- }
836
778
  if (!response.ok && response.status !== 401) {
837
779
  loggerWarn('[openai-oauth] non-OK response', {
838
780
  sessionId: config.sessionId,
@@ -892,9 +834,6 @@ export function createOpenAIOAuthFetch(config: OpenAIOAuthConfig) {
892
834
  requestStartedAt: retryStartedAt,
893
835
  },
894
836
  );
895
- if (isResponsesRequest) {
896
- trackCodexResponseHeaders(retryResponse, config.sessionId);
897
- }
898
837
  loggerDebug('[openai-oauth] retry response received', {
899
838
  sessionId: config.sessionId,
900
839
  target: isResponsesRequest ? 'codex.responses' : 'other',
@@ -1,12 +0,0 @@
1
- import { z } from 'zod/v3';
2
- import { tool } from 'ai';
3
- import DESCRIPTION from './finish.txt' with { type: 'text' };
4
- import type { ToolResponse } from '../error.ts';
5
-
6
- export const finishTool = tool({
7
- description: DESCRIPTION,
8
- inputSchema: z.object({}),
9
- async execute(): Promise<ToolResponse<{ done: true }>> {
10
- return { ok: true, done: true };
11
- },
12
- });
@@ -1,15 +0,0 @@
1
- Signal the end of your turn. Call this as the LAST action of every response, after your text/work has finished streaming.
2
-
3
- ## How it works
4
-
5
- Your text response IS the final answer. `finish` is just the end-of-turn signal — it does NOT produce visible output to the user.
6
-
7
- - For questions and conversational replies: answer directly, then call `finish`. No separate summary.
8
- - For substantive work (edits, multi-tool runs): briefly describe the outcome, then call `finish`.
9
-
10
- ## Never
11
-
12
- - NEVER add a "Summary:" label (or "Result:", "Done:", etc.) to your response. The direct answer is the response.
13
- - NEVER recap trivial single-sentence answers. "15" or "Yes" is a complete reply on its own.
14
- - NEVER call `finish` before your text finishes streaming.
15
- - NEVER forget to call `finish` — without it the system hangs waiting for more output.