@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 +2 -1
- package/src/config/src/paths.ts +16 -14
- package/src/core/src/index.ts +30 -0
- package/src/core/src/mcp/client.ts +229 -0
- package/src/core/src/mcp/index.ts +32 -0
- package/src/core/src/mcp/lifecycle.ts +168 -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 +115 -0
- package/src/core/src/mcp/server-manager.ts +332 -0
- package/src/core/src/mcp/tools.ts +97 -0
- package/src/core/src/mcp/types.ts +42 -0
- package/src/core/src/tools/builtin/ripgrep.ts +12 -4
- package/src/core/src/tools/loader.ts +10 -3
- package/src/index.ts +32 -0
- package/src/core/src/tools/builtin/grep.ts +0 -134
- package/src/core/src/tools/builtin/grep.txt +0 -9
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { getSecureOAuthDir } from '../../../../config/src/paths.ts';
|
|
4
|
+
|
|
5
|
+
export interface StoredOAuthData {
|
|
6
|
+
tokens?: {
|
|
7
|
+
access_token: string;
|
|
8
|
+
token_type?: string;
|
|
9
|
+
expires_in?: number;
|
|
10
|
+
refresh_token?: string;
|
|
11
|
+
scope?: string;
|
|
12
|
+
expires_at?: number;
|
|
13
|
+
};
|
|
14
|
+
clientInfo?: {
|
|
15
|
+
client_id: string;
|
|
16
|
+
client_secret?: string;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
};
|
|
19
|
+
codeVerifier?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class OAuthCredentialStore {
|
|
23
|
+
private storePath: string;
|
|
24
|
+
|
|
25
|
+
constructor(storePath?: string) {
|
|
26
|
+
this.storePath = storePath ?? getSecureOAuthDir();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private filePath(serverName: string): string {
|
|
30
|
+
const safe = serverName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
31
|
+
return join(this.storePath, `${safe}.json`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private async read(serverName: string): Promise<StoredOAuthData> {
|
|
35
|
+
try {
|
|
36
|
+
const text = await fs.readFile(this.filePath(serverName), 'utf-8');
|
|
37
|
+
return JSON.parse(text);
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private async write(
|
|
44
|
+
serverName: string,
|
|
45
|
+
data: StoredOAuthData,
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
await fs.mkdir(this.storePath, { recursive: true, mode: 0o700 });
|
|
48
|
+
await fs.writeFile(
|
|
49
|
+
this.filePath(serverName),
|
|
50
|
+
JSON.stringify(data, null, '\t'),
|
|
51
|
+
{ encoding: 'utf-8', mode: 0o600 },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async loadTokens(
|
|
56
|
+
serverName: string,
|
|
57
|
+
): Promise<StoredOAuthData['tokens'] | undefined> {
|
|
58
|
+
const data = await this.read(serverName);
|
|
59
|
+
return data.tokens;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async saveTokens(
|
|
63
|
+
serverName: string,
|
|
64
|
+
tokens: StoredOAuthData['tokens'],
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
const data = await this.read(serverName);
|
|
67
|
+
data.tokens = tokens;
|
|
68
|
+
await this.write(serverName, data);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async loadClientInfo(
|
|
72
|
+
serverName: string,
|
|
73
|
+
): Promise<StoredOAuthData['clientInfo'] | undefined> {
|
|
74
|
+
const data = await this.read(serverName);
|
|
75
|
+
return data.clientInfo;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async saveClientInfo(
|
|
79
|
+
serverName: string,
|
|
80
|
+
clientInfo: StoredOAuthData['clientInfo'],
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
const data = await this.read(serverName);
|
|
83
|
+
data.clientInfo = clientInfo;
|
|
84
|
+
await this.write(serverName, data);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async loadCodeVerifier(serverName: string): Promise<string | undefined> {
|
|
88
|
+
const data = await this.read(serverName);
|
|
89
|
+
return data.codeVerifier;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async saveCodeVerifier(
|
|
93
|
+
serverName: string,
|
|
94
|
+
codeVerifier: string,
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
const data = await this.read(serverName);
|
|
97
|
+
data.codeVerifier = codeVerifier;
|
|
98
|
+
await this.write(serverName, data);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async clearServer(serverName: string): Promise<void> {
|
|
102
|
+
try {
|
|
103
|
+
await fs.unlink(this.filePath(serverName));
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async isAuthenticated(serverName: string): Promise<boolean> {
|
|
108
|
+
const tokens = await this.loadTokens(serverName);
|
|
109
|
+
if (!tokens?.access_token) return false;
|
|
110
|
+
if (tokens.expires_at && tokens.expires_at < Date.now() / 1000) {
|
|
111
|
+
return !!tokens.refresh_token;
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
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
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
type IndexedTool = {
|
|
8
|
+
server: string;
|
|
9
|
+
tool: MCPToolInfo;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export class MCPServerManager {
|
|
13
|
+
private clients = new Map<string, MCPClientWrapper>();
|
|
14
|
+
private toolsMap = new Map<string, IndexedTool>();
|
|
15
|
+
private authProviders = new Map<string, OttoOAuthProvider>();
|
|
16
|
+
private pendingAuth = new Map<string, string>();
|
|
17
|
+
private oauthStore = new OAuthCredentialStore();
|
|
18
|
+
private serverScopes = new Map<string, 'global' | 'project'>();
|
|
19
|
+
private _started = false;
|
|
20
|
+
private projectRoot: string | null = null;
|
|
21
|
+
|
|
22
|
+
get started(): boolean {
|
|
23
|
+
return this._started;
|
|
24
|
+
}
|
|
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
|
+
|
|
42
|
+
async startServers(configs: MCPServerConfig[]): Promise<void> {
|
|
43
|
+
await this.stopAll();
|
|
44
|
+
|
|
45
|
+
for (const config of configs) {
|
|
46
|
+
if (config.disabled) continue;
|
|
47
|
+
this.serverScopes.set(config.name, config.scope ?? 'global');
|
|
48
|
+
await this.startSingleServer(config);
|
|
49
|
+
}
|
|
50
|
+
this._started = true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private async startSingleServer(config: MCPServerConfig): Promise<void> {
|
|
54
|
+
const client = new MCPClientWrapper(config);
|
|
55
|
+
const transport = config.transport ?? 'stdio';
|
|
56
|
+
|
|
57
|
+
if (transport !== 'stdio') {
|
|
58
|
+
const hasStaticAuth =
|
|
59
|
+
config.headers?.Authorization || config.headers?.authorization;
|
|
60
|
+
if (!hasStaticAuth) {
|
|
61
|
+
const key = this.oauthKey(config.name);
|
|
62
|
+
const provider = new OttoOAuthProvider(key, this.oauthStore, {
|
|
63
|
+
clientId: config.oauth?.clientId,
|
|
64
|
+
callbackPort: config.oauth?.callbackPort,
|
|
65
|
+
scopes: config.oauth?.scopes,
|
|
66
|
+
});
|
|
67
|
+
client.setAuthProvider(provider);
|
|
68
|
+
this.authProviders.set(config.name, provider);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await client.connect();
|
|
74
|
+
this.clients.set(config.name, client);
|
|
75
|
+
|
|
76
|
+
const tools = await client.listTools();
|
|
77
|
+
for (const tool of tools) {
|
|
78
|
+
const fullName = `${config.name}__${tool.name}`;
|
|
79
|
+
this.toolsMap.set(fullName, { server: config.name, tool });
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
this.clients.set(config.name, client);
|
|
83
|
+
|
|
84
|
+
if (client.authRequired) {
|
|
85
|
+
const provider = this.authProviders.get(config.name);
|
|
86
|
+
if (provider?.pendingAuthUrl) {
|
|
87
|
+
this.pendingAuth.set(config.name, provider.pendingAuthUrl);
|
|
88
|
+
this.waitForAuthAndReconnect(config.name, provider);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
94
|
+
console.error(`[mcp] Failed to start server "${config.name}": ${msg}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private waitForAuthAndReconnect(
|
|
99
|
+
name: string,
|
|
100
|
+
provider: OttoOAuthProvider,
|
|
101
|
+
): void {
|
|
102
|
+
provider
|
|
103
|
+
.waitForAuthCode()
|
|
104
|
+
.then(async (code) => {
|
|
105
|
+
console.log(`[mcp] Auth code received for "${name}", reconnecting...`);
|
|
106
|
+
const success = await this.completeAuth(name, code);
|
|
107
|
+
if (success) {
|
|
108
|
+
console.log(`[mcp] Successfully authenticated "${name}"`);
|
|
109
|
+
} else {
|
|
110
|
+
console.error(`[mcp] Failed to complete auth for "${name}"`);
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
.catch(() => {});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async stopAll(): Promise<void> {
|
|
117
|
+
for (const provider of this.authProviders.values()) {
|
|
118
|
+
provider.cleanup();
|
|
119
|
+
}
|
|
120
|
+
const disconnects = Array.from(this.clients.values()).map((c) =>
|
|
121
|
+
c.disconnect().catch(() => {}),
|
|
122
|
+
);
|
|
123
|
+
await Promise.all(disconnects);
|
|
124
|
+
this.clients.clear();
|
|
125
|
+
this.toolsMap.clear();
|
|
126
|
+
this.authProviders.clear();
|
|
127
|
+
this.pendingAuth.clear();
|
|
128
|
+
this.serverScopes.clear();
|
|
129
|
+
this._started = false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
getTools(): Array<{ name: string; server: string; tool: MCPToolInfo }> {
|
|
133
|
+
return Array.from(this.toolsMap.entries()).map(
|
|
134
|
+
([name, { server, tool }]) => ({
|
|
135
|
+
name,
|
|
136
|
+
server,
|
|
137
|
+
tool,
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async callTool(
|
|
143
|
+
fullName: string,
|
|
144
|
+
args: Record<string, unknown>,
|
|
145
|
+
): Promise<unknown> {
|
|
146
|
+
const entry = this.toolsMap.get(fullName);
|
|
147
|
+
if (!entry) throw new Error(`Unknown MCP tool: ${fullName}`);
|
|
148
|
+
|
|
149
|
+
const client = this.clients.get(entry.server);
|
|
150
|
+
if (!client) throw new Error(`MCP server not connected: ${entry.server}`);
|
|
151
|
+
|
|
152
|
+
return client.callTool(entry.tool.name, args);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
getStatus(): MCPServerStatus[] {
|
|
156
|
+
const statuses: MCPServerStatus[] = [];
|
|
157
|
+
for (const [name, client] of this.clients) {
|
|
158
|
+
const tools = Array.from(this.toolsMap.entries())
|
|
159
|
+
.filter(([, v]) => v.server === name)
|
|
160
|
+
.map(([k]) => k);
|
|
161
|
+
const config = client.serverConfig;
|
|
162
|
+
const key = this.oauthKey(name);
|
|
163
|
+
const _authenticated = this.oauthStore
|
|
164
|
+
.isAuthenticated(key)
|
|
165
|
+
.catch(() => false);
|
|
166
|
+
|
|
167
|
+
statuses.push({
|
|
168
|
+
name,
|
|
169
|
+
connected: client.connected,
|
|
170
|
+
tools,
|
|
171
|
+
transport: config.transport,
|
|
172
|
+
url: config.url,
|
|
173
|
+
authRequired: client.authRequired,
|
|
174
|
+
authenticated: false,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return statuses;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async getStatusAsync(): Promise<MCPServerStatus[]> {
|
|
181
|
+
const statuses: MCPServerStatus[] = [];
|
|
182
|
+
for (const [name, client] of this.clients) {
|
|
183
|
+
const tools = Array.from(this.toolsMap.entries())
|
|
184
|
+
.filter(([, v]) => v.server === name)
|
|
185
|
+
.map(([k]) => k);
|
|
186
|
+
const config = client.serverConfig;
|
|
187
|
+
const key = this.oauthKey(name);
|
|
188
|
+
const authenticated = await this.oauthStore
|
|
189
|
+
.isAuthenticated(key)
|
|
190
|
+
.catch(() => false);
|
|
191
|
+
|
|
192
|
+
statuses.push({
|
|
193
|
+
name,
|
|
194
|
+
connected: client.connected,
|
|
195
|
+
tools,
|
|
196
|
+
transport: config.transport,
|
|
197
|
+
url: config.url,
|
|
198
|
+
authRequired: client.authRequired,
|
|
199
|
+
authenticated,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return statuses;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
getServerNames(): string[] {
|
|
206
|
+
return Array.from(this.clients.keys());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
isServerConnected(name: string): boolean {
|
|
210
|
+
return this.clients.get(name)?.connected ?? false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
getAuthUrl(name: string): string | null {
|
|
214
|
+
return this.pendingAuth.get(name) ?? null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async initiateAuth(config: MCPServerConfig): Promise<string | null> {
|
|
218
|
+
const transport = config.transport ?? 'stdio';
|
|
219
|
+
if (transport === 'stdio') return null;
|
|
220
|
+
|
|
221
|
+
this.serverScopes.set(config.name, config.scope ?? 'global');
|
|
222
|
+
const key = this.oauthKey(config.name);
|
|
223
|
+
const provider = new OttoOAuthProvider(key, this.oauthStore, {
|
|
224
|
+
clientId: config.oauth?.clientId,
|
|
225
|
+
callbackPort: config.oauth?.callbackPort,
|
|
226
|
+
scopes: config.oauth?.scopes,
|
|
227
|
+
});
|
|
228
|
+
this.authProviders.set(config.name, provider);
|
|
229
|
+
|
|
230
|
+
const client = new MCPClientWrapper(config);
|
|
231
|
+
client.setAuthProvider(provider);
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
await client.connect();
|
|
235
|
+
this.clients.set(config.name, client);
|
|
236
|
+
const tools = await client.listTools();
|
|
237
|
+
for (const tool of tools) {
|
|
238
|
+
const fullName = `${config.name}__${tool.name}`;
|
|
239
|
+
this.toolsMap.set(fullName, { server: config.name, tool });
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
} catch {
|
|
243
|
+
this.clients.set(config.name, client);
|
|
244
|
+
|
|
245
|
+
if (provider.pendingAuthUrl) {
|
|
246
|
+
this.pendingAuth.set(config.name, provider.pendingAuthUrl);
|
|
247
|
+
return provider.pendingAuthUrl;
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async completeAuth(name: string, code: string): Promise<boolean> {
|
|
254
|
+
const client = this.clients.get(name);
|
|
255
|
+
const provider = this.authProviders.get(name);
|
|
256
|
+
if (!client || !provider) return false;
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
await client.finishAuth(code);
|
|
260
|
+
await client.disconnect();
|
|
261
|
+
|
|
262
|
+
const config = client.serverConfig;
|
|
263
|
+
const newClient = new MCPClientWrapper(config);
|
|
264
|
+
newClient.setAuthProvider(provider);
|
|
265
|
+
await newClient.connect();
|
|
266
|
+
|
|
267
|
+
this.clients.set(name, newClient);
|
|
268
|
+
|
|
269
|
+
const tools = await newClient.listTools();
|
|
270
|
+
for (const [key, val] of this.toolsMap) {
|
|
271
|
+
if (val.server === name) this.toolsMap.delete(key);
|
|
272
|
+
}
|
|
273
|
+
for (const tool of tools) {
|
|
274
|
+
const fullName = `${name}__${tool.name}`;
|
|
275
|
+
this.toolsMap.set(fullName, { server: name, tool });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
this.pendingAuth.delete(name);
|
|
279
|
+
provider.cleanup();
|
|
280
|
+
return true;
|
|
281
|
+
} catch (err) {
|
|
282
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
283
|
+
console.error(`[mcp] Failed to complete auth for "${name}": ${msg}`);
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async revokeAuth(name: string): Promise<void> {
|
|
289
|
+
const provider = this.authProviders.get(name);
|
|
290
|
+
if (provider) {
|
|
291
|
+
await provider.clearCredentials();
|
|
292
|
+
provider.cleanup();
|
|
293
|
+
}
|
|
294
|
+
this.authProviders.delete(name);
|
|
295
|
+
await this.stopServer(name);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async getAuthStatus(
|
|
299
|
+
name: string,
|
|
300
|
+
): Promise<{ authenticated: boolean; expiresAt?: number }> {
|
|
301
|
+
const key = this.oauthKey(name);
|
|
302
|
+
const tokens = await this.oauthStore.loadTokens(key);
|
|
303
|
+
if (!tokens?.access_token) return { authenticated: false };
|
|
304
|
+
return {
|
|
305
|
+
authenticated: true,
|
|
306
|
+
expiresAt: tokens.expires_at,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async restartServer(config: MCPServerConfig): Promise<void> {
|
|
311
|
+
await this.stopServer(config.name);
|
|
312
|
+
this.serverScopes.set(config.name, config.scope ?? 'global');
|
|
313
|
+
await this.startSingleServer(config);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async stopServer(name: string): Promise<void> {
|
|
317
|
+
const provider = this.authProviders.get(name);
|
|
318
|
+
if (provider) {
|
|
319
|
+
provider.cleanup();
|
|
320
|
+
this.authProviders.delete(name);
|
|
321
|
+
}
|
|
322
|
+
const client = this.clients.get(name);
|
|
323
|
+
if (client) {
|
|
324
|
+
await client.disconnect().catch(() => {});
|
|
325
|
+
this.clients.delete(name);
|
|
326
|
+
for (const [key, val] of this.toolsMap) {
|
|
327
|
+
if (val.server === name) this.toolsMap.delete(key);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
this.pendingAuth.delete(name);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -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
|
+
}
|