@ottocode/server 0.1.205 → 0.1.207

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/server",
3
- "version": "0.1.205",
3
+ "version": "0.1.207",
4
4
  "description": "HTTP API server for ottocode",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -49,8 +49,8 @@
49
49
  "typecheck": "tsc --noEmit"
50
50
  },
51
51
  "dependencies": {
52
- "@ottocode/sdk": "0.1.205",
53
- "@ottocode/database": "0.1.205",
52
+ "@ottocode/sdk": "0.1.207",
53
+ "@ottocode/database": "0.1.207",
54
54
  "drizzle-orm": "^0.44.5",
55
55
  "hono": "^4.9.9",
56
56
  "zod": "^4.1.8"
@@ -500,4 +500,139 @@ export const gitPaths = {
500
500
  },
501
501
  },
502
502
  },
503
+ '/v1/git/remotes': {
504
+ get: {
505
+ tags: ['git'],
506
+ operationId: 'getGitRemotes',
507
+ summary: 'List git remotes',
508
+ parameters: [projectQueryParam()],
509
+ responses: {
510
+ 200: {
511
+ description: 'OK',
512
+ content: {
513
+ 'application/json': {
514
+ schema: {
515
+ type: 'object',
516
+ properties: {
517
+ status: { type: 'string', enum: ['ok'] },
518
+ data: {
519
+ type: 'object',
520
+ properties: {
521
+ remotes: {
522
+ type: 'array',
523
+ items: {
524
+ type: 'object',
525
+ properties: {
526
+ name: { type: 'string' },
527
+ url: { type: 'string' },
528
+ type: { type: 'string' },
529
+ },
530
+ required: ['name', 'url', 'type'],
531
+ },
532
+ },
533
+ },
534
+ required: ['remotes'],
535
+ },
536
+ },
537
+ required: ['status', 'data'],
538
+ },
539
+ },
540
+ },
541
+ },
542
+ 400: gitErrorResponse(),
543
+ 500: gitErrorResponse(),
544
+ },
545
+ },
546
+ post: {
547
+ tags: ['git'],
548
+ operationId: 'addGitRemote',
549
+ summary: 'Add a git remote',
550
+ requestBody: {
551
+ required: true,
552
+ content: {
553
+ 'application/json': {
554
+ schema: {
555
+ type: 'object',
556
+ properties: {
557
+ project: { type: 'string' },
558
+ name: { type: 'string' },
559
+ url: { type: 'string' },
560
+ },
561
+ required: ['name', 'url'],
562
+ },
563
+ },
564
+ },
565
+ },
566
+ responses: {
567
+ 200: {
568
+ description: 'OK',
569
+ content: {
570
+ 'application/json': {
571
+ schema: {
572
+ type: 'object',
573
+ properties: {
574
+ status: { type: 'string', enum: ['ok'] },
575
+ data: {
576
+ type: 'object',
577
+ properties: {
578
+ name: { type: 'string' },
579
+ url: { type: 'string' },
580
+ },
581
+ required: ['name', 'url'],
582
+ },
583
+ },
584
+ required: ['status', 'data'],
585
+ },
586
+ },
587
+ },
588
+ },
589
+ 400: gitErrorResponse(),
590
+ 500: gitErrorResponse(),
591
+ },
592
+ },
593
+ delete: {
594
+ tags: ['git'],
595
+ operationId: 'removeGitRemote',
596
+ summary: 'Remove a git remote',
597
+ requestBody: {
598
+ required: true,
599
+ content: {
600
+ 'application/json': {
601
+ schema: {
602
+ type: 'object',
603
+ properties: {
604
+ project: { type: 'string' },
605
+ name: { type: 'string' },
606
+ },
607
+ required: ['name'],
608
+ },
609
+ },
610
+ },
611
+ },
612
+ responses: {
613
+ 200: {
614
+ description: 'OK',
615
+ content: {
616
+ 'application/json': {
617
+ schema: {
618
+ type: 'object',
619
+ properties: {
620
+ status: { type: 'string', enum: ['ok'] },
621
+ data: {
622
+ type: 'object',
623
+ properties: {
624
+ removed: { type: 'string' },
625
+ },
626
+ required: ['removed'],
627
+ },
628
+ },
629
+ required: ['status', 'data'],
630
+ },
631
+ },
632
+ },
633
+ },
634
+ 500: gitErrorResponse(),
635
+ },
636
+ },
637
+ },
503
638
  } as const;
@@ -234,6 +234,11 @@ export const schemas = {
234
234
  },
235
235
  hasChanges: { type: 'boolean' },
