@ottocode/server 0.1.204 → 0.1.206

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.204",
3
+ "version": "0.1.206",
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.204",
53
- "@ottocode/database": "0.1.204",
52
+ "@ottocode/sdk": "0.1.206",
53
+ "@ottocode/database": "0.1.206",
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
@@ -104,6 +104,10 @@ export function registerMCPRoutes(app: Hono) {
104
104
  try {
105
105
  const manager = getMCPManager();
106
106
  if (manager) {
107
+ const config = await loadMCPConfig(projectRoot, getGlobalConfigDir());
108
+ const serverConfig = config.servers.find((s) => s.name === name);
109
+ const scope = serverConfig?.scope ?? 'global';
110
+ await manager.clearAuthData(name, scope, projectRoot);
107
111
  await manager.stopServer(name);
108
112
  }
109
113
 
@@ -4,7 +4,6 @@ import {
4
4
  getPublicKeyFromPrivate,
5
5
  getAuth,
6
6
  loadConfig,
7
- fetchSolanaUsdcBalance,
8
7
  } from '@ottocode/sdk';
9
8
  import { logger } from '@ottocode/sdk';
10
9
  import { serializeError } from '../runtime/errors/api-error.ts';
@@ -117,18 +116,44 @@ export function registerSetuRoutes(app: Hono) {
117
116
  return c.json({ error: 'Setu wallet not configured' }, 401);
118
117
  }
119
118
 
120
- const network =
121
- (c.req.query('network') as 'mainnet' | 'devnet') || 'mainnet';
119
+ const publicKey = getPublicKeyFromPrivate(privateKey);
120
+ if (!publicKey) {
121
+ return c.json({ error: 'Invalid private key' }, 400);
122
+ }
122
123
 
123
- const balance = await fetchSolanaUsdcBalance({ privateKey }, network);
124
- if (!balance) {
125
- return c.json(
126
- { error: 'Failed to fetch USDC balance from Solana' },
127
- 502,
128
- );
124
+ const baseUrl = getSetuBaseUrl();
125
+ const response = await fetch(
126
+ `${baseUrl}/v1/wallet/${publicKey}/balances?limit=100&showNative=false&showNfts=false&showZeroBalance=false`,
127
+ {
128
+ method: 'GET',
129
+ headers: { 'Content-Type': 'application/json' },
130
+ },
131
+ );
132
+
133
+ if (!response.ok) {
134
+ return c.json({ error: 'Failed to fetch wallet balances' }, 502);
129
135
  }
130
136
 
131
- return c.json(balance);
137
+ const data = (await response.json()) as {
138
+ balances: Array<{
139
+ mint: string;
140
+ symbol: string;
141
+ name: string;
142
+ balance: number;
143
+ decimals: number;
144
+ pricePerToken: number | null;
145
+ usdValue: number | null;
146
+ }>;
147
+ totalUsdValue: number;
148
+ };
149
+
150
+ const usdcEntry = data.balances.find((b) => b.symbol === 'USDC');
151
+
152
+ return c.json({
153
+ walletAddress: publicKey,
154
+ usdcBalance: usdcEntry?.balance ?? 0,
155
+ network: 'mainnet' as const,
156
+ });
132
157
  } catch (error) {
133
158
  logger.error('Failed to fetch USDC balance', error);
134
159
  const errorResponse = serializeError(error);
@@ -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
+ }
@@ -21,6 +21,7 @@ export async function resolveModel(
21
21
  sessionId?: string;
22
22
  messageId?: string;
23
23
  topupApprovalMode?: ResolveSetuModelOptions['topupApprovalMode'];
24
+ autoPayThresholdUsd?: ResolveSetuModelOptions['autoPayThresholdUsd'];
24
25
  },
25
26
  ) {
26
27
  if (provider === 'openai') {
@@ -46,6 +47,7 @@ export async function resolveModel(
46
47
  return await resolveSetuModel(model, options?.sessionId, {
47
48
  messageId: options?.messageId,
48
49
  topupApprovalMode: options?.topupApprovalMode,
50
+ autoPayThresholdUsd: options?.autoPayThresholdUsd,
49
51
  });
50
52
  }
51
53
  if (provider === 'zai') {
@@ -21,6 +21,7 @@ function getProviderNpm(model: string): string | undefined {
21
21
  export interface ResolveSetuModelOptions {
22
22
  messageId?: string;
23
23
  topupApprovalMode?: 'auto' | 'approval';
24
+ autoPayThresholdUsd?: number;
24
25
  }
25
26
 
26
27
  async function getSetuPrivateKey(): Promise<string> {
@@ -50,7 +51,11 @@ export async function resolveSetuModel(
50
51
  }
51
52
  const baseURL = process.env.SETU_BASE_URL;
52
53
  const rpcURL = process.env.SETU_SOLANA_RPC_URL;
53
- const { messageId, topupApprovalMode = 'approval' } = options;
54
+ const {
55
+ messageId,
56
+ topupApprovalMode = 'approval',
57
+ autoPayThresholdUsd = MIN_TOPUP_USD,
58
+ } = options;
54
59
 
55
60
  const callbacks: SetuPaymentCallbacks = sessionId
56
61
  ? {
@@ -128,6 +133,7 @@ export async function resolveSetuModel(
128
133
  callbacks,
129
134
  providerNpm,
130
135
  topupApprovalMode,
136
+ autoPayThresholdUsd,
131
137
  },
132
138
  );
133
139
  }