@ottocode/sdk 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/sdk",
3
- "version": "0.1.205",
3
+ "version": "0.1.207",
4
4
  "description": "AI agent SDK for building intelligent assistants - tree-shakable and comprehensive",
5
5
  "author": "nitishxyz",
6
6
  "license": "MIT",
@@ -6,6 +6,10 @@ const POLLING_SAFETY_MARGIN_MS = 3000;
6
6
  const DEVICE_CODE_URL = 'https://github.com/login/device/code';
7
7
  const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
8
8
 
9
+ const COPILOT_DEFAULT_SCOPE = 'read:user';
10
+ const COPILOT_MCP_SCOPE =
11
+ 'repo read:org read:packages gist notifications read:project security_events';
12
+
9
13
  export type CopilotDeviceCodeResponse = {
10
14
  verification_uri: string;
11
15
  user_code: string;
@@ -41,7 +45,9 @@ async function openBrowser(url: string) {
41
45
  });
42
46
  }
43
47
 
44
- export async function requestDeviceCode(): Promise<CopilotDeviceCodeResponse> {
48
+ export async function requestDeviceCode(
49
+ scope?: string,
50
+ ): Promise<CopilotDeviceCodeResponse> {
45
51
  const response = await fetch(DEVICE_CODE_URL, {
46
52
  method: 'POST',
47
53
  headers: {
@@ -50,7 +56,7 @@ export async function requestDeviceCode(): Promise<CopilotDeviceCodeResponse> {
50
56
  },
51
57
  body: JSON.stringify({
52
58
  client_id: CLIENT_ID,
53
- scope: 'read:user',
59
+ scope: scope ?? COPILOT_DEFAULT_SCOPE,
54
60
  }),
55
61
  });
56
62
 
@@ -120,13 +126,14 @@ export async function pollForToken(
120
126
  }
121
127
  }
122
128
 
123
- export async function authorizeCopilot(): Promise<{
129
+ export async function authorizeCopilot(options?: { mcp?: boolean }): Promise<{
124
130
  verificationUri: string;
125
131
  userCode: string;
126
132
  deviceCode: string;
127
133
  interval: number;
128
134
  }> {
129
- const deviceData = await requestDeviceCode();
135
+ const scope = options?.mcp ? COPILOT_MCP_SCOPE : COPILOT_DEFAULT_SCOPE;
136
+ const deviceData = await requestDeviceCode(scope);
130
137
  return {
131
138
  verificationUri: deviceData.verification_uri,
132
139
  userCode: deviceData.user_code,
@@ -30,7 +30,7 @@ export type { ProviderId, ModelInfo } from '../../types/src/index.ts';
30
30
  // Tools
31
31
  // =======================
32
32
  export { discoverProjectTools } from './tools/loader';
33
- export type { DiscoveredTool } from './tools/loader';
33
+ export type { DiscoveredTool, DiscoverResult } from './tools/loader';
34
34
  export { setTerminalManager, getTerminalManager } from './tools/loader';
35
35
 
36
36
  // Tool error handling utilities
@@ -166,15 +166,18 @@ export class MCPClientWrapper {
166
166
  args: Record<string, unknown>,
167
167
  ): Promise<unknown> {
168
168
  const result = await this.client.callTool({ name, arguments: args });
169
+ const images = extractImages(result.content);
169
170
  if (result.isError) {
170
171
  return {
171
172
  ok: false,
172
173
  error: formatContent(result.content),
174
+ ...(images.length > 0 && { images }),
173
175
  };
174
176
  }
175
177
  return {
176
178
  ok: true,
177
179
  result: formatContent(result.content),
180
+ ...(images.length > 0 && { images }),
178
181
  };
179
182
  }
180
183
 
@@ -218,12 +221,34 @@ function formatContent(content: unknown): string {
218
221
  if (item && typeof item === 'object' && 'text' in item) {
219
222
  parts.push(String(item.text));
220
223
  } else if (item && typeof item === 'object' && 'data' in item) {
221
- parts.push(
222
- `[binary data: ${(item as { mimeType?: string }).mimeType ?? 'unknown'}]`,
223
- );
224
+ const mimeType = (item as { mimeType?: string }).mimeType ?? 'unknown';
225
+ if (mimeType.startsWith('image/')) {
226
+ parts.push(`[image: ${mimeType}]`);
227
+ } else {
228
+ parts.push(`[binary data: ${mimeType}]`);
229
+ }
224
230
  } else {
225
231
  parts.push(JSON.stringify(item));
226
232
  }
227
233
  }
228
234
  return parts.join('\n');
229
235
  }
236
+
237
+ function extractImages(
238
+ content: unknown,
239
+ ): Array<{ data: string; mimeType: string }> {
240
+ if (!Array.isArray(content)) return [];
241
+ const images: Array<{ data: string; mimeType: string }> = [];
242
+ for (const item of content) {
243
+ if (item && typeof item === 'object' && 'data' in item) {
244
+ const mimeType = (item as { mimeType?: string }).mimeType ?? 'unknown';
245
+ if (mimeType.startsWith('image/')) {
246
+ images.push({
247
+ data: String((item as { data: unknown }).data),
248
+ mimeType,
249
+ });
250
+ }
251
+ }
252
+ }
253
+ return images;
254
+ }
@@ -13,6 +13,14 @@ export { MCPServerManager } from './server-manager.ts';
13
13
 
14
14
  export { convertMCPToolsToAISDK } from './tools.ts';
15
15
 
16
+ export {
17
+ getMCPToolBriefs,
18
+ buildLoadMCPToolsTool,
19
+ getMCPToolsRecord,
20
+ buildMCPToolCatalogDescription,
21
+ type MCPToolBrief,
22
+ } from './lazy-tools.ts';
23
+
16
24
  export {
17
25
  getMCPManager,
18
26
  initializeMCP,
@@ -0,0 +1,89 @@
1
+ import { tool, type Tool } from 'ai';
2
+ import { z } from 'zod/v3';
3
+ import type { MCPServerManager } from './server-manager.ts';
4
+ import { convertMCPToolsToAISDK } from './tools.ts';
5
+
6
+ export type MCPToolBrief = {
7
+ name: string;
8
+ server: string;
9
+ description: string;
10
+ };
11
+
12
+ export function getMCPToolBriefs(manager: MCPServerManager): MCPToolBrief[] {
13
+ return manager.getTools().map(({ name, server, tool: t }) => ({
14
+ name,
15
+ server,
16
+ description: t.description ?? `MCP tool: ${t.name}`,
17
+ }));
18
+ }
19
+
20
+ export function buildMCPToolCatalogDescription(briefs: MCPToolBrief[]): string {
21
+ if (briefs.length === 0) return 'No MCP tools available.';
22
+ const grouped = new Map<string, MCPToolBrief[]>();
23
+ for (const b of briefs) {
24
+ const list = grouped.get(b.server) ?? [];
25
+ list.push(b);
26
+ grouped.set(b.server, list);
27
+ }
28
+ const lines: string[] = [];
29
+ for (const [server, tools] of grouped) {
30
+ lines.push(`[${server}]`);
31
+ for (const t of tools) {
32
+ lines.push(` ${t.name}: ${t.description.slice(0, 120)}`);
33
+ }
34
+ }
35
+ return lines.join('\n');
36
+ }
37
+
38
+ export function buildLoadMCPToolsTool(briefs: MCPToolBrief[]): {
39
+ name: string;
40
+ tool: Tool;
41
+ } {
42
+ const catalog = buildMCPToolCatalogDescription(briefs);
43
+ const validNames = new Set(briefs.map((b) => b.name));
44
+
45
+ return {
46
+ name: 'load_mcp_tools',
47
+ tool: tool({
48
+ description: `Load MCP tools by name so they become available for use in the next step. Call this with the tool names you need before using them.\n\nAvailable MCP tools:\n${catalog}`,
49
+ inputSchema: z.object({
50
+ tools: z
51
+ .array(z.string())
52
+ .describe(
53
+ 'Array of MCP tool names to load (e.g. ["chrome__click", "chrome__screenshot"])',
54
+ ),
55
+ }),
56
+ execute: async ({ tools: requested }) => {
57
+ const loaded: string[] = [];
58
+ const notFound: string[] = [];
59
+ for (const name of requested) {
60
+ if (validNames.has(name)) {
61
+ loaded.push(name);
62
+ } else {
63
+ notFound.push(name);
64
+ }
65
+ }
66
+ return {
67
+ ok: true,
68
+ loaded,
69
+ ...(notFound.length > 0 ? { notFound } : {}),
70
+ message:
71
+ loaded.length > 0
72
+ ? `Loaded ${loaded.length} tool(s). They are now available for use.`
73
+ : 'No valid tools to load.',
74
+ };
75
+ },
76
+ }),
77
+ };
78
+ }
79
+
80
+ export function getMCPToolsRecord(
81
+ manager: MCPServerManager,
82
+ ): Record<string, Tool> {
83
+ const mcpTools = convertMCPToolsToAISDK(manager);
84
+ const record: Record<string, Tool> = {};
85
+ for (const { name, tool: t } of mcpTools) {
86
+ record[name] = t;
87
+ }
88
+ return record;
89
+ }
@@ -3,6 +3,65 @@ import type { MCPServerConfig, MCPServerStatus } from './types.ts';
3
3
  import { OAuthCredentialStore } from './oauth/store.ts';
4
4
  import { OttoOAuthProvider } from './oauth/provider.ts';
5
5
  import { createHash } from 'node:crypto';
6
+ import { getAuth } from '../../../auth/src/index.ts';
7
+
8
+ const GITHUB_COPILOT_HOSTS = [
9
+ 'api.githubcopilot.com',
10
+ 'copilot-proxy.githubusercontent.com',
11
+ ];
12
+
13
+ function isGitHubCopilotUrl(url?: string): boolean {
14
+ if (!url) return false;
15
+ try {
16
+ const parsed = new URL(url);
17
+ return GITHUB_COPILOT_HOSTS.some(
18
+ (h) => parsed.hostname === h || parsed.hostname.endsWith(`.${h}`),
19
+ );
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ const COPILOT_MCP_REQUIRED_SCOPES = [
26
+ 'repo',
27
+ 'read:org',
28
+ 'gist',
29
+ 'notifications',
30
+ 'read:project',
31
+ 'security_events',
32
+ ];
33
+
34
+ function hasMCPScopes(scopes?: string): boolean {
35
+ if (!scopes) return false;
36
+ const granted = scopes.split(/[\s,]+/).filter(Boolean);
37
+ return COPILOT_MCP_REQUIRED_SCOPES.every((s) => granted.includes(s));
38
+ }
39
+
40
+ async function getCopilotToken(): Promise<string | null> {
41
+ try {
42
+ const auth = await getAuth('copilot');
43
+ if (auth?.type === 'oauth' && auth.refresh) {
44
+ return auth.refresh;
45
+ }
46
+ } catch {}
47
+ return null;
48
+ }
49
+
50
+ async function getCopilotMCPToken(): Promise<{
51
+ token: string | null;
52
+ needsReauth: boolean;
53
+ }> {
54
+ try {
55
+ const auth = await getAuth('copilot');
56
+ if (auth?.type === 'oauth' && auth.refresh) {
57
+ if (!hasMCPScopes(auth.scopes)) {
58
+ return { token: auth.refresh, needsReauth: true };
59
+ }
60
+ return { token: auth.refresh, needsReauth: false };
61
+ }
62
+ } catch {}
63
+ return { token: null, needsReauth: true };
64
+ }
6
65
 
7
66
  type IndexedTool = {
8
67
  server: string;
@@ -55,6 +114,57 @@ export class MCPServerManager {
55
114
  const transport = config.transport ?? 'stdio';
56
115
 
57
116
  if (transport !== 'stdio') {
117
+ if (isGitHubCopilotUrl(config.url)) {
118
+ const { token, needsReauth } = await getCopilotMCPToken();
119
+ if (token && !needsReauth) {
120
+ config = {
121
+ ...config,
122
+ headers: {
123
+ ...config.headers,
124
+ Authorization: `Bearer ${token}`,
125
+ },
126
+ };
127
+ const updatedClient = new MCPClientWrapper(config);
128
+ try {
129
+ await updatedClient.connect();
130
+ this.clients.set(config.name, updatedClient);
131
+ const tools = await updatedClient.listTools();
132
+ for (const tool of tools) {
133
+ const fullName = `${config.name}__${tool.name}`;
134
+ this.toolsMap.set(fullName, { server: config.name, tool });
135
+ }
136
+ } catch (err) {
137
+ this.clients.set(config.name, updatedClient);
138
+ const msg = err instanceof Error ? err.message : String(err);
139
+ if (
140
+ msg.includes('insufficient scopes') ||
141
+ msg.includes('Forbidden')
142
+ ) {
143
+ console.error(
144
+ `[mcp] GitHub Copilot MCP server "${config.name}" has insufficient scopes. Run \`otto mcp auth ${config.name}\` to re-authenticate with required permissions.`,
145
+ );
146
+ } else {
147
+ console.error(
148
+ `[mcp] Failed to start server "${config.name}": ${msg}`,
149
+ );
150
+ }
151
+ }
152
+ return;
153
+ }
154
+ if (token && needsReauth) {
155
+ console.error(
156
+ `[mcp] GitHub Copilot MCP server "${config.name}" needs broader permissions. Run \`otto mcp auth ${config.name}\` to re-authenticate.`,
157
+ );
158
+ this.clients.set(config.name, client);
159
+ return;
160
+ }
161
+ console.error(
162
+ `[mcp] GitHub Copilot MCP server "${config.name}" requires authentication. Run \`otto auth login copilot\` or \`otto mcp auth ${config.name}\`.`,
163
+ );
164
+ this.clients.set(config.name, client);
165
+ return;
166
+ }
167
+
58
168
  const hasStaticAuth =
59
169
  config.headers?.Authorization || config.headers?.authorization;
60
170
  if (!hasStaticAuth) {
@@ -184,10 +294,15 @@ export class MCPServerManager {
184
294
  .filter(([, v]) => v.server === name)
185
295
  .map(([k]) => k);
186
296
  const config = client.serverConfig;
187
- const key = this.oauthKey(name);
188
- const authenticated = await this.oauthStore
189
- .isAuthenticated(key)
190
- .catch(() => false);
297
+ let authenticated = false;
298
+ if (isGitHubCopilotUrl(config.url)) {
299
+ authenticated = !!(await getCopilotToken());
300
+ } else {
301
+ const key = this.oauthKey(name);
302
+ authenticated = await this.oauthStore
303
+ .isAuthenticated(key)
304
+ .catch(() => false);
305
+ }
191
306
 
192
307
  statuses.push({
193
308
  name,
@@ -218,8 +333,40 @@ export class MCPServerManager {
218
333
  const transport = config.transport ?? 'stdio';
219
334
  if (transport === 'stdio') return null;
220
335
 
336
+ if (isGitHubCopilotUrl(config.url)) {
337
+ const token = await getCopilotToken();
338
+ if (token) {
339
+ const authedConfig = {
340
+ ...config,
341
+ headers: {
342
+ ...config.headers,
343
+ Authorization: `Bearer ${token}`,
344
+ },
345
+ };
346
+ const client = new MCPClientWrapper(authedConfig);
347
+ try {
348
+ await client.connect();
349
+ this.clients.set(config.name, client);
350
+ const tools = await client.listTools();
351
+ for (const tool of tools) {
352
+ const fullName = `${config.name}__${tool.name}`;
353
+ this.toolsMap.set(fullName, { server: config.name, tool });
354
+ }
355
+ } catch (err) {
356
+ this.clients.set(config.name, client);
357
+ const msg = err instanceof Error ? err.message : String(err);
358
+ console.error(
359
+ `[mcp] Failed to start server "${config.name}": ${msg}`,
360
+ );
361
+ }
362
+ return null;
363
+ }
364
+ return null;
365
+ }
366
+
221
367
  this.serverScopes.set(config.name, config.scope ?? 'global');
222
368
  const key = this.oauthKey(config.name);
369
+ await this.oauthStore.clearServer(key);
223
370
  const provider = new OttoOAuthProvider(key, this.oauthStore, {
224
371
  clientId: config.oauth?.clientId,
225
372
  callbackPort: config.oauth?.callbackPort,
@@ -244,6 +391,7 @@ export class MCPServerManager {
244
391
 
245
392
  if (provider.pendingAuthUrl) {
246
393
  this.pendingAuth.set(config.name, provider.pendingAuthUrl);
394
+ this.waitForAuthAndReconnect(config.name, provider);
247
395
  return provider.pendingAuthUrl;
248
396
  }
249
397
  return null;
@@ -295,9 +443,38 @@ export class MCPServerManager {
295
443
  await this.stopServer(name);
296
444
  }
297
445
 
446
+ async clearAuthData(
447
+ name: string,
448
+ scope?: 'global' | 'project',
449
+ projectRoot?: string,
450
+ ): Promise<void> {
451
+ const provider = this.authProviders.get(name);
452
+ if (provider) {
453
+ await provider.clearCredentials();
454
+ provider.cleanup();
455
+ }
456
+ this.authProviders.delete(name);
457
+ if (scope) {
458
+ this.serverScopes.set(name, scope);
459
+ }
460
+ if (projectRoot) {
461
+ this.projectRoot = projectRoot;
462
+ }
463
+ const key = this.oauthKey(name);
464
+ await this.oauthStore.clearServer(key);
465
+ if (key !== name) {
466
+ await this.oauthStore.clearServer(name);
467
+ }
468
+ }
469
+
298
470
  async getAuthStatus(
299
471
  name: string,
300
472
  ): Promise<{ authenticated: boolean; expiresAt?: number }> {
473
+ const client = this.clients.get(name);
474
+ if (client && isGitHubCopilotUrl(client.serverConfig.url)) {
475
+ const token = await getCopilotToken();
476
+ return { authenticated: !!token };
477
+ }
301
478
  const key = this.oauthKey(name);
302
479
  const tokens = await this.oauthStore.loadTokens(key);
303
480
  if (!tokens?.access_token) return { authenticated: false };
@@ -1,7 +1,15 @@
1
1
  import { tool, type Tool } from 'ai';
2
+ import type { ToolResultOutput } from '@ai-sdk/provider-utils';
2
3
  import { z } from 'zod/v3';
3
4
  import type { MCPServerManager } from './server-manager.ts';
4
5
 
6
+ type MCPToolResult = {
7
+ ok: boolean;
8
+ result?: string;
9
+ error?: string;
10
+ images?: Array<{ data: string; mimeType: string }>;
11
+ };
12
+
5
13
  export function convertMCPToolsToAISDK(
6
14
  manager: MCPServerManager,
7
15
  ): Array<{ name: string; tool: Tool }> {
@@ -16,13 +24,38 @@ export function convertMCPToolsToAISDK(
16
24
  ) as z.ZodObject<z.ZodRawShape>,
17
25
  async execute(args: Record<string, unknown>) {
18
26
  try {
19
- return await manager.callTool(name, args);
27
+ return (await manager.callTool(name, args)) as MCPToolResult;
20
28
  } catch (err) {
21
29
  return {
22
30
  ok: false,
23
31
  error: err instanceof Error ? err.message : String(err),
24
- };
32
+ } satisfies MCPToolResult;
33
+ }
34
+ },
35
+ toModelOutput({ output }): ToolResultOutput {
36
+ const result = output as MCPToolResult;
37
+ if (result.images && result.images.length > 0) {
38
+ const parts: Array<
39
+ | { type: 'text'; text: string }
40
+ | { type: 'image-data'; data: string; mediaType: string }
41
+ > = [];
42
+ const text = result.ok ? result.result : result.error;
43
+ if (text) {
44
+ parts.push({ type: 'text', text });
45
+ }
46
+ for (const img of result.images) {
47
+ parts.push({
48
+ type: 'image-data',
49
+ data: img.data,
50
+ mediaType: img.mimeType,
51
+ });
52
+ }
53
+ return { type: 'content', value: parts } as ToolResultOutput;
25
54
  }
55
+ return {
56
+ type: 'json',
57
+ value: result as unknown as import('@ai-sdk/provider').JSONValue,
58
+ };
26
59
  },
27
60
  }),
28
61
  }));
@@ -16,7 +16,12 @@ import { buildTerminalTool } from './builtin/terminal.ts';
16
16
  import type { TerminalManager } from '../terminals/index.ts';
17
17
  import { initializeSkills, buildSkillTool } from '../../../skills/index.ts';
18
18
  import { getMCPManager } from '../mcp/index.ts';
19
- import { convertMCPToolsToAISDK } from '../mcp/tools.ts';
19
+ import {
20
+ getMCPToolBriefs,
21
+ buildLoadMCPToolsTool,
22
+ getMCPToolsRecord,
23
+ type MCPToolBrief,
24
+ } from '../mcp/lazy-tools.ts';
20
25
  import fg from 'fast-glob';
21
26
  import { dirname, isAbsolute, join } from 'node:path';
22
27
  import { pathToFileURL } from 'node:url';
@@ -25,6 +30,11 @@ import { spawn as nodeSpawn } from 'node:child_process';
25
30
 
26
31
  export type DiscoveredTool = { name: string; tool: Tool };
27
32
 
33
+ export type DiscoverResult = {
34
+ tools: DiscoveredTool[];
35
+ mcpToolsRecord: Record<string, Tool>;
36
+ };
37
+
28
38
  type PluginParameter = {
29
39
  type: 'string' | 'number' | 'boolean';
30
40
  description?: string;
@@ -108,7 +118,7 @@ export function getTerminalManager(): TerminalManager | null {
108
118
  export async function discoverProjectTools(
109
119
  projectRoot: string,
110
120
  globalConfigDir?: string,
111
- ): Promise<DiscoveredTool[]> {
121
+ ): Promise<DiscoverResult> {
112
122
  const tools = new Map<string, Tool>();
113
123
  for (const { name, tool } of buildFsTools(projectRoot)) tools.set(name, tool);
114
124
  for (const { name, tool } of buildGitTools(projectRoot))
@@ -148,10 +158,14 @@ export async function discoverProjectTools(
148
158
  tools.set(skillTool.name, skillTool.tool);
149
159
 
150
160
  const mcpManager = getMCPManager();
161
+ let mcpToolsRecord: Record<string, Tool> = {};
162
+ let mcpBriefs: MCPToolBrief[] = [];
151
163
  if (mcpManager?.started) {
152
- const mcpTools = convertMCPToolsToAISDK(mcpManager);
153
- for (const { name, tool } of mcpTools) {
154
- tools.set(name, tool);
164
+ mcpBriefs = getMCPToolBriefs(mcpManager);
165
+ if (mcpBriefs.length > 0) {
166
+ mcpToolsRecord = getMCPToolsRecord(mcpManager);
167
+ const loadTool = buildLoadMCPToolsTool(mcpBriefs);
168
+ tools.set(loadTool.name, loadTool.tool);
155
169
  }
156
170
  }
157
171
 
@@ -210,7 +224,10 @@ export async function discoverProjectTools(
210
224
 
211
225
  await loadFromBase(globalConfigDir);
212
226
  await loadFromBase(join(projectRoot, '.otto'));
213
- return Array.from(tools.entries()).map(([name, tool]) => ({ name, tool }));
227
+ return {
228
+ tools: Array.from(tools.entries()).map(([name, tool]) => ({ name, tool })),
229
+ mcpToolsRecord,
230
+ };
214
231
  }
215
232
 
216
233
  async function loadPlugin(
package/src/index.ts CHANGED
@@ -218,7 +218,7 @@ export type { ProviderName, ModelConfig } from './core/src/index.ts';
218
218
 
219
219
  // Tools
220
220
  export { discoverProjectTools } from './core/src/index.ts';
221
- export type { DiscoveredTool } from './core/src/index.ts';
221
+ export type { DiscoveredTool, DiscoverResult } from './core/src/index.ts';
222
222
  export { setTerminalManager, getTerminalManager } from './core/src/index.ts';
223
223
  export { buildFsTools } from './core/src/index.ts';
224
224
  export { buildGitTools } from './core/src/index.ts';
@@ -20,6 +20,7 @@ export type OAuth = {
20
20
  expires: number;
21
21
  accountId?: string;
22
22
  idToken?: string;
23
+ scopes?: string;
23
24
  };
24
25
 
25
26
  /**