@kontext-dev/js-sdk 0.1.0
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/README.md +70 -0
- package/dist/adapters/ai/index.cjs +175 -0
- package/dist/adapters/ai/index.cjs.map +1 -0
- package/dist/adapters/ai/index.d.cts +51 -0
- package/dist/adapters/ai/index.d.ts +51 -0
- package/dist/adapters/ai/index.js +173 -0
- package/dist/adapters/ai/index.js.map +1 -0
- package/dist/adapters/cloudflare/index.cjs +598 -0
- package/dist/adapters/cloudflare/index.cjs.map +1 -0
- package/dist/adapters/cloudflare/index.d.cts +214 -0
- package/dist/adapters/cloudflare/index.d.ts +214 -0
- package/dist/adapters/cloudflare/index.js +594 -0
- package/dist/adapters/cloudflare/index.js.map +1 -0
- package/dist/adapters/cloudflare/react.cjs +156 -0
- package/dist/adapters/cloudflare/react.cjs.map +1 -0
- package/dist/adapters/cloudflare/react.d.cts +68 -0
- package/dist/adapters/cloudflare/react.d.ts +68 -0
- package/dist/adapters/cloudflare/react.js +152 -0
- package/dist/adapters/cloudflare/react.js.map +1 -0
- package/dist/adapters/react/index.cjs +146 -0
- package/dist/adapters/react/index.cjs.map +1 -0
- package/dist/adapters/react/index.d.cts +103 -0
- package/dist/adapters/react/index.d.ts +103 -0
- package/dist/adapters/react/index.js +142 -0
- package/dist/adapters/react/index.js.map +1 -0
- package/dist/client/index.cjs +2415 -0
- package/dist/client/index.cjs.map +1 -0
- package/dist/client/index.d.cts +125 -0
- package/dist/client/index.d.ts +125 -0
- package/dist/client/index.js +2412 -0
- package/dist/client/index.js.map +1 -0
- package/dist/errors.cjs +213 -0
- package/dist/errors.cjs.map +1 -0
- package/dist/errors.d.cts +161 -0
- package/dist/errors.d.ts +161 -0
- package/dist/errors.js +201 -0
- package/dist/errors.js.map +1 -0
- package/dist/index-D5hS5PGn.d.ts +54 -0
- package/dist/index-DcL4a5Vq.d.cts +54 -0
- package/dist/index.cjs +4046 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +15 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +4029 -0
- package/dist/index.js.map +1 -0
- package/dist/kontext-CgIBANFo.d.cts +308 -0
- package/dist/kontext-CgIBANFo.d.ts +308 -0
- package/dist/management/index.cjs +867 -0
- package/dist/management/index.cjs.map +1 -0
- package/dist/management/index.d.cts +467 -0
- package/dist/management/index.d.ts +467 -0
- package/dist/management/index.js +855 -0
- package/dist/management/index.js.map +1 -0
- package/dist/mcp/index.cjs +799 -0
- package/dist/mcp/index.cjs.map +1 -0
- package/dist/mcp/index.d.cts +231 -0
- package/dist/mcp/index.d.ts +231 -0
- package/dist/mcp/index.js +797 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/oauth/index.cjs +418 -0
- package/dist/oauth/index.cjs.map +1 -0
- package/dist/oauth/index.d.cts +235 -0
- package/dist/oauth/index.d.ts +235 -0
- package/dist/oauth/index.js +414 -0
- package/dist/oauth/index.js.map +1 -0
- package/dist/server/index.cjs +1634 -0
- package/dist/server/index.cjs.map +1 -0
- package/dist/server/index.d.cts +10 -0
- package/dist/server/index.d.ts +10 -0
- package/dist/server/index.js +1629 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types-CzhnlJHW.d.cts +397 -0
- package/dist/types-CzhnlJHW.d.ts +397 -0
- package/dist/types-RIzHnRpk.d.cts +23 -0
- package/dist/types-RIzHnRpk.d.ts +23 -0
- package/dist/verifier-CoJmYiw3.d.cts +109 -0
- package/dist/verifier-CoJmYiw3.d.ts +109 -0
- package/dist/verify/index.cjs +319 -0
- package/dist/verify/index.cjs.map +1 -0
- package/dist/verify/index.d.cts +63 -0
- package/dist/verify/index.d.ts +63 -0
- package/dist/verify/index.js +315 -0
- package/dist/verify/index.js.map +1 -0
- package/package.json +221 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4029 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
3
|
+
import { isInitializeRequest, UrlElicitationRequiredError, ElicitRequestSchema, ElicitationCompleteNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { createHash, randomBytes } from 'crypto';
|
|
5
|
+
import { createRequire } from 'module';
|
|
6
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
7
|
+
import { mcpAuthMetadataRouter, getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
|
8
|
+
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
|
9
|
+
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
10
|
+
import { jwtVerify, errors, decodeProtectedHeader, createRemoteJWKSet } from 'jose';
|
|
11
|
+
|
|
12
|
+
// src/mcp/client.ts
|
|
13
|
+
|
|
14
|
+
// src/storage/memory.ts
|
|
15
|
+
var MemoryStorage = class {
|
|
16
|
+
store = /* @__PURE__ */ new Map();
|
|
17
|
+
async getJson(key) {
|
|
18
|
+
const value = this.store.get(key);
|
|
19
|
+
if (value === void 0) {
|
|
20
|
+
return void 0;
|
|
21
|
+
}
|
|
22
|
+
return JSON.parse(JSON.stringify(value));
|
|
23
|
+
}
|
|
24
|
+
async setJson(key, value) {
|
|
25
|
+
if (value === void 0) {
|
|
26
|
+
this.store.delete(key);
|
|
27
|
+
} else {
|
|
28
|
+
this.store.set(key, JSON.parse(JSON.stringify(value)));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Clear all stored data
|
|
33
|
+
*/
|
|
34
|
+
clear() {
|
|
35
|
+
this.store.clear();
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Get the number of stored items
|
|
39
|
+
*/
|
|
40
|
+
get size() {
|
|
41
|
+
return this.store.size;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Check if a key exists
|
|
45
|
+
*/
|
|
46
|
+
has(key) {
|
|
47
|
+
return this.store.has(key);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Get all keys (useful for debugging)
|
|
51
|
+
*/
|
|
52
|
+
keys() {
|
|
53
|
+
return Array.from(this.store.keys());
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// src/storage/types.ts
|
|
58
|
+
function createStorageKey(applicationClientId, sessionKey, ...parts) {
|
|
59
|
+
const namespace = sessionKey ? `kontext:${applicationClientId}:${sessionKey}` : `kontext:${applicationClientId}`;
|
|
60
|
+
return [namespace, ...parts].join(":");
|
|
61
|
+
}
|
|
62
|
+
var StorageKeys = {
|
|
63
|
+
// Existing keys
|
|
64
|
+
TOKENS: "tokens",
|
|
65
|
+
CODE_VERIFIER: "code_verifier",
|
|
66
|
+
STATE: "state",
|
|
67
|
+
// Pattern B (RFC 8693 Token Exchange) keys
|
|
68
|
+
/** Identity tokens (no audience) */
|
|
69
|
+
IDENTITY_TOKENS: "identity_tokens",
|
|
70
|
+
/** Prefix for resource-scoped tokens */
|
|
71
|
+
RESOURCE_TOKENS: "resource_tokens"
|
|
72
|
+
};
|
|
73
|
+
function resourceTokenKey(resource) {
|
|
74
|
+
return `${StorageKeys.RESOURCE_TOKENS}:${resource}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/errors.ts
|
|
78
|
+
var KontextError = class extends Error {
|
|
79
|
+
/** Brand field for type narrowing without instanceof */
|
|
80
|
+
kontextError = true;
|
|
81
|
+
/** Machine-readable error code, always prefixed with `kontext_` */
|
|
82
|
+
code;
|
|
83
|
+
/** HTTP status code when applicable */
|
|
84
|
+
statusCode;
|
|
85
|
+
/** Auto-generated link to error documentation */
|
|
86
|
+
docsUrl;
|
|
87
|
+
/** Server request ID for debugging / support escalation */
|
|
88
|
+
requestId;
|
|
89
|
+
/** Contextual metadata bag (integration IDs, param names, etc.) */
|
|
90
|
+
meta;
|
|
91
|
+
constructor(message, code, options) {
|
|
92
|
+
super(message, { cause: options?.cause });
|
|
93
|
+
this.name = "KontextError";
|
|
94
|
+
this.code = code;
|
|
95
|
+
this.statusCode = options?.statusCode;
|
|
96
|
+
this.requestId = options?.requestId;
|
|
97
|
+
this.meta = options?.meta ?? {};
|
|
98
|
+
this.docsUrl = `https://docs.kontext.dev/errors/${code}`;
|
|
99
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
100
|
+
}
|
|
101
|
+
toJSON() {
|
|
102
|
+
return {
|
|
103
|
+
name: this.name,
|
|
104
|
+
code: this.code,
|
|
105
|
+
message: this.message,
|
|
106
|
+
statusCode: this.statusCode,
|
|
107
|
+
docsUrl: this.docsUrl,
|
|
108
|
+
requestId: this.requestId,
|
|
109
|
+
meta: Object.keys(this.meta).length > 0 ? this.meta : void 0
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
toString() {
|
|
113
|
+
const parts = [`[${this.code}] ${this.message}`];
|
|
114
|
+
if (this.docsUrl) parts.push(`Docs: ${this.docsUrl}`);
|
|
115
|
+
if (this.requestId) parts.push(`Request ID: ${this.requestId}`);
|
|
116
|
+
return parts.join("\n");
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
function isKontextError(err) {
|
|
120
|
+
return typeof err === "object" && err !== null && err.kontextError === true;
|
|
121
|
+
}
|
|
122
|
+
var AuthorizationRequiredError = class extends KontextError {
|
|
123
|
+
authorizationUrl;
|
|
124
|
+
constructor(message = "Authorization required. Complete the OAuth flow to continue.", options) {
|
|
125
|
+
super(message, "kontext_authorization_required", {
|
|
126
|
+
statusCode: 401,
|
|
127
|
+
...options
|
|
128
|
+
});
|
|
129
|
+
this.name = "AuthorizationRequiredError";
|
|
130
|
+
this.authorizationUrl = options?.authorizationUrl;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
var OAuthError = class extends KontextError {
|
|
134
|
+
errorCode;
|
|
135
|
+
errorDescription;
|
|
136
|
+
constructor(message, code, options) {
|
|
137
|
+
super(message, code, {
|
|
138
|
+
statusCode: options?.statusCode ?? 400,
|
|
139
|
+
...options
|
|
140
|
+
});
|
|
141
|
+
this.name = "OAuthError";
|
|
142
|
+
this.errorCode = options?.errorCode;
|
|
143
|
+
this.errorDescription = options?.errorDescription;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
var IntegrationConnectionRequiredError = class extends KontextError {
|
|
147
|
+
integrationId;
|
|
148
|
+
integrationName;
|
|
149
|
+
connectUrl;
|
|
150
|
+
constructor(integrationId, options) {
|
|
151
|
+
super(
|
|
152
|
+
options?.message ?? `Connection to integration "${integrationId}" is required. Visit the connect URL to authorize.`,
|
|
153
|
+
"kontext_integration_connection_required",
|
|
154
|
+
{ statusCode: 403, ...options }
|
|
155
|
+
);
|
|
156
|
+
this.name = "IntegrationConnectionRequiredError";
|
|
157
|
+
this.integrationId = integrationId;
|
|
158
|
+
this.integrationName = options?.integrationName;
|
|
159
|
+
this.connectUrl = options?.connectUrl;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
var ConfigError = class extends KontextError {
|
|
163
|
+
constructor(message, code, options) {
|
|
164
|
+
super(message, code, options);
|
|
165
|
+
this.name = "ConfigError";
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
var NetworkError = class extends KontextError {
|
|
169
|
+
constructor(message = "Network error. Check your internet connection and that the server is reachable.", options) {
|
|
170
|
+
super(message, "kontext_network_error", options);
|
|
171
|
+
this.name = "NetworkError";
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
var HttpError = class extends KontextError {
|
|
175
|
+
retryAfter;
|
|
176
|
+
validationErrors;
|
|
177
|
+
constructor(message, code, options) {
|
|
178
|
+
super(message, code, {
|
|
179
|
+
statusCode: options?.statusCode,
|
|
180
|
+
...options
|
|
181
|
+
});
|
|
182
|
+
this.name = "HttpError";
|
|
183
|
+
this.retryAfter = options?.retryAfter;
|
|
184
|
+
this.validationErrors = options?.validationErrors;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
function errorProps(err) {
|
|
188
|
+
return err;
|
|
189
|
+
}
|
|
190
|
+
var NETWORK_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
191
|
+
"ECONNREFUSED",
|
|
192
|
+
"ENOTFOUND",
|
|
193
|
+
"ETIMEDOUT",
|
|
194
|
+
"ECONNRESET",
|
|
195
|
+
"ECONNABORTED",
|
|
196
|
+
"EPIPE",
|
|
197
|
+
"UND_ERR_CONNECT_TIMEOUT"
|
|
198
|
+
]);
|
|
199
|
+
function isNetworkError(err) {
|
|
200
|
+
if (err.name === "AbortError") return true;
|
|
201
|
+
const props = errorProps(err);
|
|
202
|
+
const sysCode = props.code;
|
|
203
|
+
if (typeof sysCode === "string" && NETWORK_ERROR_CODES.has(sysCode))
|
|
204
|
+
return true;
|
|
205
|
+
if (err.name === "TypeError" && err.cause instanceof Error) {
|
|
206
|
+
const causeCode = errorProps(err.cause).code;
|
|
207
|
+
if (typeof causeCode === "string" && NETWORK_ERROR_CODES.has(causeCode))
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
function isUnauthorizedError(err) {
|
|
213
|
+
const props = errorProps(err);
|
|
214
|
+
if (props.statusCode === 401 || props.status === 401) return true;
|
|
215
|
+
if (err.name === "UnauthorizedError") return true;
|
|
216
|
+
if (err.message === "Unauthorized") return true;
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
function parseHttpError(statusCode, body) {
|
|
220
|
+
const message = typeof body === "object" && body !== null && "message" in body ? String(body.message) : `HTTP ${statusCode}`;
|
|
221
|
+
const errorCode = typeof body === "object" && body !== null && "code" in body ? String(body.code) : void 0;
|
|
222
|
+
switch (statusCode) {
|
|
223
|
+
case 400:
|
|
224
|
+
if (typeof body === "object" && body !== null && "errors" in body && Array.isArray(body.errors)) {
|
|
225
|
+
return new HttpError(message, "kontext_validation_error", {
|
|
226
|
+
statusCode: 400,
|
|
227
|
+
validationErrors: body.errors
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return new KontextError(message, errorCode ?? "kontext_bad_request", {
|
|
231
|
+
statusCode: 400
|
|
232
|
+
});
|
|
233
|
+
case 401:
|
|
234
|
+
return new AuthorizationRequiredError(message);
|
|
235
|
+
case 403:
|
|
236
|
+
if (errorCode === "INTEGRATION_CONNECTION_REQUIRED") {
|
|
237
|
+
const details = body;
|
|
238
|
+
return new IntegrationConnectionRequiredError(
|
|
239
|
+
details.integrationId ?? "unknown",
|
|
240
|
+
{
|
|
241
|
+
integrationName: details.integrationName,
|
|
242
|
+
connectUrl: details.connectUrl,
|
|
243
|
+
message
|
|
244
|
+
}
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return new HttpError(message, "kontext_policy_denied", {
|
|
248
|
+
statusCode: 403,
|
|
249
|
+
meta: { policy: body?.policy }
|
|
250
|
+
});
|
|
251
|
+
case 404:
|
|
252
|
+
return new HttpError(message, "kontext_not_found", { statusCode: 404 });
|
|
253
|
+
case 429: {
|
|
254
|
+
const retryAfter = typeof body === "object" && body !== null && "retryAfter" in body ? Number(body.retryAfter) : void 0;
|
|
255
|
+
return new HttpError(
|
|
256
|
+
retryAfter ? `Rate limit exceeded. Retry after ${retryAfter} seconds.` : "Rate limit exceeded. Wait and retry.",
|
|
257
|
+
"kontext_rate_limited",
|
|
258
|
+
{ statusCode: 429, retryAfter }
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
default:
|
|
262
|
+
if (statusCode >= 500) {
|
|
263
|
+
return new HttpError(
|
|
264
|
+
`Server error (HTTP ${statusCode}): ${message}`,
|
|
265
|
+
"kontext_server_error",
|
|
266
|
+
{ statusCode }
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return new KontextError(message, errorCode ?? "kontext_unknown_error", {
|
|
270
|
+
statusCode
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// src/oauth/provider.ts
|
|
276
|
+
var KontextOAuthProvider = class {
|
|
277
|
+
config;
|
|
278
|
+
storagePrefix;
|
|
279
|
+
pendingState = null;
|
|
280
|
+
expiryBufferMs = 60 * 1e3;
|
|
281
|
+
constructor(config) {
|
|
282
|
+
this.config = config;
|
|
283
|
+
this.storagePrefix = createStorageKey(config.clientId, config.sessionKey);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The redirect URL for OAuth callbacks
|
|
287
|
+
*/
|
|
288
|
+
get redirectUrl() {
|
|
289
|
+
return this.config.redirectUri;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* OAuth client metadata
|
|
293
|
+
*/
|
|
294
|
+
get clientMetadata() {
|
|
295
|
+
return {
|
|
296
|
+
redirect_uris: [this.config.redirectUri],
|
|
297
|
+
client_name: this.config.clientName,
|
|
298
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
299
|
+
response_types: ["code"],
|
|
300
|
+
token_endpoint_auth_method: "none"
|
|
301
|
+
// Public client
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Generate a random state parameter for OAuth CSRF protection
|
|
306
|
+
*/
|
|
307
|
+
state() {
|
|
308
|
+
const array = new Uint8Array(32);
|
|
309
|
+
if (globalThis.crypto?.getRandomValues) {
|
|
310
|
+
globalThis.crypto.getRandomValues(array);
|
|
311
|
+
} else {
|
|
312
|
+
array.set(randomBytes(32));
|
|
313
|
+
}
|
|
314
|
+
const state = Array.from(
|
|
315
|
+
array,
|
|
316
|
+
(byte) => byte.toString(16).padStart(2, "0")
|
|
317
|
+
).join("");
|
|
318
|
+
this.pendingState = state;
|
|
319
|
+
void this.config.storage.setJson(
|
|
320
|
+
this.getStorageKey(StorageKeys.STATE),
|
|
321
|
+
state
|
|
322
|
+
);
|
|
323
|
+
return state;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Returns the client information (client_id)
|
|
327
|
+
* Since we're a public client with pre-registered credentials,
|
|
328
|
+
* we don't use dynamic client registration.
|
|
329
|
+
*/
|
|
330
|
+
async clientInformation() {
|
|
331
|
+
return {
|
|
332
|
+
client_id: this.config.clientId
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Load stored OAuth tokens
|
|
337
|
+
*/
|
|
338
|
+
async tokens() {
|
|
339
|
+
const key = this.getStorageKey(StorageKeys.TOKENS);
|
|
340
|
+
return this.config.storage.getJson(key);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Save OAuth tokens after successful authorization
|
|
344
|
+
*/
|
|
345
|
+
async saveTokens(tokens) {
|
|
346
|
+
const key = this.getStorageKey(StorageKeys.TOKENS);
|
|
347
|
+
await this.config.storage.setJson(key, this.withIssuedAt(tokens));
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Redirect the user agent to the authorization URL
|
|
351
|
+
*/
|
|
352
|
+
async redirectToAuthorization(authorizationUrl) {
|
|
353
|
+
await this.config.onRedirectToAuthorization(authorizationUrl);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Save the PKCE code verifier before redirecting to authorization
|
|
357
|
+
*/
|
|
358
|
+
async saveCodeVerifier(codeVerifier) {
|
|
359
|
+
const key = this.getStorageKey(StorageKeys.CODE_VERIFIER);
|
|
360
|
+
await this.config.storage.setJson(key, codeVerifier);
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Load the PKCE code verifier for token exchange
|
|
364
|
+
*/
|
|
365
|
+
async codeVerifier() {
|
|
366
|
+
const key = this.getStorageKey(StorageKeys.CODE_VERIFIER);
|
|
367
|
+
const verifier = await this.config.storage.getJson(key);
|
|
368
|
+
if (!verifier) {
|
|
369
|
+
throw new OAuthError(
|
|
370
|
+
"No PKCE code verifier found in storage. The OAuth flow may have expired or storage was cleared. Restart the auth flow.",
|
|
371
|
+
"kontext_oauth_code_verifier_missing"
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
return verifier;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Invalidate stored credentials
|
|
378
|
+
*/
|
|
379
|
+
async invalidateCredentials(scope) {
|
|
380
|
+
if (scope === "all" || scope === "tokens") {
|
|
381
|
+
const tokensKey = this.getStorageKey(StorageKeys.TOKENS);
|
|
382
|
+
await this.config.storage.setJson(tokensKey, void 0);
|
|
383
|
+
const identityKey = this.getStorageKey(StorageKeys.IDENTITY_TOKENS);
|
|
384
|
+
await this.config.storage.setJson(identityKey, void 0);
|
|
385
|
+
}
|
|
386
|
+
if (scope === "all" || scope === "verifier") {
|
|
387
|
+
const verifierKey = this.getStorageKey(StorageKeys.CODE_VERIFIER);
|
|
388
|
+
await this.config.storage.setJson(verifierKey, void 0);
|
|
389
|
+
}
|
|
390
|
+
if (scope === "all") {
|
|
391
|
+
this.pendingState = null;
|
|
392
|
+
const stateKey = this.getStorageKey(StorageKeys.STATE);
|
|
393
|
+
await this.config.storage.setJson(stateKey, void 0);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Clear all stored state (tokens, verifier, etc.)
|
|
398
|
+
* Call this to force re-authentication
|
|
399
|
+
*/
|
|
400
|
+
async clearAll() {
|
|
401
|
+
await this.invalidateCredentials("all");
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Check if we have valid (non-expired) tokens
|
|
405
|
+
*/
|
|
406
|
+
async hasValidTokens() {
|
|
407
|
+
const storedTokens = await this.tokens();
|
|
408
|
+
return this.isTokenValid(storedTokens);
|
|
409
|
+
}
|
|
410
|
+
// ==========================================================================
|
|
411
|
+
// Pattern B: Identity and Resource Token Management (RFC 8693)
|
|
412
|
+
// ==========================================================================
|
|
413
|
+
/**
|
|
414
|
+
* Save identity tokens (no audience)
|
|
415
|
+
* These are the tokens obtained from the initial OAuth flow before token exchange.
|
|
416
|
+
*/
|
|
417
|
+
async saveIdentityTokens(tokens) {
|
|
418
|
+
const key = this.getStorageKey(StorageKeys.IDENTITY_TOKENS);
|
|
419
|
+
await this.config.storage.setJson(key, this.withIssuedAt(tokens));
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Load identity tokens
|
|
423
|
+
* Returns the identity tokens obtained from the initial OAuth flow.
|
|
424
|
+
*/
|
|
425
|
+
async identityTokens() {
|
|
426
|
+
const key = this.getStorageKey(StorageKeys.IDENTITY_TOKENS);
|
|
427
|
+
return this.config.storage.getJson(key);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Save resource-scoped tokens for a specific resource
|
|
431
|
+
*
|
|
432
|
+
* @param resource The resource identifier (e.g., "mcp-gateway")
|
|
433
|
+
* @param tokens The resource-scoped tokens
|
|
434
|
+
*/
|
|
435
|
+
async saveResourceTokens(resource, tokens) {
|
|
436
|
+
const key = this.getStorageKey(resourceTokenKey(resource));
|
|
437
|
+
await this.config.storage.setJson(key, this.withIssuedAt(tokens));
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Load resource-scoped tokens for a specific resource
|
|
441
|
+
*
|
|
442
|
+
* @param resource The resource identifier (e.g., "mcp-gateway")
|
|
443
|
+
* @returns The resource-scoped tokens, or undefined if not found
|
|
444
|
+
*/
|
|
445
|
+
async resourceTokens(resource) {
|
|
446
|
+
const key = this.getStorageKey(resourceTokenKey(resource));
|
|
447
|
+
return this.config.storage.getJson(key);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Clear resource tokens for a specific resource
|
|
451
|
+
*
|
|
452
|
+
* @param resource The resource identifier (e.g., "mcp-gateway")
|
|
453
|
+
*/
|
|
454
|
+
async clearResourceTokens(resource) {
|
|
455
|
+
const key = this.getStorageKey(resourceTokenKey(resource));
|
|
456
|
+
await this.config.storage.setJson(key, void 0);
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Check if we have valid identity tokens
|
|
460
|
+
*/
|
|
461
|
+
async hasValidIdentityTokens() {
|
|
462
|
+
const tokens = await this.identityTokens();
|
|
463
|
+
return this.isTokenValid(tokens);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Check if we have valid resource tokens for a specific resource
|
|
467
|
+
*
|
|
468
|
+
* @param resource The resource identifier
|
|
469
|
+
*/
|
|
470
|
+
async hasValidResourceTokens(resource) {
|
|
471
|
+
const tokens = await this.resourceTokens(resource);
|
|
472
|
+
return this.isTokenValid(tokens);
|
|
473
|
+
}
|
|
474
|
+
async validateState(state) {
|
|
475
|
+
if (!state) {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
const key = this.getStorageKey(StorageKeys.STATE);
|
|
479
|
+
const storedState = this.pendingState ?? await this.config.storage.getJson(key);
|
|
480
|
+
const isValid = storedState === state;
|
|
481
|
+
if (isValid) {
|
|
482
|
+
this.pendingState = null;
|
|
483
|
+
await this.config.storage.setJson(key, void 0);
|
|
484
|
+
}
|
|
485
|
+
return isValid;
|
|
486
|
+
}
|
|
487
|
+
withIssuedAt(tokens) {
|
|
488
|
+
if (!tokens.expires_in) {
|
|
489
|
+
return tokens;
|
|
490
|
+
}
|
|
491
|
+
return { ...tokens, issued_at: Date.now() };
|
|
492
|
+
}
|
|
493
|
+
isTokenValid(tokens) {
|
|
494
|
+
if (!tokens?.access_token) {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
if (!tokens.expires_in) {
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
const issuedAt = tokens.issued_at;
|
|
501
|
+
if (!issuedAt) {
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
const expiresAt = issuedAt + tokens.expires_in * 1e3;
|
|
505
|
+
return Date.now() < expiresAt - this.expiryBufferMs;
|
|
506
|
+
}
|
|
507
|
+
getStorageKey(key) {
|
|
508
|
+
return `${this.storagePrefix}:${key}`;
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
function parseOAuthCallback(callbackUrl) {
|
|
512
|
+
const url = typeof callbackUrl === "string" ? new URL(callbackUrl) : callbackUrl;
|
|
513
|
+
const params = url.searchParams;
|
|
514
|
+
const error = params.get("error");
|
|
515
|
+
if (error) {
|
|
516
|
+
return {
|
|
517
|
+
error,
|
|
518
|
+
errorDescription: params.get("error_description") ?? void 0
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
return {
|
|
522
|
+
code: params.get("code") ?? void 0,
|
|
523
|
+
state: params.get("state") ?? void 0
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// src/mcp/client.ts
|
|
528
|
+
var DEFAULT_SERVER = "https://api.kontext.dev";
|
|
529
|
+
function normalizeKontextServerUrl(server) {
|
|
530
|
+
let url = server.replace(/\/$/, "");
|
|
531
|
+
url = url.replace(/\/api\/v1\/?$/, "").replace(/\/mcp\/?$/, "");
|
|
532
|
+
url = url.replace(/\/$/, "");
|
|
533
|
+
return url;
|
|
534
|
+
}
|
|
535
|
+
var KontextMcp = class {
|
|
536
|
+
config;
|
|
537
|
+
storage;
|
|
538
|
+
oauthProvider;
|
|
539
|
+
transport = null;
|
|
540
|
+
client = null;
|
|
541
|
+
_isConnected = false;
|
|
542
|
+
_pendingConnect = null;
|
|
543
|
+
_pendingAuthFlow = null;
|
|
544
|
+
_authFlowResolve = null;
|
|
545
|
+
constructor(config) {
|
|
546
|
+
this.config = config;
|
|
547
|
+
this.storage = config.storage ?? new MemoryStorage();
|
|
548
|
+
this.oauthProvider = new KontextOAuthProvider({
|
|
549
|
+
clientId: config.clientId,
|
|
550
|
+
redirectUri: config.redirectUri,
|
|
551
|
+
storage: this.storage,
|
|
552
|
+
sessionKey: config.sessionKey ?? "default",
|
|
553
|
+
clientName: config.clientName,
|
|
554
|
+
onRedirectToAuthorization: async (url) => {
|
|
555
|
+
this._pendingAuthFlow = new Promise((resolve) => {
|
|
556
|
+
this._authFlowResolve = resolve;
|
|
557
|
+
});
|
|
558
|
+
const result = await config.onAuthRequired(url);
|
|
559
|
+
if (result) {
|
|
560
|
+
await this.handleCallback(result);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Check if we're currently connected
|
|
567
|
+
*/
|
|
568
|
+
get isConnected() {
|
|
569
|
+
return this._isConnected;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Get the underlying MCP client for advanced usage
|
|
573
|
+
*/
|
|
574
|
+
get mcpClient() {
|
|
575
|
+
return this.client;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Get the session ID from the transport
|
|
579
|
+
*/
|
|
580
|
+
get sessionId() {
|
|
581
|
+
return this.transport?.sessionId;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Get the server base URL
|
|
585
|
+
*/
|
|
586
|
+
get serverUrl() {
|
|
587
|
+
return normalizeKontextServerUrl(this.config.server ?? DEFAULT_SERVER);
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Get the MCP endpoint URL.
|
|
591
|
+
* When `url` is provided, use it directly. Otherwise derive from server.
|
|
592
|
+
*/
|
|
593
|
+
get mcpEndpointUrl() {
|
|
594
|
+
return this.config.url ?? `${this.serverUrl}/mcp`;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* List available tools from the server
|
|
598
|
+
*
|
|
599
|
+
* This will automatically handle authentication if needed.
|
|
600
|
+
*/
|
|
601
|
+
async listTools() {
|
|
602
|
+
await this.ensureConnected();
|
|
603
|
+
const response = await this.client.listTools();
|
|
604
|
+
return response.tools;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Call a tool
|
|
608
|
+
*
|
|
609
|
+
* This will automatically handle authentication if needed.
|
|
610
|
+
*
|
|
611
|
+
* @param name The tool name
|
|
612
|
+
* @param args The tool arguments
|
|
613
|
+
*/
|
|
614
|
+
async callTool(name, args) {
|
|
615
|
+
await this.ensureConnected();
|
|
616
|
+
try {
|
|
617
|
+
const result = await this.client.callTool({
|
|
618
|
+
name,
|
|
619
|
+
arguments: args
|
|
620
|
+
});
|
|
621
|
+
return result;
|
|
622
|
+
} catch (error) {
|
|
623
|
+
if (error instanceof UrlElicitationRequiredError && this.config.onElicitationUrl) {
|
|
624
|
+
for (const elicitation of error.elicitations) {
|
|
625
|
+
await this.config.onElicitationUrl({
|
|
626
|
+
url: elicitation.url,
|
|
627
|
+
message: elicitation.message ?? "Action required",
|
|
628
|
+
elicitationId: elicitation.elicitationId ?? "",
|
|
629
|
+
integrationId: elicitation.integrationId,
|
|
630
|
+
integrationName: elicitation.integrationName
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Create a connect session for the hosted connect UI.
|
|
639
|
+
*
|
|
640
|
+
* Returns a URL that can be opened in a browser to let the user
|
|
641
|
+
* connect integrations proactively (before hitting -32042 errors).
|
|
642
|
+
*/
|
|
643
|
+
async createConnectSession() {
|
|
644
|
+
const tokens = await this.oauthProvider.tokens();
|
|
645
|
+
if (!tokens?.access_token) {
|
|
646
|
+
throw new AuthorizationRequiredError(
|
|
647
|
+
"Authorization required. Complete the OAuth flow first."
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
const response = await fetch(`${this.serverUrl}/mcp/connect-session`, {
|
|
651
|
+
method: "POST",
|
|
652
|
+
headers: {
|
|
653
|
+
Authorization: `Bearer ${tokens.access_token}`,
|
|
654
|
+
"Content-Type": "application/json"
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
if (response.status === 401) {
|
|
658
|
+
throw new AuthorizationRequiredError(
|
|
659
|
+
"Access token expired or invalid. Re-authenticate and retry."
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
if (!response.ok) {
|
|
663
|
+
throw new KontextError(
|
|
664
|
+
`Failed to create connect session: HTTP ${response.status}`,
|
|
665
|
+
"kontext_connect_session_failed",
|
|
666
|
+
{ statusCode: response.status }
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
return await response.json();
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* List integrations attached to the current application/runtime identity.
|
|
673
|
+
*
|
|
674
|
+
* This is used by higher-level orchestrators to discover mixed integration
|
|
675
|
+
* topologies (gateway + internal credential integrations).
|
|
676
|
+
*/
|
|
677
|
+
async listRuntimeIntegrations() {
|
|
678
|
+
const tokens = await this.oauthProvider.tokens();
|
|
679
|
+
if (!tokens?.access_token) {
|
|
680
|
+
throw new AuthorizationRequiredError(
|
|
681
|
+
"Authorization required. Complete the OAuth flow first."
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
const response = await fetch(`${this.serverUrl}/mcp/integrations`, {
|
|
685
|
+
method: "GET",
|
|
686
|
+
headers: {
|
|
687
|
+
Authorization: `Bearer ${tokens.access_token}`
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
if (response.status === 401) {
|
|
691
|
+
throw new AuthorizationRequiredError(
|
|
692
|
+
"Access token expired or invalid. Re-authenticate and retry."
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
if (!response.ok) {
|
|
696
|
+
throw new KontextError(
|
|
697
|
+
`Failed to list runtime integrations: HTTP ${response.status}`,
|
|
698
|
+
"kontext_runtime_integrations_failed",
|
|
699
|
+
{ statusCode: response.status }
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
const payload = await response.json();
|
|
703
|
+
const items = Array.isArray(payload?.items) ? payload.items : [];
|
|
704
|
+
return items.map((item) => {
|
|
705
|
+
const id = typeof item.id === "string" ? item.id : "";
|
|
706
|
+
const name = typeof item.name === "string" ? item.name : id;
|
|
707
|
+
const url = typeof item.url === "string" ? item.url : "";
|
|
708
|
+
if (!id || !url) return null;
|
|
709
|
+
const category = item.category === "internal_mcp_credentials" ? "internal_mcp_credentials" : "gateway_remote_mcp";
|
|
710
|
+
const connectType = item.connectType === "credentials" || item.connectType === "oauth" || item.connectType === "none" ? item.connectType : category === "internal_mcp_credentials" ? "credentials" : item.authMode === "oauth" ? "oauth" : "none";
|
|
711
|
+
const rawConnection = item.connection && typeof item.connection === "object" ? item.connection : void 0;
|
|
712
|
+
const connected = rawConnection && typeof rawConnection.connected === "boolean" ? rawConnection.connected : false;
|
|
713
|
+
const status = rawConnection?.status === "connected" ? "connected" : "disconnected";
|
|
714
|
+
return {
|
|
715
|
+
id,
|
|
716
|
+
name,
|
|
717
|
+
url,
|
|
718
|
+
category,
|
|
719
|
+
connectType,
|
|
720
|
+
authMode: item.authMode === "oauth" || item.authMode === "user_token" || item.authMode === "server_token" || item.authMode === "none" ? item.authMode : void 0,
|
|
721
|
+
credentialSchema: item.credentialSchema,
|
|
722
|
+
requiresOauth: typeof item.requiresOauth === "boolean" ? item.requiresOauth : void 0,
|
|
723
|
+
connection: rawConnection ? {
|
|
724
|
+
connected,
|
|
725
|
+
status,
|
|
726
|
+
expiresAt: typeof rawConnection.expiresAt === "string" ? rawConnection.expiresAt : void 0,
|
|
727
|
+
displayName: typeof rawConnection.displayName === "string" ? rawConnection.displayName : void 0
|
|
728
|
+
} : void 0
|
|
729
|
+
};
|
|
730
|
+
}).filter((item) => item !== null);
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Handle an OAuth callback URL
|
|
734
|
+
*
|
|
735
|
+
* Call this after the user has been redirected back from authorization.
|
|
736
|
+
* This is required for web apps where `onAuthRequired` redirects instead of waiting.
|
|
737
|
+
*
|
|
738
|
+
* @param callbackUrl The full callback URL with query parameters
|
|
739
|
+
*/
|
|
740
|
+
async handleCallback(callbackUrl) {
|
|
741
|
+
const { code, state, error, errorDescription } = parseOAuthCallback(callbackUrl);
|
|
742
|
+
if (error) {
|
|
743
|
+
this._authFlowResolve?.();
|
|
744
|
+
this._authFlowResolve = null;
|
|
745
|
+
throw new AuthorizationRequiredError(
|
|
746
|
+
errorDescription ?? `OAuth error: ${error}`
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
const isValidState = await this.oauthProvider.validateState(state);
|
|
750
|
+
if (!isValidState) {
|
|
751
|
+
this._authFlowResolve?.();
|
|
752
|
+
this._authFlowResolve = null;
|
|
753
|
+
throw new OAuthError(
|
|
754
|
+
"OAuth state validation failed. The state parameter did not match. Retry the authorization flow.",
|
|
755
|
+
"kontext_oauth_state_invalid"
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
if (!code) {
|
|
759
|
+
this._authFlowResolve?.();
|
|
760
|
+
this._authFlowResolve = null;
|
|
761
|
+
throw new AuthorizationRequiredError(
|
|
762
|
+
"No authorization code in callback URL"
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
if (!this.transport) {
|
|
767
|
+
this.transport = new StreamableHTTPClientTransport(
|
|
768
|
+
new URL(this.mcpEndpointUrl),
|
|
769
|
+
{
|
|
770
|
+
authProvider: this.oauthProvider
|
|
771
|
+
}
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
await this.transport.finishAuth(code);
|
|
775
|
+
const tokens = await this.oauthProvider.tokens();
|
|
776
|
+
if (!tokens?.access_token) {
|
|
777
|
+
throw new AuthorizationRequiredError("Failed to obtain tokens");
|
|
778
|
+
}
|
|
779
|
+
await this.oauthProvider.saveTokens(tokens);
|
|
780
|
+
} finally {
|
|
781
|
+
this._authFlowResolve?.();
|
|
782
|
+
this._authFlowResolve = null;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Disconnect from the server
|
|
787
|
+
*/
|
|
788
|
+
async disconnect() {
|
|
789
|
+
try {
|
|
790
|
+
if (this.transport) {
|
|
791
|
+
try {
|
|
792
|
+
await this.transport.terminateSession();
|
|
793
|
+
} catch {
|
|
794
|
+
}
|
|
795
|
+
await this.transport.close();
|
|
796
|
+
}
|
|
797
|
+
} finally {
|
|
798
|
+
this.transport = null;
|
|
799
|
+
this.client = null;
|
|
800
|
+
this._isConnected = false;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Clear stored tokens and require re-authentication
|
|
805
|
+
*/
|
|
806
|
+
async clearAuth() {
|
|
807
|
+
await this.oauthProvider.clearAll();
|
|
808
|
+
await this.disconnect();
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Check if this URL is an OAuth callback
|
|
812
|
+
*
|
|
813
|
+
* Useful for web apps to detect if the current URL is a callback.
|
|
814
|
+
*
|
|
815
|
+
* @param url The URL to check
|
|
816
|
+
*/
|
|
817
|
+
isCallback(url) {
|
|
818
|
+
const urlObj = typeof url === "string" ? new URL(url) : url;
|
|
819
|
+
const redirectUri = new URL(this.config.redirectUri);
|
|
820
|
+
return urlObj.pathname === redirectUri.pathname && (urlObj.searchParams.has("code") || urlObj.searchParams.has("error"));
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Ensure we're connected, handling auth if needed
|
|
824
|
+
*/
|
|
825
|
+
async ensureConnected() {
|
|
826
|
+
if (this._isConnected && this.client) {
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (this._pendingConnect) {
|
|
830
|
+
await this._pendingConnect;
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
this._pendingConnect = this.doConnect();
|
|
834
|
+
try {
|
|
835
|
+
await this._pendingConnect;
|
|
836
|
+
} finally {
|
|
837
|
+
this._pendingConnect = null;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Internal connection logic
|
|
842
|
+
*/
|
|
843
|
+
async doConnect(authRetryCount = 0) {
|
|
844
|
+
if (!this.transport) {
|
|
845
|
+
this.transport = new StreamableHTTPClientTransport(
|
|
846
|
+
new URL(this.mcpEndpointUrl),
|
|
847
|
+
{
|
|
848
|
+
authProvider: this.oauthProvider
|
|
849
|
+
}
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
const capabilities = {
|
|
853
|
+
tools: {}
|
|
854
|
+
};
|
|
855
|
+
if (this.config.onElicitationUrl) {
|
|
856
|
+
capabilities.elicitation = { url: {} };
|
|
857
|
+
}
|
|
858
|
+
this.client = new Client(
|
|
859
|
+
{
|
|
860
|
+
name: this.config.clientName ?? "kontext-sdk",
|
|
861
|
+
version: this.config.clientVersion ?? "0.0.1"
|
|
862
|
+
},
|
|
863
|
+
{ capabilities }
|
|
864
|
+
);
|
|
865
|
+
if (this.config.onElicitationUrl) {
|
|
866
|
+
const onElicitationUrl = this.config.onElicitationUrl;
|
|
867
|
+
this.client.setRequestHandler(ElicitRequestSchema, async (request) => {
|
|
868
|
+
const params = request.params;
|
|
869
|
+
if (params.mode === "url" && "url" in params) {
|
|
870
|
+
await onElicitationUrl({
|
|
871
|
+
url: params.url,
|
|
872
|
+
message: params.message ?? "Action required",
|
|
873
|
+
elicitationId: params.elicitationId ?? "",
|
|
874
|
+
integrationId: params.integrationId,
|
|
875
|
+
integrationName: params.integrationName
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
return { action: "accept" };
|
|
879
|
+
});
|
|
880
|
+
this.client.setNotificationHandler(
|
|
881
|
+
ElicitationCompleteNotificationSchema,
|
|
882
|
+
() => {
|
|
883
|
+
}
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
try {
|
|
887
|
+
await this.client.connect(this.transport);
|
|
888
|
+
this._isConnected = true;
|
|
889
|
+
} catch (error) {
|
|
890
|
+
if (error instanceof Error && isUnauthorizedError(error)) {
|
|
891
|
+
if (this._pendingAuthFlow) {
|
|
892
|
+
await this._pendingAuthFlow;
|
|
893
|
+
this._pendingAuthFlow = null;
|
|
894
|
+
this.transport = null;
|
|
895
|
+
this.client = null;
|
|
896
|
+
if (authRetryCount >= 1) {
|
|
897
|
+
throw new AuthorizationRequiredError(
|
|
898
|
+
"Authorization completed, but the MCP server still rejected the token. Verify OAuth resource audience and token issuer configuration.",
|
|
899
|
+
{ cause: error }
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
return this.doConnect(authRetryCount + 1);
|
|
903
|
+
}
|
|
904
|
+
this.transport = null;
|
|
905
|
+
this.client = null;
|
|
906
|
+
throw new AuthorizationRequiredError(
|
|
907
|
+
"Authorization required. Complete the OAuth flow and retry."
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
this.transport = null;
|
|
911
|
+
this.client = null;
|
|
912
|
+
if (error instanceof KontextError) throw error;
|
|
913
|
+
throw new KontextError(
|
|
914
|
+
`Failed to connect to MCP server at ${this.mcpEndpointUrl}. ${error instanceof Error ? error.message : String(error)}`,
|
|
915
|
+
"kontext_mcp_connection_failed",
|
|
916
|
+
{ cause: error }
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
// src/client/tool-utils.ts
|
|
923
|
+
function parseIntegrationStatus(tools, errors, elicitations) {
|
|
924
|
+
const seen = /* @__PURE__ */ new Set();
|
|
925
|
+
const result = [];
|
|
926
|
+
for (const t of tools) {
|
|
927
|
+
const sid = t.server?.id;
|
|
928
|
+
if (sid && !seen.has(sid)) {
|
|
929
|
+
seen.add(sid);
|
|
930
|
+
result.push({
|
|
931
|
+
id: sid,
|
|
932
|
+
name: t.server?.name ?? sid,
|
|
933
|
+
connected: true
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
for (const e of errors) {
|
|
938
|
+
if (!seen.has(e.serverId)) {
|
|
939
|
+
seen.add(e.serverId);
|
|
940
|
+
const elicitation = elicitations?.find(
|
|
941
|
+
(el) => el.integrationId === e.serverId
|
|
942
|
+
);
|
|
943
|
+
result.push({
|
|
944
|
+
id: e.serverId,
|
|
945
|
+
name: e.serverName ?? e.serverId,
|
|
946
|
+
connected: false,
|
|
947
|
+
connectUrl: elicitation?.url
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
return result;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// src/client/orchestrator/internal/backends.ts
|
|
955
|
+
function gatewayBackendId() {
|
|
956
|
+
return "gateway";
|
|
957
|
+
}
|
|
958
|
+
function internalBackendId(integrationId) {
|
|
959
|
+
return `internal:${integrationId}`;
|
|
960
|
+
}
|
|
961
|
+
function isInternalIntegration(integration) {
|
|
962
|
+
return integration.category === "internal_mcp_credentials";
|
|
963
|
+
}
|
|
964
|
+
function sortInternalIntegrations(integrations) {
|
|
965
|
+
return integrations.filter(isInternalIntegration).sort((a, b) => a.id.localeCompare(b.id));
|
|
966
|
+
}
|
|
967
|
+
function createGatewayBackend(client) {
|
|
968
|
+
return {
|
|
969
|
+
backendId: gatewayBackendId(),
|
|
970
|
+
source: "gateway",
|
|
971
|
+
client
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
function createInternalBackend(input) {
|
|
975
|
+
return {
|
|
976
|
+
backendId: internalBackendId(input.integration.id),
|
|
977
|
+
source: "internal",
|
|
978
|
+
client: input.client,
|
|
979
|
+
integrationId: input.integration.id,
|
|
980
|
+
integrationName: input.integration.name
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// src/client/orchestrator/internal/routes.ts
|
|
985
|
+
function emptyRouteInventorySnapshot() {
|
|
986
|
+
return {
|
|
987
|
+
version: 0,
|
|
988
|
+
tools: [],
|
|
989
|
+
routes: /* @__PURE__ */ new Map(),
|
|
990
|
+
conflicts: []
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
function buildRouteInventory(version, candidates, options) {
|
|
994
|
+
const tools = [];
|
|
995
|
+
const routes = /* @__PURE__ */ new Map();
|
|
996
|
+
const conflicts = [];
|
|
997
|
+
for (const candidate of candidates) {
|
|
998
|
+
const toolId = candidate.route.toolId;
|
|
999
|
+
if (!routes.has(toolId)) {
|
|
1000
|
+
routes.set(toolId, candidate.route);
|
|
1001
|
+
tools.push(candidate.tool);
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
const existing = routes.get(toolId);
|
|
1005
|
+
const decision = options?.onConflict?.(existing, candidate.route) ?? "keep_existing";
|
|
1006
|
+
let kept = existing;
|
|
1007
|
+
let dropped = candidate.route;
|
|
1008
|
+
if (decision === "replace_existing") {
|
|
1009
|
+
const existingIdx = tools.findIndex((tool) => tool.id === toolId);
|
|
1010
|
+
if (existingIdx >= 0) {
|
|
1011
|
+
tools.splice(existingIdx, 1, candidate.tool);
|
|
1012
|
+
} else {
|
|
1013
|
+
tools.push(candidate.tool);
|
|
1014
|
+
}
|
|
1015
|
+
routes.set(toolId, candidate.route);
|
|
1016
|
+
kept = candidate.route;
|
|
1017
|
+
dropped = existing;
|
|
1018
|
+
}
|
|
1019
|
+
conflicts.push({
|
|
1020
|
+
toolId,
|
|
1021
|
+
kept,
|
|
1022
|
+
dropped
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
return {
|
|
1026
|
+
version,
|
|
1027
|
+
tools,
|
|
1028
|
+
routes,
|
|
1029
|
+
conflicts
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// src/client/orchestrator/internal/inventory.ts
|
|
1034
|
+
var RouteInventoryStore = class {
|
|
1035
|
+
versionCounter = 0;
|
|
1036
|
+
resetGeneration = 0;
|
|
1037
|
+
buildRunCounter = 0;
|
|
1038
|
+
latestCommittedBuildRun = 0;
|
|
1039
|
+
snapshotState = emptyRouteInventorySnapshot();
|
|
1040
|
+
pendingBuilds = /* @__PURE__ */ new Map();
|
|
1041
|
+
get version() {
|
|
1042
|
+
return this.snapshotState.version;
|
|
1043
|
+
}
|
|
1044
|
+
get snapshot() {
|
|
1045
|
+
return this.snapshotState;
|
|
1046
|
+
}
|
|
1047
|
+
routeFor(toolId) {
|
|
1048
|
+
return this.snapshotState.routes.get(toolId);
|
|
1049
|
+
}
|
|
1050
|
+
async build(buildCandidates, options) {
|
|
1051
|
+
const key = options?.key ?? "__default__";
|
|
1052
|
+
const existingBuild = this.pendingBuilds.get(key);
|
|
1053
|
+
if (existingBuild) {
|
|
1054
|
+
return await existingBuild;
|
|
1055
|
+
}
|
|
1056
|
+
const buildGeneration = this.resetGeneration;
|
|
1057
|
+
const buildRun = ++this.buildRunCounter;
|
|
1058
|
+
const pendingPromise = (async () => {
|
|
1059
|
+
const candidates = await buildCandidates();
|
|
1060
|
+
if (this.resetGeneration !== buildGeneration) {
|
|
1061
|
+
return this.snapshotState;
|
|
1062
|
+
}
|
|
1063
|
+
if (buildRun >= this.latestCommittedBuildRun) {
|
|
1064
|
+
this.latestCommittedBuildRun = buildRun;
|
|
1065
|
+
this.versionCounter += 1;
|
|
1066
|
+
const snapshot = buildRouteInventory(this.versionCounter, candidates, {
|
|
1067
|
+
onConflict: options?.onConflict
|
|
1068
|
+
});
|
|
1069
|
+
this.snapshotState = snapshot;
|
|
1070
|
+
return snapshot;
|
|
1071
|
+
}
|
|
1072
|
+
return buildRouteInventory(this.snapshotState.version, candidates, {
|
|
1073
|
+
onConflict: options?.onConflict
|
|
1074
|
+
});
|
|
1075
|
+
})();
|
|
1076
|
+
this.pendingBuilds.set(key, pendingPromise);
|
|
1077
|
+
try {
|
|
1078
|
+
return await pendingPromise;
|
|
1079
|
+
} finally {
|
|
1080
|
+
if (this.pendingBuilds.get(key) === pendingPromise) {
|
|
1081
|
+
this.pendingBuilds.delete(key);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
reset() {
|
|
1086
|
+
this.resetGeneration += 1;
|
|
1087
|
+
this.versionCounter = 0;
|
|
1088
|
+
this.latestCommittedBuildRun = this.buildRunCounter;
|
|
1089
|
+
this.snapshotState = emptyRouteInventorySnapshot();
|
|
1090
|
+
this.pendingBuilds.clear();
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
// src/client/orchestrator/internal/policy.ts
|
|
1095
|
+
var defaultRoutingPolicy = {
|
|
1096
|
+
onRouteConflict() {
|
|
1097
|
+
return "keep_existing";
|
|
1098
|
+
}
|
|
1099
|
+
};
|
|
1100
|
+
function toRouteConflictError(input) {
|
|
1101
|
+
return new KontextError(
|
|
1102
|
+
`Route conflict for tool "${input.toolId}". Keeping backend "${input.kept.backendId}" and dropping "${input.dropped.backendId}".`,
|
|
1103
|
+
"kontext_tool_route_conflict",
|
|
1104
|
+
{
|
|
1105
|
+
meta: {
|
|
1106
|
+
toolId: input.toolId,
|
|
1107
|
+
keptBackendId: input.kept.backendId,
|
|
1108
|
+
keptSource: input.kept.source,
|
|
1109
|
+
keptIntegrationId: input.kept.integrationId,
|
|
1110
|
+
droppedBackendId: input.dropped.backendId,
|
|
1111
|
+
droppedSource: input.dropped.source,
|
|
1112
|
+
droppedIntegrationId: input.dropped.integrationId
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// src/client/orchestrator/internal/state.ts
|
|
1119
|
+
function createOrchestratorStateController(input) {
|
|
1120
|
+
let state = input.initialState ?? "idle";
|
|
1121
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
1122
|
+
function setState(next) {
|
|
1123
|
+
if (state === next) return;
|
|
1124
|
+
state = next;
|
|
1125
|
+
try {
|
|
1126
|
+
input.onStateChange?.(next);
|
|
1127
|
+
} catch {
|
|
1128
|
+
}
|
|
1129
|
+
const handlers = listeners.get("stateChange");
|
|
1130
|
+
if (!handlers) return;
|
|
1131
|
+
for (const handler of handlers) {
|
|
1132
|
+
try {
|
|
1133
|
+
handler(next);
|
|
1134
|
+
} catch {
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
function emitError(error) {
|
|
1139
|
+
const handlers = listeners.get("error");
|
|
1140
|
+
if (!handlers) return;
|
|
1141
|
+
for (const handler of handlers) {
|
|
1142
|
+
try {
|
|
1143
|
+
handler(error);
|
|
1144
|
+
} catch {
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
function on(event, handler) {
|
|
1149
|
+
if (!listeners.has(event)) {
|
|
1150
|
+
listeners.set(event, /* @__PURE__ */ new Set());
|
|
1151
|
+
}
|
|
1152
|
+
listeners.get(event).add(handler);
|
|
1153
|
+
return () => {
|
|
1154
|
+
listeners.get(event)?.delete(handler);
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
return {
|
|
1158
|
+
get state() {
|
|
1159
|
+
return state;
|
|
1160
|
+
},
|
|
1161
|
+
setState,
|
|
1162
|
+
emitError,
|
|
1163
|
+
on
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// src/client/orchestrator/token-manager.ts
|
|
1168
|
+
function hasFreshStoredToken(tokens, skewMs = 3e4) {
|
|
1169
|
+
if (!tokens?.access_token) return false;
|
|
1170
|
+
const expiresIn = typeof tokens.expires_in === "number" ? tokens.expires_in : Number(tokens.expires_in);
|
|
1171
|
+
const issuedAt = typeof tokens.issued_at === "number" ? tokens.issued_at : Number(tokens.issued_at);
|
|
1172
|
+
if (!Number.isFinite(expiresIn) || !Number.isFinite(issuedAt)) {
|
|
1173
|
+
return false;
|
|
1174
|
+
}
|
|
1175
|
+
const expiresAt = issuedAt + expiresIn * 1e3;
|
|
1176
|
+
return Date.now() + skewMs < expiresAt;
|
|
1177
|
+
}
|
|
1178
|
+
function createTokenManager(input) {
|
|
1179
|
+
const pendingInternalTokenExchange = /* @__PURE__ */ new Map();
|
|
1180
|
+
function tokenStorageKey(sessionKey) {
|
|
1181
|
+
return createStorageKey(input.clientId, sessionKey, StorageKeys.TOKENS);
|
|
1182
|
+
}
|
|
1183
|
+
async function readGatewayTokens() {
|
|
1184
|
+
const identityTokens = await input.storage.getJson(
|
|
1185
|
+
createStorageKey(
|
|
1186
|
+
input.clientId,
|
|
1187
|
+
input.gatewaySessionKey,
|
|
1188
|
+
StorageKeys.IDENTITY_TOKENS
|
|
1189
|
+
)
|
|
1190
|
+
);
|
|
1191
|
+
const gatewayTokens = await input.storage.getJson(tokenStorageKey(input.gatewaySessionKey));
|
|
1192
|
+
return {
|
|
1193
|
+
subjectToken: identityTokens?.access_token ?? gatewayTokens?.access_token ?? null
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
async function ensureInternalResourceToken(integration, options) {
|
|
1197
|
+
const internalSessionKey = `${input.baseSessionKey}:internal:${integration.id}`;
|
|
1198
|
+
if (!options?.forceExchange) {
|
|
1199
|
+
const existingToken = await input.storage.getJson(
|
|
1200
|
+
tokenStorageKey(internalSessionKey)
|
|
1201
|
+
);
|
|
1202
|
+
if (hasFreshStoredToken(existingToken)) {
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
const pending = pendingInternalTokenExchange.get(integration.id);
|
|
1207
|
+
if (pending) {
|
|
1208
|
+
return await pending;
|
|
1209
|
+
}
|
|
1210
|
+
const exchangePromise = (async () => {
|
|
1211
|
+
const { subjectToken: gatewaySubjectToken } = await readGatewayTokens();
|
|
1212
|
+
if (!gatewaySubjectToken) {
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
const body = new URLSearchParams({
|
|
1216
|
+
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
1217
|
+
subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
|
|
1218
|
+
subject_token: gatewaySubjectToken,
|
|
1219
|
+
resource: integration.url,
|
|
1220
|
+
client_id: input.clientId
|
|
1221
|
+
});
|
|
1222
|
+
const response = await fetch(`${input.serverUrl}/oauth2/token`, {
|
|
1223
|
+
method: "POST",
|
|
1224
|
+
headers: {
|
|
1225
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
1226
|
+
},
|
|
1227
|
+
body: body.toString()
|
|
1228
|
+
});
|
|
1229
|
+
if (response.status === 401) {
|
|
1230
|
+
throw new AuthorizationRequiredError(
|
|
1231
|
+
"Access token expired or invalid. Re-authenticate and retry."
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
if (!response.ok) {
|
|
1235
|
+
throw new KontextError(
|
|
1236
|
+
`Failed to exchange gateway token for internal resource "${integration.id}": HTTP ${response.status}`,
|
|
1237
|
+
"kontext_token_exchange_failed",
|
|
1238
|
+
{
|
|
1239
|
+
statusCode: response.status,
|
|
1240
|
+
meta: {
|
|
1241
|
+
integrationId: integration.id,
|
|
1242
|
+
resource: integration.url
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
const exchanged = await response.json();
|
|
1248
|
+
if (!exchanged.access_token || !exchanged.token_type) {
|
|
1249
|
+
throw new KontextError(
|
|
1250
|
+
`Token exchange returned an invalid payload for integration "${integration.id}".`,
|
|
1251
|
+
"kontext_token_exchange_failed",
|
|
1252
|
+
{
|
|
1253
|
+
meta: {
|
|
1254
|
+
integrationId: integration.id,
|
|
1255
|
+
resource: integration.url
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
await input.storage.setJson(tokenStorageKey(internalSessionKey), {
|
|
1261
|
+
...exchanged,
|
|
1262
|
+
issued_at: Date.now()
|
|
1263
|
+
});
|
|
1264
|
+
})();
|
|
1265
|
+
pendingInternalTokenExchange.set(integration.id, exchangePromise);
|
|
1266
|
+
try {
|
|
1267
|
+
await exchangePromise;
|
|
1268
|
+
} finally {
|
|
1269
|
+
pendingInternalTokenExchange.delete(integration.id);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
return {
|
|
1273
|
+
tokenStorageKey,
|
|
1274
|
+
ensureInternalResourceToken
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// src/client/orchestrator/internal-client-registry.ts
|
|
1279
|
+
function createInternalClientRegistry(input) {
|
|
1280
|
+
const internalClients = /* @__PURE__ */ new Map();
|
|
1281
|
+
async function remove(integrationId) {
|
|
1282
|
+
const existing = internalClients.get(integrationId);
|
|
1283
|
+
if (!existing) return;
|
|
1284
|
+
existing.unsubscribeState();
|
|
1285
|
+
existing.unsubscribeError();
|
|
1286
|
+
internalClients.delete(integrationId);
|
|
1287
|
+
input.backends.delete(existing.backendId);
|
|
1288
|
+
try {
|
|
1289
|
+
await existing.client.disconnect();
|
|
1290
|
+
} catch {
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
async function sync(discovered) {
|
|
1294
|
+
const sortedInternal = sortInternalIntegrations(discovered);
|
|
1295
|
+
const desiredById = new Map(sortedInternal.map((item) => [item.id, item]));
|
|
1296
|
+
for (const [integrationId, existing] of internalClients) {
|
|
1297
|
+
const next = desiredById.get(integrationId);
|
|
1298
|
+
if (!next) {
|
|
1299
|
+
await remove(integrationId);
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
if (existing.integration.url !== next.url) {
|
|
1303
|
+
await remove(integrationId);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
for (const integration of sortedInternal) {
|
|
1307
|
+
const existing = internalClients.get(integration.id);
|
|
1308
|
+
if (existing) {
|
|
1309
|
+
existing.integration = integration;
|
|
1310
|
+
const backend = input.backends.get(existing.backendId);
|
|
1311
|
+
if (backend) {
|
|
1312
|
+
backend.integrationName = integration.name;
|
|
1313
|
+
}
|
|
1314
|
+
continue;
|
|
1315
|
+
}
|
|
1316
|
+
const created = input.createClient(integration);
|
|
1317
|
+
input.backends.set(created.backend.backendId, created.backend);
|
|
1318
|
+
internalClients.set(integration.id, {
|
|
1319
|
+
integration,
|
|
1320
|
+
backendId: created.backend.backendId,
|
|
1321
|
+
client: created.client,
|
|
1322
|
+
unsubscribeState: created.unsubscribeState,
|
|
1323
|
+
unsubscribeError: created.unsubscribeError
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
function missingResolved(resolvedIntegrations) {
|
|
1328
|
+
const missing = [];
|
|
1329
|
+
for (const integrationId of internalClients.keys()) {
|
|
1330
|
+
if (!resolvedIntegrations.has(integrationId)) {
|
|
1331
|
+
missing.push(integrationId);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
return missing;
|
|
1335
|
+
}
|
|
1336
|
+
async function dispose(mode) {
|
|
1337
|
+
const entries = [...internalClients.values()];
|
|
1338
|
+
internalClients.clear();
|
|
1339
|
+
for (const backend of [...input.backends.values()]) {
|
|
1340
|
+
if (backend.source === "internal") {
|
|
1341
|
+
input.backends.delete(backend.backendId);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
await Promise.all(
|
|
1345
|
+
entries.map(async (entry) => {
|
|
1346
|
+
entry.unsubscribeState();
|
|
1347
|
+
entry.unsubscribeError();
|
|
1348
|
+
try {
|
|
1349
|
+
if (mode === "signOut") {
|
|
1350
|
+
await entry.client.auth.signOut();
|
|
1351
|
+
} else {
|
|
1352
|
+
await entry.client.disconnect();
|
|
1353
|
+
}
|
|
1354
|
+
} catch {
|
|
1355
|
+
}
|
|
1356
|
+
})
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
return {
|
|
1360
|
+
get size() {
|
|
1361
|
+
return internalClients.size;
|
|
1362
|
+
},
|
|
1363
|
+
entries() {
|
|
1364
|
+
return internalClients.entries();
|
|
1365
|
+
},
|
|
1366
|
+
values() {
|
|
1367
|
+
return internalClients.values();
|
|
1368
|
+
},
|
|
1369
|
+
keys() {
|
|
1370
|
+
return internalClients.keys();
|
|
1371
|
+
},
|
|
1372
|
+
get(integrationId) {
|
|
1373
|
+
return internalClients.get(integrationId);
|
|
1374
|
+
},
|
|
1375
|
+
sync,
|
|
1376
|
+
missingResolved,
|
|
1377
|
+
dispose
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// src/client/orchestrator/index.ts
|
|
1382
|
+
function isTransientError(err) {
|
|
1383
|
+
if (err instanceof Error && isNetworkError(err)) {
|
|
1384
|
+
return true;
|
|
1385
|
+
}
|
|
1386
|
+
if (isKontextError(err)) {
|
|
1387
|
+
if (err.code === "kontext_network_error") return true;
|
|
1388
|
+
if (err.statusCode === 429) return true;
|
|
1389
|
+
if (typeof err.statusCode === "number" && err.statusCode >= 500) {
|
|
1390
|
+
return true;
|
|
1391
|
+
}
|
|
1392
|
+
return false;
|
|
1393
|
+
}
|
|
1394
|
+
if (typeof err === "object" && err !== null) {
|
|
1395
|
+
const errObj = err;
|
|
1396
|
+
const maybeStatus = errObj.statusCode ?? errObj.status;
|
|
1397
|
+
const status = typeof maybeStatus === "number" ? maybeStatus : void 0;
|
|
1398
|
+
if (status === 429 || typeof status === "number" && status >= 500) {
|
|
1399
|
+
return true;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
return false;
|
|
1403
|
+
}
|
|
1404
|
+
async function withTransientRetry(operation, maxRetries = 1) {
|
|
1405
|
+
const retryDelayMs = 250;
|
|
1406
|
+
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
|
1407
|
+
try {
|
|
1408
|
+
return await operation();
|
|
1409
|
+
} catch (err) {
|
|
1410
|
+
if (attempt >= maxRetries || !isTransientError(err)) {
|
|
1411
|
+
throw err;
|
|
1412
|
+
}
|
|
1413
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
throw new Error("Transient retry loop exhausted unexpectedly.");
|
|
1417
|
+
}
|
|
1418
|
+
function toKontextError(err, context) {
|
|
1419
|
+
const contextMeta = context ? { ...context } : void 0;
|
|
1420
|
+
const mergeMeta = (base) => contextMeta ? { ...base ?? {}, ...contextMeta } : base ?? {};
|
|
1421
|
+
if (isKontextError(err)) {
|
|
1422
|
+
if (!contextMeta) {
|
|
1423
|
+
return err;
|
|
1424
|
+
}
|
|
1425
|
+
const cloned = Object.create(Object.getPrototypeOf(err));
|
|
1426
|
+
Object.defineProperties(cloned, Object.getOwnPropertyDescriptors(err));
|
|
1427
|
+
Object.defineProperty(cloned, "meta", {
|
|
1428
|
+
value: mergeMeta(err.meta),
|
|
1429
|
+
enumerable: true,
|
|
1430
|
+
writable: true,
|
|
1431
|
+
configurable: true
|
|
1432
|
+
});
|
|
1433
|
+
return cloned;
|
|
1434
|
+
}
|
|
1435
|
+
if (err instanceof Error) {
|
|
1436
|
+
if (isUnauthorizedError(err)) {
|
|
1437
|
+
return new AuthorizationRequiredError(err.message, {
|
|
1438
|
+
meta: mergeMeta(),
|
|
1439
|
+
cause: err
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
return new KontextError(err.message, "kontext_unknown_error", {
|
|
1443
|
+
meta: mergeMeta(),
|
|
1444
|
+
cause: err
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
return new KontextError(String(err), "kontext_unknown_error", {
|
|
1448
|
+
meta: mergeMeta()
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
function isAuthorizationRequired(err) {
|
|
1452
|
+
if (err instanceof AuthorizationRequiredError) return true;
|
|
1453
|
+
if (isKontextError(err)) {
|
|
1454
|
+
return err.code === "kontext_authorization_required";
|
|
1455
|
+
}
|
|
1456
|
+
if (typeof err === "object" && err !== null) {
|
|
1457
|
+
return err.code === "kontext_authorization_required";
|
|
1458
|
+
}
|
|
1459
|
+
return false;
|
|
1460
|
+
}
|
|
1461
|
+
function isTokenExchangeFailure(err) {
|
|
1462
|
+
if (isKontextError(err)) {
|
|
1463
|
+
return err.code === "kontext_token_exchange_failed";
|
|
1464
|
+
}
|
|
1465
|
+
if (typeof err === "object" && err !== null) {
|
|
1466
|
+
return err.code === "kontext_token_exchange_failed";
|
|
1467
|
+
}
|
|
1468
|
+
return false;
|
|
1469
|
+
}
|
|
1470
|
+
function isAuthRecoveryRequired(err) {
|
|
1471
|
+
return isAuthorizationRequired(err) || isTokenExchangeFailure(err);
|
|
1472
|
+
}
|
|
1473
|
+
function createKontextOrchestrator(config) {
|
|
1474
|
+
if (!config.clientId) {
|
|
1475
|
+
throw new ConfigError(
|
|
1476
|
+
"clientId is required. Pass it in KontextOrchestratorConfig.",
|
|
1477
|
+
"kontext_config_missing_client_id"
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
if (!config.redirectUri) {
|
|
1481
|
+
throw new ConfigError(
|
|
1482
|
+
"redirectUri is required. Set it to the URL where OAuth should redirect after authorization.",
|
|
1483
|
+
"kontext_config_missing_redirect_uri"
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
if (!config.onAuthRequired) {
|
|
1487
|
+
throw new ConfigError(
|
|
1488
|
+
"onAuthRequired callback is required. Provide a function that opens the OAuth URL.",
|
|
1489
|
+
"kontext_config_missing_auth_handler"
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
const baseSessionKey = config.sessionKey ?? "default";
|
|
1493
|
+
const serverUrl = normalizeKontextServerUrl(
|
|
1494
|
+
config.serverUrl ?? "https://api.kontext.dev"
|
|
1495
|
+
);
|
|
1496
|
+
const sharedStorage = config.storage ?? new MemoryStorage();
|
|
1497
|
+
const gatewaySessionKey = `${baseSessionKey}:gateway`;
|
|
1498
|
+
const authSourceQueue = [];
|
|
1499
|
+
const stateController = createOrchestratorStateController({
|
|
1500
|
+
initialState: "idle",
|
|
1501
|
+
onStateChange: config.onStateChange
|
|
1502
|
+
});
|
|
1503
|
+
const routeInventory = new RouteInventoryStore();
|
|
1504
|
+
const routingPolicy = defaultRoutingPolicy;
|
|
1505
|
+
const runtimeIntegrations = /* @__PURE__ */ new Map();
|
|
1506
|
+
const backends = /* @__PURE__ */ new Map();
|
|
1507
|
+
const managedInternalOps = /* @__PURE__ */ new Map();
|
|
1508
|
+
const resolvedInternalListings = /* @__PURE__ */ new Set();
|
|
1509
|
+
const tokenManager = createTokenManager({
|
|
1510
|
+
clientId: config.clientId,
|
|
1511
|
+
baseSessionKey,
|
|
1512
|
+
gatewaySessionKey,
|
|
1513
|
+
serverUrl,
|
|
1514
|
+
storage: sharedStorage
|
|
1515
|
+
});
|
|
1516
|
+
let pendingIntegrationRefresh = null;
|
|
1517
|
+
const gatewayClient = createSingleEndpointKontextClient({
|
|
1518
|
+
clientId: config.clientId,
|
|
1519
|
+
redirectUri: config.redirectUri,
|
|
1520
|
+
serverUrl: config.serverUrl,
|
|
1521
|
+
storage: sharedStorage,
|
|
1522
|
+
sessionKey: gatewaySessionKey,
|
|
1523
|
+
onAuthRequired: async (url) => {
|
|
1524
|
+
authSourceQueue.push("gateway");
|
|
1525
|
+
return await config.onAuthRequired(url);
|
|
1526
|
+
},
|
|
1527
|
+
onIntegrationRequired: config.onIntegrationRequired
|
|
1528
|
+
});
|
|
1529
|
+
const gatewayBackend = createGatewayBackend(gatewayClient);
|
|
1530
|
+
backends.set(gatewayBackendId(), gatewayBackend);
|
|
1531
|
+
gatewayClient.on("stateChange", (state) => {
|
|
1532
|
+
if (state === "needs_auth") {
|
|
1533
|
+
stateController.setState("needs_auth");
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
gatewayClient.on("error", (error) => {
|
|
1537
|
+
const translated = toKontextError(error, {
|
|
1538
|
+
backendId: gatewayBackendId(),
|
|
1539
|
+
source: "gateway",
|
|
1540
|
+
operation: "client_error_event"
|
|
1541
|
+
});
|
|
1542
|
+
stateController.emitError(translated);
|
|
1543
|
+
if (isAuthRecoveryRequired(translated)) {
|
|
1544
|
+
stateController.setState("needs_auth");
|
|
1545
|
+
}
|
|
1546
|
+
});
|
|
1547
|
+
const ensureInternalResourceToken = tokenManager.ensureInternalResourceToken;
|
|
1548
|
+
function isInternalOpManaged(integrationId) {
|
|
1549
|
+
return (managedInternalOps.get(integrationId) ?? 0) > 0;
|
|
1550
|
+
}
|
|
1551
|
+
async function runManagedInternalOp(integrationId, operation) {
|
|
1552
|
+
managedInternalOps.set(
|
|
1553
|
+
integrationId,
|
|
1554
|
+
(managedInternalOps.get(integrationId) ?? 0) + 1
|
|
1555
|
+
);
|
|
1556
|
+
try {
|
|
1557
|
+
return await operation();
|
|
1558
|
+
} finally {
|
|
1559
|
+
const next = (managedInternalOps.get(integrationId) ?? 1) - 1;
|
|
1560
|
+
if (next <= 0) {
|
|
1561
|
+
managedInternalOps.delete(integrationId);
|
|
1562
|
+
} else {
|
|
1563
|
+
managedInternalOps.set(integrationId, next);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
function createInternalClient(integration) {
|
|
1568
|
+
const client = createSingleEndpointKontextClient({
|
|
1569
|
+
clientId: config.clientId,
|
|
1570
|
+
redirectUri: config.redirectUri,
|
|
1571
|
+
url: integration.url,
|
|
1572
|
+
serverUrl: config.serverUrl,
|
|
1573
|
+
storage: sharedStorage,
|
|
1574
|
+
sessionKey: `${baseSessionKey}:internal:${integration.id}`,
|
|
1575
|
+
onAuthRequired: async (url) => {
|
|
1576
|
+
authSourceQueue.push(integration.id);
|
|
1577
|
+
return await config.onAuthRequired(url);
|
|
1578
|
+
},
|
|
1579
|
+
onIntegrationRequired: config.onIntegrationRequired
|
|
1580
|
+
});
|
|
1581
|
+
const unsubscribeState = client.on("stateChange", (state) => {
|
|
1582
|
+
if (state === "needs_auth") {
|
|
1583
|
+
stateController.setState("needs_auth");
|
|
1584
|
+
}
|
|
1585
|
+
});
|
|
1586
|
+
const unsubscribeError = client.on("error", (error) => {
|
|
1587
|
+
const translated = toKontextError(error, {
|
|
1588
|
+
backendId: internalBackendId(integration.id),
|
|
1589
|
+
source: "internal",
|
|
1590
|
+
integrationId: integration.id,
|
|
1591
|
+
operation: "client_error_event"
|
|
1592
|
+
});
|
|
1593
|
+
if (isInternalOpManaged(integration.id) && isAuthorizationRequired(translated)) {
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
if (isAuthRecoveryRequired(translated)) {
|
|
1597
|
+
stateController.setState("needs_auth");
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
stateController.emitError(translated);
|
|
1601
|
+
});
|
|
1602
|
+
const backend = createInternalBackend({ integration, client });
|
|
1603
|
+
return { backend, client, unsubscribeState, unsubscribeError };
|
|
1604
|
+
}
|
|
1605
|
+
const internalClientRegistry = createInternalClientRegistry({
|
|
1606
|
+
backends,
|
|
1607
|
+
createClient: createInternalClient
|
|
1608
|
+
});
|
|
1609
|
+
async function refreshIntegrationInventory(force = false) {
|
|
1610
|
+
if (pendingIntegrationRefresh) {
|
|
1611
|
+
if (!force) {
|
|
1612
|
+
return await pendingIntegrationRefresh;
|
|
1613
|
+
}
|
|
1614
|
+
await pendingIntegrationRefresh;
|
|
1615
|
+
}
|
|
1616
|
+
const refreshPromise = (async () => {
|
|
1617
|
+
const items = await withTransientRetry(
|
|
1618
|
+
async () => await gatewayClient.mcp.listRuntimeIntegrations()
|
|
1619
|
+
);
|
|
1620
|
+
await applyRuntimeIntegrations(items);
|
|
1621
|
+
return items;
|
|
1622
|
+
})();
|
|
1623
|
+
pendingIntegrationRefresh = refreshPromise;
|
|
1624
|
+
try {
|
|
1625
|
+
return await refreshPromise;
|
|
1626
|
+
} finally {
|
|
1627
|
+
if (pendingIntegrationRefresh === refreshPromise) {
|
|
1628
|
+
pendingIntegrationRefresh = null;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
async function applyRuntimeIntegrations(integrations) {
|
|
1633
|
+
runtimeIntegrations.clear();
|
|
1634
|
+
for (const item of integrations) {
|
|
1635
|
+
runtimeIntegrations.set(item.id, item);
|
|
1636
|
+
}
|
|
1637
|
+
await internalClientRegistry.sync(integrations);
|
|
1638
|
+
}
|
|
1639
|
+
async function buildInventoryCandidates(options) {
|
|
1640
|
+
const candidates = [];
|
|
1641
|
+
resolvedInternalListings.clear();
|
|
1642
|
+
const pendingInternalAuthRetries = /* @__PURE__ */ new Set();
|
|
1643
|
+
const gatewayTools = await gatewayClient.tools.list(options);
|
|
1644
|
+
for (const tool of gatewayTools) {
|
|
1645
|
+
candidates.push({
|
|
1646
|
+
tool,
|
|
1647
|
+
route: {
|
|
1648
|
+
toolId: tool.id,
|
|
1649
|
+
backendId: gatewayBackendId(),
|
|
1650
|
+
source: "gateway",
|
|
1651
|
+
backendToolId: tool.id
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
if (options?.runtimeIntegrations) {
|
|
1656
|
+
await applyRuntimeIntegrations(options.runtimeIntegrations);
|
|
1657
|
+
} else {
|
|
1658
|
+
await refreshIntegrationInventory();
|
|
1659
|
+
}
|
|
1660
|
+
const sortedInternalEntries = [...internalClientRegistry.values()].sort(
|
|
1661
|
+
(a, b) => a.integration.id.localeCompare(b.integration.id)
|
|
1662
|
+
);
|
|
1663
|
+
const addInternalToolsForEntry = async (entry) => {
|
|
1664
|
+
const internalTools = await withTransientRetry(
|
|
1665
|
+
async () => await runManagedInternalOp(
|
|
1666
|
+
entry.integration.id,
|
|
1667
|
+
() => entry.client.tools.list()
|
|
1668
|
+
)
|
|
1669
|
+
);
|
|
1670
|
+
resolvedInternalListings.add(entry.integration.id);
|
|
1671
|
+
for (const tool of internalTools) {
|
|
1672
|
+
const backendToolId = tool.id || tool.name;
|
|
1673
|
+
const unifiedId = `${entry.integration.id}:${tool.name}`;
|
|
1674
|
+
candidates.push({
|
|
1675
|
+
tool: {
|
|
1676
|
+
id: unifiedId,
|
|
1677
|
+
name: tool.name,
|
|
1678
|
+
description: tool.description,
|
|
1679
|
+
inputSchema: tool.inputSchema,
|
|
1680
|
+
server: {
|
|
1681
|
+
id: entry.integration.id,
|
|
1682
|
+
name: entry.integration.name
|
|
1683
|
+
}
|
|
1684
|
+
},
|
|
1685
|
+
route: {
|
|
1686
|
+
toolId: unifiedId,
|
|
1687
|
+
backendId: entry.backendId,
|
|
1688
|
+
source: "internal",
|
|
1689
|
+
backendToolId,
|
|
1690
|
+
integrationId: entry.integration.id
|
|
1691
|
+
}
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
};
|
|
1695
|
+
for (const entry of sortedInternalEntries) {
|
|
1696
|
+
try {
|
|
1697
|
+
await ensureInternalResourceToken(entry.integration);
|
|
1698
|
+
await addInternalToolsForEntry(entry);
|
|
1699
|
+
} catch (err) {
|
|
1700
|
+
const translated = toKontextError(err, {
|
|
1701
|
+
backendId: entry.backendId,
|
|
1702
|
+
source: "internal",
|
|
1703
|
+
integrationId: entry.integration.id,
|
|
1704
|
+
operation: "tools.list"
|
|
1705
|
+
});
|
|
1706
|
+
if (isAuthorizationRequired(translated)) {
|
|
1707
|
+
pendingInternalAuthRetries.add(entry.integration.id);
|
|
1708
|
+
continue;
|
|
1709
|
+
}
|
|
1710
|
+
if (isTokenExchangeFailure(translated)) {
|
|
1711
|
+
stateController.setState("needs_auth");
|
|
1712
|
+
}
|
|
1713
|
+
stateController.emitError(translated);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
if (pendingInternalAuthRetries.size > 0) {
|
|
1717
|
+
let hasUnresolvedAuth = false;
|
|
1718
|
+
for (const integrationId of pendingInternalAuthRetries) {
|
|
1719
|
+
const entry = internalClientRegistry.get(integrationId);
|
|
1720
|
+
if (!entry) continue;
|
|
1721
|
+
try {
|
|
1722
|
+
await ensureInternalResourceToken(entry.integration, {
|
|
1723
|
+
forceExchange: true
|
|
1724
|
+
});
|
|
1725
|
+
await runManagedInternalOp(integrationId, async () => {
|
|
1726
|
+
await entry.client.disconnect();
|
|
1727
|
+
await entry.client.connect();
|
|
1728
|
+
});
|
|
1729
|
+
await addInternalToolsForEntry(entry);
|
|
1730
|
+
} catch (err) {
|
|
1731
|
+
const translated = toKontextError(err);
|
|
1732
|
+
if (isAuthRecoveryRequired(translated)) {
|
|
1733
|
+
hasUnresolvedAuth = true;
|
|
1734
|
+
if (isTokenExchangeFailure(translated)) {
|
|
1735
|
+
stateController.emitError(translated);
|
|
1736
|
+
}
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
stateController.emitError(translated);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
if (hasUnresolvedAuth) {
|
|
1743
|
+
stateController.setState("needs_auth");
|
|
1744
|
+
} else if (stateController.state === "needs_auth" && gatewayClient.state === "ready") {
|
|
1745
|
+
stateController.setState("ready");
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
return candidates;
|
|
1749
|
+
}
|
|
1750
|
+
async function buildToolInventory(options) {
|
|
1751
|
+
const inventoryKey = JSON.stringify({
|
|
1752
|
+
limit: options?.limit ?? null,
|
|
1753
|
+
runtimeIntegrations: options?.runtimeIntegrations?.map((integration) => integration.id).sort() ?? null
|
|
1754
|
+
});
|
|
1755
|
+
const snapshot = await routeInventory.build(
|
|
1756
|
+
async () => await buildInventoryCandidates(options),
|
|
1757
|
+
{
|
|
1758
|
+
key: inventoryKey,
|
|
1759
|
+
onConflict: (existing, incoming) => routingPolicy.onRouteConflict({
|
|
1760
|
+
toolId: existing.toolId,
|
|
1761
|
+
existing,
|
|
1762
|
+
incoming
|
|
1763
|
+
})
|
|
1764
|
+
}
|
|
1765
|
+
);
|
|
1766
|
+
return {
|
|
1767
|
+
tools: snapshot.tools,
|
|
1768
|
+
conflicts: snapshot.conflicts
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
function emitRouteConflicts(conflicts) {
|
|
1772
|
+
for (const conflict of conflicts) {
|
|
1773
|
+
stateController.emitError(
|
|
1774
|
+
toRouteConflictError({
|
|
1775
|
+
toolId: conflict.toolId,
|
|
1776
|
+
kept: conflict.kept,
|
|
1777
|
+
dropped: conflict.dropped
|
|
1778
|
+
})
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
function getInternalIntegrationsMissingTools() {
|
|
1783
|
+
return internalClientRegistry.missingResolved(resolvedInternalListings);
|
|
1784
|
+
}
|
|
1785
|
+
async function disposeInternalClients(mode) {
|
|
1786
|
+
await internalClientRegistry.dispose(mode);
|
|
1787
|
+
}
|
|
1788
|
+
async function ensureConnected() {
|
|
1789
|
+
if (stateController.state === "ready") return;
|
|
1790
|
+
if (stateController.state === "needs_auth" && gatewayClient.state === "ready") {
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
stateController.setState("connecting");
|
|
1794
|
+
try {
|
|
1795
|
+
await gatewayClient.connect();
|
|
1796
|
+
await refreshIntegrationInventory(true);
|
|
1797
|
+
stateController.setState("ready");
|
|
1798
|
+
} catch (err) {
|
|
1799
|
+
const translated = toKontextError(err, {
|
|
1800
|
+
backendId: gatewayBackendId(),
|
|
1801
|
+
source: "gateway",
|
|
1802
|
+
operation: "connect"
|
|
1803
|
+
});
|
|
1804
|
+
if (isAuthRecoveryRequired(translated)) {
|
|
1805
|
+
stateController.setState("needs_auth");
|
|
1806
|
+
} else {
|
|
1807
|
+
stateController.setState("failed");
|
|
1808
|
+
}
|
|
1809
|
+
stateController.emitError(translated);
|
|
1810
|
+
throw translated;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
async function routeForExecution(toolId) {
|
|
1814
|
+
let route = routeInventory.routeFor(toolId);
|
|
1815
|
+
if (route) return route;
|
|
1816
|
+
const inventory = await buildToolInventory();
|
|
1817
|
+
emitRouteConflicts(inventory.conflicts);
|
|
1818
|
+
route = routeInventory.routeFor(toolId);
|
|
1819
|
+
if (route) return route;
|
|
1820
|
+
throw new KontextError(
|
|
1821
|
+
`Unknown tool "${toolId}". Call tools.list() to refresh available tools.`,
|
|
1822
|
+
"kontext_tool_not_found",
|
|
1823
|
+
{ meta: { toolId } }
|
|
1824
|
+
);
|
|
1825
|
+
}
|
|
1826
|
+
const orchestrator = {
|
|
1827
|
+
get state() {
|
|
1828
|
+
return stateController.state;
|
|
1829
|
+
},
|
|
1830
|
+
async connect() {
|
|
1831
|
+
await ensureConnected();
|
|
1832
|
+
},
|
|
1833
|
+
async disconnect() {
|
|
1834
|
+
await disposeInternalClients("disconnect");
|
|
1835
|
+
runtimeIntegrations.clear();
|
|
1836
|
+
routeInventory.reset();
|
|
1837
|
+
authSourceQueue.length = 0;
|
|
1838
|
+
await gatewayClient.disconnect();
|
|
1839
|
+
stateController.setState("idle");
|
|
1840
|
+
},
|
|
1841
|
+
async getConnectPageUrl() {
|
|
1842
|
+
await ensureConnected();
|
|
1843
|
+
return await gatewayClient.getConnectPageUrl();
|
|
1844
|
+
},
|
|
1845
|
+
auth: {
|
|
1846
|
+
async signIn() {
|
|
1847
|
+
if (stateController.state === "needs_auth" && gatewayClient.state === "ready") {
|
|
1848
|
+
await refreshIntegrationInventory(true);
|
|
1849
|
+
let unresolvedInternalAuth = false;
|
|
1850
|
+
for (const [
|
|
1851
|
+
integrationId,
|
|
1852
|
+
entry
|
|
1853
|
+
] of internalClientRegistry.entries()) {
|
|
1854
|
+
try {
|
|
1855
|
+
await runManagedInternalOp(
|
|
1856
|
+
integrationId,
|
|
1857
|
+
() => entry.client.auth.signIn()
|
|
1858
|
+
);
|
|
1859
|
+
} catch (err) {
|
|
1860
|
+
const translated = toKontextError(err);
|
|
1861
|
+
if (isAuthorizationRequired(translated)) {
|
|
1862
|
+
unresolvedInternalAuth = true;
|
|
1863
|
+
continue;
|
|
1864
|
+
}
|
|
1865
|
+
stateController.emitError(translated);
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
const inventory = await buildToolInventory();
|
|
1869
|
+
emitRouteConflicts(inventory.conflicts);
|
|
1870
|
+
const missingInternal = getInternalIntegrationsMissingTools();
|
|
1871
|
+
if (!unresolvedInternalAuth && missingInternal.length === 0) {
|
|
1872
|
+
stateController.setState("ready");
|
|
1873
|
+
}
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
await ensureConnected();
|
|
1877
|
+
},
|
|
1878
|
+
async signOut() {
|
|
1879
|
+
await disposeInternalClients("signOut");
|
|
1880
|
+
runtimeIntegrations.clear();
|
|
1881
|
+
routeInventory.reset();
|
|
1882
|
+
authSourceQueue.length = 0;
|
|
1883
|
+
await gatewayClient.auth.signOut();
|
|
1884
|
+
stateController.setState("idle");
|
|
1885
|
+
},
|
|
1886
|
+
async handleCallback(url) {
|
|
1887
|
+
if (internalClientRegistry.size === 0) {
|
|
1888
|
+
try {
|
|
1889
|
+
await refreshIntegrationInventory(true);
|
|
1890
|
+
} catch (err) {
|
|
1891
|
+
stateController.emitError(
|
|
1892
|
+
toKontextError(err, {
|
|
1893
|
+
backendId: gatewayBackendId(),
|
|
1894
|
+
source: "gateway",
|
|
1895
|
+
operation: "auth.handleCallback.preload"
|
|
1896
|
+
})
|
|
1897
|
+
);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
const ordered = [];
|
|
1901
|
+
for (const sourceId of authSourceQueue) {
|
|
1902
|
+
if (sourceId === "gateway") {
|
|
1903
|
+
ordered.push({ client: gatewayClient, sourceId });
|
|
1904
|
+
continue;
|
|
1905
|
+
}
|
|
1906
|
+
const active = internalClientRegistry.get(sourceId);
|
|
1907
|
+
if (active) {
|
|
1908
|
+
ordered.push({ client: active.client, sourceId });
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
if (gatewayClient.auth.isCallback(url)) {
|
|
1912
|
+
ordered.push({ client: gatewayClient });
|
|
1913
|
+
}
|
|
1914
|
+
for (const entry of internalClientRegistry.values()) {
|
|
1915
|
+
if (entry.client.auth.isCallback(url)) {
|
|
1916
|
+
ordered.push({ client: entry.client });
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
ordered.push({ client: gatewayClient });
|
|
1920
|
+
ordered.push(
|
|
1921
|
+
...[...internalClientRegistry.values()].sort((a, b) => a.integration.id.localeCompare(b.integration.id)).map((entry) => ({ client: entry.client }))
|
|
1922
|
+
);
|
|
1923
|
+
const unique = [];
|
|
1924
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1925
|
+
for (const entry of ordered) {
|
|
1926
|
+
if (seen.has(entry.client)) continue;
|
|
1927
|
+
seen.add(entry.client);
|
|
1928
|
+
unique.push(entry);
|
|
1929
|
+
}
|
|
1930
|
+
let handledBy;
|
|
1931
|
+
let lastError = void 0;
|
|
1932
|
+
for (const entry of unique) {
|
|
1933
|
+
try {
|
|
1934
|
+
await entry.client.auth.handleCallback(url);
|
|
1935
|
+
handledBy = entry;
|
|
1936
|
+
break;
|
|
1937
|
+
} catch (err) {
|
|
1938
|
+
lastError = err;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
if (handledBy) {
|
|
1942
|
+
for (let idx = authSourceQueue.length - 1; idx >= 0; idx -= 1) {
|
|
1943
|
+
const sourceId = authSourceQueue[idx];
|
|
1944
|
+
const sourceClient = sourceId === "gateway" ? gatewayClient : internalClientRegistry.get(sourceId)?.client;
|
|
1945
|
+
if (sourceClient === handledBy.client) {
|
|
1946
|
+
authSourceQueue.splice(idx, 1);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
if (!handledBy && lastError) {
|
|
1951
|
+
throw toKontextError(lastError, {
|
|
1952
|
+
backendId: gatewayBackendId(),
|
|
1953
|
+
source: "gateway",
|
|
1954
|
+
operation: "auth.handleCallback"
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
try {
|
|
1958
|
+
await ensureConnected();
|
|
1959
|
+
} catch (err) {
|
|
1960
|
+
stateController.emitError(
|
|
1961
|
+
toKontextError(err, {
|
|
1962
|
+
backendId: gatewayBackendId(),
|
|
1963
|
+
source: "gateway",
|
|
1964
|
+
operation: "post_callback_connect"
|
|
1965
|
+
})
|
|
1966
|
+
);
|
|
1967
|
+
}
|
|
1968
|
+
},
|
|
1969
|
+
isCallback(url) {
|
|
1970
|
+
if (gatewayClient.auth.isCallback(url)) return true;
|
|
1971
|
+
for (const entry of internalClientRegistry.values()) {
|
|
1972
|
+
if (entry.client.auth.isCallback(url)) return true;
|
|
1973
|
+
}
|
|
1974
|
+
return false;
|
|
1975
|
+
},
|
|
1976
|
+
get isAuthenticated() {
|
|
1977
|
+
return stateController.state === "ready" || gatewayClient.auth.isAuthenticated;
|
|
1978
|
+
}
|
|
1979
|
+
},
|
|
1980
|
+
integrations: {
|
|
1981
|
+
async list() {
|
|
1982
|
+
await ensureConnected();
|
|
1983
|
+
const discovered = await refreshIntegrationInventory(true);
|
|
1984
|
+
let gatewayStatuses = [];
|
|
1985
|
+
try {
|
|
1986
|
+
gatewayStatuses = await gatewayClient.integrations.list();
|
|
1987
|
+
} catch (err) {
|
|
1988
|
+
stateController.emitError(
|
|
1989
|
+
toKontextError(err, {
|
|
1990
|
+
backendId: gatewayBackendId(),
|
|
1991
|
+
source: "gateway",
|
|
1992
|
+
operation: "integrations.list"
|
|
1993
|
+
})
|
|
1994
|
+
);
|
|
1995
|
+
}
|
|
1996
|
+
const gatewayById = new Map(gatewayStatuses.map((i) => [i.id, i]));
|
|
1997
|
+
const needsInternalConnectLink = discovered.some(
|
|
1998
|
+
(i) => i.category === "internal_mcp_credentials" && !i.connection?.connected
|
|
1999
|
+
);
|
|
2000
|
+
let sharedConnectUrl;
|
|
2001
|
+
if (needsInternalConnectLink) {
|
|
2002
|
+
try {
|
|
2003
|
+
sharedConnectUrl = (await gatewayClient.getConnectPageUrl()).connectUrl;
|
|
2004
|
+
} catch {
|
|
2005
|
+
sharedConnectUrl = void 0;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
return discovered.map((integration) => {
|
|
2009
|
+
const gatewayStatus = gatewayById.get(integration.id);
|
|
2010
|
+
const connected = gatewayStatus?.connected ?? integration.connection?.connected ?? false;
|
|
2011
|
+
const connectUrl = gatewayStatus?.connectUrl ?? (!connected && integration.category === "internal_mcp_credentials" ? sharedConnectUrl : void 0);
|
|
2012
|
+
const reason = gatewayStatus?.reason ?? (!connected && integration.category === "internal_mcp_credentials" ? "credentials_required" : void 0);
|
|
2013
|
+
return {
|
|
2014
|
+
id: integration.id,
|
|
2015
|
+
name: integration.name,
|
|
2016
|
+
connected,
|
|
2017
|
+
connectUrl,
|
|
2018
|
+
reason
|
|
2019
|
+
};
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
},
|
|
2023
|
+
tools: {
|
|
2024
|
+
async list(options) {
|
|
2025
|
+
await ensureConnected();
|
|
2026
|
+
let inventory = await buildToolInventory(options);
|
|
2027
|
+
const missingInternal = getInternalIntegrationsMissingTools();
|
|
2028
|
+
if (missingInternal.length > 0) {
|
|
2029
|
+
const refreshed = await refreshIntegrationInventory(true);
|
|
2030
|
+
inventory = await buildToolInventory({
|
|
2031
|
+
...options,
|
|
2032
|
+
runtimeIntegrations: refreshed
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
emitRouteConflicts(inventory.conflicts);
|
|
2036
|
+
const unresolvedInternal = getInternalIntegrationsMissingTools();
|
|
2037
|
+
if (stateController.state === "needs_auth" && gatewayClient.state === "ready" && unresolvedInternal.length === 0) {
|
|
2038
|
+
stateController.setState("ready");
|
|
2039
|
+
}
|
|
2040
|
+
return inventory.tools;
|
|
2041
|
+
},
|
|
2042
|
+
async execute(toolId, args) {
|
|
2043
|
+
await ensureConnected();
|
|
2044
|
+
const route = await routeForExecution(toolId);
|
|
2045
|
+
try {
|
|
2046
|
+
if (route.source === "gateway") {
|
|
2047
|
+
return await gatewayClient.tools.execute(route.backendToolId, args);
|
|
2048
|
+
}
|
|
2049
|
+
const integrationId = route.integrationId;
|
|
2050
|
+
if (!integrationId) {
|
|
2051
|
+
throw new KontextError(
|
|
2052
|
+
`Route for tool "${toolId}" is missing integration metadata.`,
|
|
2053
|
+
"kontext_tool_not_found",
|
|
2054
|
+
{ meta: { toolId, route } }
|
|
2055
|
+
);
|
|
2056
|
+
}
|
|
2057
|
+
let entry = internalClientRegistry.get(integrationId);
|
|
2058
|
+
if (!entry) {
|
|
2059
|
+
await refreshIntegrationInventory(true);
|
|
2060
|
+
entry = internalClientRegistry.get(integrationId);
|
|
2061
|
+
}
|
|
2062
|
+
if (!entry) {
|
|
2063
|
+
throw new KontextError(
|
|
2064
|
+
`Internal integration "${integrationId}" is no longer attached.`,
|
|
2065
|
+
"kontext_tool_not_found",
|
|
2066
|
+
{ meta: { integrationId } }
|
|
2067
|
+
);
|
|
2068
|
+
}
|
|
2069
|
+
await ensureInternalResourceToken(entry.integration);
|
|
2070
|
+
try {
|
|
2071
|
+
const executeInternal = async () => await withTransientRetry(
|
|
2072
|
+
async () => await runManagedInternalOp(
|
|
2073
|
+
integrationId,
|
|
2074
|
+
() => entry.client.tools.execute(route.backendToolId, args)
|
|
2075
|
+
)
|
|
2076
|
+
);
|
|
2077
|
+
return await executeInternal();
|
|
2078
|
+
} catch (innerErr) {
|
|
2079
|
+
const innerTranslated = toKontextError(innerErr, {
|
|
2080
|
+
backendId: route.backendId,
|
|
2081
|
+
source: route.source,
|
|
2082
|
+
integrationId: route.integrationId,
|
|
2083
|
+
operation: "tools.execute",
|
|
2084
|
+
toolId
|
|
2085
|
+
});
|
|
2086
|
+
throw innerTranslated;
|
|
2087
|
+
}
|
|
2088
|
+
} catch (err) {
|
|
2089
|
+
const translated = toKontextError(err, {
|
|
2090
|
+
backendId: route.backendId,
|
|
2091
|
+
source: route.source,
|
|
2092
|
+
integrationId: route.integrationId,
|
|
2093
|
+
operation: "tools.execute",
|
|
2094
|
+
toolId
|
|
2095
|
+
});
|
|
2096
|
+
if (isAuthRecoveryRequired(translated)) {
|
|
2097
|
+
stateController.setState("needs_auth");
|
|
2098
|
+
}
|
|
2099
|
+
stateController.emitError(translated);
|
|
2100
|
+
throw translated;
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
},
|
|
2104
|
+
on(event, handler) {
|
|
2105
|
+
if (event === "stateChange") {
|
|
2106
|
+
return stateController.on("stateChange", handler);
|
|
2107
|
+
}
|
|
2108
|
+
return stateController.on("error", handler);
|
|
2109
|
+
},
|
|
2110
|
+
get mcp() {
|
|
2111
|
+
return gatewayClient.mcp;
|
|
2112
|
+
}
|
|
2113
|
+
};
|
|
2114
|
+
return orchestrator;
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
// src/client/index.ts
|
|
2118
|
+
var META_TOOL_NAMES = /* @__PURE__ */ new Set(["SEARCH_TOOLS", "EXECUTE_TOOL"]);
|
|
2119
|
+
function hasMetaTools(tools) {
|
|
2120
|
+
let hasSearch = false;
|
|
2121
|
+
let hasExecute = false;
|
|
2122
|
+
for (const tool of tools) {
|
|
2123
|
+
if (tool.name === "SEARCH_TOOLS") hasSearch = true;
|
|
2124
|
+
if (tool.name === "EXECUTE_TOOL") hasExecute = true;
|
|
2125
|
+
}
|
|
2126
|
+
return hasSearch && hasExecute;
|
|
2127
|
+
}
|
|
2128
|
+
function extractJsonResourceText(result) {
|
|
2129
|
+
if (!result || typeof result !== "object") return null;
|
|
2130
|
+
const content = result.content;
|
|
2131
|
+
if (!Array.isArray(content)) return null;
|
|
2132
|
+
for (const item of content) {
|
|
2133
|
+
if (item.type === "resource" && item.resource?.mimeType === "application/json" && item.resource.text) {
|
|
2134
|
+
return item.resource.text;
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
return null;
|
|
2138
|
+
}
|
|
2139
|
+
function extractTextContent(result) {
|
|
2140
|
+
if (!result || typeof result !== "object") return String(result ?? "");
|
|
2141
|
+
const r = result;
|
|
2142
|
+
if (r.content && Array.isArray(r.content)) {
|
|
2143
|
+
const texts = r.content.filter((c) => c.type === "text" && c.text).map((c) => c.text);
|
|
2144
|
+
if (texts.length > 0) return texts.join("\n");
|
|
2145
|
+
const resourceTexts = r.content.filter((c) => c.type === "resource" && c.resource?.text).map((c) => c.resource.text);
|
|
2146
|
+
if (resourceTexts.length > 0) {
|
|
2147
|
+
return resourceTexts.map((text) => {
|
|
2148
|
+
try {
|
|
2149
|
+
return extractTextContent(JSON.parse(text));
|
|
2150
|
+
} catch {
|
|
2151
|
+
return text;
|
|
2152
|
+
}
|
|
2153
|
+
}).join("\n");
|
|
2154
|
+
}
|
|
2155
|
+
return JSON.stringify(r.content);
|
|
2156
|
+
}
|
|
2157
|
+
return JSON.stringify(result);
|
|
2158
|
+
}
|
|
2159
|
+
function translateError(err) {
|
|
2160
|
+
if (isKontextError(err)) return err;
|
|
2161
|
+
if (!(err instanceof Error)) {
|
|
2162
|
+
return new KontextError(String(err), "kontext_unknown_error");
|
|
2163
|
+
}
|
|
2164
|
+
const props = err;
|
|
2165
|
+
if (props.code === -32042) {
|
|
2166
|
+
const elicitations = props.elicitations ?? props.data?.elicitations;
|
|
2167
|
+
const elicitation = elicitations?.[0];
|
|
2168
|
+
return new IntegrationConnectionRequiredError(
|
|
2169
|
+
elicitation?.integrationId ?? "unknown",
|
|
2170
|
+
{
|
|
2171
|
+
integrationName: elicitation?.integrationName,
|
|
2172
|
+
connectUrl: elicitation?.url,
|
|
2173
|
+
message: elicitation?.message,
|
|
2174
|
+
cause: err
|
|
2175
|
+
}
|
|
2176
|
+
);
|
|
2177
|
+
}
|
|
2178
|
+
const statusCode = props.statusCode ?? props.status;
|
|
2179
|
+
if (typeof statusCode === "number" && statusCode >= 400) {
|
|
2180
|
+
if (statusCode === 401) {
|
|
2181
|
+
return new AuthorizationRequiredError(err.message, { cause: err });
|
|
2182
|
+
}
|
|
2183
|
+
return new KontextError(err.message, "kontext_server_error", {
|
|
2184
|
+
statusCode,
|
|
2185
|
+
cause: err
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
if (isUnauthorizedError(err)) {
|
|
2189
|
+
return new AuthorizationRequiredError(err.message, { cause: err });
|
|
2190
|
+
}
|
|
2191
|
+
if (isNetworkError(err)) {
|
|
2192
|
+
return new NetworkError(err.message, { cause: err });
|
|
2193
|
+
}
|
|
2194
|
+
return new KontextError(err.message, "kontext_unknown_error", {
|
|
2195
|
+
cause: err
|
|
2196
|
+
});
|
|
2197
|
+
}
|
|
2198
|
+
function createSingleEndpointKontextClient(config) {
|
|
2199
|
+
if (!config.clientId) {
|
|
2200
|
+
throw new ConfigError(
|
|
2201
|
+
"clientId is required. Pass it in KontextClientConfig or set KONTEXT_CLIENT_ID.",
|
|
2202
|
+
"kontext_config_missing_client_id"
|
|
2203
|
+
);
|
|
2204
|
+
}
|
|
2205
|
+
if (!config.redirectUri) {
|
|
2206
|
+
throw new ConfigError(
|
|
2207
|
+
"redirectUri is required. Set it to the URL where OAuth should redirect after authorization.",
|
|
2208
|
+
"kontext_config_missing_redirect_uri"
|
|
2209
|
+
);
|
|
2210
|
+
}
|
|
2211
|
+
if (!config.onAuthRequired) {
|
|
2212
|
+
throw new ConfigError(
|
|
2213
|
+
"onAuthRequired callback is required. Provide a function that opens the OAuth URL.",
|
|
2214
|
+
"kontext_config_missing_auth_handler"
|
|
2215
|
+
);
|
|
2216
|
+
}
|
|
2217
|
+
let _state = "idle";
|
|
2218
|
+
const _listeners = /* @__PURE__ */ new Map();
|
|
2219
|
+
let _metaToolMode = null;
|
|
2220
|
+
const mcp = new KontextMcp({
|
|
2221
|
+
clientId: config.clientId,
|
|
2222
|
+
url: config.url,
|
|
2223
|
+
server: config.serverUrl,
|
|
2224
|
+
redirectUri: config.redirectUri,
|
|
2225
|
+
storage: config.storage,
|
|
2226
|
+
sessionKey: config.sessionKey,
|
|
2227
|
+
onAuthRequired: config.onAuthRequired,
|
|
2228
|
+
// Route MCP elicitation to the high-level callback.
|
|
2229
|
+
// KontextMcp calls this then re-throws; client.tools.execute() catches
|
|
2230
|
+
// the re-thrown error and handles translation.
|
|
2231
|
+
onElicitationUrl: config.onIntegrationRequired ? (entry) => {
|
|
2232
|
+
config.onIntegrationRequired(entry.url, {
|
|
2233
|
+
id: entry.integrationId ?? "unknown",
|
|
2234
|
+
name: entry.integrationName ?? entry.message
|
|
2235
|
+
});
|
|
2236
|
+
} : void 0
|
|
2237
|
+
});
|
|
2238
|
+
function setState(newState) {
|
|
2239
|
+
if (_state === newState) return;
|
|
2240
|
+
_state = newState;
|
|
2241
|
+
try {
|
|
2242
|
+
config.onStateChange?.(newState);
|
|
2243
|
+
} catch {
|
|
2244
|
+
}
|
|
2245
|
+
const handlers = _listeners.get("stateChange");
|
|
2246
|
+
if (handlers) {
|
|
2247
|
+
for (const handler of handlers) {
|
|
2248
|
+
try {
|
|
2249
|
+
handler(newState);
|
|
2250
|
+
} catch {
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
function emitError(error) {
|
|
2256
|
+
const handlers = _listeners.get("error");
|
|
2257
|
+
if (handlers) {
|
|
2258
|
+
for (const handler of handlers) {
|
|
2259
|
+
try {
|
|
2260
|
+
handler(error);
|
|
2261
|
+
} catch {
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
async function fetchGatewayTools(limit = 100) {
|
|
2267
|
+
const result = await mcp.callTool("SEARCH_TOOLS", { limit });
|
|
2268
|
+
const jsonText = extractJsonResourceText(result);
|
|
2269
|
+
if (!jsonText) {
|
|
2270
|
+
throw new KontextError(
|
|
2271
|
+
"SEARCH_TOOLS did not return JSON resource content. The server may not support the gateway protocol.",
|
|
2272
|
+
"kontext_tool_response_empty"
|
|
2273
|
+
);
|
|
2274
|
+
}
|
|
2275
|
+
let parsed;
|
|
2276
|
+
try {
|
|
2277
|
+
parsed = JSON.parse(jsonText);
|
|
2278
|
+
} catch (e) {
|
|
2279
|
+
throw new KontextError(
|
|
2280
|
+
`SEARCH_TOOLS returned invalid JSON: ${e instanceof Error ? e.message : String(e)}`,
|
|
2281
|
+
"kontext_tool_response_invalid_json"
|
|
2282
|
+
);
|
|
2283
|
+
}
|
|
2284
|
+
if (Array.isArray(parsed)) {
|
|
2285
|
+
return { tools: parsed, errors: [] };
|
|
2286
|
+
}
|
|
2287
|
+
if (parsed && typeof parsed === "object") {
|
|
2288
|
+
const obj = parsed;
|
|
2289
|
+
return {
|
|
2290
|
+
tools: Array.isArray(obj.items) ? obj.items : [],
|
|
2291
|
+
errors: Array.isArray(obj.errors) ? obj.errors : [],
|
|
2292
|
+
elicitations: Array.isArray(obj.elicitations) ? obj.elicitations : void 0
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
throw new KontextError(
|
|
2296
|
+
"SEARCH_TOOLS response was not a JSON array or object. Check the server version.",
|
|
2297
|
+
"kontext_tool_response_unexpected"
|
|
2298
|
+
);
|
|
2299
|
+
}
|
|
2300
|
+
function toKontextTool(tool) {
|
|
2301
|
+
return {
|
|
2302
|
+
id: tool.id,
|
|
2303
|
+
name: tool.name,
|
|
2304
|
+
description: tool.description,
|
|
2305
|
+
inputSchema: tool.inputSchema,
|
|
2306
|
+
server: tool.server ? { id: tool.server.id ?? "", name: tool.server.name } : void 0
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
async function ensureConnected() {
|
|
2310
|
+
if (_state === "ready" && mcp.isConnected) return;
|
|
2311
|
+
setState("connecting");
|
|
2312
|
+
try {
|
|
2313
|
+
const mcpTools = await mcp.listTools();
|
|
2314
|
+
_metaToolMode = hasMetaTools(mcpTools);
|
|
2315
|
+
setState("ready");
|
|
2316
|
+
} catch (err) {
|
|
2317
|
+
const translated = translateError(err);
|
|
2318
|
+
if (translated instanceof AuthorizationRequiredError) {
|
|
2319
|
+
setState("needs_auth");
|
|
2320
|
+
} else {
|
|
2321
|
+
setState("failed");
|
|
2322
|
+
}
|
|
2323
|
+
emitError(translated);
|
|
2324
|
+
throw translated;
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
const client = {
|
|
2328
|
+
get state() {
|
|
2329
|
+
return _state;
|
|
2330
|
+
},
|
|
2331
|
+
async connect() {
|
|
2332
|
+
await ensureConnected();
|
|
2333
|
+
},
|
|
2334
|
+
async disconnect() {
|
|
2335
|
+
await mcp.disconnect();
|
|
2336
|
+
_metaToolMode = null;
|
|
2337
|
+
setState("idle");
|
|
2338
|
+
},
|
|
2339
|
+
async getConnectPageUrl() {
|
|
2340
|
+
await ensureConnected();
|
|
2341
|
+
try {
|
|
2342
|
+
return await mcp.createConnectSession();
|
|
2343
|
+
} catch (err) {
|
|
2344
|
+
const translated = translateError(err);
|
|
2345
|
+
if (translated instanceof AuthorizationRequiredError) {
|
|
2346
|
+
setState("needs_auth");
|
|
2347
|
+
}
|
|
2348
|
+
throw translated;
|
|
2349
|
+
}
|
|
2350
|
+
},
|
|
2351
|
+
auth: {
|
|
2352
|
+
async signIn() {
|
|
2353
|
+
await ensureConnected();
|
|
2354
|
+
},
|
|
2355
|
+
async signOut() {
|
|
2356
|
+
await mcp.clearAuth();
|
|
2357
|
+
_metaToolMode = null;
|
|
2358
|
+
setState("idle");
|
|
2359
|
+
},
|
|
2360
|
+
async handleCallback(url) {
|
|
2361
|
+
await mcp.handleCallback(url);
|
|
2362
|
+
try {
|
|
2363
|
+
await ensureConnected();
|
|
2364
|
+
} catch (err) {
|
|
2365
|
+
emitError(translateError(err));
|
|
2366
|
+
}
|
|
2367
|
+
},
|
|
2368
|
+
isCallback(url) {
|
|
2369
|
+
return mcp.isCallback(url);
|
|
2370
|
+
},
|
|
2371
|
+
get isAuthenticated() {
|
|
2372
|
+
return _state === "ready" || mcp.isConnected;
|
|
2373
|
+
}
|
|
2374
|
+
},
|
|
2375
|
+
integrations: {
|
|
2376
|
+
async list() {
|
|
2377
|
+
await ensureConnected();
|
|
2378
|
+
try {
|
|
2379
|
+
const { tools, errors, elicitations } = await fetchGatewayTools();
|
|
2380
|
+
const statuses = parseIntegrationStatus(tools, errors, elicitations);
|
|
2381
|
+
const errorMap = new Map(errors.map((e) => [e.serverId, e.reason]));
|
|
2382
|
+
return statuses.map((s) => {
|
|
2383
|
+
const reason = errorMap.get(s.id);
|
|
2384
|
+
return reason ? { ...s, reason } : s;
|
|
2385
|
+
});
|
|
2386
|
+
} catch (err) {
|
|
2387
|
+
const translated = translateError(err);
|
|
2388
|
+
if (translated instanceof AuthorizationRequiredError) {
|
|
2389
|
+
setState("needs_auth");
|
|
2390
|
+
}
|
|
2391
|
+
throw translated;
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
},
|
|
2395
|
+
tools: {
|
|
2396
|
+
async list(options) {
|
|
2397
|
+
await ensureConnected();
|
|
2398
|
+
try {
|
|
2399
|
+
const mcpTools = await mcp.listTools();
|
|
2400
|
+
const nonMetaTools = mcpTools.filter(
|
|
2401
|
+
(t) => !META_TOOL_NAMES.has(t.name)
|
|
2402
|
+
);
|
|
2403
|
+
if (nonMetaTools.length > 0 || !hasMetaTools(mcpTools)) {
|
|
2404
|
+
_metaToolMode = false;
|
|
2405
|
+
return nonMetaTools.map((t) => ({
|
|
2406
|
+
id: t.name,
|
|
2407
|
+
name: t.name,
|
|
2408
|
+
description: t.description,
|
|
2409
|
+
inputSchema: t.inputSchema
|
|
2410
|
+
}));
|
|
2411
|
+
}
|
|
2412
|
+
_metaToolMode = true;
|
|
2413
|
+
const { tools, elicitations } = await fetchGatewayTools(
|
|
2414
|
+
options?.limit
|
|
2415
|
+
);
|
|
2416
|
+
if (elicitations?.length && config.onIntegrationRequired) {
|
|
2417
|
+
for (const e of elicitations) {
|
|
2418
|
+
if (e.url) {
|
|
2419
|
+
config.onIntegrationRequired(e.url, {
|
|
2420
|
+
id: e.integrationId ?? "",
|
|
2421
|
+
name: e.integrationName ?? e.message
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
return tools.map(toKontextTool);
|
|
2427
|
+
} catch (err) {
|
|
2428
|
+
const translated = translateError(err);
|
|
2429
|
+
if (translated instanceof AuthorizationRequiredError) {
|
|
2430
|
+
setState("needs_auth");
|
|
2431
|
+
}
|
|
2432
|
+
throw translated;
|
|
2433
|
+
}
|
|
2434
|
+
},
|
|
2435
|
+
async execute(toolId, args) {
|
|
2436
|
+
await ensureConnected();
|
|
2437
|
+
if (_metaToolMode === null) {
|
|
2438
|
+
const mcpTools = await mcp.listTools();
|
|
2439
|
+
_metaToolMode = hasMetaTools(mcpTools);
|
|
2440
|
+
}
|
|
2441
|
+
try {
|
|
2442
|
+
const result = _metaToolMode ? await mcp.callTool("EXECUTE_TOOL", {
|
|
2443
|
+
tool_id: toolId,
|
|
2444
|
+
tool_arguments: args ?? {}
|
|
2445
|
+
}) : await mcp.callTool(toolId, args);
|
|
2446
|
+
return { content: extractTextContent(result), raw: result };
|
|
2447
|
+
} catch (err) {
|
|
2448
|
+
const translated = translateError(err);
|
|
2449
|
+
if (translated instanceof AuthorizationRequiredError) {
|
|
2450
|
+
setState("needs_auth");
|
|
2451
|
+
}
|
|
2452
|
+
throw translated;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
},
|
|
2456
|
+
on(event, handler) {
|
|
2457
|
+
if (!_listeners.has(event)) {
|
|
2458
|
+
_listeners.set(event, /* @__PURE__ */ new Set());
|
|
2459
|
+
}
|
|
2460
|
+
_listeners.get(event).add(handler);
|
|
2461
|
+
return () => {
|
|
2462
|
+
_listeners.get(event)?.delete(handler);
|
|
2463
|
+
};
|
|
2464
|
+
},
|
|
2465
|
+
get mcp() {
|
|
2466
|
+
return mcp;
|
|
2467
|
+
}
|
|
2468
|
+
};
|
|
2469
|
+
return client;
|
|
2470
|
+
}
|
|
2471
|
+
function createKontextClient(config) {
|
|
2472
|
+
if (config.url !== void 0) {
|
|
2473
|
+
if (typeof config.url !== "string" || config.url.trim().length === 0) {
|
|
2474
|
+
throw new ConfigError(
|
|
2475
|
+
"url must be a non-empty string. Omit url for hybrid mode, or provide a full MCP endpoint URL.",
|
|
2476
|
+
"kontext_config_invalid_url"
|
|
2477
|
+
);
|
|
2478
|
+
}
|
|
2479
|
+
return createSingleEndpointKontextClient(config);
|
|
2480
|
+
}
|
|
2481
|
+
return createKontextOrchestrator(config);
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
// src/management/types.ts
|
|
2485
|
+
var TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange";
|
|
2486
|
+
var TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token";
|
|
2487
|
+
|
|
2488
|
+
// src/oauth/token-exchange.ts
|
|
2489
|
+
async function exchangeToken(config, subjectToken, resource, scope, subjectTokenType = TOKEN_TYPE_ACCESS_TOKEN) {
|
|
2490
|
+
const body = new URLSearchParams();
|
|
2491
|
+
body.set("grant_type", TOKEN_EXCHANGE_GRANT_TYPE);
|
|
2492
|
+
body.set("subject_token", subjectToken);
|
|
2493
|
+
body.set("subject_token_type", subjectTokenType);
|
|
2494
|
+
body.set("resource", resource);
|
|
2495
|
+
const headers = {
|
|
2496
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
2497
|
+
};
|
|
2498
|
+
if (config.clientSecret) {
|
|
2499
|
+
const credentials = Buffer.from(
|
|
2500
|
+
`${config.clientId}:${config.clientSecret}`
|
|
2501
|
+
).toString("base64");
|
|
2502
|
+
headers["Authorization"] = `Basic ${credentials}`;
|
|
2503
|
+
} else {
|
|
2504
|
+
body.set("client_id", config.clientId);
|
|
2505
|
+
}
|
|
2506
|
+
const response = await fetch(config.tokenUrl, {
|
|
2507
|
+
method: "POST",
|
|
2508
|
+
headers,
|
|
2509
|
+
body: body.toString()
|
|
2510
|
+
});
|
|
2511
|
+
if (!response.ok) {
|
|
2512
|
+
let errorMessage = `Token exchange failed: ${response.status} ${response.statusText}`;
|
|
2513
|
+
let errorCode;
|
|
2514
|
+
let integrationName;
|
|
2515
|
+
let integrationId;
|
|
2516
|
+
try {
|
|
2517
|
+
const errorBody = await response.json();
|
|
2518
|
+
errorCode = errorBody.error;
|
|
2519
|
+
if (errorBody.error_description) {
|
|
2520
|
+
errorMessage = errorBody.error_description;
|
|
2521
|
+
} else if (errorBody.error) {
|
|
2522
|
+
errorMessage = `Token exchange failed: ${errorBody.error}`;
|
|
2523
|
+
}
|
|
2524
|
+
if (errorBody.integration_name || errorBody.integration_id) {
|
|
2525
|
+
integrationName = errorBody.integration_name;
|
|
2526
|
+
integrationId = errorBody.integration_id;
|
|
2527
|
+
}
|
|
2528
|
+
} catch {
|
|
2529
|
+
}
|
|
2530
|
+
throw new OAuthError(errorMessage, "kontext_oauth_token_exchange_failed", {
|
|
2531
|
+
errorCode,
|
|
2532
|
+
meta: {
|
|
2533
|
+
integrationName,
|
|
2534
|
+
integrationId
|
|
2535
|
+
}
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
const tokenResponse = await response.json();
|
|
2539
|
+
if (!tokenResponse.access_token) {
|
|
2540
|
+
throw new OAuthError(
|
|
2541
|
+
"Token exchange response missing access_token.",
|
|
2542
|
+
"kontext_oauth_token_exchange_failed"
|
|
2543
|
+
);
|
|
2544
|
+
}
|
|
2545
|
+
if (!tokenResponse.issued_token_type) {
|
|
2546
|
+
throw new OAuthError(
|
|
2547
|
+
"Token exchange response missing issued_token_type.",
|
|
2548
|
+
"kontext_oauth_token_exchange_failed"
|
|
2549
|
+
);
|
|
2550
|
+
}
|
|
2551
|
+
if (!tokenResponse.token_type) {
|
|
2552
|
+
throw new OAuthError(
|
|
2553
|
+
"Token exchange response missing token_type.",
|
|
2554
|
+
"kontext_oauth_token_exchange_failed"
|
|
2555
|
+
);
|
|
2556
|
+
}
|
|
2557
|
+
return tokenResponse;
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
// src/verify/errors.ts
|
|
2561
|
+
var TokenVerificationError = class _TokenVerificationError extends Error {
|
|
2562
|
+
code;
|
|
2563
|
+
constructor(code, message) {
|
|
2564
|
+
super(message);
|
|
2565
|
+
this.name = "TokenVerificationError";
|
|
2566
|
+
this.code = code;
|
|
2567
|
+
Object.setPrototypeOf(this, _TokenVerificationError.prototype);
|
|
2568
|
+
}
|
|
2569
|
+
};
|
|
2570
|
+
|
|
2571
|
+
// src/verify/jwks-client.ts
|
|
2572
|
+
var DEFAULT_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
2573
|
+
var DEFAULT_REFETCH_COOLDOWN_MS = 30 * 1e3;
|
|
2574
|
+
var JwksClient = class {
|
|
2575
|
+
jwksUrl;
|
|
2576
|
+
cacheTtlMs;
|
|
2577
|
+
refetchCooldownMs;
|
|
2578
|
+
customFetch;
|
|
2579
|
+
jwks = null;
|
|
2580
|
+
lastFetchAt = 0;
|
|
2581
|
+
lastRefreshAt = 0;
|
|
2582
|
+
constructor(options) {
|
|
2583
|
+
this.jwksUrl = new URL(options.jwksUrl);
|
|
2584
|
+
this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
2585
|
+
this.refetchCooldownMs = options.refetchCooldownMs ?? DEFAULT_REFETCH_COOLDOWN_MS;
|
|
2586
|
+
this.customFetch = options.fetch;
|
|
2587
|
+
}
|
|
2588
|
+
/**
|
|
2589
|
+
* Get the JWKS key resolver for use with jose's jwtVerify.
|
|
2590
|
+
*
|
|
2591
|
+
* Creates the remote JWKS on first call and caches it.
|
|
2592
|
+
* The jose library handles internal caching and key lookup.
|
|
2593
|
+
*/
|
|
2594
|
+
getKeyResolver() {
|
|
2595
|
+
const now = Date.now();
|
|
2596
|
+
if (this.jwks && now - this.lastFetchAt > this.cacheTtlMs) {
|
|
2597
|
+
this.jwks = null;
|
|
2598
|
+
}
|
|
2599
|
+
if (!this.jwks) {
|
|
2600
|
+
this.jwks = createRemoteJWKSet(this.jwksUrl, {
|
|
2601
|
+
// jose handles caching internally, we just track our own refresh timing
|
|
2602
|
+
...this.customFetch && {
|
|
2603
|
+
[/* @__PURE__ */ Symbol.for("fetch")]: this.customFetch
|
|
2604
|
+
}
|
|
2605
|
+
});
|
|
2606
|
+
this.lastFetchAt = now;
|
|
2607
|
+
}
|
|
2608
|
+
return this.jwks;
|
|
2609
|
+
}
|
|
2610
|
+
/**
|
|
2611
|
+
* Force refresh the JWKS cache.
|
|
2612
|
+
*
|
|
2613
|
+
* Respects the refetch cooldown to prevent rapid refetching.
|
|
2614
|
+
* Returns true if refresh was performed, false if cooldown not elapsed.
|
|
2615
|
+
*/
|
|
2616
|
+
refresh() {
|
|
2617
|
+
const now = Date.now();
|
|
2618
|
+
if (!this.canRefresh()) {
|
|
2619
|
+
return false;
|
|
2620
|
+
}
|
|
2621
|
+
this.jwks = null;
|
|
2622
|
+
this.lastRefreshAt = now;
|
|
2623
|
+
return true;
|
|
2624
|
+
}
|
|
2625
|
+
/**
|
|
2626
|
+
* Check if a refresh is allowed (cooldown elapsed).
|
|
2627
|
+
*/
|
|
2628
|
+
canRefresh() {
|
|
2629
|
+
return Date.now() - this.lastRefreshAt >= this.refetchCooldownMs;
|
|
2630
|
+
}
|
|
2631
|
+
/**
|
|
2632
|
+
* Handle unknown kid errors by attempting refresh.
|
|
2633
|
+
*
|
|
2634
|
+
* @returns TokenVerificationError if refresh not allowed or already attempted
|
|
2635
|
+
*/
|
|
2636
|
+
handleUnknownKid(kid) {
|
|
2637
|
+
if (this.refresh()) {
|
|
2638
|
+
return null;
|
|
2639
|
+
}
|
|
2640
|
+
return new TokenVerificationError(
|
|
2641
|
+
"UNKNOWN_KID",
|
|
2642
|
+
`Unknown key ID: ${kid}. JWKS refresh on cooldown.`
|
|
2643
|
+
);
|
|
2644
|
+
}
|
|
2645
|
+
/**
|
|
2646
|
+
* Clear the cache, forcing a fresh fetch on next access.
|
|
2647
|
+
*/
|
|
2648
|
+
clearCache() {
|
|
2649
|
+
this.jwks = null;
|
|
2650
|
+
this.lastFetchAt = 0;
|
|
2651
|
+
}
|
|
2652
|
+
};
|
|
2653
|
+
|
|
2654
|
+
// src/verify/verifier.ts
|
|
2655
|
+
var DEFAULT_CLOCK_TOLERANCE_SEC = 30;
|
|
2656
|
+
var SUPPORTED_ALGORITHMS = ["ES256", "RS256"];
|
|
2657
|
+
var KontextTokenVerifier = class {
|
|
2658
|
+
config;
|
|
2659
|
+
jwksClient;
|
|
2660
|
+
audiences;
|
|
2661
|
+
constructor(config) {
|
|
2662
|
+
this.config = {
|
|
2663
|
+
jwksUrl: config.jwksUrl,
|
|
2664
|
+
issuer: config.issuer,
|
|
2665
|
+
audience: config.audience,
|
|
2666
|
+
requiredScopes: config.requiredScopes ?? [],
|
|
2667
|
+
cacheTtlMs: config.cacheTtlMs ?? 5 * 60 * 1e3,
|
|
2668
|
+
refetchCooldownMs: config.refetchCooldownMs ?? 30 * 1e3,
|
|
2669
|
+
clockToleranceSec: config.clockToleranceSec ?? DEFAULT_CLOCK_TOLERANCE_SEC,
|
|
2670
|
+
fetch: config.fetch
|
|
2671
|
+
};
|
|
2672
|
+
this.audiences = Array.isArray(config.audience) ? config.audience : [config.audience];
|
|
2673
|
+
this.jwksClient = new JwksClient({
|
|
2674
|
+
jwksUrl: config.jwksUrl,
|
|
2675
|
+
cacheTtlMs: this.config.cacheTtlMs,
|
|
2676
|
+
refetchCooldownMs: this.config.refetchCooldownMs,
|
|
2677
|
+
fetch: config.fetch
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
/**
|
|
2681
|
+
* Verify a JWT token.
|
|
2682
|
+
*
|
|
2683
|
+
* @param token - The JWT token string (without "Bearer " prefix)
|
|
2684
|
+
* @returns VerifyResult with success=true and claims, or success=false and error
|
|
2685
|
+
*/
|
|
2686
|
+
async verify(token) {
|
|
2687
|
+
try {
|
|
2688
|
+
return await this.verifyInternal(token, false);
|
|
2689
|
+
} catch (error) {
|
|
2690
|
+
if (error instanceof TokenVerificationError) {
|
|
2691
|
+
return { success: false, error };
|
|
2692
|
+
}
|
|
2693
|
+
return {
|
|
2694
|
+
success: false,
|
|
2695
|
+
error: new TokenVerificationError(
|
|
2696
|
+
"INVALID_TOKEN_FORMAT",
|
|
2697
|
+
`Unexpected verification error: ${error.message}`
|
|
2698
|
+
)
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
/**
|
|
2703
|
+
* Verify a JWT token and return claims or null.
|
|
2704
|
+
* Simpler API for cases where you don't need error details.
|
|
2705
|
+
*
|
|
2706
|
+
* @param token - The JWT token string (without "Bearer " prefix)
|
|
2707
|
+
* @returns VerifiedTokenClaims if valid, null if invalid
|
|
2708
|
+
*/
|
|
2709
|
+
async verifyOrNull(token) {
|
|
2710
|
+
const result = await this.verify(token);
|
|
2711
|
+
return result.success ? result.claims : null;
|
|
2712
|
+
}
|
|
2713
|
+
/**
|
|
2714
|
+
* Clear the JWKS cache, forcing a fresh fetch on next verification.
|
|
2715
|
+
*/
|
|
2716
|
+
clearCache() {
|
|
2717
|
+
this.jwksClient.clearCache();
|
|
2718
|
+
}
|
|
2719
|
+
async verifyInternal(token, isRetry) {
|
|
2720
|
+
const JWKS = this.jwksClient.getKeyResolver();
|
|
2721
|
+
try {
|
|
2722
|
+
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
|
|
2723
|
+
issuer: this.config.issuer,
|
|
2724
|
+
audience: this.audiences,
|
|
2725
|
+
clockTolerance: this.config.clockToleranceSec,
|
|
2726
|
+
algorithms: SUPPORTED_ALGORITHMS
|
|
2727
|
+
});
|
|
2728
|
+
const alg = protectedHeader.alg;
|
|
2729
|
+
if (!SUPPORTED_ALGORITHMS.includes(alg)) {
|
|
2730
|
+
throw new TokenVerificationError(
|
|
2731
|
+
"UNSUPPORTED_ALGORITHM",
|
|
2732
|
+
`Unsupported algorithm: ${alg}. Expected one of: ${SUPPORTED_ALGORITHMS.join(", ")}`
|
|
2733
|
+
);
|
|
2734
|
+
}
|
|
2735
|
+
const jwtPayload = payload;
|
|
2736
|
+
if (typeof jwtPayload.exp !== "number" || !Number.isFinite(jwtPayload.exp) || jwtPayload.exp <= 0) {
|
|
2737
|
+
throw new TokenVerificationError(
|
|
2738
|
+
"MISSING_CLAIMS",
|
|
2739
|
+
"Token missing required exp claim"
|
|
2740
|
+
);
|
|
2741
|
+
}
|
|
2742
|
+
const scopes = this.parseScopes(jwtPayload.scope);
|
|
2743
|
+
for (const required of this.config.requiredScopes) {
|
|
2744
|
+
if (!scopes.includes(required)) {
|
|
2745
|
+
throw new TokenVerificationError(
|
|
2746
|
+
"MISSING_SCOPE",
|
|
2747
|
+
`Missing required scope: ${required}`
|
|
2748
|
+
);
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
const clientId = jwtPayload.client_id || jwtPayload.sub;
|
|
2752
|
+
if (!clientId) {
|
|
2753
|
+
throw new TokenVerificationError(
|
|
2754
|
+
"MISSING_CLAIMS",
|
|
2755
|
+
"Token missing client_id and sub claims"
|
|
2756
|
+
);
|
|
2757
|
+
}
|
|
2758
|
+
const claims = {
|
|
2759
|
+
sub: jwtPayload.sub || "",
|
|
2760
|
+
clientId,
|
|
2761
|
+
scopes,
|
|
2762
|
+
expiresAt: new Date(jwtPayload.exp * 1e3),
|
|
2763
|
+
jti: jwtPayload.jti,
|
|
2764
|
+
payload: jwtPayload
|
|
2765
|
+
};
|
|
2766
|
+
return { success: true, claims };
|
|
2767
|
+
} catch (error) {
|
|
2768
|
+
if (error instanceof errors.JWKSNoMatchingKey) {
|
|
2769
|
+
if (!isRetry) {
|
|
2770
|
+
const kid = this.extractKid(token);
|
|
2771
|
+
const refreshError = this.jwksClient.handleUnknownKid(
|
|
2772
|
+
kid || "unknown"
|
|
2773
|
+
);
|
|
2774
|
+
if (!refreshError) {
|
|
2775
|
+
return this.verifyInternal(token, true);
|
|
2776
|
+
}
|
|
2777
|
+
return { success: false, error: refreshError };
|
|
2778
|
+
}
|
|
2779
|
+
return {
|
|
2780
|
+
success: false,
|
|
2781
|
+
error: new TokenVerificationError(
|
|
2782
|
+
"UNKNOWN_KID",
|
|
2783
|
+
"No matching key found in JWKS"
|
|
2784
|
+
)
|
|
2785
|
+
};
|
|
2786
|
+
}
|
|
2787
|
+
if (error instanceof errors.JWTExpired) {
|
|
2788
|
+
return {
|
|
2789
|
+
success: false,
|
|
2790
|
+
error: new TokenVerificationError(
|
|
2791
|
+
"TOKEN_EXPIRED",
|
|
2792
|
+
"Token has expired"
|
|
2793
|
+
)
|
|
2794
|
+
};
|
|
2795
|
+
}
|
|
2796
|
+
if (error instanceof errors.JWTClaimValidationFailed) {
|
|
2797
|
+
const message = error.message;
|
|
2798
|
+
if (message.includes("iss")) {
|
|
2799
|
+
const expected = Array.isArray(this.config.issuer) ? this.config.issuer.join(" or ") : this.config.issuer;
|
|
2800
|
+
return {
|
|
2801
|
+
success: false,
|
|
2802
|
+
error: new TokenVerificationError(
|
|
2803
|
+
"INVALID_ISSUER",
|
|
2804
|
+
`Invalid issuer: expected ${expected}`
|
|
2805
|
+
)
|
|
2806
|
+
};
|
|
2807
|
+
}
|
|
2808
|
+
if (message.includes("aud")) {
|
|
2809
|
+
return {
|
|
2810
|
+
success: false,
|
|
2811
|
+
error: new TokenVerificationError(
|
|
2812
|
+
"INVALID_AUDIENCE",
|
|
2813
|
+
`Invalid audience: expected one of ${this.audiences.join(", ")}`
|
|
2814
|
+
)
|
|
2815
|
+
};
|
|
2816
|
+
}
|
|
2817
|
+
if (message.includes("nbf")) {
|
|
2818
|
+
return {
|
|
2819
|
+
success: false,
|
|
2820
|
+
error: new TokenVerificationError(
|
|
2821
|
+
"TOKEN_NOT_YET_VALID",
|
|
2822
|
+
"Token is not yet valid (nbf claim)"
|
|
2823
|
+
)
|
|
2824
|
+
};
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
if (error instanceof errors.JWSSignatureVerificationFailed) {
|
|
2828
|
+
return {
|
|
2829
|
+
success: false,
|
|
2830
|
+
error: new TokenVerificationError(
|
|
2831
|
+
"INVALID_SIGNATURE",
|
|
2832
|
+
"Signature verification failed"
|
|
2833
|
+
)
|
|
2834
|
+
};
|
|
2835
|
+
}
|
|
2836
|
+
if (error instanceof errors.JWSInvalid) {
|
|
2837
|
+
return {
|
|
2838
|
+
success: false,
|
|
2839
|
+
error: new TokenVerificationError(
|
|
2840
|
+
"INVALID_TOKEN_FORMAT",
|
|
2841
|
+
`Invalid JWS: ${error.message}`
|
|
2842
|
+
)
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
if (error instanceof TokenVerificationError) {
|
|
2846
|
+
throw error;
|
|
2847
|
+
}
|
|
2848
|
+
throw new TokenVerificationError(
|
|
2849
|
+
"INVALID_TOKEN_FORMAT",
|
|
2850
|
+
`Verification failed: ${error.message}`
|
|
2851
|
+
);
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
parseScopes(scope) {
|
|
2855
|
+
if (!scope) return [];
|
|
2856
|
+
return scope.split(" ").map((s) => s.trim()).filter(Boolean);
|
|
2857
|
+
}
|
|
2858
|
+
extractKid(token) {
|
|
2859
|
+
try {
|
|
2860
|
+
const header = decodeProtectedHeader(token);
|
|
2861
|
+
return header.kid ?? null;
|
|
2862
|
+
} catch {
|
|
2863
|
+
return null;
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
};
|
|
2867
|
+
|
|
2868
|
+
// src/server/sessions.ts
|
|
2869
|
+
var SessionManager = class _SessionManager {
|
|
2870
|
+
transports = /* @__PURE__ */ new Map();
|
|
2871
|
+
lastAccessed = /* @__PURE__ */ new Map();
|
|
2872
|
+
expiresAt = /* @__PURE__ */ new Map();
|
|
2873
|
+
cleanupInterval;
|
|
2874
|
+
static STALE_TIMEOUT_MS = 60 * 60 * 1e3;
|
|
2875
|
+
// 1 hour
|
|
2876
|
+
static CLEANUP_INTERVAL_MS = 5 * 60 * 1e3;
|
|
2877
|
+
// 5 minutes
|
|
2878
|
+
constructor() {
|
|
2879
|
+
this.cleanupInterval = setInterval(
|
|
2880
|
+
() => this.cleanupStaleSessions(),
|
|
2881
|
+
_SessionManager.CLEANUP_INTERVAL_MS
|
|
2882
|
+
);
|
|
2883
|
+
if (this.cleanupInterval.unref) {
|
|
2884
|
+
this.cleanupInterval.unref();
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
getTransport(sessionId) {
|
|
2888
|
+
return this.transports.get(sessionId);
|
|
2889
|
+
}
|
|
2890
|
+
registerSession(sessionId, transport, callbacks, expiresAt) {
|
|
2891
|
+
this.transports.set(sessionId, transport);
|
|
2892
|
+
this.lastAccessed.set(sessionId, Date.now());
|
|
2893
|
+
if (expiresAt !== void 0) {
|
|
2894
|
+
this.expiresAt.set(sessionId, expiresAt);
|
|
2895
|
+
}
|
|
2896
|
+
transport.onclose = () => {
|
|
2897
|
+
this.removeSession(sessionId);
|
|
2898
|
+
callbacks?.onSessionClosed?.(sessionId);
|
|
2899
|
+
};
|
|
2900
|
+
}
|
|
2901
|
+
touchSession(sessionId) {
|
|
2902
|
+
if (this.transports.has(sessionId)) {
|
|
2903
|
+
this.lastAccessed.set(sessionId, Date.now());
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
removeSession(sessionId) {
|
|
2907
|
+
this.transports.delete(sessionId);
|
|
2908
|
+
this.lastAccessed.delete(sessionId);
|
|
2909
|
+
this.expiresAt.delete(sessionId);
|
|
2910
|
+
}
|
|
2911
|
+
/**
|
|
2912
|
+
* Check if a session's token has expired.
|
|
2913
|
+
* Returns true if the token's `expiresAt` has passed.
|
|
2914
|
+
*/
|
|
2915
|
+
isSessionExpired(sessionId) {
|
|
2916
|
+
const exp = this.expiresAt.get(sessionId);
|
|
2917
|
+
return exp !== void 0 && Date.now() / 1e3 >= exp;
|
|
2918
|
+
}
|
|
2919
|
+
cleanupStaleSessions() {
|
|
2920
|
+
const now = Date.now();
|
|
2921
|
+
for (const [sid, lastTime] of this.lastAccessed.entries()) {
|
|
2922
|
+
if (now - lastTime > _SessionManager.STALE_TIMEOUT_MS) {
|
|
2923
|
+
const transport = this.transports.get(sid);
|
|
2924
|
+
if (transport) {
|
|
2925
|
+
void transport.close?.();
|
|
2926
|
+
}
|
|
2927
|
+
this.removeSession(sid);
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
destroy() {
|
|
2932
|
+
clearInterval(this.cleanupInterval);
|
|
2933
|
+
for (const [sid, transport] of this.transports.entries()) {
|
|
2934
|
+
void transport.close?.();
|
|
2935
|
+
this.removeSession(sid);
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
};
|
|
2939
|
+
|
|
2940
|
+
// src/server/kontext.ts
|
|
2941
|
+
var DEFAULT_API_URL = "https://api.kontext.dev";
|
|
2942
|
+
var METADATA_CACHE_TTL_MS = 60 * 60 * 1e3;
|
|
2943
|
+
var CREDENTIAL_CACHE_MAX_ENTRIES = 500;
|
|
2944
|
+
var RUNTIME_AUTH_CACHE_MAX_ENTRIES = 8;
|
|
2945
|
+
var RESOLVED_CREDENTIAL_CACHE_TTL_MS = 30 * 1e3;
|
|
2946
|
+
var SDK_VERSION = (() => {
|
|
2947
|
+
try {
|
|
2948
|
+
const esmRequire = createRequire(import.meta.url);
|
|
2949
|
+
const pkg = esmRequire("../../package.json");
|
|
2950
|
+
return pkg.version ?? "unknown";
|
|
2951
|
+
} catch {
|
|
2952
|
+
return "unknown";
|
|
2953
|
+
}
|
|
2954
|
+
})();
|
|
2955
|
+
var Kontext = class _Kontext {
|
|
2956
|
+
static shutdownInstances = /* @__PURE__ */ new Set();
|
|
2957
|
+
static shutdownHandlersRegistered = false;
|
|
2958
|
+
clientId;
|
|
2959
|
+
clientSecret;
|
|
2960
|
+
apiUrl;
|
|
2961
|
+
tokenIssuers;
|
|
2962
|
+
// AS metadata: fetched lazily, cached with TTL
|
|
2963
|
+
oauthMetadata = null;
|
|
2964
|
+
metadataFetchedAt = 0;
|
|
2965
|
+
metadataPromise = null;
|
|
2966
|
+
// Token exchange caching: keyed by `${integration}\0${subjectToken}`
|
|
2967
|
+
credentialCache = /* @__PURE__ */ new Map();
|
|
2968
|
+
resolvedCredentialCache = /* @__PURE__ */ new Map();
|
|
2969
|
+
runtimeAuthCache = /* @__PURE__ */ new Map();
|
|
2970
|
+
runtimeVerifierIds = /* @__PURE__ */ new WeakMap();
|
|
2971
|
+
runtimeVerifierIdCounter = 0;
|
|
2972
|
+
// Telemetry: cached service token for event reporting
|
|
2973
|
+
serviceToken = null;
|
|
2974
|
+
serviceTokenExp = 0;
|
|
2975
|
+
serviceTokenPromise = null;
|
|
2976
|
+
// Session tracking: MCP sessionId → API agentSessionId
|
|
2977
|
+
agentSessionIds = /* @__PURE__ */ new Map();
|
|
2978
|
+
pendingSessionDisconnects = /* @__PURE__ */ new Set();
|
|
2979
|
+
constructor(options) {
|
|
2980
|
+
this.clientId = options.clientId;
|
|
2981
|
+
this.clientSecret = options.clientSecret ?? process.env.KONTEXT_CLIENT_SECRET;
|
|
2982
|
+
this.apiUrl = (options.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
|
|
2983
|
+
const rawTokenIssuers = Array.isArray(options.tokenIssuer) ? options.tokenIssuer : options.tokenIssuer ? options.tokenIssuer.split(",") : process.env.KONTEXT_TOKEN_ISSUER?.split(",");
|
|
2984
|
+
this.tokenIssuers = Array.from(
|
|
2985
|
+
new Set(rawTokenIssuers?.map((issuer) => issuer.trim()).filter(Boolean))
|
|
2986
|
+
);
|
|
2987
|
+
_Kontext.shutdownInstances.add(this);
|
|
2988
|
+
_Kontext.ensureShutdownHandlers();
|
|
2989
|
+
}
|
|
2990
|
+
/**
|
|
2991
|
+
* Cleanup method for runtimes that create/dispose SDK instances dynamically.
|
|
2992
|
+
* Ensures this instance can be garbage-collected by removing static references.
|
|
2993
|
+
*/
|
|
2994
|
+
async destroy() {
|
|
2995
|
+
await this.disconnectAllSessions();
|
|
2996
|
+
_Kontext.shutdownInstances.delete(this);
|
|
2997
|
+
this.credentialCache.clear();
|
|
2998
|
+
this.resolvedCredentialCache.clear();
|
|
2999
|
+
this.oauthMetadata = null;
|
|
3000
|
+
this.metadataFetchedAt = 0;
|
|
3001
|
+
this.metadataPromise = null;
|
|
3002
|
+
this.serviceToken = null;
|
|
3003
|
+
this.serviceTokenExp = 0;
|
|
3004
|
+
this.serviceTokenPromise = null;
|
|
3005
|
+
this.agentSessionIds.clear();
|
|
3006
|
+
this.pendingSessionDisconnects.clear();
|
|
3007
|
+
}
|
|
3008
|
+
static ensureShutdownHandlers() {
|
|
3009
|
+
if (_Kontext.shutdownHandlersRegistered) return;
|
|
3010
|
+
const onShutdown = () => {
|
|
3011
|
+
for (const instance of _Kontext.shutdownInstances) {
|
|
3012
|
+
void instance.disconnectAllSessions();
|
|
3013
|
+
}
|
|
3014
|
+
};
|
|
3015
|
+
process.once("SIGINT", onShutdown);
|
|
3016
|
+
process.once("SIGTERM", onShutdown);
|
|
3017
|
+
_Kontext.shutdownHandlersRegistered = true;
|
|
3018
|
+
}
|
|
3019
|
+
// ===========================================================================
|
|
3020
|
+
// middleware()
|
|
3021
|
+
// ===========================================================================
|
|
3022
|
+
/**
|
|
3023
|
+
* Express middleware: `.well-known` metadata + bearer auth + MCP transport + sessions.
|
|
3024
|
+
*
|
|
3025
|
+
* Must be mounted at the app root (not a sub-path) because RFC 9728 requires
|
|
3026
|
+
* `/.well-known/oauth-protected-resource` at the root. Use `mcpPath` to set
|
|
3027
|
+
* the transport endpoint path.
|
|
3028
|
+
*
|
|
3029
|
+
* @param server - An `McpServer` instance for single-session use, or a
|
|
3030
|
+
* `() => McpServer` factory for concurrent sessions (recommended in production).
|
|
3031
|
+
* `McpServer.connect()` is 1:1 per the MCP spec — passing a factory ensures
|
|
3032
|
+
* each session gets its own instance.
|
|
3033
|
+
*
|
|
3034
|
+
* @example Factory pattern (recommended for concurrent sessions)
|
|
3035
|
+
* ```typescript
|
|
3036
|
+
* app.use(kontext.middleware(() => createServer()));
|
|
3037
|
+
* ```
|
|
3038
|
+
*
|
|
3039
|
+
* @example Single instance (local dev / single session)
|
|
3040
|
+
* ```typescript
|
|
3041
|
+
* app.use(kontext.middleware(server));
|
|
3042
|
+
* ```
|
|
3043
|
+
*
|
|
3044
|
+
* @example Custom path
|
|
3045
|
+
* ```typescript
|
|
3046
|
+
* app.use(kontext.middleware(createServer, { mcpPath: "/api/mcp" }));
|
|
3047
|
+
* ```
|
|
3048
|
+
*/
|
|
3049
|
+
middleware(server, options) {
|
|
3050
|
+
const esmRequire = createRequire(import.meta.url);
|
|
3051
|
+
const express = esmRequire("express");
|
|
3052
|
+
const router = express.Router();
|
|
3053
|
+
const mcpPath = options?.mcpPath ?? "/mcp";
|
|
3054
|
+
const sessionManager = new SessionManager();
|
|
3055
|
+
const omitAuth = options?.dangerouslyOmitAuth ?? false;
|
|
3056
|
+
router.use((_req, res, next) => {
|
|
3057
|
+
res.header("Access-Control-Allow-Origin", "*");
|
|
3058
|
+
res.header(
|
|
3059
|
+
"Access-Control-Allow-Headers",
|
|
3060
|
+
"Content-Type, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Accept"
|
|
3061
|
+
);
|
|
3062
|
+
res.header("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
3063
|
+
res.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
3064
|
+
if (_req.method === "OPTIONS") {
|
|
3065
|
+
res.sendStatus(204);
|
|
3066
|
+
return;
|
|
3067
|
+
}
|
|
3068
|
+
next();
|
|
3069
|
+
});
|
|
3070
|
+
if (omitAuth) {
|
|
3071
|
+
console.warn(
|
|
3072
|
+
"[kontext] \u26A0\uFE0F Auth is disabled (dangerouslyOmitAuth). Do NOT use in production."
|
|
3073
|
+
);
|
|
3074
|
+
router.use(mcpPath, express.json({ limit: options?.bodyLimit ?? "1mb" }));
|
|
3075
|
+
const mcpHandler2 = this.createMcpHandler(
|
|
3076
|
+
server,
|
|
3077
|
+
sessionManager,
|
|
3078
|
+
null,
|
|
3079
|
+
options
|
|
3080
|
+
);
|
|
3081
|
+
router.post(mcpPath, mcpHandler2.post);
|
|
3082
|
+
router.get(mcpPath, mcpHandler2.get);
|
|
3083
|
+
router.delete(mcpPath, mcpHandler2.delete);
|
|
3084
|
+
return router;
|
|
3085
|
+
}
|
|
3086
|
+
const getRuntimeAuth = async (req) => {
|
|
3087
|
+
const metadata = this.applyMetadataTransform(
|
|
3088
|
+
await this.getOAuthMetadata(),
|
|
3089
|
+
options?.metadataTransform
|
|
3090
|
+
);
|
|
3091
|
+
const rsUrl = this.resolveResourceServerUrl(req, mcpPath, options);
|
|
3092
|
+
return this.getOrCreateRuntimeAuthContext(
|
|
3093
|
+
metadata,
|
|
3094
|
+
rsUrl,
|
|
3095
|
+
options?.verifier
|
|
3096
|
+
);
|
|
3097
|
+
};
|
|
3098
|
+
router.use(async (req, res, next) => {
|
|
3099
|
+
const path = req.path || req.url || "";
|
|
3100
|
+
const isMetadataRequest = path.startsWith("/.well-known/oauth-authorization-server") || path.startsWith("/.well-known/oauth-protected-resource");
|
|
3101
|
+
if (!isMetadataRequest) {
|
|
3102
|
+
next();
|
|
3103
|
+
return;
|
|
3104
|
+
}
|
|
3105
|
+
try {
|
|
3106
|
+
const runtimeAuth = await getRuntimeAuth(req);
|
|
3107
|
+
runtimeAuth.metadataRouter(req, res, next);
|
|
3108
|
+
} catch (error) {
|
|
3109
|
+
this.respondMetadataInitError(res, error);
|
|
3110
|
+
}
|
|
3111
|
+
});
|
|
3112
|
+
router.use(mcpPath, express.json({ limit: options?.bodyLimit ?? "1mb" }));
|
|
3113
|
+
const mcpHandler = this.createMcpHandler(
|
|
3114
|
+
server,
|
|
3115
|
+
sessionManager,
|
|
3116
|
+
getRuntimeAuth,
|
|
3117
|
+
options
|
|
3118
|
+
);
|
|
3119
|
+
router.post(mcpPath, mcpHandler.post);
|
|
3120
|
+
router.get(mcpPath, mcpHandler.get);
|
|
3121
|
+
router.delete(mcpPath, mcpHandler.delete);
|
|
3122
|
+
return router;
|
|
3123
|
+
}
|
|
3124
|
+
// ===========================================================================
|
|
3125
|
+
// require()
|
|
3126
|
+
// ===========================================================================
|
|
3127
|
+
/**
|
|
3128
|
+
* Exchange a user's access token for an integration credential.
|
|
3129
|
+
*
|
|
3130
|
+
* @param integration - Integration name (e.g., "github")
|
|
3131
|
+
* @param token - The user's Bearer token (from `authInfo.token`)
|
|
3132
|
+
* @returns Integration credential with `accessToken` and `authorization` header
|
|
3133
|
+
*
|
|
3134
|
+
* @throws {IntegrationConnectionRequiredError} User hasn't connected this integration
|
|
3135
|
+
* @throws {OAuthError} Token exchange failed
|
|
3136
|
+
*/
|
|
3137
|
+
async require(integration, token) {
|
|
3138
|
+
const now = Date.now();
|
|
3139
|
+
this.evictExpiredCredentials(now);
|
|
3140
|
+
const cacheKey = `${integration}\0${token}`;
|
|
3141
|
+
const cached = this.credentialCache.get(cacheKey);
|
|
3142
|
+
if (cached && now < cached.expiresAt) {
|
|
3143
|
+
this.credentialCache.delete(cacheKey);
|
|
3144
|
+
this.credentialCache.set(cacheKey, cached);
|
|
3145
|
+
return cached.credential;
|
|
3146
|
+
}
|
|
3147
|
+
if (cached) {
|
|
3148
|
+
this.credentialCache.delete(cacheKey);
|
|
3149
|
+
}
|
|
3150
|
+
const exchangeConfig = {
|
|
3151
|
+
tokenUrl: `${this.apiUrl}/oauth2/token`,
|
|
3152
|
+
clientId: this.clientId,
|
|
3153
|
+
clientSecret: this.clientSecret
|
|
3154
|
+
};
|
|
3155
|
+
let response;
|
|
3156
|
+
try {
|
|
3157
|
+
response = await exchangeToken(exchangeConfig, token, integration);
|
|
3158
|
+
} catch (err) {
|
|
3159
|
+
if (err instanceof OAuthError) {
|
|
3160
|
+
if (err.errorCode === "integration_required" || err.message.includes("not connected") || err.message.includes("expired") && err.message.includes("reconnect")) {
|
|
3161
|
+
const integrationId = err.meta.integrationId || integration;
|
|
3162
|
+
const connectUrl = await this.fetchConnectUrl(
|
|
3163
|
+
token,
|
|
3164
|
+
integrationId,
|
|
3165
|
+
exchangeConfig
|
|
3166
|
+
);
|
|
3167
|
+
throw new IntegrationConnectionRequiredError(integrationId, {
|
|
3168
|
+
integrationName: err.meta.integrationName,
|
|
3169
|
+
connectUrl,
|
|
3170
|
+
message: err.message
|
|
3171
|
+
});
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
throw err;
|
|
3175
|
+
}
|
|
3176
|
+
const credential = {
|
|
3177
|
+
accessToken: response.access_token,
|
|
3178
|
+
tokenType: response.token_type,
|
|
3179
|
+
authorization: `${response.token_type} ${response.access_token}`,
|
|
3180
|
+
expiresIn: response.expires_in,
|
|
3181
|
+
scope: response.scope,
|
|
3182
|
+
integration
|
|
3183
|
+
};
|
|
3184
|
+
if (response.expires_in) {
|
|
3185
|
+
const ttlMs = Math.min(response.expires_in - 60, 5 * 60) * 1e3;
|
|
3186
|
+
if (ttlMs > 0) {
|
|
3187
|
+
this.trimCacheToFit(this.credentialCache, CREDENTIAL_CACHE_MAX_ENTRIES);
|
|
3188
|
+
this.credentialCache.set(cacheKey, {
|
|
3189
|
+
credential,
|
|
3190
|
+
expiresAt: now + ttlMs
|
|
3191
|
+
});
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
return credential;
|
|
3195
|
+
}
|
|
3196
|
+
/**
|
|
3197
|
+
* Resolve per-user credential key/value pairs for an internal MCP integration.
|
|
3198
|
+
*
|
|
3199
|
+
* @param integration - Integration UUID or name
|
|
3200
|
+
* @param token - The user's Bearer token (from `authInfo.token`)
|
|
3201
|
+
* @returns Decrypted credential map for the current user and integration
|
|
3202
|
+
*
|
|
3203
|
+
* @throws {IntegrationConnectionRequiredError} User has not provided required credentials
|
|
3204
|
+
* @throws {OAuthError} Runtime credential resolution failed
|
|
3205
|
+
*/
|
|
3206
|
+
async requireCredentials(integration, token) {
|
|
3207
|
+
const now = Date.now();
|
|
3208
|
+
this.evictExpiredResolvedCredentials(now);
|
|
3209
|
+
const cacheKey = `${integration}\0${token}\0internal_credentials`;
|
|
3210
|
+
const cached = this.resolvedCredentialCache.get(cacheKey);
|
|
3211
|
+
if (cached && now < cached.expiresAt) {
|
|
3212
|
+
this.resolvedCredentialCache.delete(cacheKey);
|
|
3213
|
+
this.resolvedCredentialCache.set(cacheKey, cached);
|
|
3214
|
+
return cached.credential;
|
|
3215
|
+
}
|
|
3216
|
+
if (cached) {
|
|
3217
|
+
this.resolvedCredentialCache.delete(cacheKey);
|
|
3218
|
+
}
|
|
3219
|
+
const exchangeConfig = {
|
|
3220
|
+
tokenUrl: `${this.apiUrl}/oauth2/token`,
|
|
3221
|
+
clientId: this.clientId,
|
|
3222
|
+
clientSecret: this.clientSecret
|
|
3223
|
+
};
|
|
3224
|
+
let gatewayAccessToken = token;
|
|
3225
|
+
if (!this.isGatewayScopedToken(token)) {
|
|
3226
|
+
try {
|
|
3227
|
+
const exchanged = await exchangeToken(
|
|
3228
|
+
exchangeConfig,
|
|
3229
|
+
token,
|
|
3230
|
+
"mcp-gateway"
|
|
3231
|
+
);
|
|
3232
|
+
gatewayAccessToken = exchanged.access_token;
|
|
3233
|
+
} catch (err) {
|
|
3234
|
+
throw new OAuthError(
|
|
3235
|
+
"Failed to exchange subject token for runtime",
|
|
3236
|
+
"kontext_credentials_exchange_failed",
|
|
3237
|
+
{
|
|
3238
|
+
errorCode: "credentials_exchange_failed",
|
|
3239
|
+
errorDescription: err instanceof Error ? err.message : String(err ?? "unknown error")
|
|
3240
|
+
}
|
|
3241
|
+
);
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
const integrationId = await this.resolveRuntimeIntegrationId(
|
|
3245
|
+
integration,
|
|
3246
|
+
gatewayAccessToken
|
|
3247
|
+
);
|
|
3248
|
+
const res = await fetch(
|
|
3249
|
+
`${this.apiUrl}/mcp/integrations/${integrationId}/credentials/resolve`,
|
|
3250
|
+
{
|
|
3251
|
+
method: "POST",
|
|
3252
|
+
headers: {
|
|
3253
|
+
Authorization: `Bearer ${gatewayAccessToken}`,
|
|
3254
|
+
"Content-Type": "application/json"
|
|
3255
|
+
},
|
|
3256
|
+
body: "{}"
|
|
3257
|
+
}
|
|
3258
|
+
);
|
|
3259
|
+
if (!res.ok) {
|
|
3260
|
+
const text = await res.text().catch(() => "");
|
|
3261
|
+
const message = text && text.trim().length > 0 ? text : `HTTP ${res.status} while resolving credentials`;
|
|
3262
|
+
if (res.status === 400 && message.toLowerCase().includes("credentials required")) {
|
|
3263
|
+
throw new IntegrationConnectionRequiredError(integrationId, {
|
|
3264
|
+
integrationName: String(integration),
|
|
3265
|
+
message
|
|
3266
|
+
});
|
|
3267
|
+
}
|
|
3268
|
+
throw new OAuthError(
|
|
3269
|
+
`Failed to resolve credentials for integration ${integrationId}`,
|
|
3270
|
+
"kontext_credentials_resolve_failed",
|
|
3271
|
+
{
|
|
3272
|
+
errorCode: "credentials_resolve_failed",
|
|
3273
|
+
errorDescription: message
|
|
3274
|
+
}
|
|
3275
|
+
);
|
|
3276
|
+
}
|
|
3277
|
+
const payload = await res.json();
|
|
3278
|
+
if (!payload.credentials || typeof payload.credentials !== "object" || Array.isArray(payload.credentials)) {
|
|
3279
|
+
throw new OAuthError(
|
|
3280
|
+
"Credential resolve returned invalid payload",
|
|
3281
|
+
"kontext_credentials_invalid_payload"
|
|
3282
|
+
);
|
|
3283
|
+
}
|
|
3284
|
+
const credentials = {};
|
|
3285
|
+
for (const [key, value] of Object.entries(payload.credentials)) {
|
|
3286
|
+
if (typeof value === "string") {
|
|
3287
|
+
credentials[key] = value;
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
if (Object.keys(credentials).length === 0) {
|
|
3291
|
+
throw new IntegrationConnectionRequiredError(integrationId, {
|
|
3292
|
+
integrationName: String(integration),
|
|
3293
|
+
message: "No credentials configured for this integration"
|
|
3294
|
+
});
|
|
3295
|
+
}
|
|
3296
|
+
const resolved = {
|
|
3297
|
+
integration,
|
|
3298
|
+
integrationId: payload.integrationId ?? integrationId,
|
|
3299
|
+
credentials
|
|
3300
|
+
};
|
|
3301
|
+
this.trimCacheToFit(
|
|
3302
|
+
this.resolvedCredentialCache,
|
|
3303
|
+
CREDENTIAL_CACHE_MAX_ENTRIES
|
|
3304
|
+
);
|
|
3305
|
+
this.resolvedCredentialCache.set(cacheKey, {
|
|
3306
|
+
credential: resolved,
|
|
3307
|
+
expiresAt: now + RESOLVED_CREDENTIAL_CACHE_TTL_MS
|
|
3308
|
+
});
|
|
3309
|
+
return resolved;
|
|
3310
|
+
}
|
|
3311
|
+
getGatewayAudiences() {
|
|
3312
|
+
return /* @__PURE__ */ new Set([`${new URL(this.apiUrl).origin}/mcp`, "mcp-gateway"]);
|
|
3313
|
+
}
|
|
3314
|
+
isGatewayScopedToken(token) {
|
|
3315
|
+
const audiences = this.extractTokenAudiences(token);
|
|
3316
|
+
if (audiences.length === 0) {
|
|
3317
|
+
return false;
|
|
3318
|
+
}
|
|
3319
|
+
const gatewayAudiences = this.getGatewayAudiences();
|
|
3320
|
+
return audiences.some((audience) => gatewayAudiences.has(audience));
|
|
3321
|
+
}
|
|
3322
|
+
extractTokenAudiences(token) {
|
|
3323
|
+
const [, payloadPart] = token.split(".");
|
|
3324
|
+
if (!payloadPart) return [];
|
|
3325
|
+
try {
|
|
3326
|
+
const payload = JSON.parse(
|
|
3327
|
+
Buffer.from(payloadPart, "base64url").toString("utf8")
|
|
3328
|
+
);
|
|
3329
|
+
if (typeof payload.aud === "string") {
|
|
3330
|
+
return [payload.aud];
|
|
3331
|
+
}
|
|
3332
|
+
if (Array.isArray(payload.aud)) {
|
|
3333
|
+
return payload.aud.filter(
|
|
3334
|
+
(value) => typeof value === "string"
|
|
3335
|
+
);
|
|
3336
|
+
}
|
|
3337
|
+
} catch {
|
|
3338
|
+
}
|
|
3339
|
+
return [];
|
|
3340
|
+
}
|
|
3341
|
+
// ===========================================================================
|
|
3342
|
+
// Private: fetch connect URL (spec §2 — two-step init)
|
|
3343
|
+
// ===========================================================================
|
|
3344
|
+
async resolveRuntimeIntegrationId(integration, runtimeToken) {
|
|
3345
|
+
const raw = String(integration);
|
|
3346
|
+
if (this.isUuid(raw)) {
|
|
3347
|
+
return raw;
|
|
3348
|
+
}
|
|
3349
|
+
const res = await fetch(`${this.apiUrl}/mcp/integrations`, {
|
|
3350
|
+
headers: {
|
|
3351
|
+
Authorization: `Bearer ${runtimeToken}`
|
|
3352
|
+
}
|
|
3353
|
+
});
|
|
3354
|
+
if (!res.ok) {
|
|
3355
|
+
const text = await res.text().catch(() => "");
|
|
3356
|
+
throw new OAuthError(
|
|
3357
|
+
"Failed to resolve integration identifier",
|
|
3358
|
+
"kontext_integration_lookup_failed",
|
|
3359
|
+
{
|
|
3360
|
+
errorCode: "integration_lookup_failed",
|
|
3361
|
+
errorDescription: text || `HTTP ${res.status}`
|
|
3362
|
+
}
|
|
3363
|
+
);
|
|
3364
|
+
}
|
|
3365
|
+
const payload = await res.json();
|
|
3366
|
+
const items = Array.isArray(payload.items) ? payload.items : [];
|
|
3367
|
+
const match = items.find((item) => item.id === raw || item.name === raw);
|
|
3368
|
+
const integrationId = match?.id;
|
|
3369
|
+
if (!integrationId) {
|
|
3370
|
+
throw new IntegrationConnectionRequiredError(raw, {
|
|
3371
|
+
integrationName: raw,
|
|
3372
|
+
message: `Integration ${raw} is not attached to this application`
|
|
3373
|
+
});
|
|
3374
|
+
}
|
|
3375
|
+
return integrationId;
|
|
3376
|
+
}
|
|
3377
|
+
isUuid(value) {
|
|
3378
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
|
3379
|
+
value
|
|
3380
|
+
);
|
|
3381
|
+
}
|
|
3382
|
+
/**
|
|
3383
|
+
* Fetch a browser-openable connect URL for a missing integration.
|
|
3384
|
+
*
|
|
3385
|
+
* Per the integration-interrupt-flow spec, the SDK:
|
|
3386
|
+
* 1. Exchanges the user's token for a resource-scoped mcp-gateway JWT
|
|
3387
|
+
* 2. Calls POST /mcp/integrations/:id/oauth/init with that JWT
|
|
3388
|
+
* 3. Returns the `connectUrl` (intermediate endpoint with one-time token)
|
|
3389
|
+
*
|
|
3390
|
+
* The connect URL points to our own server (ticket pattern), which
|
|
3391
|
+
* validates the ticket, sets a browser session cookie, then redirects
|
|
3392
|
+
* to the actual OAuth provider.
|
|
3393
|
+
*/
|
|
3394
|
+
async fetchConnectUrl(subjectToken, integrationId, exchangeConfig) {
|
|
3395
|
+
try {
|
|
3396
|
+
const gatewayToken = await exchangeToken(
|
|
3397
|
+
exchangeConfig,
|
|
3398
|
+
subjectToken,
|
|
3399
|
+
"mcp-gateway"
|
|
3400
|
+
);
|
|
3401
|
+
const initUrl = `${this.apiUrl}/mcp/integrations/${integrationId}/oauth/init`;
|
|
3402
|
+
const res = await fetch(initUrl, {
|
|
3403
|
+
method: "POST",
|
|
3404
|
+
headers: {
|
|
3405
|
+
Authorization: `Bearer ${gatewayToken.access_token}`,
|
|
3406
|
+
"Content-Type": "application/json"
|
|
3407
|
+
},
|
|
3408
|
+
body: JSON.stringify({})
|
|
3409
|
+
});
|
|
3410
|
+
if (!res.ok) {
|
|
3411
|
+
const text = await res.text().catch(() => "");
|
|
3412
|
+
console.warn(
|
|
3413
|
+
`[kontext] fetchConnectUrl: init endpoint returned ${res.status}: ${text}`
|
|
3414
|
+
);
|
|
3415
|
+
return void 0;
|
|
3416
|
+
}
|
|
3417
|
+
const data = await res.json();
|
|
3418
|
+
return data.connectUrl ?? data.authorizationUrl;
|
|
3419
|
+
} catch (err) {
|
|
3420
|
+
console.warn(
|
|
3421
|
+
`[kontext] fetchConnectUrl failed:`,
|
|
3422
|
+
err instanceof Error ? err.message : String(err)
|
|
3423
|
+
);
|
|
3424
|
+
return void 0;
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
// ===========================================================================
|
|
3428
|
+
// Private: AS metadata
|
|
3429
|
+
// ===========================================================================
|
|
3430
|
+
async getOAuthMetadata() {
|
|
3431
|
+
const now = Date.now();
|
|
3432
|
+
if (this.oauthMetadata && now - this.metadataFetchedAt < METADATA_CACHE_TTL_MS) {
|
|
3433
|
+
return this.oauthMetadata;
|
|
3434
|
+
}
|
|
3435
|
+
if (this.metadataPromise) {
|
|
3436
|
+
return this.metadataPromise;
|
|
3437
|
+
}
|
|
3438
|
+
this.metadataPromise = this.fetchOAuthMetadata();
|
|
3439
|
+
try {
|
|
3440
|
+
const metadata = await this.metadataPromise;
|
|
3441
|
+
this.oauthMetadata = metadata;
|
|
3442
|
+
this.metadataFetchedAt = Date.now();
|
|
3443
|
+
return metadata;
|
|
3444
|
+
} finally {
|
|
3445
|
+
this.metadataPromise = null;
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
applyMetadataTransform(metadata, metadataTransform) {
|
|
3449
|
+
if (!metadataTransform) {
|
|
3450
|
+
return metadata;
|
|
3451
|
+
}
|
|
3452
|
+
return metadataTransform(this.cloneOAuthMetadata(metadata));
|
|
3453
|
+
}
|
|
3454
|
+
cloneOAuthMetadata(metadata) {
|
|
3455
|
+
return JSON.parse(JSON.stringify(metadata));
|
|
3456
|
+
}
|
|
3457
|
+
async fetchOAuthMetadata() {
|
|
3458
|
+
const urls = [
|
|
3459
|
+
`${this.apiUrl}/.well-known/oauth-authorization-server`,
|
|
3460
|
+
`${this.apiUrl}/.well-known/openid-configuration`
|
|
3461
|
+
];
|
|
3462
|
+
let lastError;
|
|
3463
|
+
for (const url of urls) {
|
|
3464
|
+
try {
|
|
3465
|
+
const res = await fetch(url);
|
|
3466
|
+
if (res.ok) {
|
|
3467
|
+
return await res.json();
|
|
3468
|
+
}
|
|
3469
|
+
} catch (err) {
|
|
3470
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
3471
|
+
}
|
|
3472
|
+
}
|
|
3473
|
+
throw new Error(
|
|
3474
|
+
`Failed to fetch AS metadata from ${this.apiUrl}: ${lastError?.message ?? "unknown error"}`
|
|
3475
|
+
);
|
|
3476
|
+
}
|
|
3477
|
+
resolveResourceServerUrl(req, mcpPath, options) {
|
|
3478
|
+
if (options?.resourceServerUrl) {
|
|
3479
|
+
return new URL(options.resourceServerUrl);
|
|
3480
|
+
}
|
|
3481
|
+
const host = req.get("host");
|
|
3482
|
+
if (!host) {
|
|
3483
|
+
throw new Error(
|
|
3484
|
+
"Missing Host header. Set middleware({ resourceServerUrl }) to a trusted public URL."
|
|
3485
|
+
);
|
|
3486
|
+
}
|
|
3487
|
+
return new URL(`${req.protocol}://${host}${mcpPath}`);
|
|
3488
|
+
}
|
|
3489
|
+
getOrCreateRuntimeAuthContext(metadata, rsUrl, customVerifier) {
|
|
3490
|
+
const key = this.getRuntimeAuthCacheKey(rsUrl, customVerifier);
|
|
3491
|
+
const cached = this.runtimeAuthCache.get(key);
|
|
3492
|
+
if (cached) {
|
|
3493
|
+
this.runtimeAuthCache.delete(key);
|
|
3494
|
+
this.runtimeAuthCache.set(key, cached);
|
|
3495
|
+
return cached;
|
|
3496
|
+
}
|
|
3497
|
+
const proxiedMetadata = { ...metadata, issuer: `${rsUrl.origin}/` };
|
|
3498
|
+
const metadataRouter = mcpAuthMetadataRouter({
|
|
3499
|
+
oauthMetadata: proxiedMetadata,
|
|
3500
|
+
resourceServerUrl: rsUrl
|
|
3501
|
+
});
|
|
3502
|
+
const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(rsUrl);
|
|
3503
|
+
const verifier = customVerifier ?? this.createTokenVerifier(metadata, rsUrl);
|
|
3504
|
+
const runtimeAuth = {
|
|
3505
|
+
metadataRouter,
|
|
3506
|
+
bearerAuth: requireBearerAuth({
|
|
3507
|
+
verifier,
|
|
3508
|
+
resourceMetadataUrl
|
|
3509
|
+
})
|
|
3510
|
+
};
|
|
3511
|
+
this.trimCacheToFit(this.runtimeAuthCache, RUNTIME_AUTH_CACHE_MAX_ENTRIES);
|
|
3512
|
+
this.runtimeAuthCache.set(key, runtimeAuth);
|
|
3513
|
+
return runtimeAuth;
|
|
3514
|
+
}
|
|
3515
|
+
getRuntimeAuthCacheKey(rsUrl, customVerifier) {
|
|
3516
|
+
if (!customVerifier) {
|
|
3517
|
+
return `${rsUrl.href}\0default`;
|
|
3518
|
+
}
|
|
3519
|
+
let verifierId = this.runtimeVerifierIds.get(customVerifier);
|
|
3520
|
+
if (verifierId === void 0) {
|
|
3521
|
+
verifierId = ++this.runtimeVerifierIdCounter;
|
|
3522
|
+
this.runtimeVerifierIds.set(customVerifier, verifierId);
|
|
3523
|
+
}
|
|
3524
|
+
return `${rsUrl.href}\0custom:${verifierId}`;
|
|
3525
|
+
}
|
|
3526
|
+
respondMetadataInitError(res, error) {
|
|
3527
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3528
|
+
console.error(`[kontext] Failed to fetch AS metadata: ${message}`);
|
|
3529
|
+
if (res.headersSent) return;
|
|
3530
|
+
res.status(503).json({
|
|
3531
|
+
error: "service_unavailable",
|
|
3532
|
+
error_description: "Failed to fetch authorization server metadata. Retry later."
|
|
3533
|
+
});
|
|
3534
|
+
}
|
|
3535
|
+
evictExpiredCredentials(now) {
|
|
3536
|
+
for (const [key, value] of this.credentialCache.entries()) {
|
|
3537
|
+
if (value.expiresAt <= now) {
|
|
3538
|
+
this.credentialCache.delete(key);
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
evictExpiredResolvedCredentials(now) {
|
|
3543
|
+
for (const [key, value] of this.resolvedCredentialCache.entries()) {
|
|
3544
|
+
if (value.expiresAt <= now) {
|
|
3545
|
+
this.resolvedCredentialCache.delete(key);
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
trimCacheToFit(cache, maxEntries) {
|
|
3550
|
+
while (cache.size >= maxEntries) {
|
|
3551
|
+
const oldestKey = cache.keys().next().value;
|
|
3552
|
+
if (!oldestKey) break;
|
|
3553
|
+
cache.delete(oldestKey);
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
// ===========================================================================
|
|
3557
|
+
// Private: token verifier
|
|
3558
|
+
// ===========================================================================
|
|
3559
|
+
createTokenVerifier(metadata, resourceUrl) {
|
|
3560
|
+
const metadataRaw = metadata;
|
|
3561
|
+
const jwksUri = metadataRaw.jwks_uri ?? `${this.apiUrl}/.well-known/jwks.json`;
|
|
3562
|
+
const clientId = this.clientId;
|
|
3563
|
+
const issuers = Array.from(
|
|
3564
|
+
new Set(
|
|
3565
|
+
[metadata.issuer, ...this.tokenIssuers].filter(
|
|
3566
|
+
(issuer2) => typeof issuer2 === "string" && !!issuer2
|
|
3567
|
+
)
|
|
3568
|
+
)
|
|
3569
|
+
);
|
|
3570
|
+
if (!issuers.length) {
|
|
3571
|
+
throw new Error("OAuth metadata missing issuer");
|
|
3572
|
+
}
|
|
3573
|
+
const issuer = issuers.length === 1 ? issuers[0] : issuers;
|
|
3574
|
+
const verifier = new KontextTokenVerifier({
|
|
3575
|
+
jwksUrl: jwksUri,
|
|
3576
|
+
issuer,
|
|
3577
|
+
audience: resourceUrl.href
|
|
3578
|
+
});
|
|
3579
|
+
return {
|
|
3580
|
+
async verifyAccessToken(token) {
|
|
3581
|
+
const result = await verifier.verify(token);
|
|
3582
|
+
if (!result.success) {
|
|
3583
|
+
throw new InvalidTokenError(
|
|
3584
|
+
`Token verification failed: ${result.error.message}`
|
|
3585
|
+
);
|
|
3586
|
+
}
|
|
3587
|
+
const { claims } = result;
|
|
3588
|
+
const payload = claims.payload;
|
|
3589
|
+
const ext = payload.ext ?? {};
|
|
3590
|
+
return {
|
|
3591
|
+
token,
|
|
3592
|
+
clientId: claims.clientId ?? clientId,
|
|
3593
|
+
scopes: claims.scopes,
|
|
3594
|
+
expiresAt: Math.floor(claims.expiresAt.getTime() / 1e3),
|
|
3595
|
+
extra: {
|
|
3596
|
+
...ext,
|
|
3597
|
+
sub: claims.sub,
|
|
3598
|
+
email: payload.email ?? ext.email
|
|
3599
|
+
}
|
|
3600
|
+
};
|
|
3601
|
+
}
|
|
3602
|
+
};
|
|
3603
|
+
}
|
|
3604
|
+
// ===========================================================================
|
|
3605
|
+
// Private: telemetry
|
|
3606
|
+
// ===========================================================================
|
|
3607
|
+
async getServiceToken() {
|
|
3608
|
+
if (this.serviceToken && Date.now() < this.serviceTokenExp - 3e4) {
|
|
3609
|
+
return this.serviceToken;
|
|
3610
|
+
}
|
|
3611
|
+
if (this.serviceTokenPromise) {
|
|
3612
|
+
return this.serviceTokenPromise;
|
|
3613
|
+
}
|
|
3614
|
+
this.serviceTokenPromise = (async () => {
|
|
3615
|
+
const res = await fetch(`${this.apiUrl}/oauth2/token`, {
|
|
3616
|
+
method: "POST",
|
|
3617
|
+
headers: {
|
|
3618
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
3619
|
+
Authorization: `Basic ${Buffer.from(this.clientId + ":" + this.clientSecret).toString("base64")}`
|
|
3620
|
+
},
|
|
3621
|
+
body: "grant_type=client_credentials"
|
|
3622
|
+
});
|
|
3623
|
+
if (!res.ok) {
|
|
3624
|
+
const text = await res.text().catch(() => "");
|
|
3625
|
+
throw new Error(
|
|
3626
|
+
`[kontext:telemetry] client_credentials grant failed: HTTP ${res.status} ${text}`
|
|
3627
|
+
);
|
|
3628
|
+
}
|
|
3629
|
+
const data = await res.json();
|
|
3630
|
+
this.serviceToken = data.access_token;
|
|
3631
|
+
this.serviceTokenExp = Date.now() + data.expires_in * 1e3;
|
|
3632
|
+
return data.access_token;
|
|
3633
|
+
})();
|
|
3634
|
+
try {
|
|
3635
|
+
return await this.serviceTokenPromise;
|
|
3636
|
+
} finally {
|
|
3637
|
+
this.serviceTokenPromise = null;
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
reportEvent(event) {
|
|
3641
|
+
if (!this.clientSecret || !event.sessionId) return;
|
|
3642
|
+
this.getServiceToken().then(
|
|
3643
|
+
(token) => fetch(`${this.apiUrl}/api/v1/mcp-events`, {
|
|
3644
|
+
method: "POST",
|
|
3645
|
+
headers: {
|
|
3646
|
+
"Content-Type": "application/json",
|
|
3647
|
+
Authorization: `Bearer ${token}`
|
|
3648
|
+
},
|
|
3649
|
+
body: JSON.stringify({
|
|
3650
|
+
...event,
|
|
3651
|
+
agentId: this.clientId,
|
|
3652
|
+
clientId: this.clientId,
|
|
3653
|
+
clientVersion: SDK_VERSION
|
|
3654
|
+
})
|
|
3655
|
+
}).then((res) => {
|
|
3656
|
+
if (!res.ok) {
|
|
3657
|
+
console.warn(
|
|
3658
|
+
`[kontext:telemetry] event report failed: HTTP ${res.status}`
|
|
3659
|
+
);
|
|
3660
|
+
}
|
|
3661
|
+
})
|
|
3662
|
+
).catch((err) => {
|
|
3663
|
+
console.warn(
|
|
3664
|
+
`[kontext:telemetry] error:`,
|
|
3665
|
+
err instanceof Error ? err.message : String(err)
|
|
3666
|
+
);
|
|
3667
|
+
});
|
|
3668
|
+
}
|
|
3669
|
+
// ===========================================================================
|
|
3670
|
+
// Private: session lifecycle
|
|
3671
|
+
// ===========================================================================
|
|
3672
|
+
createAgentSession(userToken, mcpSessionId, metadata) {
|
|
3673
|
+
if (!this.clientSecret || !userToken) return;
|
|
3674
|
+
const tokenIdentifier = createHash("sha256").update(userToken).digest("hex");
|
|
3675
|
+
this.getServiceToken().then(
|
|
3676
|
+
(token) => fetch(`${this.apiUrl}/api/v1/agent-sessions`, {
|
|
3677
|
+
method: "POST",
|
|
3678
|
+
headers: {
|
|
3679
|
+
"Content-Type": "application/json",
|
|
3680
|
+
Authorization: `Bearer ${token}`
|
|
3681
|
+
},
|
|
3682
|
+
body: JSON.stringify({
|
|
3683
|
+
tokenIdentifier,
|
|
3684
|
+
hostname: metadata?.hostname,
|
|
3685
|
+
userAgent: metadata?.userAgent,
|
|
3686
|
+
clientInfo: metadata?.clientInfo,
|
|
3687
|
+
tokenExpiresAt: metadata?.tokenExpiresAt ? new Date(metadata.tokenExpiresAt * 1e3).toISOString() : void 0
|
|
3688
|
+
})
|
|
3689
|
+
}).then(async (res) => {
|
|
3690
|
+
if (res.ok) {
|
|
3691
|
+
const data = await res.json();
|
|
3692
|
+
if (this.pendingSessionDisconnects.delete(mcpSessionId)) {
|
|
3693
|
+
this.disconnectAgentSessionByAgentSessionId(
|
|
3694
|
+
data.sessionId,
|
|
3695
|
+
token
|
|
3696
|
+
);
|
|
3697
|
+
return;
|
|
3698
|
+
}
|
|
3699
|
+
this.agentSessionIds.set(mcpSessionId, data.sessionId);
|
|
3700
|
+
} else {
|
|
3701
|
+
this.pendingSessionDisconnects.delete(mcpSessionId);
|
|
3702
|
+
console.warn(
|
|
3703
|
+
`[kontext:sessions] create failed: HTTP ${res.status}`
|
|
3704
|
+
);
|
|
3705
|
+
}
|
|
3706
|
+
})
|
|
3707
|
+
).catch((err) => {
|
|
3708
|
+
this.pendingSessionDisconnects.delete(mcpSessionId);
|
|
3709
|
+
console.warn(
|
|
3710
|
+
`[kontext:sessions] error:`,
|
|
3711
|
+
err instanceof Error ? err.message : String(err)
|
|
3712
|
+
);
|
|
3713
|
+
});
|
|
3714
|
+
}
|
|
3715
|
+
disconnectAgentSessionByAgentSessionId(agentSessionId, serviceToken) {
|
|
3716
|
+
if (!this.clientSecret) return;
|
|
3717
|
+
const tokenPromise = serviceToken ? Promise.resolve(serviceToken) : this.getServiceToken();
|
|
3718
|
+
tokenPromise.then(
|
|
3719
|
+
(token) => fetch(
|
|
3720
|
+
`${this.apiUrl}/api/v1/agent-sessions/${agentSessionId}/disconnect`,
|
|
3721
|
+
{
|
|
3722
|
+
method: "POST",
|
|
3723
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
3724
|
+
}
|
|
3725
|
+
)
|
|
3726
|
+
).catch(() => {
|
|
3727
|
+
});
|
|
3728
|
+
}
|
|
3729
|
+
disconnectAgentSession(mcpSessionId) {
|
|
3730
|
+
if (!this.clientSecret) return;
|
|
3731
|
+
const agentSessionId = this.agentSessionIds.get(mcpSessionId);
|
|
3732
|
+
this.agentSessionIds.delete(mcpSessionId);
|
|
3733
|
+
if (!agentSessionId) {
|
|
3734
|
+
this.pendingSessionDisconnects.add(mcpSessionId);
|
|
3735
|
+
return;
|
|
3736
|
+
}
|
|
3737
|
+
this.pendingSessionDisconnects.delete(mcpSessionId);
|
|
3738
|
+
this.disconnectAgentSessionByAgentSessionId(agentSessionId);
|
|
3739
|
+
}
|
|
3740
|
+
async disconnectAllSessions() {
|
|
3741
|
+
if (!this.clientSecret) return;
|
|
3742
|
+
if (this.agentSessionIds.size === 0) {
|
|
3743
|
+
this.pendingSessionDisconnects.clear();
|
|
3744
|
+
return;
|
|
3745
|
+
}
|
|
3746
|
+
try {
|
|
3747
|
+
const token = await this.getServiceToken();
|
|
3748
|
+
await Promise.allSettled(
|
|
3749
|
+
[...this.agentSessionIds.values()].map(
|
|
3750
|
+
(agentSessionId) => fetch(
|
|
3751
|
+
`${this.apiUrl}/api/v1/agent-sessions/${agentSessionId}/disconnect`,
|
|
3752
|
+
{
|
|
3753
|
+
method: "POST",
|
|
3754
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
3755
|
+
}
|
|
3756
|
+
)
|
|
3757
|
+
)
|
|
3758
|
+
);
|
|
3759
|
+
} catch {
|
|
3760
|
+
}
|
|
3761
|
+
this.agentSessionIds.clear();
|
|
3762
|
+
this.pendingSessionDisconnects.clear();
|
|
3763
|
+
}
|
|
3764
|
+
// ===========================================================================
|
|
3765
|
+
// Private: MCP transport handlers
|
|
3766
|
+
// ===========================================================================
|
|
3767
|
+
async runBearerAuth(bearerAuth, req, res) {
|
|
3768
|
+
await new Promise((resolve, reject) => {
|
|
3769
|
+
let settled = false;
|
|
3770
|
+
let nextCalled = false;
|
|
3771
|
+
const cleanup = () => {
|
|
3772
|
+
res.removeListener("finish", onResponseDone);
|
|
3773
|
+
res.removeListener("close", onResponseDone);
|
|
3774
|
+
};
|
|
3775
|
+
const settleResolve = () => {
|
|
3776
|
+
if (settled) return;
|
|
3777
|
+
settled = true;
|
|
3778
|
+
cleanup();
|
|
3779
|
+
resolve();
|
|
3780
|
+
};
|
|
3781
|
+
const settleReject = (err) => {
|
|
3782
|
+
if (settled) return;
|
|
3783
|
+
settled = true;
|
|
3784
|
+
cleanup();
|
|
3785
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
3786
|
+
};
|
|
3787
|
+
const onResponseDone = () => {
|
|
3788
|
+
settleResolve();
|
|
3789
|
+
};
|
|
3790
|
+
res.once("finish", onResponseDone);
|
|
3791
|
+
res.once("close", onResponseDone);
|
|
3792
|
+
let middlewareResult;
|
|
3793
|
+
try {
|
|
3794
|
+
middlewareResult = bearerAuth(req, res, (err) => {
|
|
3795
|
+
nextCalled = true;
|
|
3796
|
+
if (err) {
|
|
3797
|
+
settleReject(err);
|
|
3798
|
+
return;
|
|
3799
|
+
}
|
|
3800
|
+
settleResolve();
|
|
3801
|
+
});
|
|
3802
|
+
} catch (err) {
|
|
3803
|
+
settleReject(err);
|
|
3804
|
+
return;
|
|
3805
|
+
}
|
|
3806
|
+
void Promise.resolve(middlewareResult).then(
|
|
3807
|
+
() => {
|
|
3808
|
+
if (!nextCalled && res.headersSent) {
|
|
3809
|
+
settleResolve();
|
|
3810
|
+
}
|
|
3811
|
+
},
|
|
3812
|
+
(err) => {
|
|
3813
|
+
settleReject(err);
|
|
3814
|
+
}
|
|
3815
|
+
);
|
|
3816
|
+
});
|
|
3817
|
+
}
|
|
3818
|
+
createMcpHandler(server, sessionManager, getRuntimeAuth, options) {
|
|
3819
|
+
const callbacks = {
|
|
3820
|
+
onSessionClosed: (sessionId) => {
|
|
3821
|
+
options?.onSessionClosed?.(sessionId);
|
|
3822
|
+
this.disconnectAgentSession(sessionId);
|
|
3823
|
+
}
|
|
3824
|
+
};
|
|
3825
|
+
const post = async (req, res) => {
|
|
3826
|
+
const traceId = crypto.randomUUID();
|
|
3827
|
+
const authReq = req;
|
|
3828
|
+
if (getRuntimeAuth) {
|
|
3829
|
+
let bearerAuth;
|
|
3830
|
+
try {
|
|
3831
|
+
const runtimeAuth = await getRuntimeAuth(req);
|
|
3832
|
+
bearerAuth = runtimeAuth.bearerAuth;
|
|
3833
|
+
} catch (error) {
|
|
3834
|
+
this.respondMetadataInitError(res, error);
|
|
3835
|
+
return;
|
|
3836
|
+
}
|
|
3837
|
+
await this.runBearerAuth(bearerAuth, req, res);
|
|
3838
|
+
const sessionId2 = req.headers["mcp-session-id"];
|
|
3839
|
+
if (sessionId2) {
|
|
3840
|
+
if (res.headersSent) {
|
|
3841
|
+
this.reportEvent({
|
|
3842
|
+
eventType: "auth_error",
|
|
3843
|
+
traceId,
|
|
3844
|
+
sessionId: sessionId2,
|
|
3845
|
+
durationMs: 0,
|
|
3846
|
+
status: "error_auth"
|
|
3847
|
+
});
|
|
3848
|
+
return;
|
|
3849
|
+
}
|
|
3850
|
+
if (authReq.auth) {
|
|
3851
|
+
this.reportEvent({
|
|
3852
|
+
eventType: "auth_ok",
|
|
3853
|
+
traceId,
|
|
3854
|
+
ownerUserId: authReq.auth.extra?.sub,
|
|
3855
|
+
sessionId: sessionId2,
|
|
3856
|
+
durationMs: 0,
|
|
3857
|
+
status: "ok"
|
|
3858
|
+
});
|
|
3859
|
+
}
|
|
3860
|
+
} else if (res.headersSent) {
|
|
3861
|
+
return;
|
|
3862
|
+
}
|
|
3863
|
+
}
|
|
3864
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
3865
|
+
if (sessionId) {
|
|
3866
|
+
const transport2 = sessionManager.getTransport(sessionId);
|
|
3867
|
+
if (transport2) {
|
|
3868
|
+
sessionManager.touchSession(sessionId);
|
|
3869
|
+
await transport2.handleRequest(req, res, req.body);
|
|
3870
|
+
return;
|
|
3871
|
+
}
|
|
3872
|
+
}
|
|
3873
|
+
if (!isInitializeRequest(req.body)) {
|
|
3874
|
+
res.status(400).json({
|
|
3875
|
+
jsonrpc: "2.0",
|
|
3876
|
+
error: {
|
|
3877
|
+
code: -32e3,
|
|
3878
|
+
message: sessionId ? `Session ${sessionId} not found` : "No valid session ID provided"
|
|
3879
|
+
},
|
|
3880
|
+
id: null
|
|
3881
|
+
});
|
|
3882
|
+
return;
|
|
3883
|
+
}
|
|
3884
|
+
const authInfo = authReq.auth;
|
|
3885
|
+
const transport = new StreamableHTTPServerTransport({
|
|
3886
|
+
sessionIdGenerator: () => crypto.randomUUID(),
|
|
3887
|
+
onsessioninitialized: (sid) => {
|
|
3888
|
+
sessionManager.registerSession(
|
|
3889
|
+
sid,
|
|
3890
|
+
transport,
|
|
3891
|
+
callbacks,
|
|
3892
|
+
authInfo?.expiresAt
|
|
3893
|
+
);
|
|
3894
|
+
options?.onSessionInitialized?.(sid, authInfo, transport);
|
|
3895
|
+
this.reportEvent({
|
|
3896
|
+
eventType: "initialize",
|
|
3897
|
+
traceId,
|
|
3898
|
+
sessionId: sid,
|
|
3899
|
+
ownerUserId: authInfo?.extra?.sub,
|
|
3900
|
+
durationMs: 0,
|
|
3901
|
+
status: "ok"
|
|
3902
|
+
});
|
|
3903
|
+
this.createAgentSession(authInfo?.token, sid, {
|
|
3904
|
+
hostname: req.headers["x-forwarded-for"],
|
|
3905
|
+
userAgent: req.headers["user-agent"],
|
|
3906
|
+
tokenExpiresAt: authInfo?.expiresAt
|
|
3907
|
+
});
|
|
3908
|
+
}
|
|
3909
|
+
});
|
|
3910
|
+
const originalHandle = transport.handleRequest.bind(transport);
|
|
3911
|
+
transport.handleRequest = async (wrappedReq, wrappedRes, parsedBody) => {
|
|
3912
|
+
const reqTraceId = wrappedReq === req ? traceId : crypto.randomUUID();
|
|
3913
|
+
const sid = wrappedReq.headers["mcp-session-id"] ?? transport.sessionId;
|
|
3914
|
+
const start = Date.now();
|
|
3915
|
+
try {
|
|
3916
|
+
await originalHandle(wrappedReq, wrappedRes, parsedBody);
|
|
3917
|
+
if (parsedBody?.method === "tools/call") {
|
|
3918
|
+
this.reportEvent({
|
|
3919
|
+
eventType: "execute_tool",
|
|
3920
|
+
traceId: reqTraceId,
|
|
3921
|
+
toolName: parsedBody.params?.name,
|
|
3922
|
+
durationMs: Date.now() - start,
|
|
3923
|
+
sessionId: sid,
|
|
3924
|
+
ownerUserId: authInfo?.extra?.sub,
|
|
3925
|
+
status: "ok",
|
|
3926
|
+
requestJson: parsedBody.params
|
|
3927
|
+
});
|
|
3928
|
+
} else if (parsedBody?.method === "tools/list") {
|
|
3929
|
+
this.reportEvent({
|
|
3930
|
+
eventType: "search_tools",
|
|
3931
|
+
traceId: reqTraceId,
|
|
3932
|
+
durationMs: Date.now() - start,
|
|
3933
|
+
sessionId: sid,
|
|
3934
|
+
ownerUserId: authInfo?.extra?.sub,
|
|
3935
|
+
status: "ok"
|
|
3936
|
+
});
|
|
3937
|
+
}
|
|
3938
|
+
} catch (err) {
|
|
3939
|
+
if (parsedBody?.method === "tools/call") {
|
|
3940
|
+
this.reportEvent({
|
|
3941
|
+
eventType: "execute_tool",
|
|
3942
|
+
traceId: reqTraceId,
|
|
3943
|
+
toolName: parsedBody.params?.name,
|
|
3944
|
+
durationMs: Date.now() - start,
|
|
3945
|
+
sessionId: sid,
|
|
3946
|
+
ownerUserId: authInfo?.extra?.sub,
|
|
3947
|
+
status: "error_remote",
|
|
3948
|
+
errorMessage: err instanceof Error ? err.message : String(err)
|
|
3949
|
+
});
|
|
3950
|
+
} else if (parsedBody?.method === "tools/list") {
|
|
3951
|
+
this.reportEvent({
|
|
3952
|
+
eventType: "search_tools",
|
|
3953
|
+
traceId: reqTraceId,
|
|
3954
|
+
durationMs: Date.now() - start,
|
|
3955
|
+
sessionId: sid,
|
|
3956
|
+
ownerUserId: authInfo?.extra?.sub,
|
|
3957
|
+
status: "error_remote",
|
|
3958
|
+
errorMessage: err instanceof Error ? err.message : String(err)
|
|
3959
|
+
});
|
|
3960
|
+
}
|
|
3961
|
+
throw err;
|
|
3962
|
+
}
|
|
3963
|
+
};
|
|
3964
|
+
const mcpServer = typeof server === "function" ? server() : server;
|
|
3965
|
+
await mcpServer.connect(transport);
|
|
3966
|
+
await transport.handleRequest(req, res, req.body);
|
|
3967
|
+
};
|
|
3968
|
+
const get = async (req, res) => {
|
|
3969
|
+
if (getRuntimeAuth) {
|
|
3970
|
+
let bearerAuth;
|
|
3971
|
+
try {
|
|
3972
|
+
const runtimeAuth = await getRuntimeAuth(req);
|
|
3973
|
+
bearerAuth = runtimeAuth.bearerAuth;
|
|
3974
|
+
} catch (error) {
|
|
3975
|
+
this.respondMetadataInitError(res, error);
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3978
|
+
await this.runBearerAuth(bearerAuth, req, res);
|
|
3979
|
+
if (res.headersSent) {
|
|
3980
|
+
return;
|
|
3981
|
+
}
|
|
3982
|
+
}
|
|
3983
|
+
const sessionId = req.headers["mcp-session-id"] || req.headers["Mcp-Session-Id"];
|
|
3984
|
+
if (!sessionId) {
|
|
3985
|
+
res.status(400).json({ error: "Missing Mcp-Session-Id header" });
|
|
3986
|
+
return;
|
|
3987
|
+
}
|
|
3988
|
+
const transport = sessionManager.getTransport(sessionId);
|
|
3989
|
+
if (!transport) {
|
|
3990
|
+
res.status(400).json({ error: "Session not found" });
|
|
3991
|
+
return;
|
|
3992
|
+
}
|
|
3993
|
+
sessionManager.touchSession(sessionId);
|
|
3994
|
+
await transport.handleRequest(req, res);
|
|
3995
|
+
};
|
|
3996
|
+
const del = async (req, res) => {
|
|
3997
|
+
if (getRuntimeAuth) {
|
|
3998
|
+
let bearerAuth;
|
|
3999
|
+
try {
|
|
4000
|
+
const runtimeAuth = await getRuntimeAuth(req);
|
|
4001
|
+
bearerAuth = runtimeAuth.bearerAuth;
|
|
4002
|
+
} catch (error) {
|
|
4003
|
+
this.respondMetadataInitError(res, error);
|
|
4004
|
+
return;
|
|
4005
|
+
}
|
|
4006
|
+
await this.runBearerAuth(bearerAuth, req, res);
|
|
4007
|
+
if (res.headersSent) {
|
|
4008
|
+
return;
|
|
4009
|
+
}
|
|
4010
|
+
}
|
|
4011
|
+
const sessionId = req.headers["mcp-session-id"] || req.headers["Mcp-Session-Id"];
|
|
4012
|
+
if (!sessionId) {
|
|
4013
|
+
res.status(400).json({ error: "Missing Mcp-Session-Id header" });
|
|
4014
|
+
return;
|
|
4015
|
+
}
|
|
4016
|
+
const transport = sessionManager.getTransport(sessionId);
|
|
4017
|
+
if (!transport) {
|
|
4018
|
+
res.status(400).json({ error: "Session not found" });
|
|
4019
|
+
return;
|
|
4020
|
+
}
|
|
4021
|
+
await transport.handleRequest(req, res);
|
|
4022
|
+
};
|
|
4023
|
+
return { post, get, delete: del };
|
|
4024
|
+
}
|
|
4025
|
+
};
|
|
4026
|
+
|
|
4027
|
+
export { AuthorizationRequiredError, ConfigError, HttpError, IntegrationConnectionRequiredError, Kontext, KontextError, KontextTokenVerifier, NetworkError, OAuthError, createKontextClient, createKontextOrchestrator, isKontextError, isNetworkError, isUnauthorizedError, parseHttpError };
|
|
4028
|
+
//# sourceMappingURL=index.js.map
|
|
4029
|
+
//# sourceMappingURL=index.js.map
|