@ottocode/sdk 0.1.199 → 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.
- package/package.json +2 -1
- package/src/core/src/index.ts +29 -0
- package/src/core/src/mcp/client.ts +229 -0
- package/src/core/src/mcp/index.ts +31 -0
- package/src/core/src/mcp/lifecycle.ts +134 -0
- package/src/core/src/mcp/oauth/callback.ts +83 -0
- package/src/core/src/mcp/oauth/index.ts +6 -0
- package/src/core/src/mcp/oauth/provider.ts +149 -0
- package/src/core/src/mcp/oauth/store.ts +121 -0
- package/src/core/src/mcp/server-manager.ts +304 -0
- package/src/core/src/mcp/tools.ts +97 -0
- package/src/core/src/mcp/types.ts +39 -0
- package/src/core/src/tools/builtin/ripgrep.ts +12 -4
- package/src/core/src/tools/loader.ts +10 -3
- package/src/index.ts +29 -0
- package/src/core/src/tools/builtin/grep.ts +0 -134
- package/src/core/src/tools/builtin/grep.txt +0 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ottocode/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.201",
|
|
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",
|
package/src/core/src/index.ts
CHANGED
|
@@ -108,3 +108,32 @@ 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
|
+
StoredOAuthData,
|
|
137
|
+
OttoOAuthProviderOptions,
|
|
138
|
+
CallbackResult,
|
|
139
|
+
} 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,31 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
MCPServerConfig,
|
|
3
|
+
MCPConfig,
|
|
4
|
+
MCPServerStatus,
|
|
5
|
+
MCPTransport,
|
|
6
|
+
MCPOAuthConfig,
|
|
7
|
+
} from './types.ts';
|
|
8
|
+
|
|
9
|
+
export { MCPClientWrapper, type MCPToolInfo } from './client.ts';
|
|
10
|
+
|
|
11
|
+
export { MCPServerManager } from './server-manager.ts';
|
|
12
|
+
|
|
13
|
+
export { convertMCPToolsToAISDK } from './tools.ts';
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
getMCPManager,
|
|
17
|
+
initializeMCP,
|
|
18
|
+
shutdownMCP,
|
|
19
|
+
loadMCPConfig,
|
|
20
|
+
addMCPServerToConfig,
|
|
21
|
+
removeMCPServerFromConfig,
|
|
22
|
+
} from './lifecycle.ts';
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
OAuthCredentialStore,
|
|
26
|
+
OttoOAuthProvider,
|
|
27
|
+
OAuthCallbackServer,
|
|
28
|
+
type StoredOAuthData,
|
|
29
|
+
type OttoOAuthProviderOptions,
|
|
30
|
+
type CallbackResult,
|
|
31
|
+
} from './oauth/index.ts';
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { MCPServerManager } from './server-manager.ts';
|
|
2
|
+
import type { MCPConfig, MCPServerConfig } 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
|
+
): Promise<MCPServerManager> {
|
|
15
|
+
if (globalMCPManager) {
|
|
16
|
+
await globalMCPManager.stopAll();
|
|
17
|
+
}
|
|
18
|
+
globalMCPManager = new MCPServerManager();
|
|
19
|
+
await globalMCPManager.startServers(config.servers);
|
|
20
|
+
return globalMCPManager;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function shutdownMCP(): Promise<void> {
|
|
24
|
+
if (globalMCPManager) {
|
|
25
|
+
await globalMCPManager.stopAll();
|
|
26
|
+
globalMCPManager = null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function loadMCPConfig(
|
|
31
|
+
projectRoot: string,
|
|
32
|
+
globalConfigDir?: string,
|
|
33
|
+
): Promise<MCPConfig> {
|
|
34
|
+
const servers: MCPServerConfig[] = [];
|
|
35
|
+
const seen = new Set<string>();
|
|
36
|
+
|
|
37
|
+
const globalPath = globalConfigDir
|
|
38
|
+
? join(globalConfigDir, 'config.json')
|
|
39
|
+
: null;
|
|
40
|
+
if (globalPath) {
|
|
41
|
+
const globalServers = await readMCPServersFromFile(globalPath);
|
|
42
|
+
for (const s of globalServers) {
|
|
43
|
+
seen.add(s.name);
|
|
44
|
+
servers.push(s);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const projectPath = join(projectRoot, '.otto', 'config.json');
|
|
49
|
+
const projectServers = await readMCPServersFromFile(projectPath);
|
|
50
|
+
for (const s of projectServers) {
|
|
51
|
+
if (seen.has(s.name)) {
|
|
52
|
+
const idx = servers.findIndex((existing) => existing.name === s.name);
|
|
53
|
+
if (idx >= 0) servers[idx] = s;
|
|
54
|
+
} else {
|
|
55
|
+
servers.push(s);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { servers };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function readMCPServersFromFile(
|
|
63
|
+
filePath: string,
|
|
64
|
+
): Promise<MCPServerConfig[]> {
|
|
65
|
+
try {
|
|
66
|
+
const text = await fs.readFile(filePath, 'utf-8');
|
|
67
|
+
const json = JSON.parse(text);
|
|
68
|
+
if (!json?.mcp?.servers) return [];
|
|
69
|
+
const raw = json.mcp.servers;
|
|
70
|
+
if (!Array.isArray(raw)) return [];
|
|
71
|
+
return raw.filter(
|
|
72
|
+
(s: unknown): s is MCPServerConfig =>
|
|
73
|
+
typeof s === 'object' &&
|
|
74
|
+
s !== null &&
|
|
75
|
+
typeof (s as MCPServerConfig).name === 'string' &&
|
|
76
|
+
(typeof (s as MCPServerConfig).command === 'string' ||
|
|
77
|
+
typeof (s as MCPServerConfig).url === 'string'),
|
|
78
|
+
);
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function addMCPServerToConfig(
|
|
85
|
+
projectRoot: string,
|
|
86
|
+
server: MCPServerConfig,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const configPath = join(projectRoot, '.otto', 'config.json');
|
|
89
|
+
let json: Record<string, unknown> = {};
|
|
90
|
+
try {
|
|
91
|
+
const text = await fs.readFile(configPath, 'utf-8');
|
|
92
|
+
json = JSON.parse(text);
|
|
93
|
+
} catch {}
|
|
94
|
+
|
|
95
|
+
if (!json.mcp) json.mcp = {};
|
|
96
|
+
const mcp = json.mcp as Record<string, unknown>;
|
|
97
|
+
if (!Array.isArray(mcp.servers)) mcp.servers = [];
|
|
98
|
+
|
|
99
|
+
const servers = mcp.servers as MCPServerConfig[];
|
|
100
|
+
const idx = servers.findIndex((s) => s.name === server.name);
|
|
101
|
+
if (idx >= 0) {
|
|
102
|
+
servers[idx] = server;
|
|
103
|
+
} else {
|
|
104
|
+
servers.push(server);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await fs.mkdir(join(projectRoot, '.otto'), { recursive: true });
|
|
108
|
+
await fs.writeFile(configPath, JSON.stringify(json, null, '\t'), 'utf-8');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function removeMCPServerFromConfig(
|
|
112
|
+
projectRoot: string,
|
|
113
|
+
name: string,
|
|
114
|
+
): 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
|
+
}
|
|
123
|
+
|
|
124
|
+
const mcp = json.mcp as Record<string, unknown> | undefined;
|
|
125
|
+
if (!mcp || !Array.isArray(mcp.servers)) return false;
|
|
126
|
+
|
|
127
|
+
const servers = mcp.servers as MCPServerConfig[];
|
|
128
|
+
const idx = servers.findIndex((s) => s.name === name);
|
|
129
|
+
if (idx < 0) return false;
|
|
130
|
+
|
|
131
|
+
servers.splice(idx, 1);
|
|
132
|
+
await fs.writeFile(configPath, JSON.stringify(json, null, '\t'), 'utf-8');
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
@@ -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,149 @@
|
|
|
1
|
+
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
|
2
|
+
import type {
|
|
3
|
+
OAuthClientMetadata,
|
|
4
|
+
OAuthClientInformationMixed,
|
|
5
|
+
OAuthTokens,
|
|
6
|
+
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
|
7
|
+
import type { OAuthCredentialStore } from './store.ts';
|
|
8
|
+
import { OAuthCallbackServer } from './callback.ts';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_CALLBACK_PORT = 8090;
|
|
11
|
+
|
|
12
|
+
export interface OttoOAuthProviderOptions {
|
|
13
|
+
clientId?: string;
|
|
14
|
+
callbackPort?: number;
|
|
15
|
+
scopes?: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class OttoOAuthProvider implements OAuthClientProvider {
|
|
19
|
+
private serverName: string;
|
|
20
|
+
private store: OAuthCredentialStore;
|
|
21
|
+
private callbackPort: number;
|
|
22
|
+
private presetClientId?: string;
|
|
23
|
+
private scopes?: string[];
|
|
24
|
+
private callbackServer: OAuthCallbackServer | null = null;
|
|
25
|
+
private _pendingAuthUrl: string | null = null;
|
|
26
|
+
private _authResolve: ((code: string) => void) | null = null;
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
serverName: string,
|
|
30
|
+
store: OAuthCredentialStore,
|
|
31
|
+
options?: OttoOAuthProviderOptions,
|
|
32
|
+
) {
|
|
33
|
+
this.serverName = serverName;
|
|
34
|
+
this.store = store;
|
|
35
|
+
this.callbackPort = options?.callbackPort ?? DEFAULT_CALLBACK_PORT;
|
|
36
|
+
this.presetClientId = options?.clientId;
|
|
37
|
+
this.scopes = options?.scopes;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get redirectUrl(): string {
|
|
41
|
+
return `http://localhost:${this.callbackPort}/callback`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get clientMetadata(): OAuthClientMetadata {
|
|
45
|
+
return {
|
|
46
|
+
client_name: 'ottocode',
|
|
47
|
+
redirect_uris: [this.redirectUrl],
|
|
48
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
49
|
+
response_types: ['code'],
|
|
50
|
+
token_endpoint_auth_method: 'none',
|
|
51
|
+
...(this.scopes?.length ? { scope: this.scopes.join(' ') } : {}),
|
|
52
|
+
} as OAuthClientMetadata;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get pendingAuthUrl(): string | null {
|
|
56
|
+
return this._pendingAuthUrl;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
|
|
60
|
+
if (this.presetClientId) {
|
|
61
|
+
return { client_id: this.presetClientId } as OAuthClientInformationMixed;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const stored = await this.store.loadClientInfo(this.serverName);
|
|
65
|
+
if (stored?.client_id) {
|
|
66
|
+
return stored as unknown as OAuthClientInformationMixed;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async saveClientInformation(
|
|
72
|
+
clientInformation: OAuthClientInformationMixed,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
await this.store.saveClientInfo(
|
|
75
|
+
this.serverName,
|
|
76
|
+
clientInformation as unknown as {
|
|
77
|
+
client_id: string;
|
|
78
|
+
[key: string]: unknown;
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async tokens(): Promise<OAuthTokens | undefined> {
|
|
84
|
+
const stored = await this.store.loadTokens(this.serverName);
|
|
85
|
+
if (!stored) return undefined;
|
|
86
|
+
return stored as unknown as OAuthTokens;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
|
90
|
+
const expiresAt = tokens.expires_in
|
|
91
|
+
? Math.floor(Date.now() / 1000) + tokens.expires_in
|
|
92
|
+
: undefined;
|
|
93
|
+
|
|
94
|
+
await this.store.saveTokens(this.serverName, {
|
|
95
|
+
...(tokens as unknown as Record<string, unknown>),
|
|
96
|
+
expires_at: expiresAt,
|
|
97
|
+
} as {
|
|
98
|
+
access_token: string;
|
|
99
|
+
token_type?: string;
|
|
100
|
+
expires_in?: number;
|
|
101
|
+
refresh_token?: string;
|
|
102
|
+
scope?: string;
|
|
103
|
+
expires_at?: number;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
|
108
|
+
this._pendingAuthUrl = authorizationUrl.toString();
|
|
109
|
+
|
|
110
|
+
this.callbackServer = new OAuthCallbackServer(this.callbackPort);
|
|
111
|
+
|
|
112
|
+
this.callbackServer
|
|
113
|
+
.waitForCallback()
|
|
114
|
+
.then((result) => {
|
|
115
|
+
if (this._authResolve) {
|
|
116
|
+
this._authResolve(result.code);
|
|
117
|
+
this._authResolve = null;
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
.catch(() => {});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
|
124
|
+
await this.store.saveCodeVerifier(this.serverName, codeVerifier);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async codeVerifier(): Promise<string> {
|
|
128
|
+
const stored = await this.store.loadCodeVerifier(this.serverName);
|
|
129
|
+
if (!stored) throw new Error('No code verifier found');
|
|
130
|
+
return stored;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
waitForAuthCode(): Promise<string> {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
this._authResolve = resolve;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
cleanup(): void {
|
|
140
|
+
this.callbackServer?.close();
|
|
141
|
+
this.callbackServer = null;
|
|
142
|
+
this._pendingAuthUrl = null;
|
|
143
|
+
this._authResolve = null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async clearCredentials(): Promise<void> {
|
|
147
|
+
await this.store.clearServer(this.serverName);
|
|
148
|
+
}
|
|
149
|
+
}
|