@ottocode/sdk 0.1.200 → 0.1.201

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.
@@ -0,0 +1,121 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export interface StoredOAuthData {
5
+ tokens?: {
6
+ access_token: string;
7
+ token_type?: string;
8
+ expires_in?: number;
9
+ refresh_token?: string;
10
+ scope?: string;
11
+ expires_at?: number;
12
+ };
13
+ clientInfo?: {
14
+ client_id: string;
15
+ client_secret?: string;
16
+ [key: string]: unknown;
17
+ };
18
+ codeVerifier?: string;
19
+ }
20
+
21
+ export class OAuthCredentialStore {
22
+ private storePath: string;
23
+
24
+ constructor(storePath?: string) {
25
+ this.storePath =
26
+ storePath ??
27
+ join(
28
+ process.env.HOME ?? process.env.USERPROFILE ?? '',
29
+ '.config',
30
+ 'otto',
31
+ 'oauth',
32
+ );
33
+ }
34
+
35
+ private filePath(serverName: string): string {
36
+ const safe = serverName.replace(/[^a-zA-Z0-9_-]/g, '_');
37
+ return join(this.storePath, `${safe}.json`);
38
+ }
39
+
40
+ private async read(serverName: string): Promise<StoredOAuthData> {
41
+ try {
42
+ const text = await fs.readFile(this.filePath(serverName), 'utf-8');
43
+ return JSON.parse(text);
44
+ } catch {
45
+ return {};
46
+ }
47
+ }
48
+
49
+ private async write(
50
+ serverName: string,
51
+ data: StoredOAuthData,
52
+ ): Promise<void> {
53
+ await fs.mkdir(this.storePath, { recursive: true, mode: 0o700 });
54
+ await fs.writeFile(
55
+ this.filePath(serverName),
56
+ JSON.stringify(data, null, '\t'),
57
+ { encoding: 'utf-8', mode: 0o600 },
58
+ );
59
+ }
60
+
61
+ async loadTokens(
62
+ serverName: string,
63
+ ): Promise<StoredOAuthData['tokens'] | undefined> {
64
+ const data = await this.read(serverName);
65
+ return data.tokens;
66
+ }
67
+
68
+ async saveTokens(
69
+ serverName: string,
70
+ tokens: StoredOAuthData['tokens'],
71
+ ): Promise<void> {
72
+ const data = await this.read(serverName);
73
+ data.tokens = tokens;
74
+ await this.write(serverName, data);
75
+ }
76
+
77
+ async loadClientInfo(
78
+ serverName: string,
79
+ ): Promise<StoredOAuthData['clientInfo'] | undefined> {
80
+ const data = await this.read(serverName);
81
+ return data.clientInfo;
82
+ }
83
+
84
+ async saveClientInfo(
85
+ serverName: string,
86
+ clientInfo: StoredOAuthData['clientInfo'],
87
+ ): Promise<void> {
88
+ const data = await this.read(serverName);
89
+ data.clientInfo = clientInfo;
90
+ await this.write(serverName, data);
91
+ }
92
+
93
+ async loadCodeVerifier(serverName: string): Promise<string | undefined> {
94
+ const data = await this.read(serverName);
95
+ return data.codeVerifier;
96
+ }
97
+
98
+ async saveCodeVerifier(
99
+ serverName: string,
100
+ codeVerifier: string,
101
+ ): Promise<void> {
102
+ const data = await this.read(serverName);
103
+ data.codeVerifier = codeVerifier;
104
+ await this.write(serverName, data);
105
+ }
106
+
107
+ async clearServer(serverName: string): Promise<void> {
108
+ try {
109
+ await fs.unlink(this.filePath(serverName));
110
+ } catch {}
111
+ }
112
+
113
+ async isAuthenticated(serverName: string): Promise<boolean> {
114
+ const tokens = await this.loadTokens(serverName);
115
+ if (!tokens?.access_token) return false;
116
+ if (tokens.expires_at && tokens.expires_at < Date.now() / 1000) {
117
+ return !!tokens.refresh_token;
118
+ }
119
+ return true;
120
+ }
121
+ }
@@ -0,0 +1,304 @@
1
+ import { MCPClientWrapper, type MCPToolInfo } from './client.ts';
2
+ import type { MCPServerConfig, MCPServerStatus } from './types.ts';
3
+ import { OAuthCredentialStore } from './oauth/store.ts';
4
+ import { OttoOAuthProvider } from './oauth/provider.ts';
5
+
6
+ type IndexedTool = {
7
+ server: string;
8
+ tool: MCPToolInfo;
9
+ };
10
+
11
+ export class MCPServerManager {
12
+ private clients = new Map<string, MCPClientWrapper>();
13
+ private toolsMap = new Map<string, IndexedTool>();
14
+ private authProviders = new Map<string, OttoOAuthProvider>();
15
+ private pendingAuth = new Map<string, string>();
16
+ private oauthStore = new OAuthCredentialStore();
17
+ private _started = false;
18
+
19
+ get started(): boolean {
20
+ return this._started;
21
+ }
22
+
23
+ async startServers(configs: MCPServerConfig[]): Promise<void> {
24
+ await this.stopAll();
25
+
26
+ for (const config of configs) {
27
+ if (config.disabled) continue;
28
+ await this.startSingleServer(config);
29
+ }
30
+ this._started = true;
31
+ }
32
+
33
+ private async startSingleServer(config: MCPServerConfig): Promise<void> {
34
+ const client = new MCPClientWrapper(config);
35
+ const transport = config.transport ?? 'stdio';
36
+
37
+ if (transport !== 'stdio') {
38
+ const hasStaticAuth =
39
+ config.headers?.Authorization || config.headers?.authorization;
40
+ if (!hasStaticAuth) {
41
+ const provider = new OttoOAuthProvider(config.name, this.oauthStore, {
42
+ clientId: config.oauth?.clientId,
43
+ callbackPort: config.oauth?.callbackPort,
44
+ scopes: config.oauth?.scopes,
45
+ });
46
+ client.setAuthProvider(provider);
47
+ this.authProviders.set(config.name, provider);
48
+ }
49
+ }
50
+
51
+ try {
52
+ await client.connect();
53
+ this.clients.set(config.name, client);
54
+
55
+ const tools = await client.listTools();
56
+ for (const tool of tools) {
57
+ const fullName = `${config.name}__${tool.name}`;
58
+ this.toolsMap.set(fullName, { server: config.name, tool });
59
+ }
60
+ } catch (err) {
61
+ this.clients.set(config.name, client);
62
+
63
+ if (client.authRequired) {
64
+ const provider = this.authProviders.get(config.name);
65
+ if (provider?.pendingAuthUrl) {
66
+ this.pendingAuth.set(config.name, provider.pendingAuthUrl);
67
+ this.waitForAuthAndReconnect(config.name, provider);
68
+ }
69
+ return;
70
+ }
71
+
72
+ const msg = err instanceof Error ? err.message : String(err);
73
+ console.error(`[mcp] Failed to start server "${config.name}": ${msg}`);
74
+ }
75
+ }
76
+
77
+ private waitForAuthAndReconnect(
78
+ name: string,
79
+ provider: OttoOAuthProvider,
80
+ ): void {
81
+ provider
82
+ .waitForAuthCode()
83
+ .then(async (code) => {
84
+ console.log(`[mcp] Auth code received for "${name}", reconnecting...`);
85
+ const success = await this.completeAuth(name, code);
86
+ if (success) {
87
+ console.log(`[mcp] Successfully authenticated "${name}"`);
88
+ } else {
89
+ console.error(`[mcp] Failed to complete auth for "${name}"`);
90
+ }
91
+ })
92
+ .catch(() => {});
93
+ }
94
+
95
+ async stopAll(): Promise<void> {
96
+ for (const provider of this.authProviders.values()) {
97
+ provider.cleanup();
98
+ }
99
+ const disconnects = Array.from(this.clients.values()).map((c) =>
100
+ c.disconnect().catch(() => {}),
101
+ );
102
+ await Promise.all(disconnects);
103
+ this.clients.clear();
104
+ this.toolsMap.clear();
105
+ this.authProviders.clear();
106
+ this.pendingAuth.clear();
107
+ this._started = false;
108
+ }
109
+
110
+ getTools(): Array<{ name: string; server: string; tool: MCPToolInfo }> {
111
+ return Array.from(this.toolsMap.entries()).map(
112
+ ([name, { server, tool }]) => ({
113
+ name,
114
+ server,
115
+ tool,
116
+ }),
117
+ );
118
+ }
119
+
120
+ async callTool(
121
+ fullName: string,
122
+ args: Record<string, unknown>,
123
+ ): Promise<unknown> {
124
+ const entry = this.toolsMap.get(fullName);
125
+ if (!entry) throw new Error(`Unknown MCP tool: ${fullName}`);
126
+
127
+ const client = this.clients.get(entry.server);
128
+ if (!client) throw new Error(`MCP server not connected: ${entry.server}`);
129
+
130
+ return client.callTool(entry.tool.name, args);
131
+ }
132
+
133
+ getStatus(): MCPServerStatus[] {
134
+ const statuses: MCPServerStatus[] = [];
135
+ for (const [name, client] of this.clients) {
136
+ const tools = Array.from(this.toolsMap.entries())
137
+ .filter(([, v]) => v.server === name)
138
+ .map(([k]) => k);
139
+ const config = client.serverConfig;
140
+ const _authenticated = this.oauthStore
141
+ .isAuthenticated(name)
142
+ .catch(() => false);
143
+
144
+ statuses.push({
145
+ name,
146
+ connected: client.connected,
147
+ tools,
148
+ transport: config.transport,
149
+ url: config.url,
150
+ authRequired: client.authRequired,
151
+ authenticated: false,
152
+ });
153
+ }
154
+ return statuses;
155
+ }
156
+
157
+ async getStatusAsync(): Promise<MCPServerStatus[]> {
158
+ const statuses: MCPServerStatus[] = [];
159
+ for (const [name, client] of this.clients) {
160
+ const tools = Array.from(this.toolsMap.entries())
161
+ .filter(([, v]) => v.server === name)
162
+ .map(([k]) => k);
163
+ const config = client.serverConfig;
164
+ const authenticated = await this.oauthStore
165
+ .isAuthenticated(name)
166
+ .catch(() => false);
167
+
168
+ statuses.push({
169
+ name,
170
+ connected: client.connected,
171
+ tools,
172
+ transport: config.transport,
173
+ url: config.url,
174
+ authRequired: client.authRequired,
175
+ authenticated,
176
+ });
177
+ }
178
+ return statuses;
179
+ }
180
+
181
+ getServerNames(): string[] {
182
+ return Array.from(this.clients.keys());
183
+ }
184
+
185
+ isServerConnected(name: string): boolean {
186
+ return this.clients.get(name)?.connected ?? false;
187
+ }
188
+
189
+ getAuthUrl(name: string): string | null {
190
+ return this.pendingAuth.get(name) ?? null;
191
+ }
192
+
193
+ async initiateAuth(config: MCPServerConfig): Promise<string | null> {
194
+ const transport = config.transport ?? 'stdio';
195
+ if (transport === 'stdio') return null;
196
+
197
+ const provider = new OttoOAuthProvider(config.name, this.oauthStore, {
198
+ clientId: config.oauth?.clientId,
199
+ callbackPort: config.oauth?.callbackPort,
200
+ scopes: config.oauth?.scopes,
201
+ });
202
+ this.authProviders.set(config.name, provider);
203
+
204
+ const client = new MCPClientWrapper(config);
205
+ client.setAuthProvider(provider);
206
+
207
+ try {
208
+ await client.connect();
209
+ this.clients.set(config.name, client);
210
+ const tools = await client.listTools();
211
+ for (const tool of tools) {
212
+ const fullName = `${config.name}__${tool.name}`;
213
+ this.toolsMap.set(fullName, { server: config.name, tool });
214
+ }
215
+ return null;
216
+ } catch {
217
+ this.clients.set(config.name, client);
218
+
219
+ if (provider.pendingAuthUrl) {
220
+ this.pendingAuth.set(config.name, provider.pendingAuthUrl);
221
+ return provider.pendingAuthUrl;
222
+ }
223
+ return null;
224
+ }
225
+ }
226
+
227
+ async completeAuth(name: string, code: string): Promise<boolean> {
228
+ const client = this.clients.get(name);
229
+ const provider = this.authProviders.get(name);
230
+ if (!client || !provider) return false;
231
+
232
+ try {
233
+ await client.finishAuth(code);
234
+ await client.disconnect();
235
+
236
+ const config = client.serverConfig;
237
+ const newClient = new MCPClientWrapper(config);
238
+ newClient.setAuthProvider(provider);
239
+ await newClient.connect();
240
+
241
+ this.clients.set(name, newClient);
242
+
243
+ const tools = await newClient.listTools();
244
+ for (const [key, val] of this.toolsMap) {
245
+ if (val.server === name) this.toolsMap.delete(key);
246
+ }
247
+ for (const tool of tools) {
248
+ const fullName = `${name}__${tool.name}`;
249
+ this.toolsMap.set(fullName, { server: name, tool });
250
+ }
251
+
252
+ this.pendingAuth.delete(name);
253
+ provider.cleanup();
254
+ return true;
255
+ } catch (err) {
256
+ const msg = err instanceof Error ? err.message : String(err);
257
+ console.error(`[mcp] Failed to complete auth for "${name}": ${msg}`);
258
+ return false;
259
+ }
260
+ }
261
+
262
+ async revokeAuth(name: string): Promise<void> {
263
+ const provider = this.authProviders.get(name);
264
+ if (provider) {
265
+ await provider.clearCredentials();
266
+ provider.cleanup();
267
+ }
268
+ this.authProviders.delete(name);
269
+ await this.stopServer(name);
270
+ }
271
+
272
+ async getAuthStatus(
273
+ name: string,
274
+ ): Promise<{ authenticated: boolean; expiresAt?: number }> {
275
+ const tokens = await this.oauthStore.loadTokens(name);
276
+ if (!tokens?.access_token) return { authenticated: false };
277
+ return {
278
+ authenticated: true,
279
+ expiresAt: tokens.expires_at,
280
+ };
281
+ }
282
+
283
+ async restartServer(config: MCPServerConfig): Promise<void> {
284
+ await this.stopServer(config.name);
285
+ await this.startSingleServer(config);
286
+ }
287
+
288
+ async stopServer(name: string): Promise<void> {
289
+ const provider = this.authProviders.get(name);
290
+ if (provider) {
291
+ provider.cleanup();
292
+ this.authProviders.delete(name);
293
+ }
294
+ const client = this.clients.get(name);
295
+ if (client) {
296
+ await client.disconnect().catch(() => {});
297
+ this.clients.delete(name);
298
+ for (const [key, val] of this.toolsMap) {
299
+ if (val.server === name) this.toolsMap.delete(key);
300
+ }
301
+ }
302
+ this.pendingAuth.delete(name);
303
+ }
304
+ }
@@ -0,0 +1,97 @@
1
+ import { tool, type Tool } from 'ai';
2
+ import { z } from 'zod/v3';
3
+ import type { MCPServerManager } from './server-manager.ts';
4
+
5
+ export function convertMCPToolsToAISDK(
6
+ manager: MCPServerManager,
7
+ ): Array<{ name: string; tool: Tool }> {
8
+ const mcpTools = manager.getTools();
9
+
10
+ return mcpTools.map(({ name, tool: mcpTool }) => ({
11
+ name,
12
+ tool: tool({
13
+ description: mcpTool.description ?? `MCP tool: ${mcpTool.name}`,
14
+ inputSchema: jsonSchemaToZod(
15
+ mcpTool.inputSchema,
16
+ ) as z.ZodObject<z.ZodRawShape>,
17
+ async execute(args: Record<string, unknown>) {
18
+ try {
19
+ return await manager.callTool(name, args);
20
+ } catch (err) {
21
+ return {
22
+ ok: false,
23
+ error: err instanceof Error ? err.message : String(err),
24
+ };
25
+ }
26
+ },
27
+ }),
28
+ }));
29
+ }
30
+
31
+ type JSONSchema = {
32
+ type?: string;
33
+ description?: string;
34
+ enum?: string[];
35
+ properties?: Record<string, JSONSchema>;
36
+ required?: string[];
37
+ items?: JSONSchema;
38
+ default?: unknown;
39
+ };
40
+
41
+ function jsonSchemaToZod(schema: Record<string, unknown>): z.ZodTypeAny {
42
+ const properties = schema.properties as
43
+ | Record<string, JSONSchema>
44
+ | undefined;
45
+ if (!properties) return z.object({});
46
+
47
+ const required = new Set((schema.required as string[]) ?? []);
48
+ const shape: Record<string, z.ZodTypeAny> = {};
49
+
50
+ for (const [key, prop] of Object.entries(properties)) {
51
+ let field = convertProperty(prop);
52
+ if (!required.has(key)) field = field.optional();
53
+ shape[key] = field;
54
+ }
55
+
56
+ return z.object(shape);
57
+ }
58
+
59
+ function convertProperty(prop: JSONSchema): z.ZodTypeAny {
60
+ if (prop.enum) {
61
+ const enumSchema = z.enum(prop.enum as [string, ...string[]]);
62
+ return prop.description
63
+ ? enumSchema.describe(prop.description)
64
+ : enumSchema;
65
+ }
66
+
67
+ switch (prop.type) {
68
+ case 'string': {
69
+ const s = z.string();
70
+ return prop.description ? s.describe(prop.description) : s;
71
+ }
72
+ case 'number': {
73
+ const n = z.number();
74
+ return prop.description ? n.describe(prop.description) : n;
75
+ }
76
+ case 'integer': {
77
+ const i = z.number().int();
78
+ return prop.description ? i.describe(prop.description) : i;
79
+ }
80
+ case 'boolean': {
81
+ const b = z.boolean();
82
+ return prop.description ? b.describe(prop.description) : b;
83
+ }
84
+ case 'array': {
85
+ const items = prop.items ? convertProperty(prop.items) : z.unknown();
86
+ const a = z.array(items);
87
+ return prop.description ? a.describe(prop.description) : a;
88
+ }
89
+ case 'object': {
90
+ return jsonSchemaToZod(prop as Record<string, unknown>);
91
+ }
92
+ default: {
93
+ const u = z.unknown();
94
+ return prop.description ? u.describe(prop.description) : u;
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,39 @@
1
+ export type MCPTransport = 'stdio' | 'http' | 'sse';
2
+
3
+ export interface MCPOAuthConfig {
4
+ clientId?: string;
5
+ callbackPort?: number;
6
+ scopes?: string[];
7
+ }
8
+
9
+ export interface MCPServerConfig {
10
+ name: string;
11
+ transport?: MCPTransport;
12
+
13
+ command?: string;
14
+ args?: string[];
15
+ env?: Record<string, string>;
16
+ cwd?: string;
17
+
18
+ url?: string;
19
+ headers?: Record<string, string>;
20
+
21
+ oauth?: MCPOAuthConfig;
22
+
23
+ disabled?: boolean;
24
+ }
25
+
26
+ export interface MCPConfig {
27
+ servers: MCPServerConfig[];
28
+ }
29
+
30
+ export interface MCPServerStatus {
31
+ name: string;
32
+ connected: boolean;
33
+ tools: string[];
34
+ error?: string;
35
+ transport?: MCPTransport;
36
+ url?: string;
37
+ authRequired?: boolean;
38
+ authenticated?: boolean;
39
+ }
@@ -24,14 +24,14 @@ export function buildRipgrepTool(projectRoot: string): {
24
24
  .array(z.string())
25
25
  .optional()
26
26
  .describe('One or more glob patterns to include'),
27
- maxResults: z.number().int().min(1).max(5000).optional().default(500),
27
+ maxResults: z.number().int().min(1).max(5000).optional().default(100),
28
28
  }),
29
29
  async execute({
30
30
  query,
31
31
  path = '.',
32
32
  ignoreCase,
33
33
  glob,
34
- maxResults = 500,
34
+ maxResults = 100,
35
35
  }: {
36
36
  query: string;
37
37
  path?: string;
@@ -95,12 +95,20 @@ export function buildRipgrepTool(projectRoot: string): {
95
95
  .split('\n')
96
96
  .filter(Boolean)
97
97
  .slice(0, maxResults);
98
+ const TEXT_MAX = 200;
98
99
  const matches = lines.map((l) => {
99
100
  const m = l.match(/^(.+?):(\d+):(.*)$/s);
100
- if (!m) return { file: '', line: 0, text: l };
101
+ if (!m)
102
+ return {
103
+ file: '',
104
+ line: 0,
105
+ text: l.length > TEXT_MAX ? l.slice(0, TEXT_MAX) + '…' : l,
106
+ };
101
107
  const file = m[1];
102
108
  const line = Number.parseInt(m[2], 10);
103
- const text = m[3];
109
+ const raw = m[3];
110
+ const text =
111
+ raw.length > TEXT_MAX ? raw.slice(0, TEXT_MAX) + '…' : raw;
104
112
  return { file, line, text };
105
113
  });
106
114
  resolve({ ok: true, count: matches.length, matches });
@@ -6,7 +6,6 @@ import { buildGitTools } from './builtin/git.ts';
6
6
  import { progressUpdateTool } from './builtin/progress.ts';
7
7
  import { buildBashTool } from './builtin/bash.ts';
8
8
  import { buildRipgrepTool } from './builtin/ripgrep.ts';
9
- import { buildGrepTool } from './builtin/grep.ts';
10
9
  import { buildGlobTool } from './builtin/glob.ts';
11
10
  import { buildApplyPatchTool } from './builtin/patch.ts';
12
11
  import { buildEditTool } from './builtin/edit.ts';
@@ -16,6 +15,8 @@ import { buildWebSearchTool } from './builtin/websearch.ts';
16
15
  import { buildTerminalTool } from './builtin/terminal.ts';
17
16
  import type { TerminalManager } from '../terminals/index.ts';
18
17
  import { initializeSkills, buildSkillTool } from '../../../skills/index.ts';
18
+ import { getMCPManager } from '../mcp/index.ts';
19
+ import { convertMCPToolsToAISDK } from '../mcp/tools.ts';
19
20
  import fg from 'fast-glob';
20
21
  import { dirname, isAbsolute, join } from 'node:path';
21
22
  import { pathToFileURL } from 'node:url';
@@ -120,8 +121,6 @@ export async function discoverProjectTools(
120
121
  // Search
121
122
  const rg = buildRipgrepTool(projectRoot);
122
123
  tools.set(rg.name, rg.tool);
123
- const grep = buildGrepTool(projectRoot);
124
- tools.set(grep.name, grep.tool);
125
124
  const glob = buildGlobTool(projectRoot);
126
125
  tools.set(glob.name, glob.tool);
127
126
  // Patch/apply
@@ -148,6 +147,14 @@ export async function discoverProjectTools(
148
147
  const skillTool = buildSkillTool();
149
148
  tools.set(skillTool.name, skillTool.tool);
150
149
 
150
+ const mcpManager = getMCPManager();
151
+ if (mcpManager?.started) {
152
+ const mcpTools = convertMCPToolsToAISDK(mcpManager);
153
+ for (const { name, tool } of mcpTools) {
154
+ tools.set(name, tool);
155
+ }
156
+ }
157
+
151
158
  async function loadFromBase(base: string | null | undefined) {
152
159
  if (!base) return;
153
160
  try {
package/src/index.ts CHANGED
@@ -333,3 +333,32 @@ export {
333
333
  } from './tunnel/index.ts';
334
334
 
335
335
  export type { TunnelConnection, TunnelEvents } from './tunnel/index.ts';
336
+
337
+ // =======================
338
+ // MCP (Model Context Protocol)
339
+ // =======================
340
+ export {
341
+ MCPClientWrapper,
342
+ MCPServerManager,
343
+ convertMCPToolsToAISDK,
344
+ getMCPManager,
345
+ initializeMCP,
346
+ shutdownMCP,
347
+ loadMCPConfig,
348
+ addMCPServerToConfig,
349
+ removeMCPServerFromConfig,
350
+ OAuthCredentialStore,
351
+ OttoOAuthProvider,
352
+ OAuthCallbackServer,
353
+ } from './core/src/index.ts';
354
+ export type {
355
+ MCPServerConfig,
356
+ MCPConfig,
357
+ MCPServerStatus,
358
+ MCPToolInfo,
359
+ MCPTransport,
360
+ MCPOAuthConfig,
361
+ StoredOAuthData,
362
+ OttoOAuthProviderOptions,
363
+ CallbackResult,
364
+ } from './core/src/index.ts';