236
236
  hasConflicts: { type: 'boolean' },
237
+ hasUpstream: { type: 'boolean' },
238
+ remotes: {
239
+ type: 'array',
240
+ items: { type: 'string' },
241
+ },
237
242
  },
238
243
  required: [
239
244
  'branch',
@@ -245,6 +250,8 @@ export const schemas = {
245
250
  'conflicted',
246
251
  'hasChanges',
247
252
  'hasConflicts',
253
+ 'hasUpstream',
254
+ 'remotes',
248
255
  ],
249
256
  },
250
257
  GitFile: {
@@ -346,4 +346,45 @@ export function registerFilesRoutes(app: Hono) {
346
346
  return c.json({ error: serializeError(err) }, 500);
347
347
  }
348
348
  });
349
+
350
+ app.get('/v1/files/raw', async (c) => {
351
+ try {
352
+ const projectRoot = c.req.query('project') || process.cwd();
353
+ const filePath = c.req.query('path');
354
+
355
+ if (!filePath) {
356
+ return c.json({ error: 'Missing required query parameter: path' }, 400);
357
+ }
358
+
359
+ const absPath = join(projectRoot, filePath);
360
+ if (!absPath.startsWith(projectRoot)) {
361
+ return c.json({ error: 'Path traversal not allowed' }, 403);
362
+ }
363
+
364
+ const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
365
+ const mimeTypes: Record<string, string> = {
366
+ png: 'image/png',
367
+ jpg: 'image/jpeg',
368
+ jpeg: 'image/jpeg',
369
+ gif: 'image/gif',
370
+ svg: 'image/svg+xml',
371
+ webp: 'image/webp',
372
+ ico: 'image/x-icon',
373
+ bmp: 'image/bmp',
374
+ avif: 'image/avif',
375
+ };
376
+ const contentType = mimeTypes[ext] || 'application/octet-stream';
377
+
378
+ const data = await readFile(absPath);
379
+ return new Response(data, {
380
+ headers: {
381
+ 'Content-Type': contentType,
382
+ 'Cache-Control': 'no-cache',
383
+ },
384
+ });
385
+ } catch (err) {
386
+ logger.error('Files raw route error:', err);
387
+ return c.json({ error: serializeError(err) }, 500);
388
+ }
389
+ });
349
390
  }
@@ -7,6 +7,7 @@ import { registerCommitRoutes } from './commit.ts';
7
7
  import { registerPushRoute } from './push.ts';
8
8
  import { registerPullRoute } from './pull.ts';
9
9
  import { registerInitRoute } from './init.ts';
10
+ import { registerRemoteRoutes } from './remote.ts';
10
11
 
11
12
  export type { GitFile } from './types.ts';
12
13
 
@@ -19,4 +20,5 @@ export function registerGitRoutes(app: Hono) {
19
20
  registerPushRoute(app);
20
21
  registerPullRoute(app);
21
22
  registerInitRoute(app);
23
+ registerRemoteRoutes(app);
22
24
  }
