morpheus-cli 0.9.33 → 0.9.40
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/dist/channels/telegram.js +1 -1
- package/dist/cli/commands/start.js +7 -0
- package/dist/config/paths.js +1 -0
- package/dist/config/schemas.js +7 -0
- package/dist/http/api.js +5 -0
- package/dist/http/routers/model-presets.js +169 -0
- package/dist/http/routers/oauth.js +93 -0
- package/dist/http/server.js +4 -0
- package/dist/runtime/memory/sqlite.js +96 -19
- package/dist/runtime/oauth/manager.js +218 -0
- package/dist/runtime/oauth/provider.js +90 -0
- package/dist/runtime/oauth/store.js +118 -0
- package/dist/runtime/oauth/types.js +1 -0
- package/dist/runtime/providers/factory.js +93 -1
- package/dist/runtime/tools/cache.js +66 -47
- package/dist/ui/assets/{AuditDashboard-rap15I-4.js → AuditDashboard-EvtKjy5H.js} +1 -1
- package/dist/ui/assets/{Chat-CnuRZFBT.js → Chat-yptierPt.js} +3 -3
- package/dist/ui/assets/{Chronos-C81_HP6e.js → Chronos-BA77MYbp.js} +1 -1
- package/dist/ui/assets/{ConfirmationModal-CT_v8cAi.js → ConfirmationModal-NOZr-ipQ.js} +1 -1
- package/dist/ui/assets/{Dashboard-0VfNJ9BZ.js → Dashboard-ly1MJiB4.js} +1 -1
- package/dist/ui/assets/{DeleteConfirmationModal-P2foiqon.js → DeleteConfirmationModal-2HMraacH.js} +1 -1
- package/dist/ui/assets/{Documents-C8UfbcGD.js → Documents-C31fAm0Z.js} +2 -2
- package/dist/ui/assets/{Logs-qdsCW9u9.js → Logs-BiajoLAB.js} +1 -1
- package/dist/ui/assets/{MCPManager-CaZLnrKz.js → MCPManager-DS9jfiZT.js} +1 -1
- package/dist/ui/assets/ModelPresets-CxhKcalw.js +1 -0
- package/dist/ui/assets/{ModelPricing-C73OfGhc.js → ModelPricing-CN8flHnP.js} +1 -1
- package/dist/ui/assets/{Notifications-CwqeagwF.js → Notifications-BfP1_CM3.js} +1 -1
- package/dist/ui/assets/{Pagination-3P6KG-u6.js → Pagination-Doam4_qd.js} +1 -1
- package/dist/ui/assets/SatiMemories-Bk4_ubo7.js +1 -0
- package/dist/ui/assets/{SessionAudit-Cykp4Sv_.js → SessionAudit-D3E6QSQw.js} +2 -2
- package/dist/ui/assets/Settings-3VBK8muv.js +49 -0
- package/dist/ui/assets/{Skills-B6io4GZh.js → Skills-Dp0_GwiW.js} +1 -1
- package/dist/ui/assets/{Smiths-XoDzX1K0.js → Smiths-COTgI2R4.js} +1 -1
- package/dist/ui/assets/{Tasks-vui0C_76.js → Tasks-COe4lIJ7.js} +1 -1
- package/dist/ui/assets/{TrinityDatabases-Dp71dyUn.js → TrinityDatabases-BEU4mmyW.js} +1 -1
- package/dist/ui/assets/{UsageStats-Dz4LXfr4.js → UsageStats-BTmDeG2V.js} +1 -1
- package/dist/ui/assets/{WebhookManager-CC4Mbo2v.js → WebhookManager-FQVyKyW-.js} +2 -2
- package/dist/ui/assets/{agents-DV1oGA7P.js → agents-B6e9N0QI.js} +1 -1
- package/dist/ui/assets/{audit-DnegNntR.js → audit-giQG2WRk.js} +1 -1
- package/dist/ui/assets/{chronos-BDlP8kzg.js → chronos-sweaRcNj.js} +1 -1
- package/dist/ui/assets/{config-BhjCL4aM.js → config-CbUdj76n.js} +1 -1
- package/dist/ui/assets/index-CRPT77Yo.css +1 -0
- package/dist/ui/assets/{index-C3qfojVn.js → index-yu2c4ry1.js} +7 -7
- package/dist/ui/assets/{mcp-uYhIyjyx.js → mcp-v64BBpUk.js} +1 -1
- package/dist/ui/assets/modelPresets-BaNh-gxn.js +1 -0
- package/dist/ui/assets/{skills-_9hplz7d.js → skills-ClRXBlVt.js} +1 -1
- package/dist/ui/assets/{stats-BwaWi9yN.js → stats-nI-89hEX.js} +1 -1
- package/dist/ui/assets/{useCurrency-RBarItCC.js → useCurrency-D5An8I2f.js} +1 -1
- package/dist/ui/assets/vendor-icons-LSkmAkBj.js +1 -0
- package/dist/ui/index.html +3 -3
- package/dist/ui/sw.js +1 -1
- package/package.json +1 -1
- package/dist/ui/assets/SatiMemories-CVhOdyAk.js +0 -1
- package/dist/ui/assets/Settings-DnyG6tDx.js +0 -49
- package/dist/ui/assets/index-gx__iEcl.css +0 -1
- package/dist/ui/assets/vendor-icons-tocJCdt5.js +0 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
2
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
3
|
+
import { DynamicStructuredTool } from '@langchain/core/tools';
|
|
4
|
+
import { OAuthStore } from './store.js';
|
|
5
|
+
import { MorpheusOAuthProvider } from './provider.js';
|
|
6
|
+
import { DisplayManager } from '../display.js';
|
|
7
|
+
const display = DisplayManager.getInstance();
|
|
8
|
+
/**
|
|
9
|
+
* OAuthManager — singleton coordinating OAuth 2 flows for HTTP MCP servers.
|
|
10
|
+
*
|
|
11
|
+
* Responsibilities:
|
|
12
|
+
* - Creates MorpheusOAuthProvider per server
|
|
13
|
+
* - Tracks pending transports awaiting user authorization (auth_code flow)
|
|
14
|
+
* - Handles callback (finishAuth) after user authorizes
|
|
15
|
+
* - Provides status of all OAuth MCP servers
|
|
16
|
+
*/
|
|
17
|
+
export class OAuthManager {
|
|
18
|
+
static instance = null;
|
|
19
|
+
store;
|
|
20
|
+
pending = new Map();
|
|
21
|
+
redirectUri;
|
|
22
|
+
notifyFn = null;
|
|
23
|
+
constructor(port) {
|
|
24
|
+
this.store = OAuthStore.getInstance();
|
|
25
|
+
this.redirectUri = `http://localhost:${port}/api/oauth/callback`;
|
|
26
|
+
}
|
|
27
|
+
static getInstance(port) {
|
|
28
|
+
if (!OAuthManager.instance) {
|
|
29
|
+
if (!port)
|
|
30
|
+
throw new Error('OAuthManager.getInstance() requires port on first call');
|
|
31
|
+
OAuthManager.instance = new OAuthManager(port);
|
|
32
|
+
}
|
|
33
|
+
return OAuthManager.instance;
|
|
34
|
+
}
|
|
35
|
+
static resetInstance() {
|
|
36
|
+
OAuthManager.instance = null;
|
|
37
|
+
}
|
|
38
|
+
/** Set the notification function (called when user needs to authorize) */
|
|
39
|
+
setNotifyFn(fn) {
|
|
40
|
+
this.notifyFn = fn;
|
|
41
|
+
}
|
|
42
|
+
getRedirectUri() {
|
|
43
|
+
return this.redirectUri;
|
|
44
|
+
}
|
|
45
|
+
// ── Connection ────────────────────────────────────────
|
|
46
|
+
/**
|
|
47
|
+
* Attempt to connect to an HTTP MCP server with OAuth support.
|
|
48
|
+
* Returns loaded tools if successful, or empty array if auth is pending.
|
|
49
|
+
*/
|
|
50
|
+
async connectHttpMcp(serverName, serverUrl, oauth2Config, extraHeaders) {
|
|
51
|
+
const provider = new MorpheusOAuthProvider(serverName, this.store, async (url) => {
|
|
52
|
+
display.log(`MCP '${serverName}' requires OAuth authorization`, {
|
|
53
|
+
level: 'info',
|
|
54
|
+
source: 'OAuth',
|
|
55
|
+
meta: { url: url.toString() },
|
|
56
|
+
});
|
|
57
|
+
if (this.notifyFn) {
|
|
58
|
+
await this.notifyFn(serverName, url);
|
|
59
|
+
}
|
|
60
|
+
}, this.redirectUri, oauth2Config?.scope, oauth2Config?.client_id, oauth2Config?.client_secret, oauth2Config?.grant_type);
|
|
61
|
+
const transportUrl = new URL(serverUrl);
|
|
62
|
+
const transport = new StreamableHTTPClientTransport(transportUrl, {
|
|
63
|
+
authProvider: provider,
|
|
64
|
+
requestInit: extraHeaders ? { headers: extraHeaders } : undefined,
|
|
65
|
+
});
|
|
66
|
+
try {
|
|
67
|
+
const client = new Client({ name: `morpheus-${serverName}`, version: '1.0.0' });
|
|
68
|
+
await client.connect(transport);
|
|
69
|
+
// Connection succeeded — extract tools
|
|
70
|
+
const { tools: toolDefs } = await client.listTools();
|
|
71
|
+
const tools = toolDefs.map((td) => this.mcpToolToStructuredTool(serverName, td, client));
|
|
72
|
+
display.log(`OAuth MCP '${serverName}': loaded ${tools.length} tools`, {
|
|
73
|
+
level: 'info',
|
|
74
|
+
source: 'OAuth',
|
|
75
|
+
});
|
|
76
|
+
return { tools, authPending: false };
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
// Check if auth redirect was triggered (SDK calls redirectToAuthorization)
|
|
80
|
+
if (error?.message?.includes('Unauthorized') || error?.code === 401) {
|
|
81
|
+
display.log(`OAuth MCP '${serverName}': authorization required, waiting for user`, {
|
|
82
|
+
level: 'info',
|
|
83
|
+
source: 'OAuth',
|
|
84
|
+
});
|
|
85
|
+
this.pending.set(serverName, { transport, provider, serverUrl });
|
|
86
|
+
return { tools: [], authPending: true };
|
|
87
|
+
}
|
|
88
|
+
// The SDK may throw after calling redirectToAuthorization for auth_code flow.
|
|
89
|
+
// Check if we have a pending state for this server (set during redirectToAuthorization).
|
|
90
|
+
const hasPkce = this.store.getLatestPkceVerifier(serverName);
|
|
91
|
+
if (hasPkce) {
|
|
92
|
+
display.log(`OAuth MCP '${serverName}': authorization redirect sent, waiting for callback`, {
|
|
93
|
+
level: 'info',
|
|
94
|
+
source: 'OAuth',
|
|
95
|
+
});
|
|
96
|
+
this.pending.set(serverName, { transport, provider, serverUrl });
|
|
97
|
+
return { tools: [], authPending: true };
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// ── Callback ──────────────────────────────────────────
|
|
103
|
+
/**
|
|
104
|
+
* Handle OAuth callback after user authorizes.
|
|
105
|
+
* Called by GET /api/oauth/callback?code=...&state=...
|
|
106
|
+
*/
|
|
107
|
+
async finishAuth(code, state) {
|
|
108
|
+
// Resolve which server this callback is for
|
|
109
|
+
let serverName;
|
|
110
|
+
if (state) {
|
|
111
|
+
serverName = this.store.resolveServerByState(state);
|
|
112
|
+
}
|
|
113
|
+
if (!serverName) {
|
|
114
|
+
// Fallback: use the only pending server if there's just one
|
|
115
|
+
const pendingNames = [...this.pending.keys()];
|
|
116
|
+
if (pendingNames.length === 1) {
|
|
117
|
+
serverName = pendingNames[0];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!serverName) {
|
|
121
|
+
throw new Error('Could not resolve OAuth callback: unknown state parameter');
|
|
122
|
+
}
|
|
123
|
+
const entry = this.pending.get(serverName);
|
|
124
|
+
if (!entry) {
|
|
125
|
+
throw new Error(`No pending OAuth flow for server '${serverName}'`);
|
|
126
|
+
}
|
|
127
|
+
// Complete the auth flow — SDK exchanges code for token
|
|
128
|
+
await entry.transport.finishAuth(code);
|
|
129
|
+
this.pending.delete(serverName);
|
|
130
|
+
// Clean up PKCE
|
|
131
|
+
if (state)
|
|
132
|
+
this.store.deletePkce(serverName, state);
|
|
133
|
+
// Now reconnect to get tools
|
|
134
|
+
const client = new Client({ name: `morpheus-${serverName}`, version: '1.0.0' });
|
|
135
|
+
const transport = new StreamableHTTPClientTransport(new URL(entry.serverUrl), {
|
|
136
|
+
authProvider: entry.provider,
|
|
137
|
+
});
|
|
138
|
+
await client.connect(transport);
|
|
139
|
+
const { tools: toolDefs } = await client.listTools();
|
|
140
|
+
display.log(`OAuth MCP '${serverName}': authorized! ${toolDefs.length} tools available`, {
|
|
141
|
+
level: 'info',
|
|
142
|
+
source: 'OAuth',
|
|
143
|
+
});
|
|
144
|
+
return { serverName, toolCount: toolDefs.length };
|
|
145
|
+
}
|
|
146
|
+
// ── Status ────────────────────────────────────────────
|
|
147
|
+
getStatus() {
|
|
148
|
+
const statuses = [];
|
|
149
|
+
for (const name of this.store.listServers()) {
|
|
150
|
+
const tokens = this.store.getTokens(name);
|
|
151
|
+
if (!tokens) {
|
|
152
|
+
statuses.push({ name, status: this.pending.has(name) ? 'pending_auth' : 'no_token' });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// OAuthTokens may have expires_in but not expires_at — check if present
|
|
156
|
+
const expiresAt = tokens.expires_at;
|
|
157
|
+
if (expiresAt && Date.now() > expiresAt) {
|
|
158
|
+
statuses.push({ name, status: 'expired', expiresAt });
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
statuses.push({ name, status: 'authorized', expiresAt });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Add pending servers that aren't in the store yet
|
|
165
|
+
for (const name of this.pending.keys()) {
|
|
166
|
+
if (!statuses.find(s => s.name === name)) {
|
|
167
|
+
statuses.push({ name, status: 'pending_auth' });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return statuses;
|
|
171
|
+
}
|
|
172
|
+
isPending(serverName) {
|
|
173
|
+
return this.pending.has(serverName);
|
|
174
|
+
}
|
|
175
|
+
// ── Revoke ────────────────────────────────────────────
|
|
176
|
+
revokeToken(serverName) {
|
|
177
|
+
this.store.deleteTokens(serverName);
|
|
178
|
+
this.pending.delete(serverName);
|
|
179
|
+
display.log(`OAuth tokens revoked for MCP '${serverName}'`, {
|
|
180
|
+
level: 'info',
|
|
181
|
+
source: 'OAuth',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
// ── Internal: convert MCP tool def to LangChain StructuredTool ──
|
|
185
|
+
mcpToolToStructuredTool(serverName, toolDef, mcpClient) {
|
|
186
|
+
const prefixedName = `${serverName}_${toolDef.name}`;
|
|
187
|
+
const originalToolName = toolDef.name;
|
|
188
|
+
// Pass raw JSON Schema directly (same approach as @langchain/mcp-adapters).
|
|
189
|
+
// Avoids Zod v4 $required serialization issues with LangChain's zodToJsonSchema.
|
|
190
|
+
const rawSchema = toolDef.inputSchema ?? { type: 'object', properties: {} };
|
|
191
|
+
if (!rawSchema.properties)
|
|
192
|
+
rawSchema.properties = {};
|
|
193
|
+
return new DynamicStructuredTool({
|
|
194
|
+
name: prefixedName,
|
|
195
|
+
description: toolDef.description || `MCP tool ${toolDef.name} from ${serverName}`,
|
|
196
|
+
schema: rawSchema,
|
|
197
|
+
func: async (input) => {
|
|
198
|
+
try {
|
|
199
|
+
const result = await mcpClient.callTool({
|
|
200
|
+
name: originalToolName,
|
|
201
|
+
arguments: input,
|
|
202
|
+
});
|
|
203
|
+
if (Array.isArray(result.content)) {
|
|
204
|
+
return result.content
|
|
205
|
+
.map((c) => c.text ?? JSON.stringify(c))
|
|
206
|
+
.join('\n');
|
|
207
|
+
}
|
|
208
|
+
return typeof result.content === 'string'
|
|
209
|
+
? result.content
|
|
210
|
+
: JSON.stringify(result.content);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
return `Error calling MCP tool ${originalToolName}: ${error.message}`;
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Morpheus implementation of OAuthClientProvider from the MCP SDK.
|
|
4
|
+
* Delegates persistence to OAuthStore and user notification to a callback.
|
|
5
|
+
*/
|
|
6
|
+
export class MorpheusOAuthProvider {
|
|
7
|
+
serverName;
|
|
8
|
+
store;
|
|
9
|
+
notifyUser;
|
|
10
|
+
_redirectUri;
|
|
11
|
+
_scope;
|
|
12
|
+
_clientId;
|
|
13
|
+
_clientSecret;
|
|
14
|
+
_grantType;
|
|
15
|
+
_lastState;
|
|
16
|
+
constructor(serverName, store, notifyUser, _redirectUri, _scope, _clientId, _clientSecret, _grantType) {
|
|
17
|
+
this.serverName = serverName;
|
|
18
|
+
this.store = store;
|
|
19
|
+
this.notifyUser = notifyUser;
|
|
20
|
+
this._redirectUri = _redirectUri;
|
|
21
|
+
this._scope = _scope;
|
|
22
|
+
this._clientId = _clientId;
|
|
23
|
+
this._clientSecret = _clientSecret;
|
|
24
|
+
this._grantType = _grantType;
|
|
25
|
+
}
|
|
26
|
+
get redirectUrl() {
|
|
27
|
+
if (this._grantType === 'client_credentials')
|
|
28
|
+
return undefined;
|
|
29
|
+
return this._redirectUri;
|
|
30
|
+
}
|
|
31
|
+
get clientMetadata() {
|
|
32
|
+
const meta = {
|
|
33
|
+
redirect_uris: [new URL(this._redirectUri)],
|
|
34
|
+
client_name: `Morpheus - ${this.serverName}`,
|
|
35
|
+
grant_types: this._grantType === 'client_credentials'
|
|
36
|
+
? ['client_credentials']
|
|
37
|
+
: ['authorization_code', 'refresh_token'],
|
|
38
|
+
response_types: this._grantType === 'client_credentials' ? [] : ['code'],
|
|
39
|
+
};
|
|
40
|
+
if (this._scope)
|
|
41
|
+
meta.scope = this._scope;
|
|
42
|
+
return meta;
|
|
43
|
+
}
|
|
44
|
+
async state() {
|
|
45
|
+
this._lastState = crypto.randomUUID();
|
|
46
|
+
return this._lastState;
|
|
47
|
+
}
|
|
48
|
+
async clientInformation() {
|
|
49
|
+
// If user provided client_id, return it as pre-registered info
|
|
50
|
+
if (this._clientId) {
|
|
51
|
+
return {
|
|
52
|
+
client_id: this._clientId,
|
|
53
|
+
client_secret: this._clientSecret,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// Otherwise check store for dynamically registered client info
|
|
57
|
+
return this.store.getClientInfo(this.serverName);
|
|
58
|
+
}
|
|
59
|
+
async saveClientInformation(info) {
|
|
60
|
+
// OAuthClientInformationFull has client_id_issued_at
|
|
61
|
+
this.store.saveClientInfo(this.serverName, info);
|
|
62
|
+
}
|
|
63
|
+
async tokens() {
|
|
64
|
+
return this.store.getTokens(this.serverName);
|
|
65
|
+
}
|
|
66
|
+
async saveTokens(tokens) {
|
|
67
|
+
this.store.saveTokens(this.serverName, tokens);
|
|
68
|
+
}
|
|
69
|
+
async redirectToAuthorization(authorizationUrl) {
|
|
70
|
+
await this.notifyUser(authorizationUrl);
|
|
71
|
+
}
|
|
72
|
+
async saveCodeVerifier(codeVerifier) {
|
|
73
|
+
const state = this._lastState ?? '__default';
|
|
74
|
+
this.store.savePkceVerifier(this.serverName, state, codeVerifier);
|
|
75
|
+
}
|
|
76
|
+
async codeVerifier() {
|
|
77
|
+
const verifier = this.store.getLatestPkceVerifier(this.serverName);
|
|
78
|
+
if (!verifier)
|
|
79
|
+
throw new Error(`No PKCE code verifier found for ${this.serverName}`);
|
|
80
|
+
return verifier;
|
|
81
|
+
}
|
|
82
|
+
async invalidateCredentials(scope) {
|
|
83
|
+
if (scope === 'all' || scope === 'tokens') {
|
|
84
|
+
this.store.deleteTokens(this.serverName);
|
|
85
|
+
}
|
|
86
|
+
if (scope === 'all') {
|
|
87
|
+
this.store.deleteServer(this.serverName);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { PATHS } from '../../config/paths.js';
|
|
4
|
+
/**
|
|
5
|
+
* Persists OAuth tokens, client info, and PKCE state in ~/.morpheus/oauth-tokens.json.
|
|
6
|
+
* Atomic writes (temp → rename). Lazy reads.
|
|
7
|
+
*/
|
|
8
|
+
export class OAuthStore {
|
|
9
|
+
static instance = null;
|
|
10
|
+
data = null;
|
|
11
|
+
filePath;
|
|
12
|
+
constructor() {
|
|
13
|
+
this.filePath = PATHS.oauthTokens;
|
|
14
|
+
}
|
|
15
|
+
static getInstance() {
|
|
16
|
+
if (!OAuthStore.instance) {
|
|
17
|
+
OAuthStore.instance = new OAuthStore();
|
|
18
|
+
}
|
|
19
|
+
return OAuthStore.instance;
|
|
20
|
+
}
|
|
21
|
+
static resetInstance() {
|
|
22
|
+
OAuthStore.instance = null;
|
|
23
|
+
}
|
|
24
|
+
// ── Read ──────────────────────────────────────────────
|
|
25
|
+
read() {
|
|
26
|
+
if (this.data)
|
|
27
|
+
return this.data;
|
|
28
|
+
try {
|
|
29
|
+
const raw = fs.readFileSync(this.filePath, 'utf-8');
|
|
30
|
+
this.data = JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
this.data = {};
|
|
34
|
+
}
|
|
35
|
+
return this.data;
|
|
36
|
+
}
|
|
37
|
+
getRecord(serverName) {
|
|
38
|
+
const data = this.read();
|
|
39
|
+
if (!data[serverName])
|
|
40
|
+
data[serverName] = {};
|
|
41
|
+
return data[serverName];
|
|
42
|
+
}
|
|
43
|
+
// ── Write ─────────────────────────────────────────────
|
|
44
|
+
persist() {
|
|
45
|
+
const dir = path.dirname(this.filePath);
|
|
46
|
+
if (!fs.existsSync(dir))
|
|
47
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
48
|
+
const tmp = this.filePath + '.tmp';
|
|
49
|
+
fs.writeFileSync(tmp, JSON.stringify(this.data ?? {}, null, 2), 'utf-8');
|
|
50
|
+
fs.renameSync(tmp, this.filePath);
|
|
51
|
+
}
|
|
52
|
+
// ── Tokens ────────────────────────────────────────────
|
|
53
|
+
getTokens(serverName) {
|
|
54
|
+
return this.getRecord(serverName).tokens;
|
|
55
|
+
}
|
|
56
|
+
saveTokens(serverName, tokens) {
|
|
57
|
+
this.getRecord(serverName).tokens = tokens;
|
|
58
|
+
this.persist();
|
|
59
|
+
}
|
|
60
|
+
deleteTokens(serverName) {
|
|
61
|
+
const record = this.getRecord(serverName);
|
|
62
|
+
delete record.tokens;
|
|
63
|
+
this.persist();
|
|
64
|
+
}
|
|
65
|
+
// ── Client Info (dynamic registration) ────────────────
|
|
66
|
+
getClientInfo(serverName) {
|
|
67
|
+
return this.getRecord(serverName).clientInfo;
|
|
68
|
+
}
|
|
69
|
+
saveClientInfo(serverName, info) {
|
|
70
|
+
this.getRecord(serverName).clientInfo = info;
|
|
71
|
+
this.persist();
|
|
72
|
+
}
|
|
73
|
+
// ── PKCE ──────────────────────────────────────────────
|
|
74
|
+
savePkceVerifier(serverName, state, verifier) {
|
|
75
|
+
const record = this.getRecord(serverName);
|
|
76
|
+
if (!record.pkce)
|
|
77
|
+
record.pkce = {};
|
|
78
|
+
record.pkce[state] = verifier;
|
|
79
|
+
this.persist();
|
|
80
|
+
}
|
|
81
|
+
getPkceVerifier(serverName, state) {
|
|
82
|
+
return this.getRecord(serverName).pkce?.[state];
|
|
83
|
+
}
|
|
84
|
+
getLatestPkceVerifier(serverName) {
|
|
85
|
+
const pkce = this.getRecord(serverName).pkce;
|
|
86
|
+
if (!pkce)
|
|
87
|
+
return undefined;
|
|
88
|
+
const entries = Object.values(pkce);
|
|
89
|
+
return entries[entries.length - 1];
|
|
90
|
+
}
|
|
91
|
+
deletePkce(serverName, state) {
|
|
92
|
+
const pkce = this.getRecord(serverName).pkce;
|
|
93
|
+
if (pkce) {
|
|
94
|
+
delete pkce[state];
|
|
95
|
+
this.persist();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// ── Queries ───────────────────────────────────────────
|
|
99
|
+
/** List all server names with stored OAuth data */
|
|
100
|
+
listServers() {
|
|
101
|
+
return Object.keys(this.read());
|
|
102
|
+
}
|
|
103
|
+
/** Remove all data for a server */
|
|
104
|
+
deleteServer(serverName) {
|
|
105
|
+
const data = this.read();
|
|
106
|
+
delete data[serverName];
|
|
107
|
+
this.persist();
|
|
108
|
+
}
|
|
109
|
+
/** Map state → serverName for callback resolution */
|
|
110
|
+
resolveServerByState(state) {
|
|
111
|
+
const data = this.read();
|
|
112
|
+
for (const [name, record] of Object.entries(data)) {
|
|
113
|
+
if (record.pkce?.[state])
|
|
114
|
+
return name;
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -5,8 +5,99 @@ import { ConfigManager } from "../../config/manager.js";
|
|
|
5
5
|
import { TaskRequestContext } from "../tasks/context.js";
|
|
6
6
|
import { ChannelRegistry } from "../../channels/registry.js";
|
|
7
7
|
import { getStrategy, registerStrategy } from "./strategies.js";
|
|
8
|
+
import { isInteropZodSchema } from "@langchain/core/utils/types";
|
|
9
|
+
import { toJsonSchema } from "@langchain/core/utils/json_schema";
|
|
8
10
|
/** Channels that should NOT receive verbose tool notifications */
|
|
9
11
|
const SILENT_CHANNELS = new Set(['api', 'ui']);
|
|
12
|
+
/** The ONLY JSON Schema keywords the Gemini API accepts.
|
|
13
|
+
* Anything not in this set is stripped after $ref dereferencing. */
|
|
14
|
+
const GEMINI_ALLOWED_SCHEMA_FIELDS = new Set([
|
|
15
|
+
'type', 'description', 'properties', 'required',
|
|
16
|
+
'enum', 'items', 'format', 'nullable',
|
|
17
|
+
'anyOf', 'oneOf', 'allOf',
|
|
18
|
+
'minimum', 'maximum',
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* Collect all entries from $defs and definitions into a flat lookup map.
|
|
22
|
+
*/
|
|
23
|
+
function collectDefs(obj, defs = {}) {
|
|
24
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj))
|
|
25
|
+
return defs;
|
|
26
|
+
const record = obj;
|
|
27
|
+
for (const key of ['$defs', 'definitions']) {
|
|
28
|
+
if (record[key] && typeof record[key] === 'object' && !Array.isArray(record[key])) {
|
|
29
|
+
Object.assign(defs, record[key]);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const v of Object.values(record))
|
|
33
|
+
collectDefs(v, defs);
|
|
34
|
+
return defs;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve a $ref string like "#/$defs/Foo" or "#/definitions/Foo" to its def name.
|
|
38
|
+
*/
|
|
39
|
+
function resolveRef(ref) {
|
|
40
|
+
const m = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/);
|
|
41
|
+
return m ? m[1] : null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Recursively strip unsupported keywords and inline $ref references.
|
|
45
|
+
*/
|
|
46
|
+
function sanitizeSchemaForGemini(obj, defs, depth = 0) {
|
|
47
|
+
if (depth > 20)
|
|
48
|
+
return {}; // guard against circular refs
|
|
49
|
+
if (obj === null || typeof obj !== 'object')
|
|
50
|
+
return obj;
|
|
51
|
+
if (Array.isArray(obj))
|
|
52
|
+
return obj.map((v) => sanitizeSchemaForGemini(v, defs, depth));
|
|
53
|
+
const record = obj;
|
|
54
|
+
// Inline $ref — look up and recurse into the definition
|
|
55
|
+
if ('$ref' in record && typeof record['$ref'] === 'string') {
|
|
56
|
+
const defName = resolveRef(record['$ref']);
|
|
57
|
+
if (defName && defs[defName]) {
|
|
58
|
+
return sanitizeSchemaForGemini(defs[defName], defs, depth + 1);
|
|
59
|
+
}
|
|
60
|
+
return {}; // unresolvable ref — fall back to empty schema
|
|
61
|
+
}
|
|
62
|
+
const out = {};
|
|
63
|
+
for (const [k, v] of Object.entries(record)) {
|
|
64
|
+
if (!GEMINI_ALLOWED_SCHEMA_FIELDS.has(k))
|
|
65
|
+
continue;
|
|
66
|
+
out[k] = sanitizeSchemaForGemini(v, defs, depth);
|
|
67
|
+
}
|
|
68
|
+
// Ensure every entry in `required` has a matching key in `properties`
|
|
69
|
+
if (Array.isArray(out['required']) && out['properties'] && typeof out['properties'] === 'object') {
|
|
70
|
+
const props = out['properties'];
|
|
71
|
+
out['required'] = out['required'].filter((r) => r in props);
|
|
72
|
+
if (out['required'].length === 0)
|
|
73
|
+
delete out['required'];
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
function sanitizeToolSchemasForGemini(tools) {
|
|
78
|
+
for (const tool of tools) {
|
|
79
|
+
const schema = tool.schema;
|
|
80
|
+
if (!schema || typeof schema !== 'object')
|
|
81
|
+
continue;
|
|
82
|
+
// If the schema is a Zod instance, convert to plain JSON Schema first.
|
|
83
|
+
// Otherwise LangChain recognises it as Zod and re-generates $defs.
|
|
84
|
+
let jsonSchema;
|
|
85
|
+
if (isInteropZodSchema(schema)) {
|
|
86
|
+
try {
|
|
87
|
+
jsonSchema = toJsonSchema(schema);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
jsonSchema = schema;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
jsonSchema = schema;
|
|
95
|
+
}
|
|
96
|
+
const defs = collectDefs(jsonSchema);
|
|
97
|
+
tool.schema = sanitizeSchemaForGemini(jsonSchema, defs);
|
|
98
|
+
}
|
|
99
|
+
return tools;
|
|
100
|
+
}
|
|
10
101
|
export { registerStrategy };
|
|
11
102
|
export class ProviderFactory {
|
|
12
103
|
static buildMonitoringMiddleware() {
|
|
@@ -84,7 +175,8 @@ export class ProviderFactory {
|
|
|
84
175
|
try {
|
|
85
176
|
const model = ProviderFactory.buildModel(config);
|
|
86
177
|
const middleware = ProviderFactory.buildMonitoringMiddleware();
|
|
87
|
-
|
|
178
|
+
const safeTools = config.provider === 'gemini' ? sanitizeToolSchemasForGemini(tools) : tools;
|
|
179
|
+
return createAgent({ model, tools: safeTools, middleware: [middleware] });
|
|
88
180
|
}
|
|
89
181
|
catch (error) {
|
|
90
182
|
ProviderFactory.handleProviderError(config, error);
|
|
@@ -1,35 +1,8 @@
|
|
|
1
1
|
import { MultiServerMCPClient } from "@langchain/mcp-adapters";
|
|
2
2
|
import { loadMCPConfig } from "../../config/mcp-loader.js";
|
|
3
3
|
import { DisplayManager } from "../display.js";
|
|
4
|
+
import { OAuthManager } from "../oauth/manager.js";
|
|
4
5
|
const display = DisplayManager.getInstance();
|
|
5
|
-
/** Fields not supported by Google Gemini API */
|
|
6
|
-
const UNSUPPORTED_SCHEMA_FIELDS = ['examples', 'additionalInfo', 'default', '$schema'];
|
|
7
|
-
/**
|
|
8
|
-
* Recursively removes unsupported fields from JSON schema objects.
|
|
9
|
-
*/
|
|
10
|
-
function sanitizeSchema(obj) {
|
|
11
|
-
if (obj === null || typeof obj !== 'object')
|
|
12
|
-
return obj;
|
|
13
|
-
if (Array.isArray(obj))
|
|
14
|
-
return obj.map(sanitizeSchema);
|
|
15
|
-
const sanitized = {};
|
|
16
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
17
|
-
if (!UNSUPPORTED_SCHEMA_FIELDS.includes(key)) {
|
|
18
|
-
sanitized[key] = sanitizeSchema(value);
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
return sanitized;
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Wraps a tool to sanitize its schema for Gemini compatibility.
|
|
25
|
-
*/
|
|
26
|
-
function wrapToolWithSanitizedSchema(tool) {
|
|
27
|
-
const originalSchema = tool.schema;
|
|
28
|
-
if (originalSchema && typeof originalSchema === 'object') {
|
|
29
|
-
tool.schema = sanitizeSchema(originalSchema);
|
|
30
|
-
}
|
|
31
|
-
return tool;
|
|
32
|
-
}
|
|
33
6
|
/** Timeout (ms) for connecting to each MCP server */
|
|
34
7
|
const MCP_CONNECT_TIMEOUT_MS = 60_000;
|
|
35
8
|
function connectTimeout(serverName, ms) {
|
|
@@ -118,31 +91,31 @@ export class MCPToolCache {
|
|
|
118
91
|
source: 'MCPToolCache',
|
|
119
92
|
meta: { server: serverName, transport: serverConfig.transport }
|
|
120
93
|
});
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
newTools.push(...
|
|
94
|
+
let serverTools;
|
|
95
|
+
if (serverConfig.transport === 'http') {
|
|
96
|
+
// HTTP MCPs: use MCP SDK directly (supports OAuth auto-discovery)
|
|
97
|
+
serverTools = await Promise.race([
|
|
98
|
+
this.loadHttpMcpServer(serverName, serverConfig),
|
|
99
|
+
connectTimeout(serverName, MCP_CONNECT_TIMEOUT_MS),
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
// stdio MCPs: use MultiServerMCPClient as before
|
|
104
|
+
serverTools = await Promise.race([
|
|
105
|
+
this.loadStdioMcpServer(serverName, serverConfig),
|
|
106
|
+
connectTimeout(serverName, MCP_CONNECT_TIMEOUT_MS),
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
newTools.push(...serverTools);
|
|
137
110
|
newServerInfos.push({
|
|
138
111
|
name: serverName,
|
|
139
|
-
toolCount:
|
|
140
|
-
tools:
|
|
112
|
+
toolCount: serverTools.length,
|
|
113
|
+
tools: serverTools,
|
|
141
114
|
ok: true,
|
|
142
115
|
loadedAt: new Date(),
|
|
143
116
|
});
|
|
144
117
|
const elapsed = Date.now() - serverStart;
|
|
145
|
-
display.log(`Loaded ${
|
|
118
|
+
display.log(`Loaded ${serverTools.length} tools from '${serverName}' in ${elapsed}ms`, {
|
|
146
119
|
level: 'info',
|
|
147
120
|
source: 'MCPToolCache'
|
|
148
121
|
});
|
|
@@ -174,6 +147,52 @@ export class MCPToolCache {
|
|
|
174
147
|
source: 'MCPToolCache'
|
|
175
148
|
});
|
|
176
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Load tools from an HTTP MCP server using MCP SDK directly.
|
|
152
|
+
* Supports OAuth auto-discovery — if the server returns 401,
|
|
153
|
+
* the SDK triggers authorization and the user gets a link.
|
|
154
|
+
*/
|
|
155
|
+
async loadHttpMcpServer(serverName, serverConfig) {
|
|
156
|
+
let oauthManager;
|
|
157
|
+
try {
|
|
158
|
+
oauthManager = OAuthManager.getInstance();
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// OAuthManager not initialized (no port yet) — fall back to basic HTTP
|
|
162
|
+
return this.loadViaMultiServerClient(serverName, serverConfig);
|
|
163
|
+
}
|
|
164
|
+
const { tools, authPending } = await oauthManager.connectHttpMcp(serverName, serverConfig.url, serverConfig.oauth2, serverConfig.headers);
|
|
165
|
+
if (authPending) {
|
|
166
|
+
display.log(`MCP '${serverName}': OAuth authorization pending — tools will load after user authorizes`, {
|
|
167
|
+
level: 'info',
|
|
168
|
+
source: 'MCPToolCache',
|
|
169
|
+
});
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
return tools;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Load tools from a stdio MCP server using MultiServerMCPClient.
|
|
176
|
+
*/
|
|
177
|
+
async loadStdioMcpServer(serverName, serverConfig) {
|
|
178
|
+
return this.loadViaMultiServerClient(serverName, serverConfig);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Shared fallback: load tools via @langchain/mcp-adapters MultiServerMCPClient.
|
|
182
|
+
*/
|
|
183
|
+
async loadViaMultiServerClient(serverName, serverConfig) {
|
|
184
|
+
const client = new MultiServerMCPClient({
|
|
185
|
+
mcpServers: { [serverName]: serverConfig },
|
|
186
|
+
onConnectionError: "ignore",
|
|
187
|
+
});
|
|
188
|
+
const tools = await client.getTools();
|
|
189
|
+
// Rename tools with server prefix to avoid collisions
|
|
190
|
+
tools.forEach(tool => {
|
|
191
|
+
const newName = `${serverName}_${tool.name}`;
|
|
192
|
+
Object.defineProperty(tool, "name", { value: newName });
|
|
193
|
+
});
|
|
194
|
+
return tools;
|
|
195
|
+
}
|
|
177
196
|
/**
|
|
178
197
|
* Force reload tools from MCP servers.
|
|
179
198
|
* Clears the cache and loads fresh tools.
|