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.
Files changed (56) hide show
  1. package/dist/channels/telegram.js +1 -1
  2. package/dist/cli/commands/start.js +7 -0
  3. package/dist/config/paths.js +1 -0
  4. package/dist/config/schemas.js +7 -0
  5. package/dist/http/api.js +5 -0
  6. package/dist/http/routers/model-presets.js +169 -0
  7. package/dist/http/routers/oauth.js +93 -0
  8. package/dist/http/server.js +4 -0
  9. package/dist/runtime/memory/sqlite.js +96 -19
  10. package/dist/runtime/oauth/manager.js +218 -0
  11. package/dist/runtime/oauth/provider.js +90 -0
  12. package/dist/runtime/oauth/store.js +118 -0
  13. package/dist/runtime/oauth/types.js +1 -0
  14. package/dist/runtime/providers/factory.js +93 -1
  15. package/dist/runtime/tools/cache.js +66 -47
  16. package/dist/ui/assets/{AuditDashboard-rap15I-4.js → AuditDashboard-EvtKjy5H.js} +1 -1
  17. package/dist/ui/assets/{Chat-CnuRZFBT.js → Chat-yptierPt.js} +3 -3
  18. package/dist/ui/assets/{Chronos-C81_HP6e.js → Chronos-BA77MYbp.js} +1 -1
  19. package/dist/ui/assets/{ConfirmationModal-CT_v8cAi.js → ConfirmationModal-NOZr-ipQ.js} +1 -1
  20. package/dist/ui/assets/{Dashboard-0VfNJ9BZ.js → Dashboard-ly1MJiB4.js} +1 -1
  21. package/dist/ui/assets/{DeleteConfirmationModal-P2foiqon.js → DeleteConfirmationModal-2HMraacH.js} +1 -1
  22. package/dist/ui/assets/{Documents-C8UfbcGD.js → Documents-C31fAm0Z.js} +2 -2
  23. package/dist/ui/assets/{Logs-qdsCW9u9.js → Logs-BiajoLAB.js} +1 -1
  24. package/dist/ui/assets/{MCPManager-CaZLnrKz.js → MCPManager-DS9jfiZT.js} +1 -1
  25. package/dist/ui/assets/ModelPresets-CxhKcalw.js +1 -0
  26. package/dist/ui/assets/{ModelPricing-C73OfGhc.js → ModelPricing-CN8flHnP.js} +1 -1
  27. package/dist/ui/assets/{Notifications-CwqeagwF.js → Notifications-BfP1_CM3.js} +1 -1
  28. package/dist/ui/assets/{Pagination-3P6KG-u6.js → Pagination-Doam4_qd.js} +1 -1
  29. package/dist/ui/assets/SatiMemories-Bk4_ubo7.js +1 -0
  30. package/dist/ui/assets/{SessionAudit-Cykp4Sv_.js → SessionAudit-D3E6QSQw.js} +2 -2
  31. package/dist/ui/assets/Settings-3VBK8muv.js +49 -0
  32. package/dist/ui/assets/{Skills-B6io4GZh.js → Skills-Dp0_GwiW.js} +1 -1
  33. package/dist/ui/assets/{Smiths-XoDzX1K0.js → Smiths-COTgI2R4.js} +1 -1
  34. package/dist/ui/assets/{Tasks-vui0C_76.js → Tasks-COe4lIJ7.js} +1 -1
  35. package/dist/ui/assets/{TrinityDatabases-Dp71dyUn.js → TrinityDatabases-BEU4mmyW.js} +1 -1
  36. package/dist/ui/assets/{UsageStats-Dz4LXfr4.js → UsageStats-BTmDeG2V.js} +1 -1
  37. package/dist/ui/assets/{WebhookManager-CC4Mbo2v.js → WebhookManager-FQVyKyW-.js} +2 -2
  38. package/dist/ui/assets/{agents-DV1oGA7P.js → agents-B6e9N0QI.js} +1 -1
  39. package/dist/ui/assets/{audit-DnegNntR.js → audit-giQG2WRk.js} +1 -1
  40. package/dist/ui/assets/{chronos-BDlP8kzg.js → chronos-sweaRcNj.js} +1 -1
  41. package/dist/ui/assets/{config-BhjCL4aM.js → config-CbUdj76n.js} +1 -1
  42. package/dist/ui/assets/index-CRPT77Yo.css +1 -0
  43. package/dist/ui/assets/{index-C3qfojVn.js → index-yu2c4ry1.js} +7 -7
  44. package/dist/ui/assets/{mcp-uYhIyjyx.js → mcp-v64BBpUk.js} +1 -1
  45. package/dist/ui/assets/modelPresets-BaNh-gxn.js +1 -0
  46. package/dist/ui/assets/{skills-_9hplz7d.js → skills-ClRXBlVt.js} +1 -1
  47. package/dist/ui/assets/{stats-BwaWi9yN.js → stats-nI-89hEX.js} +1 -1
  48. package/dist/ui/assets/{useCurrency-RBarItCC.js → useCurrency-D5An8I2f.js} +1 -1
  49. package/dist/ui/assets/vendor-icons-LSkmAkBj.js +1 -0
  50. package/dist/ui/index.html +3 -3
  51. package/dist/ui/sw.js +1 -1
  52. package/package.json +1 -1
  53. package/dist/ui/assets/SatiMemories-CVhOdyAk.js +0 -1
  54. package/dist/ui/assets/Settings-DnyG6tDx.js +0 -49
  55. package/dist/ui/assets/index-gx__iEcl.css +0 -1
  56. 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
- return createAgent({ model, tools, middleware: [middleware] });
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
- const client = new MultiServerMCPClient({
122
- mcpServers: { [serverName]: serverConfig },
123
- onConnectionError: "ignore",
124
- });
125
- const tools = await Promise.race([
126
- client.getTools(),
127
- connectTimeout(serverName, MCP_CONNECT_TIMEOUT_MS),
128
- ]);
129
- // Rename tools with server prefix to avoid collisions
130
- tools.forEach(tool => {
131
- const newName = `${serverName}_${tool.name}`;
132
- Object.defineProperty(tool, "name", { value: newName });
133
- });
134
- // Sanitize schemas for Gemini compatibility
135
- const sanitizedTools = tools.map(wrapToolWithSanitizedSchema);
136
- newTools.push(...sanitizedTools);
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: sanitizedTools.length,
140
- tools: sanitizedTools,
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 ${sanitizedTools.length} tools from '${serverName}' in ${elapsed}ms`, {
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.