@ottocode/sdk 0.1.201 → 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.201",
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",
@@ -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
@@ -133,6 +133,7 @@ export type {
133
133
  MCPToolInfo,
134
134
  MCPTransport,
135
135
  MCPOAuthConfig,
136
+ MCPScope,
136
137
  StoredOAuthData,
137
138
  OttoOAuthProviderOptions,
138
139
  CallbackResult,
@@ -4,6 +4,7 @@ export type {
4
4
  MCPServerStatus,
5
5
  MCPTransport,
6
6
  MCPOAuthConfig,
7
+ MCPScope,
7
8
  } from './types.ts';
8
9
 
9
10
  export { MCPClientWrapper, type MCPToolInfo } from './client.ts';
@@ -1,5 +1,5 @@
1
1
  import { MCPServerManager } from './server-manager.ts';
2
- import type { MCPConfig, MCPServerConfig } from './types.ts';
2
+ import type { MCPConfig, MCPServerConfig, MCPScope } from './types.ts';
3
3
  import { promises as fs } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
 
@@ -11,11 +11,15 @@ export function getMCPManager(): MCPServerManager | null {
11
11
 
12
12
  export async function initializeMCP(
13
13
  config: MCPConfig,
14
+ projectRoot?: string,
14
15
  ): Promise<MCPServerManager> {
15
16
  if (globalMCPManager) {
16
17
  await globalMCPManager.stopAll();
17
18
  }
18
19
  globalMCPManager = new MCPServerManager();
20
+ if (projectRoot) {
21
+ globalMCPManager.setProjectRoot(projectRoot);
22
+ }
19
23
  await globalMCPManager.startServers(config.servers);
20
24
  return globalMCPManager;
21
25
  }
@@ -41,7 +45,7 @@ export async function loadMCPConfig(
41
45
  const globalServers = await readMCPServersFromFile(globalPath);
42
46
  for (const s of globalServers) {
43
47
  seen.add(s.name);
44
- servers.push(s);
48
+ servers.push({ ...s, scope: 'global' });
45
49
  }
46
50
  }
47
51
 
@@ -50,9 +54,9 @@ export async function loadMCPConfig(
50
54
  for (const s of projectServers) {
51
55
  if (seen.has(s.name)) {
52
56
  const idx = servers.findIndex((existing) => existing.name === s.name);
53
- if (idx >= 0) servers[idx] = s;
57
+ if (idx >= 0) servers[idx] = { ...s, scope: 'project' };
54
58
  } else {
55
- servers.push(s);
59
+ servers.push({ ...s, scope: 'project' });
56
60
  }
57
61
  }
58
62
 
@@ -81,11 +85,30 @@ async function readMCPServersFromFile(
81
85
  }
82
86
  }
83
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
+
84
104
  export async function addMCPServerToConfig(
85
105
  projectRoot: string,
86
106
  server: MCPServerConfig,
107
+ globalConfigDir?: string,
87
108
  ): Promise<void> {
88
- const configPath = join(projectRoot, '.otto', 'config.json');
109
+ const scope: MCPScope = server.scope ?? 'global';
110
+ const configPath = resolveConfigPath(projectRoot, globalConfigDir, scope);
111
+
89
112
  let json: Record<string, unknown> = {};
90
113
  try {
91
114
  const text = await fs.readFile(configPath, 'utf-8');
@@ -98,37 +121,48 @@ export async function addMCPServerToConfig(
98
121
 
99
122
  const servers = mcp.servers as MCPServerConfig[];
100
123
  const idx = servers.findIndex((s) => s.name === server.name);
124
+
125
+ const { scope: _scope, ...serverWithoutScope } = server;
101
126
  if (idx >= 0) {
102
- servers[idx] = server;
127
+ servers[idx] = serverWithoutScope;
103
128
  } else {
104
- servers.push(server);
129
+ servers.push(serverWithoutScope);
105
130
  }
106
131
 
107
- await fs.mkdir(join(projectRoot, '.otto'), { recursive: true });
132
+ await ensureConfigDir(configPath);
108
133
  await fs.writeFile(configPath, JSON.stringify(json, null, '\t'), 'utf-8');
109
134
  }
110
135
 
111
136
  export async function removeMCPServerFromConfig(
112
137
  projectRoot: string,
113
138
  name: string,
139
+ globalConfigDir?: string,
114
140
  ): Promise<boolean> {
115
- const configPath = join(projectRoot, '.otto', 'config.json');
116
- let json: Record<string, unknown> = {};
117
- try {
118
- const text = await fs.readFile(configPath, 'utf-8');
119
- json = JSON.parse(text);
120
- } catch {
121
- return false;
122
- }
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
+ }
123
154
 
124
- const mcp = json.mcp as Record<string, unknown> | undefined;
125
- if (!mcp || !Array.isArray(mcp.servers)) return false;
155
+ const mcp = json.mcp as Record<string, unknown> | undefined;
156
+ if (!mcp || !Array.isArray(mcp.servers)) continue;
126
157
 
127
- const servers = mcp.servers as MCPServerConfig[];
128
- const idx = servers.findIndex((s) => s.name === name);
129
- if (idx < 0) return false;
158
+ const servers = mcp.servers as MCPServerConfig[];
159
+ const idx = servers.findIndex((s) => s.name === name);
160
+ if (idx < 0) continue;
130
161
 
131
- servers.splice(idx, 1);
132
- await fs.writeFile(configPath, JSON.stringify(json, null, '\t'), 'utf-8');
133
- return true;
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;
134
168
  }
@@ -1,5 +1,6 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { getSecureOAuthDir } from '../../../../config/src/paths.ts';
3
4
 
4
5
  export interface StoredOAuthData {
5
6
  tokens?: {
@@ -22,14 +23,7 @@ export class OAuthCredentialStore {
22
23
  private storePath: string;
23
24
 
24
25
  constructor(storePath?: string) {
25
- this.storePath =
26
- storePath ??
27
- join(
28
- process.env.HOME ?? process.env.USERPROFILE ?? '',
29
- '.config',
30
- 'otto',
31
- 'oauth',
32
- );
26
+ this.storePath = storePath ?? getSecureOAuthDir();
33
27
  }
34
28
 
35
29
  private filePath(serverName: string): string {
@@ -2,6 +2,7 @@ import { MCPClientWrapper, type MCPToolInfo } from './client.ts';
2
2
  import type { MCPServerConfig, MCPServerStatus } from './types.ts';
3
3
  import { OAuthCredentialStore } from './oauth/store.ts';
4
4
  import { OttoOAuthProvider } from './oauth/provider.ts';
5
+ import { createHash } from 'node:crypto';
5
6
 
6
7
  type IndexedTool = {
7
8
  server: string;
@@ -14,17 +15,36 @@ export class MCPServerManager {
14
15
  private authProviders = new Map<string, OttoOAuthProvider>();
15
16
  private pendingAuth = new Map<string, string>();
16
17
  private oauthStore = new OAuthCredentialStore();
18
+ private serverScopes = new Map<string, 'global' | 'project'>();
17
19
  private _started = false;
20
+ private projectRoot: string | null = null;
18
21
 
19
22
  get started(): boolean {
20
23
  return this._started;
21
24
  }
22
25
 
26
+ setProjectRoot(projectRoot: string): void {
27
+ this.projectRoot = projectRoot;
28
+ }
29
+
30
+ private oauthKey(serverName: string): string {
31
+ const scope = this.serverScopes.get(serverName);
32
+ if (scope === 'project' && this.projectRoot) {
33
+ const hash = createHash('sha256')
34
+ .update(this.projectRoot)
35
+ .digest('hex')
36
+ .slice(0, 8);
37
+ return `${serverName}_proj_${hash}`;
38
+ }
39
+ return serverName;
40
+ }
41
+
23
42
  async startServers(configs: MCPServerConfig[]): Promise<void> {
24
43
  await this.stopAll();
25
44
 
26
45
  for (const config of configs) {
27
46
  if (config.disabled) continue;
47
+ this.serverScopes.set(config.name, config.scope ?? 'global');
28
48
  await this.startSingleServer(config);
29
49
  }
30
50
  this._started = true;
@@ -38,7 +58,8 @@ export class MCPServerManager {
38
58
  const hasStaticAuth =
39
59
  config.headers?.Authorization || config.headers?.authorization;
40
60
  if (!hasStaticAuth) {
41
- const provider = new OttoOAuthProvider(config.name, this.oauthStore, {
61
+ const key = this.oauthKey(config.name);
62
+ const provider = new OttoOAuthProvider(key, this.oauthStore, {
42
63
  clientId: config.oauth?.clientId,
43
64
  callbackPort: config.oauth?.callbackPort,
44
65
  scopes: config.oauth?.scopes,
@@ -104,6 +125,7 @@ export class MCPServerManager {
104
125
  this.toolsMap.clear();
105
126
  this.authProviders.clear();
106
127
  this.pendingAuth.clear();
128
+ this.serverScopes.clear();
107
129
  this._started = false;
108
130
  }
109
131
 
@@ -137,8 +159,9 @@ export class MCPServerManager {
137
159
  .filter(([, v]) => v.server === name)
138
160
  .map(([k]) => k);
139
161
  const config = client.serverConfig;
162
+ const key = this.oauthKey(name);
140
163
  const _authenticated = this.oauthStore
141
- .isAuthenticated(name)
164
+ .isAuthenticated(key)
142
165
  .catch(() => false);
143
166
 
144
167
  statuses.push({
@@ -161,8 +184,9 @@ export class MCPServerManager {
161
184
  .filter(([, v]) => v.server === name)
162
185
  .map(([k]) => k);
163
186
  const config = client.serverConfig;
187
+ const key = this.oauthKey(name);
164
188
  const authenticated = await this.oauthStore
165
- .isAuthenticated(name)
189
+ .isAuthenticated(key)
166
190
  .catch(() => false);
167
191
 
168
192
  statuses.push({
@@ -194,7 +218,9 @@ export class MCPServerManager {
194
218
  const transport = config.transport ?? 'stdio';
195
219
  if (transport === 'stdio') return null;
196
220
 
197
- const provider = new OttoOAuthProvider(config.name, this.oauthStore, {
221
+ this.serverScopes.set(config.name, config.scope ?? 'global');
222
+ const key = this.oauthKey(config.name);
223
+ const provider = new OttoOAuthProvider(key, this.oauthStore, {
198
224
  clientId: config.oauth?.clientId,
199
225
  callbackPort: config.oauth?.callbackPort,
200
226
  scopes: config.oauth?.scopes,
@@ -272,7 +298,8 @@ export class MCPServerManager {
272
298
  async getAuthStatus(
273
299
  name: string,
274
300
  ): Promise<{ authenticated: boolean; expiresAt?: number }> {
275
- const tokens = await this.oauthStore.loadTokens(name);
301
+ const key = this.oauthKey(name);
302
+ const tokens = await this.oauthStore.loadTokens(key);
276
303
  if (!tokens?.access_token) return { authenticated: false };
277
304
  return {
278
305
  authenticated: true,
@@ -282,6 +309,7 @@ export class MCPServerManager {
282
309
 
283
310
  async restartServer(config: MCPServerConfig): Promise<void> {
284
311
  await this.stopServer(config.name);
312
+ this.serverScopes.set(config.name, config.scope ?? 'global');
285
313
  await this.startSingleServer(config);
286
314
  }
287
315
 
@@ -1,4 +1,5 @@
1
1
  export type MCPTransport = 'stdio' | 'http' | 'sse';
2
+ export type MCPScope = 'global' | 'project';
2
3
 
3
4
  export interface MCPOAuthConfig {
4
5
  clientId?: string;
@@ -21,6 +22,8 @@ export interface MCPServerConfig {
21
22
  oauth?: MCPOAuthConfig;
22
23
 
23
24
  disabled?: boolean;
25
+
26
+ scope?: MCPScope;
24
27
  }
25
28
 
26
29
  export interface MCPConfig {
package/src/index.ts CHANGED
@@ -171,6 +171,8 @@ export {
171
171
  getGlobalToolsDir,
172
172
  getGlobalCommandsDir,
173
173
  getSecureAuthPath,
174
+ getSecureBaseDir,
175
+ getSecureOAuthDir,
174
176
  getHomeDir,
175
177
  } from './config/src/paths.ts';
176
178
  export {
@@ -358,6 +360,7 @@ export type {
358
360
  MCPToolInfo,
359
361
  MCPTransport,
360
362
  MCPOAuthConfig,
363
+ MCPScope,
361
364
  StoredOAuthData,
362
365
  OttoOAuthProviderOptions,
363
366
  CallbackResult,