@ottocode/sdk 0.1.200 → 0.1.202

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.200",
3
+ "version": "0.1.202",
4
4
  "description": "AI agent SDK for building intelligent assistants - tree-shakable and comprehensive",
5
5
  "author": "nitishxyz",
6
6
  "license": "MIT",
@@ -98,6 +98,7 @@
98
98
  "@ai-sdk/google": "^3.0.0",
99
99
  "@ai-sdk/openai": "^3.0.0",
100
100
  "@ai-sdk/openai-compatible": "^2.0.0",
101
+ "@modelcontextprotocol/sdk": "^1.12",
101
102
  "@openauthjs/openauth": "^0.4.3",
102
103
  "@openrouter/ai-sdk-provider": "^1.2.0",
103
104
  "@solana/web3.js": "^1.95.2",
@@ -35,29 +35,31 @@ export function getGlobalAuthPath(): string {
35
35
  return joinPath(getGlobalConfigDir(), 'auth.json');
36
36
  }
37
37
 
38
- // Secure location for auth secrets (not in config dir or project)
39
- // - Linux: $XDG_STATE_HOME/otto/auth.json or ~/.local/state/otto/auth.json
40
- // - macOS: ~/Library/Application Support/otto/auth.json
41
- // - Windows: %APPDATA%\otto\auth.json
42
- export function getSecureAuthPath(): string {
38
+ export function getSecureBaseDir(): string {
43
39
  const platform = process.platform;
44
40
  if (platform === 'darwin') {
45
- return joinPath(
46
- getHomeDir(),
47
- 'Library',
48
- 'Application Support',
49
- 'otto',
50
- 'auth.json',
51
- );
41
+ return joinPath(getHomeDir(), 'Library', 'Application Support', 'otto');
52
42
  }
53
43
  if (platform === 'win32') {
54
44
  const appData = (process.env.APPDATA || '').replace(/\\/g, '/');
55
45
  const base = appData || joinPath(getHomeDir(), 'AppData', 'Roaming');
56
- return joinPath(base, 'otto', 'auth.json');
46
+ return joinPath(base, 'otto');
57
47
  }
58
48
  const stateHome = (process.env.XDG_STATE_HOME || '').replace(/\\/g, '/');
59
49
  const base = stateHome || joinPath(getHomeDir(), '.local', 'state');
60
- return joinPath(base, 'otto', 'auth.json');
50
+ return joinPath(base, 'otto');
51
+ }
52
+
53
+ export function getSecureOAuthDir(): string {
54
+ return joinPath(getSecureBaseDir(), 'oauth');
55
+ }
56
+
57
+ // Secure location for auth secrets (not in config dir or project)
58
+ // - Linux: $XDG_STATE_HOME/otto/auth.json or ~/.local/state/otto/auth.json
59
+ // - macOS: ~/Library/Application Support/otto/auth.json
60
+ // - Windows: %APPDATA%\otto\auth.json
61
+ export function getSecureAuthPath(): string {
62
+ return joinPath(getSecureBaseDir(), 'auth.json');
61
63
  }
62
64
 
63
65
  // Global content under config dir
@@ -108,3 +108,33 @@ export {
108
108
  // =======================
109
109
  export { logger, debug, info, warn, error, time } from './utils/logger.ts';
110
110
  export { isDebugEnabled, isTraceEnabled } from './utils/debug.ts';
111
+
112
+ // =======================
113
+ // MCP (Model Context Protocol)
114
+ // =======================
115
+ export {
116
+ MCPClientWrapper,
117
+ MCPServerManager,
118
+ convertMCPToolsToAISDK,
119
+ getMCPManager,
120
+ initializeMCP,
121
+ shutdownMCP,
122
+ loadMCPConfig,
123
+ addMCPServerToConfig,
124
+ removeMCPServerFromConfig,
125
+ OAuthCredentialStore,
126
+ OttoOAuthProvider,
127
+ OAuthCallbackServer,
128
+ } from './mcp/index.ts';
129
+ export type {
130
+ MCPServerConfig,
131
+ MCPConfig,
132
+ MCPServerStatus,
133
+ MCPToolInfo,
134
+ MCPTransport,
135
+ MCPOAuthConfig,
136
+ MCPScope,
137
+ StoredOAuthData,
138
+ OttoOAuthProviderOptions,
139
+ CallbackResult,
140
+ } from './mcp/index.ts';
@@ -0,0 +1,229 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
3
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
4
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
5
+ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
6
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
7
+ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
8
+ import type { MCPServerConfig } from './types.ts';
9
+
10
+ export type MCPToolInfo = {
11
+ name: string;
12
+ description?: string;
13
+ inputSchema: Record<string, unknown>;
14
+ };
15
+
16
+ export class MCPClientWrapper {
17
+ private client: Client;
18
+ private transport: Transport | null = null;
19
+ private config: MCPServerConfig;
20
+ private _connected = false;
21
+ private _authRequired = false;
22
+ private _authProvider: OAuthClientProvider | null = null;
23
+ private _authUrl: string | null = null;
24
+
25
+ constructor(config: MCPServerConfig) {
26
+ this.config = config;
27
+ this.client = new Client(
28
+ { name: 'ottocode', version: '1.0.0' },
29
+ { capabilities: {} },
30
+ );
31
+ }
32
+
33
+ get connected(): boolean {
34
+ return this._connected;
35
+ }
36
+
37
+ get name(): string {
38
+ return this.config.name;
39
+ }
40
+
41
+ get authRequired(): boolean {
42
+ return this._authRequired;
43
+ }
44
+
45
+ get authUrl(): string | null {
46
+ return this._authUrl;
47
+ }
48
+
49
+ get serverConfig(): MCPServerConfig {
50
+ return this.config;
51
+ }
52
+
53
+ setAuthProvider(provider: OAuthClientProvider): void {
54
+ this._authProvider = provider;
55
+ }
56
+
57
+ async connect(): Promise<void> {
58
+ const transport = this.config.transport ?? 'stdio';
59
+
60
+ switch (transport) {
61
+ case 'stdio':
62
+ await this.connectStdio();
63
+ break;
64
+ case 'http':
65
+ await this.connectHTTP();
66
+ break;
67
+ case 'sse':
68
+ await this.connectSSE();
69
+ break;
70
+ default:
71
+ throw new Error(`Unknown transport: ${transport}`);
72
+ }
73
+ }
74
+
75
+ private async connectStdio(): Promise<void> {
76
+ if (!this.config.command) {
77
+ throw new Error('command is required for stdio transport');
78
+ }
79
+
80
+ const env = this.resolveEnv(this.config.env ?? {});
81
+ this.transport = new StdioClientTransport({
82
+ command: this.config.command,
83
+ args: this.config.args,
84
+ env: { ...process.env, ...env } as Record<string, string>,
85
+ cwd: this.config.cwd,
86
+ stderr: 'pipe',
87
+ });
88
+
89
+ await this.client.connect(this.transport);
90
+ this._connected = true;
91
+ }
92
+
93
+ private async connectHTTP(): Promise<void> {
94
+ if (!this.config.url) {
95
+ throw new Error('url is required for http transport');
96
+ }
97
+
98
+ const url = new URL(this.config.url);
99
+ const headers = this.resolveHeaders(this.config.headers ?? {});
100
+
101
+ try {
102
+ this.transport = new StreamableHTTPClientTransport(url, {
103
+ authProvider: this._authProvider ?? undefined,
104
+ requestInit: Object.keys(headers).length > 0 ? { headers } : undefined,
105
+ });
106
+
107
+ await this.client.connect(this.transport);
108
+ this._connected = true;
109
+ this._authRequired = false;
110
+ } catch (err) {
111
+ if (err instanceof UnauthorizedError) {
112
+ this._authRequired = true;
113
+ throw err;
114
+ }
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ private async connectSSE(): Promise<void> {
120
+ if (!this.config.url) {
121
+ throw new Error('url is required for sse transport');
122
+ }
123
+
124
+ const url = new URL(this.config.url);
125
+ const headers = this.resolveHeaders(this.config.headers ?? {});
126
+
127
+ try {
128
+ this.transport = new SSEClientTransport(url, {
129
+ authProvider: this._authProvider ?? undefined,
130
+ requestInit: Object.keys(headers).length > 0 ? { headers } : undefined,
131
+ });
132
+
133
+ await this.client.connect(this.transport);
134
+ this._connected = true;
135
+ this._authRequired = false;
136
+ } catch (err) {
137
+ if (err instanceof UnauthorizedError) {
138
+ this._authRequired = true;
139
+ throw err;
140
+ }
141
+ throw err;
142
+ }
143
+ }
144
+
145
+ async finishAuth(authorizationCode: string): Promise<void> {
146
+ const transport = this.transport as
147
+ | StreamableHTTPClientTransport
148
+ | SSEClientTransport
149
+ | null;
150
+ if (transport && 'finishAuth' in transport) {
151
+ await transport.finishAuth(authorizationCode);
152
+ }
153
+ }
154
+
155
+ async listTools(): Promise<MCPToolInfo[]> {
156
+ const result = await this.client.listTools();
157
+ return (result.tools ?? []).map((t) => ({
158
+ name: t.name,
159
+ description: t.description,
160
+ inputSchema: t.inputSchema as Record<string, unknown>,
161
+ }));
162
+ }
163
+
164
+ async callTool(
165
+ name: string,
166
+ args: Record<string, unknown>,
167
+ ): Promise<unknown> {
168
+ const result = await this.client.callTool({ name, arguments: args });
169
+ if (result.isError) {
170
+ return {
171
+ ok: false,
172
+ error: formatContent(result.content),
173
+ };
174
+ }
175
+ return {
176
+ ok: true,
177
+ result: formatContent(result.content),
178
+ };
179
+ }
180
+
181
+ async disconnect(): Promise<void> {
182
+ this._connected = false;
183
+ try {
184
+ await this.transport?.close();
185
+ } catch {}
186
+ this.transport = null;
187
+ }
188
+
189
+ private resolveEnv(env: Record<string, string>): Record<string, string> {
190
+ const resolved: Record<string, string> = {};
191
+ for (const [key, value] of Object.entries(env)) {
192
+ resolved[key] = value.replace(
193
+ /\$\{(\w+)\}/g,
194
+ (_, name) => process.env[name] ?? '',
195
+ );
196
+ }
197
+ return resolved;
198
+ }
199
+
200
+ private resolveHeaders(
201
+ headers: Record<string, string>,
202
+ ): Record<string, string> {
203
+ const resolved: Record<string, string> = {};
204
+ for (const [key, value] of Object.entries(headers)) {
205
+ resolved[key] = value.replace(
206
+ /\$\{(\w+)\}/g,
207
+ (_, name) => process.env[name] ?? '',
208
+ );
209
+ }
210
+ return resolved;
211
+ }
212
+ }
213
+
214
+ function formatContent(content: unknown): string {
215
+ if (!Array.isArray(content)) return String(content ?? '');
216
+ const parts: string[] = [];
217
+ for (const item of content) {
218
+ if (item && typeof item === 'object' && 'text' in item) {
219
+ parts.push(String(item.text));
220
+ } else if (item && typeof item === 'object' && 'data' in item) {
221
+ parts.push(
222
+ `[binary data: ${(item as { mimeType?: string }).mimeType ?? 'unknown'}]`,
223
+ );
224
+ } else {
225
+ parts.push(JSON.stringify(item));
226
+ }
227
+ }
228
+ return parts.join('\n');
229
+ }
@@ -0,0 +1,32 @@
1
+ export type {
2
+ MCPServerConfig,
3
+ MCPConfig,
4
+ MCPServerStatus,
5
+ MCPTransport,
6
+ MCPOAuthConfig,
7
+ MCPScope,
8
+ } from './types.ts';
9
+
10
+ export { MCPClientWrapper, type MCPToolInfo } from './client.ts';
11
+
12
+ export { MCPServerManager } from './server-manager.ts';
13
+
14
+ export { convertMCPToolsToAISDK } from './tools.ts';
15
+
16
+ export {
17
+ getMCPManager,
18
+ initializeMCP,
19
+ shutdownMCP,
20
+ loadMCPConfig,
21
+ addMCPServerToConfig,
22
+ removeMCPServerFromConfig,
23
+ } from './lifecycle.ts';
24
+
25
+ export {
26
+ OAuthCredentialStore,
27
+ OttoOAuthProvider,
28
+ OAuthCallbackServer,
29
+ type StoredOAuthData,
30
+ type OttoOAuthProviderOptions,
31
+ type CallbackResult,
32
+ } from './oauth/index.ts';
@@ -0,0 +1,168 @@
1
+ import { MCPServerManager } from './server-manager.ts';
2
+ import type { MCPConfig, MCPServerConfig, MCPScope } from './types.ts';
3
+ import { promises as fs } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ let globalMCPManager: MCPServerManager | null = null;
7
+
8
+ export function getMCPManager(): MCPServerManager | null {
9
+ return globalMCPManager;
10
+ }
11
+
12
+ export async function initializeMCP(
13
+ config: MCPConfig,
14
+ projectRoot?: string,
15
+ ): Promise<MCPServerManager> {
16
+ if (globalMCPManager) {
17
+ await globalMCPManager.stopAll();
18
+ }
19
+ globalMCPManager = new MCPServerManager();
20
+ if (projectRoot) {
21
+ globalMCPManager.setProjectRoot(projectRoot);
22
+ }
23
+ await globalMCPManager.startServers(config.servers);
24
+ return globalMCPManager;
25
+ }
26
+
27
+ export async function shutdownMCP(): Promise<void> {
28
+ if (globalMCPManager) {
29
+ await globalMCPManager.stopAll();
30
+ globalMCPManager = null;
31
+ }
32
+ }
33
+
34
+ export async function loadMCPConfig(
35
+ projectRoot: string,
36
+ globalConfigDir?: string,
37
+ ): Promise<MCPConfig> {
38
+ const servers: MCPServerConfig[] = [];
39
+ const seen = new Set<string>();
40
+
41
+ const globalPath = globalConfigDir
42
+ ? join(globalConfigDir, 'config.json')
43
+ : null;
44
+ if (globalPath) {
45
+ const globalServers = await readMCPServersFromFile(globalPath);
46
+ for (const s of globalServers) {
47
+ seen.add(s.name);
48
+ servers.push({ ...s, scope: 'global' });
49
+ }
50
+ }
51
+
52
+ const projectPath = join(projectRoot, '.otto', 'config.json');
53
+ const projectServers = await readMCPServersFromFile(projectPath);
54
+ for (const s of projectServers) {
55
+ if (seen.has(s.name)) {
56
+ const idx = servers.findIndex((existing) => existing.name === s.name);
57
+ if (idx >= 0) servers[idx] = { ...s, scope: 'project' };
58
+ } else {
59
+ servers.push({ ...s, scope: 'project' });
60
+ }
61
+ }
62
+
63
+ return { servers };
64
+ }
65
+
66
+ async function readMCPServersFromFile(
67
+ filePath: string,
68
+ ): Promise<MCPServerConfig[]> {
69
+ try {
70
+ const text = await fs.readFile(filePath, 'utf-8');
71
+ const json = JSON.parse(text);
72
+ if (!json?.mcp?.servers) return [];
73
+ const raw = json.mcp.servers;
74
+ if (!Array.isArray(raw)) return [];
75
+ return raw.filter(
76
+ (s: unknown): s is MCPServerConfig =>
77
+ typeof s === 'object' &&
78
+ s !== null &&
79
+ typeof (s as MCPServerConfig).name === 'string' &&
80
+ (typeof (s as MCPServerConfig).command === 'string' ||
81
+ typeof (s as MCPServerConfig).url === 'string'),
82
+ );
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ function resolveConfigPath(
89
+ projectRoot: string,
90
+ globalConfigDir: string | undefined,
91
+ scope: MCPScope,
92
+ ): string {
93
+ if (scope === 'global' && globalConfigDir) {
94
+ return join(globalConfigDir, 'config.json');
95
+ }
96
+ return join(projectRoot, '.otto', 'config.json');
97
+ }
98
+
99
+ async function ensureConfigDir(configPath: string): Promise<void> {
100
+ const dir = configPath.replace(/[/\\][^/\\]+$/, '');
101
+ await fs.mkdir(dir, { recursive: true });
102
+ }
103
+
104
+ export async function addMCPServerToConfig(
105
+ projectRoot: string,
106
+ server: MCPServerConfig,
107
+ globalConfigDir?: string,
108
+ ): Promise<void> {
109
+ const scope: MCPScope = server.scope ?? 'global';
110
+ const configPath = resolveConfigPath(projectRoot, globalConfigDir, scope);
111
+
112
+ let json: Record<string, unknown> = {};
113
+ try {
114
+ const text = await fs.readFile(configPath, 'utf-8');
115
+ json = JSON.parse(text);
116
+ } catch {}
117
+
118
+ if (!json.mcp) json.mcp = {};
119
+ const mcp = json.mcp as Record<string, unknown>;
120
+ if (!Array.isArray(mcp.servers)) mcp.servers = [];
121
+
122
+ const servers = mcp.servers as MCPServerConfig[];
123
+ const idx = servers.findIndex((s) => s.name === server.name);
124
+
125
+ const { scope: _scope, ...serverWithoutScope } = server;
126
+ if (idx >= 0) {
127
+ servers[idx] = serverWithoutScope;
128
+ } else {
129
+ servers.push(serverWithoutScope);
130
+ }
131
+
132
+ await ensureConfigDir(configPath);
133
+ await fs.writeFile(configPath, JSON.stringify(json, null, '\t'), 'utf-8');
134
+ }
135
+
136
+ export async function removeMCPServerFromConfig(
137
+ projectRoot: string,
138
+ name: string,
139
+ globalConfigDir?: string,
140
+ ): Promise<boolean> {
141
+ const paths = [
142
+ ...(globalConfigDir ? [join(globalConfigDir, 'config.json')] : []),
143
+ join(projectRoot, '.otto', 'config.json'),
144
+ ];
145
+
146
+ for (const configPath of paths) {
147
+ let json: Record<string, unknown> = {};
148
+ try {
149
+ const text = await fs.readFile(configPath, 'utf-8');
150
+ json = JSON.parse(text);
151
+ } catch {
152
+ continue;
153
+ }
154
+
155
+ const mcp = json.mcp as Record<string, unknown> | undefined;
156
+ if (!mcp || !Array.isArray(mcp.servers)) continue;
157
+
158
+ const servers = mcp.servers as MCPServerConfig[];
159
+ const idx = servers.findIndex((s) => s.name === name);
160
+ if (idx < 0) continue;
161
+
162
+ servers.splice(idx, 1);
163
+ await fs.writeFile(configPath, JSON.stringify(json, null, '\t'), 'utf-8');
164
+ return true;
165
+ }
166
+
167
+ return false;
168
+ }
@@ -0,0 +1,83 @@
1
+ import { createServer, type Server } from 'node:http';
2
+
3
+ const SUCCESS_HTML = `<!DOCTYPE html>
4
+ <html>
5
+ <body style="font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#111;color:#fff">
6
+ <div style="text-align:center">
7
+ <h1>Authorized</h1>
8
+ <p>You can close this window and return to ottocode.</p>
9
+ <script>setTimeout(()=>window.close(),2000)</script>
10
+ </div>
11
+ </body>
12
+ </html>`;
13
+
14
+ export interface CallbackResult {
15
+ code: string;
16
+ state?: string;
17
+ }
18
+
19
+ export class OAuthCallbackServer {
20
+ private server: Server | null = null;
21
+ private port: number;
22
+
23
+ constructor(port: number) {
24
+ this.port = port;
25
+ }
26
+
27
+ waitForCallback(timeoutMs = 300000): Promise<CallbackResult> {
28
+ return new Promise((resolve, reject) => {
29
+ const timer = setTimeout(() => {
30
+ this.close();
31
+ reject(new Error('OAuth callback timed out'));
32
+ }, timeoutMs);
33
+
34
+ this.server = createServer((req, res) => {
35
+ if (!req.url || req.url === '/favicon.ico') {
36
+ res.writeHead(404);
37
+ res.end();
38
+ return;
39
+ }
40
+
41
+ const parsed = new URL(req.url, `http://localhost:${this.port}`);
42
+ const code = parsed.searchParams.get('code');
43
+ const error = parsed.searchParams.get('error');
44
+ const state = parsed.searchParams.get('state');
45
+
46
+ if (error) {
47
+ res.writeHead(400, { 'Content-Type': 'text/html' });
48
+ res.end(`<h1>Authorization failed: ${error}</h1>`);
49
+ clearTimeout(timer);
50
+ this.close();
51
+ reject(new Error(`OAuth error: ${error}`));
52
+ return;
53
+ }
54
+
55
+ if (code) {
56
+ res.writeHead(200, { 'Content-Type': 'text/html' });
57
+ res.end(SUCCESS_HTML);
58
+ clearTimeout(timer);
59
+ this.close();
60
+ resolve({ code, state: state ?? undefined });
61
+ return;
62
+ }
63
+
64
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
65
+ res.end('Missing authorization code');
66
+ });
67
+
68
+ this.server.listen(this.port, '127.0.0.1', () => {});
69
+
70
+ this.server.on('error', (err) => {
71
+ clearTimeout(timer);
72
+ reject(err);
73
+ });
74
+ });
75
+ }
76
+
77
+ close(): void {
78
+ if (this.server) {
79
+ this.server.close();
80
+ this.server = null;
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,6 @@
1
+ export { OAuthCredentialStore, type StoredOAuthData } from './store.ts';
2
+ export { OAuthCallbackServer, type CallbackResult } from './callback.ts';
3
+ export {
4
+ OttoOAuthProvider,
5
+ type OttoOAuthProviderOptions,
6
+ } from './provider.ts';