@@ -0,0 +1,121 @@
1
+ import type { Hono } from 'hono';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { gitRemoteAddSchema, gitRemoteRemoveSchema } from './schemas.ts';
5
+ import { validateAndGetGitRoot } from './utils.ts';
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ export function registerRemoteRoutes(app: Hono) {
10
+ app.get('/v1/git/remotes', async (c) => {
11
+ try {
12
+ const project = c.req.query('project');
13
+ const requestedPath = project || process.cwd();
14
+
15
+ const validation = await validateAndGetGitRoot(requestedPath);
16
+ if ('error' in validation) {
17
+ return c.json(
18
+ { status: 'error', error: validation.error, code: validation.code },
19
+ 400,
20
+ );
21
+ }
22
+
23
+ const { gitRoot } = validation;
24
+
25
+ const { stdout } = await execFileAsync('git', ['remote', '-v'], {
26
+ cwd: gitRoot,
27
+ });
28
+
29
+ const remotes: { name: string; url: string; type: string }[] = [];
30
+ const seen = new Set<string>();
31
+ for (const line of stdout.trim().split('\n').filter(Boolean)) {
32
+ const match = line.match(/^(\S+)\s+(\S+)\s+\((\w+)\)$/);
33
+ if (match) {
34
+ const key = `${match[1]}:${match[3]}`;
35
+ if (!seen.has(key)) {
36
+ seen.add(key);
37
+ remotes.push({
38
+ name: match[1],
39
+ url: match[2],
40
+ type: match[3],
41
+ });
42
+ }
43
+ }
44
+ }
45
+
46
+ return c.json({ status: 'ok', data: { remotes } });
47
+ } catch (error) {
48
+ return c.json(
49
+ {
50
+ status: 'error',
51
+ error:
52
+ error instanceof Error ? error.message : 'Failed to list remotes',
53
+ },
54
+ 500,
55
+ );
56
+ }
57
+ });
58
+
59
+ app.post('/v1/git/remotes', async (c) => {
60
+ try {
61
+ const body = await c.req.json().catch(() => ({}));
62
+ const { project, name, url } = gitRemoteAddSchema.parse(body);
63
+ const requestedPath = project || process.cwd();
64
+
65
+ const validation = await validateAndGetGitRoot(requestedPath);
66
+ if ('error' in validation) {
67
+ return c.json(
68
+ { status: 'error', error: validation.error, code: validation.code },
69
+ 400,
70
+ );
71
+ }
72
+
73
+ const { gitRoot } = validation;
74
+
75
+ await execFileAsync('git', ['remote', 'add', name, url], {
76
+ cwd: gitRoot,
77
+ });
78
+
79
+ return c.json({
80
+ status: 'ok',
81
+ data: { name, url },
82
+ });
83
+ } catch (error) {
84
+ const message =
85
+ error instanceof Error ? error.message : 'Failed to add remote';
86
+ const status = message.includes('already exists') ? 400 : 500;
87
+ return c.json({ status: 'error', error: message }, status);
88
+ }
89
+ });
90
+
91
+ app.delete('/v1/git/remotes', async (c) => {
92
+ try {
93
+ const body = await c.req.json().catch(() => ({}));
94
+ const { project, name } = gitRemoteRemoveSchema.parse(body);
95
+ const requestedPath = project || process.cwd();
96
+
97
+ const validation = await validateAndGetGitRoot(requestedPath);
98
+ if ('error' in validation) {
99
+ return c.json(
100
+ { status: 'error', error: validation.error, code: validation.code },
101
+ 400,
102
+ );
103
+ }
104
+
105
+ const { gitRoot } = validation;
106
+
107
+ await execFileAsync('git', ['remote', 'remove', name], {
108
+ cwd: gitRoot,
109
+ });
110
+
111
+ return c.json({
112
+ status: 'ok',
113
+ data: { removed: name },
114
+ });
115
+ } catch (error) {
116
+ const message =
117
+ error instanceof Error ? error.message : 'Failed to remove remote';
118
+ return c.json({ status: 'error', error: message }, 500);
119
+ }
120
+ });
121
+ }
@@ -50,3 +50,14 @@ export const gitPushSchema = z.object({
50
50
  export const gitPullSchema = z.object({
51
51
  project: z.string().optional(),
52
52
  });
53
+
54
+ export const gitRemoteAddSchema = z.object({
55
+ project: z.string().optional(),
56
+ name: z.string().min(1),
57
+ url: z.string().min(1),
58
+ });
59
+
60
+ export const gitRemoteRemoveSchema = z.object({
61
+ project: z.string().optional(),
62
+ name: z.string().min(1),
63
+ });
@@ -45,6 +45,26 @@ export function registerStatusRoute(app: Hono) {
45
45
 
46
46
  const branch = await getCurrentBranch(gitRoot);
47
47
 
48
+ let hasUpstream = false;
49
+ try {
50
+ await execFileAsync(
51
+ 'git',
52
+ ['rev-parse', '--abbrev-ref', '@{upstream}'],
53
+ { cwd: gitRoot },
54
+ );
55
+ hasUpstream = true;
56
+ } catch {}
57
+
58
+ let remotes: string[] = [];
59
+ try {
60
+ const { stdout: remotesOutput } = await execFileAsync(
61
+ 'git',
62
+ ['remote'],
63
+ { cwd: gitRoot },
64
+ );
65
+ remotes = remotesOutput.trim().split('\n').filter(Boolean);
66
+ } catch {}
67
+
48
68
  const hasChanges =
49
69
  staged.length > 0 ||
50
70
  unstaged.length > 0 ||
@@ -59,6 +79,8 @@ export function registerStatusRoute(app: Hono) {
59
79
  branch,
60
80
  ahead,
61
81
  behind,
82
+ hasUpstream,
83
+ remotes,
62
84
  gitRoot,
63
85
  workingDir: requestedPath,
64
86
  staged,
package/src/routes/mcp.ts CHANGED
@@ -8,6 +8,39 @@ import {
8
8
  addMCPServerToConfig,
9
9
  removeMCPServerFromConfig,
10
10
  } from '@ottocode/sdk';
11
+ import {
12
+ authorizeCopilot,
13
+ pollForCopilotTokenOnce,
14
+ getAuth,
15
+ setAuth,
16
+ } from '@ottocode/sdk';
17
+
18
+ const GITHUB_COPILOT_HOSTS = [
19
+ 'api.githubcopilot.com',
20
+ 'copilot-proxy.githubusercontent.com',
21
+ ];
22
+
23
+ function isGitHubCopilotUrl(url?: string): boolean {
24
+ if (!url) return false;
25
+ try {
26
+ const parsed = new URL(url);
27
+ return GITHUB_COPILOT_HOSTS.some(
28
+ (h) => parsed.hostname === h || parsed.hostname.endsWith(`.${h}`),
29
+ );
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ const copilotMCPSessions = new Map<
36
+ string,
37
+ {
38
+ deviceCode: string;
39
+ interval: number;
40
+ serverName: string;
41
+ createdAt: number;
42
+ }
43
+ >();
11
44
 
12
45
  export function registerMCPRoutes(app: Hono) {
13
46
  app.get('/v1/mcp/servers', async (c) => {
@@ -30,6 +63,7 @@ export function registerMCPRoutes(app: Hono) {
30
63
  authRequired: status?.authRequired ?? false,
31
64
  authenticated: status?.authenticated ?? false,
32
65
  scope: s.scope ?? 'global',
66
+ ...(isGitHubCopilotUrl(s.url) ? { authType: 'copilot-device' } : {}),
33
67
  };
34
68
  });
35
69
 
@@ -104,6 +138,10 @@ export function registerMCPRoutes(app: Hono) {
104
138
  try {
105
139
  const manager = getMCPManager();
106
140
  if (manager) {
141
+ const config = await loadMCPConfig(projectRoot, getGlobalConfigDir());
142
+ const serverConfig = config.servers.find((s) => s.name === name);
143
+ const scope = serverConfig?.scope ?? 'global';
144
+ await manager.clearAuthData(name, scope, projectRoot);
107
145
  await manager.stopServer(name);
108
146
  }
109
147
 
@@ -144,6 +182,37 @@ export function registerMCPRoutes(app: Hono) {
144
182
  const status = (await manager.getStatusAsync()).find(
145
183
  (s) => s.name === name,
146
184
  );
185
+
186
+ if (isGitHubCopilotUrl(serverConfig.url) && !status?.connected) {
187
+ const MCP_SCOPES =
188
+ 'repo read:org read:packages gist notifications read:project security_events';
189
+ const existingAuth = await getAuth('copilot');
190
+ const hasMCPScopes =
191
+ existingAuth?.type === 'oauth' && existingAuth.scopes === MCP_SCOPES;
192
+
193
+ if (!existingAuth || existingAuth.type !== 'oauth' || !hasMCPScopes) {
194
+ const deviceData = await authorizeCopilot({ mcp: true });
195
+ const sessionId = crypto.randomUUID();
196
+ copilotMCPSessions.set(sessionId, {
197
+ deviceCode: deviceData.deviceCode,
198
+ interval: deviceData.interval,
199
+ serverName: name,
200
+ createdAt: Date.now(),
201
+ });
202
+ return c.json({
203
+ ok: true,
204
+ name,
205
+ connected: false,
206
+ authRequired: true,
207
+ authType: 'copilot-device',
208
+ sessionId,
209
+ userCode: deviceData.userCode,
210
+ verificationUri: deviceData.verificationUri,
211
+ interval: deviceData.interval,
212
+ });
213
+ }
214
+ }
215
+
147
216
  return c.json({
148
217
  ok: true,
149
218
  name,
@@ -185,6 +254,48 @@ export function registerMCPRoutes(app: Hono) {
185
254
  return c.json({ ok: false, error: `Server "${name}" not found` }, 404);
186
255
  }
187
256
 
257
+ if (isGitHubCopilotUrl(serverConfig.url)) {
258
+ try {
259
+ const MCP_SCOPES =
260
+ 'repo read:org read:packages gist notifications read:project security_events';
261
+ const existingAuth = await getAuth('copilot');
262
+ if (
263
+ existingAuth?.type === 'oauth' &&
264
+ existingAuth.refresh &&
265
+ existingAuth.scopes === MCP_SCOPES
266
+ ) {
267
+ return c.json({
268
+ ok: true,
269
+ name,
270
+ authType: 'copilot-device',
271
+ authenticated: true,
272
+ message: 'Already authenticated with MCP scopes',
273
+ });
274
+ }
275
+
276
+ const deviceData = await authorizeCopilot({ mcp: true });
277
+ const sessionId = crypto.randomUUID();
278
+ copilotMCPSessions.set(sessionId, {
279
+ deviceCode: deviceData.deviceCode,
280
+ interval: deviceData.interval,
281
+ serverName: name,
282
+ createdAt: Date.now(),
283
+ });
284
+ return c.json({
285
+ ok: true,
286
+ name,
287
+ authType: 'copilot-device',
288
+ sessionId,
289
+ userCode: deviceData.userCode,
290
+ verificationUri: deviceData.verificationUri,
291
+ interval: deviceData.interval,
292
+ });
293
+ } catch (err) {
294
+ const msg = err instanceof Error ? err.message : String(err);
295
+ return c.json({ ok: false, error: msg }, 500);
296
+ }
297
+ }
298
+
188
299
  try {
189
300
  let manager = getMCPManager();
190
301
  if (!manager) {
@@ -212,7 +323,66 @@ export function registerMCPRoutes(app: Hono) {
212
323
  app.post('/v1/mcp/servers/:name/auth/callback', async (c) => {
213
324
  const name = c.req.param('name');
214
325
  const body = await c.req.json();
215
- const { code } = body;
326
+ const { code, sessionId } = body;
327
+
328
+ if (sessionId) {
329
+ const session = copilotMCPSessions.get(sessionId);
330
+ if (!session || session.serverName !== name) {
331
+ return c.json({ ok: false, error: 'Session expired or invalid' }, 400);
332
+ }
333
+ try {
334
+ const result = await pollForCopilotTokenOnce(session.deviceCode);
335
+ if (result.status === 'complete') {
336
+ copilotMCPSessions.delete(sessionId);
337
+ await setAuth(
338
+ 'copilot',
339
+ {
340
+ type: 'oauth',
341
+ refresh: result.accessToken,
342
+ access: result.accessToken,
343
+ expires: 0,
344
+ scopes:
345
+ 'repo read:org read:packages gist notifications read:project security_events',
346
+ },
347
+ undefined,
348
+ 'global',
349
+ );
350
+ const projectRoot = process.cwd();
351
+ const config = await loadMCPConfig(projectRoot, getGlobalConfigDir());
352
+ const serverConfig = config.servers.find((s) => s.name === name);
353
+ let mcpMgr = getMCPManager();
354
+ if (serverConfig) {
355
+ if (!mcpMgr) {
356
+ mcpMgr = await initializeMCP({ servers: [] }, projectRoot);
357
+ }
358
+ await mcpMgr.restartServer(serverConfig);
359
+ }
360
+ mcpMgr = getMCPManager();
361
+ const status = mcpMgr
362
+ ? (await mcpMgr.getStatusAsync()).find((s) => s.name === name)
363
+ : undefined;
364
+ return c.json({
365
+ ok: true,
366
+ status: 'complete',
367
+ name,
368
+ connected: status?.connected ?? false,
369
+ tools: status?.tools ?? [],
370
+ });
371
+ }
372
+ if (result.status === 'pending') {
373
+ return c.json({ ok: true, status: 'pending' });
374
+ }
375
+ copilotMCPSessions.delete(sessionId);
376
+ return c.json({
377
+ ok: false,
378
+ status: 'error',
379
+ error: result.status === 'error' ? result.error : 'Unknown error',
380
+ });
381
+ } catch (err) {
382
+ const msg = err instanceof Error ? err.message : String(err);
383
+ return c.json({ ok: false, error: msg }, 500);
384
+ }
385
+ }
216
386
 
217
387
  if (!code) {
218
388
  return c.json({ ok: false, error: 'code is required' }, 400);
@@ -245,8 +415,21 @@ export function registerMCPRoutes(app: Hono) {
245
415
 
246
416
  app.get('/v1/mcp/servers/:name/auth/status', async (c) => {
247
417
  const name = c.req.param('name');
248
- const manager = getMCPManager();
418
+ const projectRoot = process.cwd();
419
+ const config = await loadMCPConfig(projectRoot, getGlobalConfigDir());
420
+ const serverConfig = config.servers.find((s) => s.name === name);
421
+
422
+ if (serverConfig && isGitHubCopilotUrl(serverConfig.url)) {
423
+ try {
424
+ const auth = await getAuth('copilot');
425
+ const authenticated = auth?.type === 'oauth' && !!auth.refresh;
426
+ return c.json({ authenticated, authType: 'copilot-device' });
427
+ } catch {
428
+ return c.json({ authenticated: false, authType: 'copilot-device' });
429
+ }
430
+ }
249
431
 
432
+ const manager = getMCPManager();
250
433
  if (!manager) {
251
434
  return c.json({ authenticated: false });
252
435
  }
@@ -261,8 +444,26 @@ export function registerMCPRoutes(app: Hono) {
261
444
 
262
445
  app.delete('/v1/mcp/servers/:name/auth', async (c) => {
263
446
  const name = c.req.param('name');
264
- const manager = getMCPManager();
447
+ const projectRoot = process.cwd();
448
+ const config = await loadMCPConfig(projectRoot, getGlobalConfigDir());
449
+ const serverConfig = config.servers.find((s) => s.name === name);
265
450
 
451
+ if (serverConfig && isGitHubCopilotUrl(serverConfig.url)) {
452
+ try {
453
+ const { removeAuth } = await import('@ottocode/sdk');
454
+ await removeAuth('copilot');
455
+ const manager = getMCPManager();
456
+ if (manager) {
457
+ await manager.stopServer(name);
458
+ }
459
+ return c.json({ ok: true, name });
460
+ } catch (err) {
461
+ const msg = err instanceof Error ? err.message : String(err);
462
+ return c.json({ ok: false, error: msg }, 500);
463
+ }
464
+ }
465
+
466
+ const manager = getMCPManager();
266
467
  if (!manager) {
267
468
  return c.json({ ok: false, error: 'No MCP manager active' }, 400);
268
469
  }
@@ -0,0 +1,69 @@
1
+ import type { Tool } from 'ai';
2
+ import { debugLog } from '../debug/index.ts';
3
+
4
+ export interface MCPPrepareStepState {
5
+ mcpToolsRecord: Record<string, Tool>;
6
+ loadedMCPTools: Set<string>;
7
+ baseToolNames: string[];
8
+ canonicalToRegistration: Record<string, string>;
9
+ loadToolRegistrationName: string;
10
+ }
11
+
12
+ export function createMCPPrepareStepState(
13
+ mcpToolsRecord: Record<string, Tool>,
14
+ baseToolNames: string[],
15
+ canonicalToRegistration: Record<string, string>,
16
+ loadToolRegistrationName: string,
17
+ ): MCPPrepareStepState {
18
+ return {
19
+ mcpToolsRecord,
20
+ loadedMCPTools: new Set(),
21
+ baseToolNames,
22
+ canonicalToRegistration,
23
+ loadToolRegistrationName,
24
+ };
25
+ }
26
+
27
+ export function buildPrepareStep(state: MCPPrepareStepState) {
28
+ return async ({
29
+ stepNumber,
30
+ steps,
31
+ }: {
32
+ stepNumber: number;
33
+ steps: unknown[];
34
+ }) => {
35
+ const previousSteps = steps as Array<{
36
+ toolCalls?: Array<{ toolName: string; input: unknown }>;
37
+ toolResults?: Array<{ toolName: string; output: unknown }>;
38
+ }>;
39
+
40
+ for (const step of previousSteps) {
41
+ if (!step.toolCalls) continue;
42
+ for (const call of step.toolCalls) {
43
+ if (call.toolName !== state.loadToolRegistrationName) continue;
44
+ const result = (step.toolResults ?? []).find(
45
+ (r) => r.toolName === state.loadToolRegistrationName,
46
+ );
47
+ const output = result?.output as { loaded?: string[] } | undefined;
48
+ if (!output?.loaded) continue;
49
+ for (const canonicalName of output.loaded) {
50
+ const regName =
51
+ state.canonicalToRegistration[canonicalName] ?? canonicalName;
52
+ if (!state.loadedMCPTools.has(regName)) {
53
+ state.loadedMCPTools.add(regName);
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ const activeTools = [...state.baseToolNames, ...state.loadedMCPTools];
60
+
61
+ if (state.loadedMCPTools.size > 0) {
62
+ debugLog(
63
+ `[MCP prepareStep] step=${stepNumber}, active MCP tools: ${[...state.loadedMCPTools].join(', ')}`,
64
+ );
65
+ }
66
+
67
+ return { activeTools };
68
+ };
69
+ }
@@ -8,6 +8,7 @@ import { resolveModel } from '../provider/index.ts';
8
8
  import { resolveAgentConfig } from './registry.ts';
9
9
  import { composeSystemPrompt } from '../prompt/builder.ts';
10
10
  import { discoverProjectTools } from '@ottocode/sdk';
11
+ import type { Tool } from 'ai';
11
12
  import { adaptTools } from '../../tools/adapter.ts';
12
13
  import { buildDatabaseTools } from '../../tools/database/index.ts';
13
14
  import { debugLog, time, isDebugEnabled } from '../debug/index.ts';
@@ -39,6 +40,7 @@ export interface SetupResult {
39
40
  providerOptions: Record<string, unknown>;
40
41
  needsSpoof: boolean;
41
42
  isOpenAIOAuth: boolean;
43
+ mcpToolsRecord: Record<string, Tool>;
42
44
  }
43
45
 
44
46
  const THINKING_BUDGET = 16000;
@@ -143,7 +145,9 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
143
145
  }
144
146
 
145
147
  const toolsTimer = time('runner:discoverTools');
146
- const allTools = await discoverProjectTools(cfg.projectRoot);
148
+ const discovered = await discoverProjectTools(cfg.projectRoot);
149
+ const allTools = discovered.tools;
150
+ const { mcpToolsRecord } = discovered;
147
151
 
148
152
  if (opts.agent === 'research') {
149
153
  const currentSession = sessionRows[0];
@@ -151,19 +155,23 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
151
155
 
152
156
  const dbTools = buildDatabaseTools(cfg.projectRoot, parentSessionId);
153
157
  for (const dt of dbTools) {
154
- allTools.push(dt);
158
+ discovered.tools.push(dt);
155
159
  }
156
160
  debugLog(
157
161
  `[tools] Added ${dbTools.length} database tools for research agent (parent: ${parentSessionId ?? 'none'})`,
158
162
  );
159
163
  }
160
164
 
161
- toolsTimer.end({ count: allTools.length });
165
+ toolsTimer.end({
166
+ count: allTools.length + Object.keys(mcpToolsRecord).length,
167
+ });
162
168
  const allowedNames = new Set([...(agentCfg.tools || []), 'finish']);
163
169
  const gated = allTools.filter(
164
- (tool) => allowedNames.has(tool.name) || tool.name.includes('__'),
170
+ (tool) => allowedNames.has(tool.name) || tool.name === 'load_mcp_tools',
171
+ );
172
+ debugLog(
173
+ `[tools] ${gated.length} gated tools, ${Object.keys(mcpToolsRecord).length} lazy MCP tools`,
165
174
  );
166
- debugLog(`[tools] ${gated.length} allowed tools (including MCP)`);
167
175
 
168
176
  debugLog(`[RUNNER] About to create model with provider: ${opts.provider}`);
169
177
  debugLog(`[RUNNER] About to create model ID: ${opts.model}`);
@@ -249,6 +257,7 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
249
257
  providerOptions,
250
258
  needsSpoof: oauth.needsSpoof,
251
259
  isOpenAIOAuth: oauth.isOpenAIOAuth,
260
+ mcpToolsRecord,
252
261
  };
253
262
  }
254
263
 
@@ -26,6 +26,11 @@ import {
26
26
  import { pruneSession } from '../message/compaction.ts';
27
27
  import { triggerDeferredTitleGeneration } from '../message/service.ts';
28
28
  import { setupRunner } from './runner-setup.ts';
29
+ import {
30
+ createMCPPrepareStepState,
31
+ buildPrepareStep,
32
+ } from './mcp-prepare-step.ts';
33
+ import { adaptTools as adaptToolsFn } from '../../tools/adapter.ts';
29
34
  import {
30
35
  type ReasoningState,
31
36
  handleReasoningStart,
@@ -83,13 +88,54 @@ async function runAssistant(opts: RunOpts) {
83
88
  additionalSystemMessages,
84
89
  model,
85
90
  effectiveMaxOutputTokens,
86
- toolset,
87
91
  sharedCtx,
88
92
  firstToolTimer,
89
93
  firstToolSeen,
90
94
  providerOptions,
91
95
  isOpenAIOAuth,
96
+ mcpToolsRecord,
92
97
  } = setup;
98
+ let { toolset } = setup;
99
+
100
+ const hasMCPTools = Object.keys(mcpToolsRecord).length > 0;
101
+ let prepareStep: ReturnType<typeof buildPrepareStep> | undefined;
102
+
103
+ if (hasMCPTools) {
104
+ const baseToolNames = Object.keys(toolset);
105
+ const { getAuth: getAuthFn } = await import('@ottocode/sdk');
106
+ const providerAuth = await getAuthFn(opts.provider, cfg.projectRoot);
107
+ const adaptedMCP = adaptToolsFn(
108
+ Object.entries(mcpToolsRecord).map(([name, tool]) => ({ name, tool })),
109
+ sharedCtx,
110
+ opts.provider,
111
+ providerAuth?.type,
112
+ );
113
+ toolset = { ...toolset, ...adaptedMCP };
114
+ const canonicalToRegistration: Record<string, string> = {};
115
+ for (const canonical of Object.keys(mcpToolsRecord)) {
116
+ const regKeys = Object.keys(adaptedMCP);
117
+ const regName = regKeys.find(
118
+ (k) =>
119
+ k === canonical ||
120
+ k.toLowerCase().replace(/_/g, '') ===
121
+ canonical.toLowerCase().replace(/_/g, ''),
122
+ );
123
+ canonicalToRegistration[canonical] = regName ?? canonical;
124
+ }
125
+ const loadToolRegName =
126
+ Object.keys(toolset).find(
127
+ (k) =>
128
+ k === 'load_mcp_tools' ||
129
+ k.toLowerCase().replace(/_/g, '') === 'loadmcptools',
130
+ ) ?? 'load_mcp_tools';
131
+ const mcpState = createMCPPrepareStepState(
132
+ mcpToolsRecord,
133
+ baseToolNames,
134
+ canonicalToRegistration,
135
+ loadToolRegName,
136
+ );
137
+ prepareStep = buildPrepareStep(mcpState);
138
+ }
93
139
 
94
140
  const isFirstMessage = !history.some((m) => m.role === 'assistant');
95
141
 
@@ -213,6 +259,7 @@ async function runAssistant(opts: RunOpts) {
213
259
  ...(Object.keys(providerOptions).length > 0 ? { providerOptions } : {}),
214
260
  abortSignal: opts.abortSignal,
215
261
  stopWhen: stopWhenCondition,
262
+ ...(prepareStep ? { prepareStep } : {}),
216
263
  // biome-ignore lint/suspicious/noExplicitAny: AI SDK callback types mismatch
217
264
  onStepFinish: onStepFinish as any,
218
265
  // biome-ignore lint/suspicious/noExplicitAny: AI SDK callback types mismatch
@@ -80,6 +80,9 @@ function describeToolResult(info: ToolResultInfo): TargetDescriptor | null {
80
80
  case 'multiedit':
81
81
  return describeEdit(info);
82
82
  default:
83
+ if (toolName.includes('__')) {
84
+ return describeMcpTool(info);
85
+ }
83
86
  return null;
84
87
  }
85
88
  }
@@ -215,3 +218,54 @@ function describeEdit(info: ToolResultInfo): TargetDescriptor | null {
215
218
  const summary = `[previous edit] ${normalized}`;
216
219
  return { keys: [key], summary };
217
220
  }
221
+
222
+ function describeMcpTool(info: ToolResultInfo): TargetDescriptor | null {
223
+ const { toolName } = info;
224
+ const result = getRecord(info.result);
225
+ const args = getRecord(info.args);
226
+
227
+ const hasImages =
228
+ result && Array.isArray(result.images) && result.images.length > 0;
229
+ const resultStr =
230
+ result && typeof result.result === 'string' ? result.result : null;
231
+ const estimatedSize = hasImages
232
+ ? estimateBase64Size(result.images as Array<{ data: string }>)
233
+ : resultStr
234
+ ? resultStr.length
235
+ : 0;
236
+
237
+ if (estimatedSize < 2000 && !hasImages) return null;
238
+
239
+ const argsHint = args
240
+ ? Object.entries(args)
241
+ .slice(0, 3)
242
+ .map(([k, v]) => {
243
+ const val =
244
+ typeof v === 'string'
245
+ ? v.length > 30
246
+ ? `${v.slice(0, 27)}…`
247
+ : v
248
+ : JSON.stringify(v);
249
+ return `${k}=${val}`;
250
+ })
251
+ .join(' ')
252
+ : '';
253
+
254
+ const sizeLabel = hasImages
255
+ ? `${(result.images as unknown[]).length} image(s), ~${Math.round(estimatedSize / 1024)}KB`
256
+ : `~${Math.round(estimatedSize / 1024)}KB`;
257
+
258
+ const key = `mcp:${toolName}`;
259
+ const summary = `[previous MCP call] ${toolName}${argsHint ? ` (${argsHint})` : ''} → ${sizeLabel}`;
260
+ return { keys: [key], summary };
261
+ }
262
+
263
+ function estimateBase64Size(images: Array<{ data: string }>): number {
264
+ let total = 0;
265
+ for (const img of images) {
266
+ if (typeof img.data === 'string') {
267
+ total += Math.floor(img.data.length * 0.75);
268
+ }
269
+ }
270
+ return total;
271
+ }