@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
|
@@ -0,0 +1,1634 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var crypto$1 = require('crypto');
|
|
4
|
+
var module$1 = require('module');
|
|
5
|
+
var streamableHttp_js = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
|
|
6
|
+
var types_js = require('@modelcontextprotocol/sdk/types.js');
|
|
7
|
+
var router_js = require('@modelcontextprotocol/sdk/server/auth/router.js');
|
|
8
|
+
var bearerAuth_js = require('@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js');
|
|
9
|
+
var errors_js = require('@modelcontextprotocol/sdk/server/auth/errors.js');
|
|
10
|
+
var jose = require('jose');
|
|
11
|
+
|
|
12
|
+
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
13
|
+
// src/server/kontext.ts
|
|
14
|
+
|
|
15
|
+
// src/management/types.ts
|
|
16
|
+
var TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange";
|
|
17
|
+
var TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token";
|
|
18
|
+
|
|
19
|
+
// src/errors.ts
|
|
20
|
+
var KontextError = class extends Error {
|
|
21
|
+
/** Brand field for type narrowing without instanceof */
|
|
22
|
+
kontextError = true;
|
|
23
|
+
/** Machine-readable error code, always prefixed with `kontext_` */
|
|
24
|
+
code;
|
|
25
|
+
/** HTTP status code when applicable */
|
|
26
|
+
statusCode;
|
|
27
|
+
/** Auto-generated link to error documentation */
|
|
28
|
+
docsUrl;
|
|
29
|
+
/** Server request ID for debugging / support escalation */
|
|
30
|
+
requestId;
|
|
31
|
+
/** Contextual metadata bag (integration IDs, param names, etc.) */
|
|
32
|
+
meta;
|
|
33
|
+
constructor(message, code, options) {
|
|
34
|
+
super(message, { cause: options?.cause });
|
|
35
|
+
this.name = "KontextError";
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.statusCode = options?.statusCode;
|
|
38
|
+
this.requestId = options?.requestId;
|
|
39
|
+
this.meta = options?.meta ?? {};
|
|
40
|
+
this.docsUrl = `https://docs.kontext.dev/errors/${code}`;
|
|
41
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
42
|
+
}
|
|
43
|
+
toJSON() {
|
|
44
|
+
return {
|
|
45
|
+
name: this.name,
|
|
46
|
+
code: this.code,
|
|
47
|
+
message: this.message,
|
|
48
|
+
statusCode: this.statusCode,
|
|
49
|
+
docsUrl: this.docsUrl,
|
|
50
|
+
requestId: this.requestId,
|
|
51
|
+
meta: Object.keys(this.meta).length > 0 ? this.meta : void 0
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
toString() {
|
|
55
|
+
const parts = [`[${this.code}] ${this.message}`];
|
|
56
|
+
if (this.docsUrl) parts.push(`Docs: ${this.docsUrl}`);
|
|
57
|
+
if (this.requestId) parts.push(`Request ID: ${this.requestId}`);
|
|
58
|
+
return parts.join("\n");
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
var OAuthError = class extends KontextError {
|
|
62
|
+
errorCode;
|
|
63
|
+
errorDescription;
|
|
64
|
+
constructor(message, code, options) {
|
|
65
|
+
super(message, code, {
|
|
66
|
+
statusCode: options?.statusCode ?? 400,
|
|
67
|
+
...options
|
|
68
|
+
});
|
|
69
|
+
this.name = "OAuthError";
|
|
70
|
+
this.errorCode = options?.errorCode;
|
|
71
|
+
this.errorDescription = options?.errorDescription;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
var IntegrationConnectionRequiredError = class extends KontextError {
|
|
75
|
+
integrationId;
|
|
76
|
+
integrationName;
|
|
77
|
+
connectUrl;
|
|
78
|
+
constructor(integrationId, options) {
|
|
79
|
+
super(
|
|
80
|
+
options?.message ?? `Connection to integration "${integrationId}" is required. Visit the connect URL to authorize.`,
|
|
81
|
+
"kontext_integration_connection_required",
|
|
82
|
+
{ statusCode: 403, ...options }
|
|
83
|
+
);
|
|
84
|
+
this.name = "IntegrationConnectionRequiredError";
|
|
85
|
+
this.integrationId = integrationId;
|
|
86
|
+
this.integrationName = options?.integrationName;
|
|
87
|
+
this.connectUrl = options?.connectUrl;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// src/oauth/token-exchange.ts
|
|
92
|
+
async function exchangeToken(config, subjectToken, resource, scope, subjectTokenType = TOKEN_TYPE_ACCESS_TOKEN) {
|
|
93
|
+
const body = new URLSearchParams();
|
|
94
|
+
body.set("grant_type", TOKEN_EXCHANGE_GRANT_TYPE);
|
|
95
|
+
body.set("subject_token", subjectToken);
|
|
96
|
+
body.set("subject_token_type", subjectTokenType);
|
|
97
|
+
body.set("resource", resource);
|
|
98
|
+
const headers = {
|
|
99
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
100
|
+
};
|
|
101
|
+
if (config.clientSecret) {
|
|
102
|
+
const credentials = Buffer.from(
|
|
103
|
+
`${config.clientId}:${config.clientSecret}`
|
|
104
|
+
).toString("base64");
|
|
105
|
+
headers["Authorization"] = `Basic ${credentials}`;
|
|
106
|
+
} else {
|
|
107
|
+
body.set("client_id", config.clientId);
|
|
108
|
+
}
|
|
109
|
+
const response = await fetch(config.tokenUrl, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers,
|
|
112
|
+
body: body.toString()
|
|
113
|
+
});
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
let errorMessage = `Token exchange failed: ${response.status} ${response.statusText}`;
|
|
116
|
+
let errorCode;
|
|
117
|
+
let integrationName;
|
|
118
|
+
let integrationId;
|
|
119
|
+
try {
|
|
120
|
+
const errorBody = await response.json();
|
|
121
|
+
errorCode = errorBody.error;
|
|
122
|
+
if (errorBody.error_description) {
|
|
123
|
+
errorMessage = errorBody.error_description;
|
|
124
|
+
} else if (errorBody.error) {
|
|
125
|
+
errorMessage = `Token exchange failed: ${errorBody.error}`;
|
|
126
|
+
}
|
|
127
|
+
if (errorBody.integration_name || errorBody.integration_id) {
|
|
128
|
+
integrationName = errorBody.integration_name;
|
|
129
|
+
integrationId = errorBody.integration_id;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
}
|
|
133
|
+
throw new OAuthError(errorMessage, "kontext_oauth_token_exchange_failed", {
|
|
134
|
+
errorCode,
|
|
135
|
+
meta: {
|
|
136
|
+
integrationName,
|
|
137
|
+
integrationId
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const tokenResponse = await response.json();
|
|
142
|
+
if (!tokenResponse.access_token) {
|
|
143
|
+
throw new OAuthError(
|
|
144
|
+
"Token exchange response missing access_token.",
|
|
145
|
+
"kontext_oauth_token_exchange_failed"
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (!tokenResponse.issued_token_type) {
|
|
149
|
+
throw new OAuthError(
|
|
150
|
+
"Token exchange response missing issued_token_type.",
|
|
151
|
+
"kontext_oauth_token_exchange_failed"
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (!tokenResponse.token_type) {
|
|
155
|
+
throw new OAuthError(
|
|
156
|
+
"Token exchange response missing token_type.",
|
|
157
|
+
"kontext_oauth_token_exchange_failed"
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return tokenResponse;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/verify/errors.ts
|
|
164
|
+
var TokenVerificationError = class _TokenVerificationError extends Error {
|
|
165
|
+
code;
|
|
166
|
+
constructor(code, message) {
|
|
167
|
+
super(message);
|
|
168
|
+
this.name = "TokenVerificationError";
|
|
169
|
+
this.code = code;
|
|
170
|
+
Object.setPrototypeOf(this, _TokenVerificationError.prototype);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// src/verify/jwks-client.ts
|
|
175
|
+
var DEFAULT_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
176
|
+
var DEFAULT_REFETCH_COOLDOWN_MS = 30 * 1e3;
|
|
177
|
+
var JwksClient = class {
|
|
178
|
+
jwksUrl;
|
|
179
|
+
cacheTtlMs;
|
|
180
|
+
refetchCooldownMs;
|
|
181
|
+
customFetch;
|
|
182
|
+
jwks = null;
|
|
183
|
+
lastFetchAt = 0;
|
|
184
|
+
lastRefreshAt = 0;
|
|
185
|
+
constructor(options) {
|
|
186
|
+
this.jwksUrl = new URL(options.jwksUrl);
|
|
187
|
+
this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
188
|
+
this.refetchCooldownMs = options.refetchCooldownMs ?? DEFAULT_REFETCH_COOLDOWN_MS;
|
|
189
|
+
this.customFetch = options.fetch;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Get the JWKS key resolver for use with jose's jwtVerify.
|
|
193
|
+
*
|
|
194
|
+
* Creates the remote JWKS on first call and caches it.
|
|
195
|
+
* The jose library handles internal caching and key lookup.
|
|
196
|
+
*/
|
|
197
|
+
getKeyResolver() {
|
|
198
|
+
const now = Date.now();
|
|
199
|
+
if (this.jwks && now - this.lastFetchAt > this.cacheTtlMs) {
|
|
200
|
+
this.jwks = null;
|
|
201
|
+
}
|
|
202
|
+
if (!this.jwks) {
|
|
203
|
+
this.jwks = jose.createRemoteJWKSet(this.jwksUrl, {
|
|
204
|
+
// jose handles caching internally, we just track our own refresh timing
|
|
205
|
+
...this.customFetch && {
|
|
206
|
+
[/* @__PURE__ */ Symbol.for("fetch")]: this.customFetch
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
this.lastFetchAt = now;
|
|
210
|
+
}
|
|
211
|
+
return this.jwks;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Force refresh the JWKS cache.
|
|
215
|
+
*
|
|
216
|
+
* Respects the refetch cooldown to prevent rapid refetching.
|
|
217
|
+
* Returns true if refresh was performed, false if cooldown not elapsed.
|
|
218
|
+
*/
|
|
219
|
+
refresh() {
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
if (!this.canRefresh()) {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
this.jwks = null;
|
|
225
|
+
this.lastRefreshAt = now;
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Check if a refresh is allowed (cooldown elapsed).
|
|
230
|
+
*/
|
|
231
|
+
canRefresh() {
|
|
232
|
+
return Date.now() - this.lastRefreshAt >= this.refetchCooldownMs;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Handle unknown kid errors by attempting refresh.
|
|
236
|
+
*
|
|
237
|
+
* @returns TokenVerificationError if refresh not allowed or already attempted
|
|
238
|
+
*/
|
|
239
|
+
handleUnknownKid(kid) {
|
|
240
|
+
if (this.refresh()) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
return new TokenVerificationError(
|
|
244
|
+
"UNKNOWN_KID",
|
|
245
|
+
`Unknown key ID: ${kid}. JWKS refresh on cooldown.`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Clear the cache, forcing a fresh fetch on next access.
|
|
250
|
+
*/
|
|
251
|
+
clearCache() {
|
|
252
|
+
this.jwks = null;
|
|
253
|
+
this.lastFetchAt = 0;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// src/verify/verifier.ts
|
|
258
|
+
var DEFAULT_CLOCK_TOLERANCE_SEC = 30;
|
|
259
|
+
var SUPPORTED_ALGORITHMS = ["ES256", "RS256"];
|
|
260
|
+
var KontextTokenVerifier = class {
|
|
261
|
+
config;
|
|
262
|
+
jwksClient;
|
|
263
|
+
audiences;
|
|
264
|
+
constructor(config) {
|
|
265
|
+
this.config = {
|
|
266
|
+
jwksUrl: config.jwksUrl,
|
|
267
|
+
issuer: config.issuer,
|
|
268
|
+
audience: config.audience,
|
|
269
|
+
requiredScopes: config.requiredScopes ?? [],
|
|
270
|
+
cacheTtlMs: config.cacheTtlMs ?? 5 * 60 * 1e3,
|
|
271
|
+
refetchCooldownMs: config.refetchCooldownMs ?? 30 * 1e3,
|
|
272
|
+
clockToleranceSec: config.clockToleranceSec ?? DEFAULT_CLOCK_TOLERANCE_SEC,
|
|
273
|
+
fetch: config.fetch
|
|
274
|
+
};
|
|
275
|
+
this.audiences = Array.isArray(config.audience) ? config.audience : [config.audience];
|
|
276
|
+
this.jwksClient = new JwksClient({
|
|
277
|
+
jwksUrl: config.jwksUrl,
|
|
278
|
+
cacheTtlMs: this.config.cacheTtlMs,
|
|
279
|
+
refetchCooldownMs: this.config.refetchCooldownMs,
|
|
280
|
+
fetch: config.fetch
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Verify a JWT token.
|
|
285
|
+
*
|
|
286
|
+
* @param token - The JWT token string (without "Bearer " prefix)
|
|
287
|
+
* @returns VerifyResult with success=true and claims, or success=false and error
|
|
288
|
+
*/
|
|
289
|
+
async verify(token) {
|
|
290
|
+
try {
|
|
291
|
+
return await this.verifyInternal(token, false);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (error instanceof TokenVerificationError) {
|
|
294
|
+
return { success: false, error };
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
success: false,
|
|
298
|
+
error: new TokenVerificationError(
|
|
299
|
+
"INVALID_TOKEN_FORMAT",
|
|
300
|
+
`Unexpected verification error: ${error.message}`
|
|
301
|
+
)
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Verify a JWT token and return claims or null.
|
|
307
|
+
* Simpler API for cases where you don't need error details.
|
|
308
|
+
*
|
|
309
|
+
* @param token - The JWT token string (without "Bearer " prefix)
|
|
310
|
+
* @returns VerifiedTokenClaims if valid, null if invalid
|
|
311
|
+
*/
|
|
312
|
+
async verifyOrNull(token) {
|
|
313
|
+
const result = await this.verify(token);
|
|
314
|
+
return result.success ? result.claims : null;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Clear the JWKS cache, forcing a fresh fetch on next verification.
|
|
318
|
+
*/
|
|
319
|
+
clearCache() {
|
|
320
|
+
this.jwksClient.clearCache();
|
|
321
|
+
}
|
|
322
|
+
async verifyInternal(token, isRetry) {
|
|
323
|
+
const JWKS = this.jwksClient.getKeyResolver();
|
|
324
|
+
try {
|
|
325
|
+
const { payload, protectedHeader } = await jose.jwtVerify(token, JWKS, {
|
|
326
|
+
issuer: this.config.issuer,
|
|
327
|
+
audience: this.audiences,
|
|
328
|
+
clockTolerance: this.config.clockToleranceSec,
|
|
329
|
+
algorithms: SUPPORTED_ALGORITHMS
|
|
330
|
+
});
|
|
331
|
+
const alg = protectedHeader.alg;
|
|
332
|
+
if (!SUPPORTED_ALGORITHMS.includes(alg)) {
|
|
333
|
+
throw new TokenVerificationError(
|
|
334
|
+
"UNSUPPORTED_ALGORITHM",
|
|
335
|
+
`Unsupported algorithm: ${alg}. Expected one of: ${SUPPORTED_ALGORITHMS.join(", ")}`
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
const jwtPayload = payload;
|
|
339
|
+
if (typeof jwtPayload.exp !== "number" || !Number.isFinite(jwtPayload.exp) || jwtPayload.exp <= 0) {
|
|
340
|
+
throw new TokenVerificationError(
|
|
341
|
+
"MISSING_CLAIMS",
|
|
342
|
+
"Token missing required exp claim"
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
const scopes = this.parseScopes(jwtPayload.scope);
|
|
346
|
+
for (const required of this.config.requiredScopes) {
|
|
347
|
+
if (!scopes.includes(required)) {
|
|
348
|
+
throw new TokenVerificationError(
|
|
349
|
+
"MISSING_SCOPE",
|
|
350
|
+
`Missing required scope: ${required}`
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const clientId = jwtPayload.client_id || jwtPayload.sub;
|
|
355
|
+
if (!clientId) {
|
|
356
|
+
throw new TokenVerificationError(
|
|
357
|
+
"MISSING_CLAIMS",
|
|
358
|
+
"Token missing client_id and sub claims"
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
const claims = {
|
|
362
|
+
sub: jwtPayload.sub || "",
|
|
363
|
+
clientId,
|
|
364
|
+
scopes,
|
|
365
|
+
expiresAt: new Date(jwtPayload.exp * 1e3),
|
|
366
|
+
jti: jwtPayload.jti,
|
|
367
|
+
payload: jwtPayload
|
|
368
|
+
};
|
|
369
|
+
return { success: true, claims };
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (error instanceof jose.errors.JWKSNoMatchingKey) {
|
|
372
|
+
if (!isRetry) {
|
|
373
|
+
const kid = this.extractKid(token);
|
|
374
|
+
const refreshError = this.jwksClient.handleUnknownKid(
|
|
375
|
+
kid || "unknown"
|
|
376
|
+
);
|
|
377
|
+
if (!refreshError) {
|
|
378
|
+
return this.verifyInternal(token, true);
|
|
379
|
+
}
|
|
380
|
+
return { success: false, error: refreshError };
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
success: false,
|
|
384
|
+
error: new TokenVerificationError(
|
|
385
|
+
"UNKNOWN_KID",
|
|
386
|
+
"No matching key found in JWKS"
|
|
387
|
+
)
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
if (error instanceof jose.errors.JWTExpired) {
|
|
391
|
+
return {
|
|
392
|
+
success: false,
|
|
393
|
+
error: new TokenVerificationError(
|
|
394
|
+
"TOKEN_EXPIRED",
|
|
395
|
+
"Token has expired"
|
|
396
|
+
)
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
if (error instanceof jose.errors.JWTClaimValidationFailed) {
|
|
400
|
+
const message = error.message;
|
|
401
|
+
if (message.includes("iss")) {
|
|
402
|
+
const expected = Array.isArray(this.config.issuer) ? this.config.issuer.join(" or ") : this.config.issuer;
|
|
403
|
+
return {
|
|
404
|
+
success: false,
|
|
405
|
+
error: new TokenVerificationError(
|
|
406
|
+
"INVALID_ISSUER",
|
|
407
|
+
`Invalid issuer: expected ${expected}`
|
|
408
|
+
)
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (message.includes("aud")) {
|
|
412
|
+
return {
|
|
413
|
+
success: false,
|
|
414
|
+
error: new TokenVerificationError(
|
|
415
|
+
"INVALID_AUDIENCE",
|
|
416
|
+
`Invalid audience: expected one of ${this.audiences.join(", ")}`
|
|
417
|
+
)
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
if (message.includes("nbf")) {
|
|
421
|
+
return {
|
|
422
|
+
success: false,
|
|
423
|
+
error: new TokenVerificationError(
|
|
424
|
+
"TOKEN_NOT_YET_VALID",
|
|
425
|
+
"Token is not yet valid (nbf claim)"
|
|
426
|
+
)
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (error instanceof jose.errors.JWSSignatureVerificationFailed) {
|
|
431
|
+
return {
|
|
432
|
+
success: false,
|
|
433
|
+
error: new TokenVerificationError(
|
|
434
|
+
"INVALID_SIGNATURE",
|
|
435
|
+
"Signature verification failed"
|
|
436
|
+
)
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
if (error instanceof jose.errors.JWSInvalid) {
|
|
440
|
+
return {
|
|
441
|
+
success: false,
|
|
442
|
+
error: new TokenVerificationError(
|
|
443
|
+
"INVALID_TOKEN_FORMAT",
|
|
444
|
+
`Invalid JWS: ${error.message}`
|
|
445
|
+
)
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
if (error instanceof TokenVerificationError) {
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
throw new TokenVerificationError(
|
|
452
|
+
"INVALID_TOKEN_FORMAT",
|
|
453
|
+
`Verification failed: ${error.message}`
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
parseScopes(scope) {
|
|
458
|
+
if (!scope) return [];
|
|
459
|
+
return scope.split(" ").map((s) => s.trim()).filter(Boolean);
|
|
460
|
+
}
|
|
461
|
+
extractKid(token) {
|
|
462
|
+
try {
|
|
463
|
+
const header = jose.decodeProtectedHeader(token);
|
|
464
|
+
return header.kid ?? null;
|
|
465
|
+
} catch {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// src/server/sessions.ts
|
|
472
|
+
var SessionManager = class _SessionManager {
|
|
473
|
+
transports = /* @__PURE__ */ new Map();
|
|
474
|
+
lastAccessed = /* @__PURE__ */ new Map();
|
|
475
|
+
expiresAt = /* @__PURE__ */ new Map();
|
|
476
|
+
cleanupInterval;
|
|
477
|
+
static STALE_TIMEOUT_MS = 60 * 60 * 1e3;
|
|
478
|
+
// 1 hour
|
|
479
|
+
static CLEANUP_INTERVAL_MS = 5 * 60 * 1e3;
|
|
480
|
+
// 5 minutes
|
|
481
|
+
constructor() {
|
|
482
|
+
this.cleanupInterval = setInterval(
|
|
483
|
+
() => this.cleanupStaleSessions(),
|
|
484
|
+
_SessionManager.CLEANUP_INTERVAL_MS
|
|
485
|
+
);
|
|
486
|
+
if (this.cleanupInterval.unref) {
|
|
487
|
+
this.cleanupInterval.unref();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
getTransport(sessionId) {
|
|
491
|
+
return this.transports.get(sessionId);
|
|
492
|
+
}
|
|
493
|
+
registerSession(sessionId, transport, callbacks, expiresAt) {
|
|
494
|
+
this.transports.set(sessionId, transport);
|
|
495
|
+
this.lastAccessed.set(sessionId, Date.now());
|
|
496
|
+
if (expiresAt !== void 0) {
|
|
497
|
+
this.expiresAt.set(sessionId, expiresAt);
|
|
498
|
+
}
|
|
499
|
+
transport.onclose = () => {
|
|
500
|
+
this.removeSession(sessionId);
|
|
501
|
+
callbacks?.onSessionClosed?.(sessionId);
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
touchSession(sessionId) {
|
|
505
|
+
if (this.transports.has(sessionId)) {
|
|
506
|
+
this.lastAccessed.set(sessionId, Date.now());
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
removeSession(sessionId) {
|
|
510
|
+
this.transports.delete(sessionId);
|
|
511
|
+
this.lastAccessed.delete(sessionId);
|
|
512
|
+
this.expiresAt.delete(sessionId);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Check if a session's token has expired.
|
|
516
|
+
* Returns true if the token's `expiresAt` has passed.
|
|
517
|
+
*/
|
|
518
|
+
isSessionExpired(sessionId) {
|
|
519
|
+
const exp = this.expiresAt.get(sessionId);
|
|
520
|
+
return exp !== void 0 && Date.now() / 1e3 >= exp;
|
|
521
|
+
}
|
|
522
|
+
cleanupStaleSessions() {
|
|
523
|
+
const now = Date.now();
|
|
524
|
+
for (const [sid, lastTime] of this.lastAccessed.entries()) {
|
|
525
|
+
if (now - lastTime > _SessionManager.STALE_TIMEOUT_MS) {
|
|
526
|
+
const transport = this.transports.get(sid);
|
|
527
|
+
if (transport) {
|
|
528
|
+
void transport.close?.();
|
|
529
|
+
}
|
|
530
|
+
this.removeSession(sid);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
destroy() {
|
|
535
|
+
clearInterval(this.cleanupInterval);
|
|
536
|
+
for (const [sid, transport] of this.transports.entries()) {
|
|
537
|
+
void transport.close?.();
|
|
538
|
+
this.removeSession(sid);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
// src/server/kontext.ts
|
|
544
|
+
var DEFAULT_API_URL = "https://api.kontext.dev";
|
|
545
|
+
var METADATA_CACHE_TTL_MS = 60 * 60 * 1e3;
|
|
546
|
+
var CREDENTIAL_CACHE_MAX_ENTRIES = 500;
|
|
547
|
+
var RUNTIME_AUTH_CACHE_MAX_ENTRIES = 8;
|
|
548
|
+
var RESOLVED_CREDENTIAL_CACHE_TTL_MS = 30 * 1e3;
|
|
549
|
+
var SDK_VERSION = (() => {
|
|
550
|
+
try {
|
|
551
|
+
const esmRequire = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
|
|
552
|
+
const pkg = esmRequire("../../package.json");
|
|
553
|
+
return pkg.version ?? "unknown";
|
|
554
|
+
} catch {
|
|
555
|
+
return "unknown";
|
|
556
|
+
}
|
|
557
|
+
})();
|
|
558
|
+
var Kontext = class _Kontext {
|
|
559
|
+
static shutdownInstances = /* @__PURE__ */ new Set();
|
|
560
|
+
static shutdownHandlersRegistered = false;
|
|
561
|
+
clientId;
|
|
562
|
+
clientSecret;
|
|
563
|
+
apiUrl;
|
|
564
|
+
tokenIssuers;
|
|
565
|
+
// AS metadata: fetched lazily, cached with TTL
|
|
566
|
+
oauthMetadata = null;
|
|
567
|
+
metadataFetchedAt = 0;
|
|
568
|
+
metadataPromise = null;
|
|
569
|
+
// Token exchange caching: keyed by `${integration}\0${subjectToken}`
|
|
570
|
+
credentialCache = /* @__PURE__ */ new Map();
|
|
571
|
+
resolvedCredentialCache = /* @__PURE__ */ new Map();
|
|
572
|
+
runtimeAuthCache = /* @__PURE__ */ new Map();
|
|
573
|
+
runtimeVerifierIds = /* @__PURE__ */ new WeakMap();
|
|
574
|
+
runtimeVerifierIdCounter = 0;
|
|
575
|
+
// Telemetry: cached service token for event reporting
|
|
576
|
+
serviceToken = null;
|
|
577
|
+
serviceTokenExp = 0;
|
|
578
|
+
serviceTokenPromise = null;
|
|
579
|
+
// Session tracking: MCP sessionId → API agentSessionId
|
|
580
|
+
agentSessionIds = /* @__PURE__ */ new Map();
|
|
581
|
+
pendingSessionDisconnects = /* @__PURE__ */ new Set();
|
|
582
|
+
constructor(options) {
|
|
583
|
+
this.clientId = options.clientId;
|
|
584
|
+
this.clientSecret = options.clientSecret ?? process.env.KONTEXT_CLIENT_SECRET;
|
|
585
|
+
this.apiUrl = (options.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
|
|
586
|
+
const rawTokenIssuers = Array.isArray(options.tokenIssuer) ? options.tokenIssuer : options.tokenIssuer ? options.tokenIssuer.split(",") : process.env.KONTEXT_TOKEN_ISSUER?.split(",");
|
|
587
|
+
this.tokenIssuers = Array.from(
|
|
588
|
+
new Set(rawTokenIssuers?.map((issuer) => issuer.trim()).filter(Boolean))
|
|
589
|
+
);
|
|
590
|
+
_Kontext.shutdownInstances.add(this);
|
|
591
|
+
_Kontext.ensureShutdownHandlers();
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Cleanup method for runtimes that create/dispose SDK instances dynamically.
|
|
595
|
+
* Ensures this instance can be garbage-collected by removing static references.
|
|
596
|
+
*/
|
|
597
|
+
async destroy() {
|
|
598
|
+
await this.disconnectAllSessions();
|
|
599
|
+
_Kontext.shutdownInstances.delete(this);
|
|
600
|
+
this.credentialCache.clear();
|
|
601
|
+
this.resolvedCredentialCache.clear();
|
|
602
|
+
this.oauthMetadata = null;
|
|
603
|
+
this.metadataFetchedAt = 0;
|
|
604
|
+
this.metadataPromise = null;
|
|
605
|
+
this.serviceToken = null;
|
|
606
|
+
this.serviceTokenExp = 0;
|
|
607
|
+
this.serviceTokenPromise = null;
|
|
608
|
+
this.agentSessionIds.clear();
|
|
609
|
+
this.pendingSessionDisconnects.clear();
|
|
610
|
+
}
|
|
611
|
+
static ensureShutdownHandlers() {
|
|
612
|
+
if (_Kontext.shutdownHandlersRegistered) return;
|
|
613
|
+
const onShutdown = () => {
|
|
614
|
+
for (const instance of _Kontext.shutdownInstances) {
|
|
615
|
+
void instance.disconnectAllSessions();
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
process.once("SIGINT", onShutdown);
|
|
619
|
+
process.once("SIGTERM", onShutdown);
|
|
620
|
+
_Kontext.shutdownHandlersRegistered = true;
|
|
621
|
+
}
|
|
622
|
+
// ===========================================================================
|
|
623
|
+
// middleware()
|
|
624
|
+
// ===========================================================================
|
|
625
|
+
/**
|
|
626
|
+
* Express middleware: `.well-known` metadata + bearer auth + MCP transport + sessions.
|
|
627
|
+
*
|
|
628
|
+
* Must be mounted at the app root (not a sub-path) because RFC 9728 requires
|
|
629
|
+
* `/.well-known/oauth-protected-resource` at the root. Use `mcpPath` to set
|
|
630
|
+
* the transport endpoint path.
|
|
631
|
+
*
|
|
632
|
+
* @param server - An `McpServer` instance for single-session use, or a
|
|
633
|
+
* `() => McpServer` factory for concurrent sessions (recommended in production).
|
|
634
|
+
* `McpServer.connect()` is 1:1 per the MCP spec — passing a factory ensures
|
|
635
|
+
* each session gets its own instance.
|
|
636
|
+
*
|
|
637
|
+
* @example Factory pattern (recommended for concurrent sessions)
|
|
638
|
+
* ```typescript
|
|
639
|
+
* app.use(kontext.middleware(() => createServer()));
|
|
640
|
+
* ```
|
|
641
|
+
*
|
|
642
|
+
* @example Single instance (local dev / single session)
|
|
643
|
+
* ```typescript
|
|
644
|
+
* app.use(kontext.middleware(server));
|
|
645
|
+
* ```
|
|
646
|
+
*
|
|
647
|
+
* @example Custom path
|
|
648
|
+
* ```typescript
|
|
649
|
+
* app.use(kontext.middleware(createServer, { mcpPath: "/api/mcp" }));
|
|
650
|
+
* ```
|
|
651
|
+
*/
|
|
652
|
+
middleware(server, options) {
|
|
653
|
+
const esmRequire = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
|
|
654
|
+
const express = esmRequire("express");
|
|
655
|
+
const router = express.Router();
|
|
656
|
+
const mcpPath = options?.mcpPath ?? "/mcp";
|
|
657
|
+
const sessionManager = new SessionManager();
|
|
658
|
+
const omitAuth = options?.dangerouslyOmitAuth ?? false;
|
|
659
|
+
router.use((_req, res, next) => {
|
|
660
|
+
res.header("Access-Control-Allow-Origin", "*");
|
|
661
|
+
res.header(
|
|
662
|
+
"Access-Control-Allow-Headers",
|
|
663
|
+
"Content-Type, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Accept"
|
|
664
|
+
);
|
|
665
|
+
res.header("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
666
|
+
res.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
667
|
+
if (_req.method === "OPTIONS") {
|
|
668
|
+
res.sendStatus(204);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
next();
|
|
672
|
+
});
|
|
673
|
+
if (omitAuth) {
|
|
674
|
+
console.warn(
|
|
675
|
+
"[kontext] \u26A0\uFE0F Auth is disabled (dangerouslyOmitAuth). Do NOT use in production."
|
|
676
|
+
);
|
|
677
|
+
router.use(mcpPath, express.json({ limit: options?.bodyLimit ?? "1mb" }));
|
|
678
|
+
const mcpHandler2 = this.createMcpHandler(
|
|
679
|
+
server,
|
|
680
|
+
sessionManager,
|
|
681
|
+
null,
|
|
682
|
+
options
|
|
683
|
+
);
|
|
684
|
+
router.post(mcpPath, mcpHandler2.post);
|
|
685
|
+
router.get(mcpPath, mcpHandler2.get);
|
|
686
|
+
router.delete(mcpPath, mcpHandler2.delete);
|
|
687
|
+
return router;
|
|
688
|
+
}
|
|
689
|
+
const getRuntimeAuth = async (req) => {
|
|
690
|
+
const metadata = this.applyMetadataTransform(
|
|
691
|
+
await this.getOAuthMetadata(),
|
|
692
|
+
options?.metadataTransform
|
|
693
|
+
);
|
|
694
|
+
const rsUrl = this.resolveResourceServerUrl(req, mcpPath, options);
|
|
695
|
+
return this.getOrCreateRuntimeAuthContext(
|
|
696
|
+
metadata,
|
|
697
|
+
rsUrl,
|
|
698
|
+
options?.verifier
|
|
699
|
+
);
|
|
700
|
+
};
|
|
701
|
+
router.use(async (req, res, next) => {
|
|
702
|
+
const path = req.path || req.url || "";
|
|
703
|
+
const isMetadataRequest = path.startsWith("/.well-known/oauth-authorization-server") || path.startsWith("/.well-known/oauth-protected-resource");
|
|
704
|
+
if (!isMetadataRequest) {
|
|
705
|
+
next();
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
const runtimeAuth = await getRuntimeAuth(req);
|
|
710
|
+
runtimeAuth.metadataRouter(req, res, next);
|
|
711
|
+
} catch (error) {
|
|
712
|
+
this.respondMetadataInitError(res, error);
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
router.use(mcpPath, express.json({ limit: options?.bodyLimit ?? "1mb" }));
|
|
716
|
+
const mcpHandler = this.createMcpHandler(
|
|
717
|
+
server,
|
|
718
|
+
sessionManager,
|
|
719
|
+
getRuntimeAuth,
|
|
720
|
+
options
|
|
721
|
+
);
|
|
722
|
+
router.post(mcpPath, mcpHandler.post);
|
|
723
|
+
router.get(mcpPath, mcpHandler.get);
|
|
724
|
+
router.delete(mcpPath, mcpHandler.delete);
|
|
725
|
+
return router;
|
|
726
|
+
}
|
|
727
|
+
// ===========================================================================
|
|
728
|
+
// require()
|
|
729
|
+
// ===========================================================================
|
|
730
|
+
/**
|
|
731
|
+
* Exchange a user's access token for an integration credential.
|
|
732
|
+
*
|
|
733
|
+
* @param integration - Integration name (e.g., "github")
|
|
734
|
+
* @param token - The user's Bearer token (from `authInfo.token`)
|
|
735
|
+
* @returns Integration credential with `accessToken` and `authorization` header
|
|
736
|
+
*
|
|
737
|
+
* @throws {IntegrationConnectionRequiredError} User hasn't connected this integration
|
|
738
|
+
* @throws {OAuthError} Token exchange failed
|
|
739
|
+
*/
|
|
740
|
+
async require(integration, token) {
|
|
741
|
+
const now = Date.now();
|
|
742
|
+
this.evictExpiredCredentials(now);
|
|
743
|
+
const cacheKey = `${integration}\0${token}`;
|
|
744
|
+
const cached = this.credentialCache.get(cacheKey);
|
|
745
|
+
if (cached && now < cached.expiresAt) {
|
|
746
|
+
this.credentialCache.delete(cacheKey);
|
|
747
|
+
this.credentialCache.set(cacheKey, cached);
|
|
748
|
+
return cached.credential;
|
|
749
|
+
}
|
|
750
|
+
if (cached) {
|
|
751
|
+
this.credentialCache.delete(cacheKey);
|
|
752
|
+
}
|
|
753
|
+
const exchangeConfig = {
|
|
754
|
+
tokenUrl: `${this.apiUrl}/oauth2/token`,
|
|
755
|
+
clientId: this.clientId,
|
|
756
|
+
clientSecret: this.clientSecret
|
|
757
|
+
};
|
|
758
|
+
let response;
|
|
759
|
+
try {
|
|
760
|
+
response = await exchangeToken(exchangeConfig, token, integration);
|
|
761
|
+
} catch (err) {
|
|
762
|
+
if (err instanceof OAuthError) {
|
|
763
|
+
if (err.errorCode === "integration_required" || err.message.includes("not connected") || err.message.includes("expired") && err.message.includes("reconnect")) {
|
|
764
|
+
const integrationId = err.meta.integrationId || integration;
|
|
765
|
+
const connectUrl = await this.fetchConnectUrl(
|
|
766
|
+
token,
|
|
767
|
+
integrationId,
|
|
768
|
+
exchangeConfig
|
|
769
|
+
);
|
|
770
|
+
throw new IntegrationConnectionRequiredError(integrationId, {
|
|
771
|
+
integrationName: err.meta.integrationName,
|
|
772
|
+
connectUrl,
|
|
773
|
+
message: err.message
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
throw err;
|
|
778
|
+
}
|
|
779
|
+
const credential = {
|
|
780
|
+
accessToken: response.access_token,
|
|
781
|
+
tokenType: response.token_type,
|
|
782
|
+
authorization: `${response.token_type} ${response.access_token}`,
|
|
783
|
+
expiresIn: response.expires_in,
|
|
784
|
+
scope: response.scope,
|
|
785
|
+
integration
|
|
786
|
+
};
|
|
787
|
+
if (response.expires_in) {
|
|
788
|
+
const ttlMs = Math.min(response.expires_in - 60, 5 * 60) * 1e3;
|
|
789
|
+
if (ttlMs > 0) {
|
|
790
|
+
this.trimCacheToFit(this.credentialCache, CREDENTIAL_CACHE_MAX_ENTRIES);
|
|
791
|
+
this.credentialCache.set(cacheKey, {
|
|
792
|
+
credential,
|
|
793
|
+
expiresAt: now + ttlMs
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return credential;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Resolve per-user credential key/value pairs for an internal MCP integration.
|
|
801
|
+
*
|
|
802
|
+
* @param integration - Integration UUID or name
|
|
803
|
+
* @param token - The user's Bearer token (from `authInfo.token`)
|
|
804
|
+
* @returns Decrypted credential map for the current user and integration
|
|
805
|
+
*
|
|
806
|
+
* @throws {IntegrationConnectionRequiredError} User has not provided required credentials
|
|
807
|
+
* @throws {OAuthError} Runtime credential resolution failed
|
|
808
|
+
*/
|
|
809
|
+
async requireCredentials(integration, token) {
|
|
810
|
+
const now = Date.now();
|
|
811
|
+
this.evictExpiredResolvedCredentials(now);
|
|
812
|
+
const cacheKey = `${integration}\0${token}\0internal_credentials`;
|
|
813
|
+
const cached = this.resolvedCredentialCache.get(cacheKey);
|
|
814
|
+
if (cached && now < cached.expiresAt) {
|
|
815
|
+
this.resolvedCredentialCache.delete(cacheKey);
|
|
816
|
+
this.resolvedCredentialCache.set(cacheKey, cached);
|
|
817
|
+
return cached.credential;
|
|
818
|
+
}
|
|
819
|
+
if (cached) {
|
|
820
|
+
this.resolvedCredentialCache.delete(cacheKey);
|
|
821
|
+
}
|
|
822
|
+
const exchangeConfig = {
|
|
823
|
+
tokenUrl: `${this.apiUrl}/oauth2/token`,
|
|
824
|
+
clientId: this.clientId,
|
|
825
|
+
clientSecret: this.clientSecret
|
|
826
|
+
};
|
|
827
|
+
let gatewayAccessToken = token;
|
|
828
|
+
if (!this.isGatewayScopedToken(token)) {
|
|
829
|
+
try {
|
|
830
|
+
const exchanged = await exchangeToken(
|
|
831
|
+
exchangeConfig,
|
|
832
|
+
token,
|
|
833
|
+
"mcp-gateway"
|
|
834
|
+
);
|
|
835
|
+
gatewayAccessToken = exchanged.access_token;
|
|
836
|
+
} catch (err) {
|
|
837
|
+
throw new OAuthError(
|
|
838
|
+
"Failed to exchange subject token for runtime",
|
|
839
|
+
"kontext_credentials_exchange_failed",
|
|
840
|
+
{
|
|
841
|
+
errorCode: "credentials_exchange_failed",
|
|
842
|
+
errorDescription: err instanceof Error ? err.message : String(err ?? "unknown error")
|
|
843
|
+
}
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
const integrationId = await this.resolveRuntimeIntegrationId(
|
|
848
|
+
integration,
|
|
849
|
+
gatewayAccessToken
|
|
850
|
+
);
|
|
851
|
+
const res = await fetch(
|
|
852
|
+
`${this.apiUrl}/mcp/integrations/${integrationId}/credentials/resolve`,
|
|
853
|
+
{
|
|
854
|
+
method: "POST",
|
|
855
|
+
headers: {
|
|
856
|
+
Authorization: `Bearer ${gatewayAccessToken}`,
|
|
857
|
+
"Content-Type": "application/json"
|
|
858
|
+
},
|
|
859
|
+
body: "{}"
|
|
860
|
+
}
|
|
861
|
+
);
|
|
862
|
+
if (!res.ok) {
|
|
863
|
+
const text = await res.text().catch(() => "");
|
|
864
|
+
const message = text && text.trim().length > 0 ? text : `HTTP ${res.status} while resolving credentials`;
|
|
865
|
+
if (res.status === 400 && message.toLowerCase().includes("credentials required")) {
|
|
866
|
+
throw new IntegrationConnectionRequiredError(integrationId, {
|
|
867
|
+
integrationName: String(integration),
|
|
868
|
+
message
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
throw new OAuthError(
|
|
872
|
+
`Failed to resolve credentials for integration ${integrationId}`,
|
|
873
|
+
"kontext_credentials_resolve_failed",
|
|
874
|
+
{
|
|
875
|
+
errorCode: "credentials_resolve_failed",
|
|
876
|
+
errorDescription: message
|
|
877
|
+
}
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
const payload = await res.json();
|
|
881
|
+
if (!payload.credentials || typeof payload.credentials !== "object" || Array.isArray(payload.credentials)) {
|
|
882
|
+
throw new OAuthError(
|
|
883
|
+
"Credential resolve returned invalid payload",
|
|
884
|
+
"kontext_credentials_invalid_payload"
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
const credentials = {};
|
|
888
|
+
for (const [key, value] of Object.entries(payload.credentials)) {
|
|
889
|
+
if (typeof value === "string") {
|
|
890
|
+
credentials[key] = value;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
if (Object.keys(credentials).length === 0) {
|
|
894
|
+
throw new IntegrationConnectionRequiredError(integrationId, {
|
|
895
|
+
integrationName: String(integration),
|
|
896
|
+
message: "No credentials configured for this integration"
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
const resolved = {
|
|
900
|
+
integration,
|
|
901
|
+
integrationId: payload.integrationId ?? integrationId,
|
|
902
|
+
credentials
|
|
903
|
+
};
|
|
904
|
+
this.trimCacheToFit(
|
|
905
|
+
this.resolvedCredentialCache,
|
|
906
|
+
CREDENTIAL_CACHE_MAX_ENTRIES
|
|
907
|
+
);
|
|
908
|
+
this.resolvedCredentialCache.set(cacheKey, {
|
|
909
|
+
credential: resolved,
|
|
910
|
+
expiresAt: now + RESOLVED_CREDENTIAL_CACHE_TTL_MS
|
|
911
|
+
});
|
|
912
|
+
return resolved;
|
|
913
|
+
}
|
|
914
|
+
getGatewayAudiences() {
|
|
915
|
+
return /* @__PURE__ */ new Set([`${new URL(this.apiUrl).origin}/mcp`, "mcp-gateway"]);
|
|
916
|
+
}
|
|
917
|
+
isGatewayScopedToken(token) {
|
|
918
|
+
const audiences = this.extractTokenAudiences(token);
|
|
919
|
+
if (audiences.length === 0) {
|
|
920
|
+
return false;
|
|
921
|
+
}
|
|
922
|
+
const gatewayAudiences = this.getGatewayAudiences();
|
|
923
|
+
return audiences.some((audience) => gatewayAudiences.has(audience));
|
|
924
|
+
}
|
|
925
|
+
extractTokenAudiences(token) {
|
|
926
|
+
const [, payloadPart] = token.split(".");
|
|
927
|
+
if (!payloadPart) return [];
|
|
928
|
+
try {
|
|
929
|
+
const payload = JSON.parse(
|
|
930
|
+
Buffer.from(payloadPart, "base64url").toString("utf8")
|
|
931
|
+
);
|
|
932
|
+
if (typeof payload.aud === "string") {
|
|
933
|
+
return [payload.aud];
|
|
934
|
+
}
|
|
935
|
+
if (Array.isArray(payload.aud)) {
|
|
936
|
+
return payload.aud.filter(
|
|
937
|
+
(value) => typeof value === "string"
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
} catch {
|
|
941
|
+
}
|
|
942
|
+
return [];
|
|
943
|
+
}
|
|
944
|
+
// ===========================================================================
|
|
945
|
+
// Private: fetch connect URL (spec §2 — two-step init)
|
|
946
|
+
// ===========================================================================
|
|
947
|
+
async resolveRuntimeIntegrationId(integration, runtimeToken) {
|
|
948
|
+
const raw = String(integration);
|
|
949
|
+
if (this.isUuid(raw)) {
|
|
950
|
+
return raw;
|
|
951
|
+
}
|
|
952
|
+
const res = await fetch(`${this.apiUrl}/mcp/integrations`, {
|
|
953
|
+
headers: {
|
|
954
|
+
Authorization: `Bearer ${runtimeToken}`
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
if (!res.ok) {
|
|
958
|
+
const text = await res.text().catch(() => "");
|
|
959
|
+
throw new OAuthError(
|
|
960
|
+
"Failed to resolve integration identifier",
|
|
961
|
+
"kontext_integration_lookup_failed",
|
|
962
|
+
{
|
|
963
|
+
errorCode: "integration_lookup_failed",
|
|
964
|
+
errorDescription: text || `HTTP ${res.status}`
|
|
965
|
+
}
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
const payload = await res.json();
|
|
969
|
+
const items = Array.isArray(payload.items) ? payload.items : [];
|
|
970
|
+
const match = items.find((item) => item.id === raw || item.name === raw);
|
|
971
|
+
const integrationId = match?.id;
|
|
972
|
+
if (!integrationId) {
|
|
973
|
+
throw new IntegrationConnectionRequiredError(raw, {
|
|
974
|
+
integrationName: raw,
|
|
975
|
+
message: `Integration ${raw} is not attached to this application`
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
return integrationId;
|
|
979
|
+
}
|
|
980
|
+
isUuid(value) {
|
|
981
|
+
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(
|
|
982
|
+
value
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Fetch a browser-openable connect URL for a missing integration.
|
|
987
|
+
*
|
|
988
|
+
* Per the integration-interrupt-flow spec, the SDK:
|
|
989
|
+
* 1. Exchanges the user's token for a resource-scoped mcp-gateway JWT
|
|
990
|
+
* 2. Calls POST /mcp/integrations/:id/oauth/init with that JWT
|
|
991
|
+
* 3. Returns the `connectUrl` (intermediate endpoint with one-time token)
|
|
992
|
+
*
|
|
993
|
+
* The connect URL points to our own server (ticket pattern), which
|
|
994
|
+
* validates the ticket, sets a browser session cookie, then redirects
|
|
995
|
+
* to the actual OAuth provider.
|
|
996
|
+
*/
|
|
997
|
+
async fetchConnectUrl(subjectToken, integrationId, exchangeConfig) {
|
|
998
|
+
try {
|
|
999
|
+
const gatewayToken = await exchangeToken(
|
|
1000
|
+
exchangeConfig,
|
|
1001
|
+
subjectToken,
|
|
1002
|
+
"mcp-gateway"
|
|
1003
|
+
);
|
|
1004
|
+
const initUrl = `${this.apiUrl}/mcp/integrations/${integrationId}/oauth/init`;
|
|
1005
|
+
const res = await fetch(initUrl, {
|
|
1006
|
+
method: "POST",
|
|
1007
|
+
headers: {
|
|
1008
|
+
Authorization: `Bearer ${gatewayToken.access_token}`,
|
|
1009
|
+
"Content-Type": "application/json"
|
|
1010
|
+
},
|
|
1011
|
+
body: JSON.stringify({})
|
|
1012
|
+
});
|
|
1013
|
+
if (!res.ok) {
|
|
1014
|
+
const text = await res.text().catch(() => "");
|
|
1015
|
+
console.warn(
|
|
1016
|
+
`[kontext] fetchConnectUrl: init endpoint returned ${res.status}: ${text}`
|
|
1017
|
+
);
|
|
1018
|
+
return void 0;
|
|
1019
|
+
}
|
|
1020
|
+
const data = await res.json();
|
|
1021
|
+
return data.connectUrl ?? data.authorizationUrl;
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
console.warn(
|
|
1024
|
+
`[kontext] fetchConnectUrl failed:`,
|
|
1025
|
+
err instanceof Error ? err.message : String(err)
|
|
1026
|
+
);
|
|
1027
|
+
return void 0;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
// ===========================================================================
|
|
1031
|
+
// Private: AS metadata
|
|
1032
|
+
// ===========================================================================
|
|
1033
|
+
async getOAuthMetadata() {
|
|
1034
|
+
const now = Date.now();
|
|
1035
|
+
if (this.oauthMetadata && now - this.metadataFetchedAt < METADATA_CACHE_TTL_MS) {
|
|
1036
|
+
return this.oauthMetadata;
|
|
1037
|
+
}
|
|
1038
|
+
if (this.metadataPromise) {
|
|
1039
|
+
return this.metadataPromise;
|
|
1040
|
+
}
|
|
1041
|
+
this.metadataPromise = this.fetchOAuthMetadata();
|
|
1042
|
+
try {
|
|
1043
|
+
const metadata = await this.metadataPromise;
|
|
1044
|
+
this.oauthMetadata = metadata;
|
|
1045
|
+
this.metadataFetchedAt = Date.now();
|
|
1046
|
+
return metadata;
|
|
1047
|
+
} finally {
|
|
1048
|
+
this.metadataPromise = null;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
applyMetadataTransform(metadata, metadataTransform) {
|
|
1052
|
+
if (!metadataTransform) {
|
|
1053
|
+
return metadata;
|
|
1054
|
+
}
|
|
1055
|
+
return metadataTransform(this.cloneOAuthMetadata(metadata));
|
|
1056
|
+
}
|
|
1057
|
+
cloneOAuthMetadata(metadata) {
|
|
1058
|
+
return JSON.parse(JSON.stringify(metadata));
|
|
1059
|
+
}
|
|
1060
|
+
async fetchOAuthMetadata() {
|
|
1061
|
+
const urls = [
|
|
1062
|
+
`${this.apiUrl}/.well-known/oauth-authorization-server`,
|
|
1063
|
+
`${this.apiUrl}/.well-known/openid-configuration`
|
|
1064
|
+
];
|
|
1065
|
+
let lastError;
|
|
1066
|
+
for (const url of urls) {
|
|
1067
|
+
try {
|
|
1068
|
+
const res = await fetch(url);
|
|
1069
|
+
if (res.ok) {
|
|
1070
|
+
return await res.json();
|
|
1071
|
+
}
|
|
1072
|
+
} catch (err) {
|
|
1073
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
throw new Error(
|
|
1077
|
+
`Failed to fetch AS metadata from ${this.apiUrl}: ${lastError?.message ?? "unknown error"}`
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
resolveResourceServerUrl(req, mcpPath, options) {
|
|
1081
|
+
if (options?.resourceServerUrl) {
|
|
1082
|
+
return new URL(options.resourceServerUrl);
|
|
1083
|
+
}
|
|
1084
|
+
const host = req.get("host");
|
|
1085
|
+
if (!host) {
|
|
1086
|
+
throw new Error(
|
|
1087
|
+
"Missing Host header. Set middleware({ resourceServerUrl }) to a trusted public URL."
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
return new URL(`${req.protocol}://${host}${mcpPath}`);
|
|
1091
|
+
}
|
|
1092
|
+
getOrCreateRuntimeAuthContext(metadata, rsUrl, customVerifier) {
|
|
1093
|
+
const key = this.getRuntimeAuthCacheKey(rsUrl, customVerifier);
|
|
1094
|
+
const cached = this.runtimeAuthCache.get(key);
|
|
1095
|
+
if (cached) {
|
|
1096
|
+
this.runtimeAuthCache.delete(key);
|
|
1097
|
+
this.runtimeAuthCache.set(key, cached);
|
|
1098
|
+
return cached;
|
|
1099
|
+
}
|
|
1100
|
+
const proxiedMetadata = { ...metadata, issuer: `${rsUrl.origin}/` };
|
|
1101
|
+
const metadataRouter = router_js.mcpAuthMetadataRouter({
|
|
1102
|
+
oauthMetadata: proxiedMetadata,
|
|
1103
|
+
resourceServerUrl: rsUrl
|
|
1104
|
+
});
|
|
1105
|
+
const resourceMetadataUrl = router_js.getOAuthProtectedResourceMetadataUrl(rsUrl);
|
|
1106
|
+
const verifier = customVerifier ?? this.createTokenVerifier(metadata, rsUrl);
|
|
1107
|
+
const runtimeAuth = {
|
|
1108
|
+
metadataRouter,
|
|
1109
|
+
bearerAuth: bearerAuth_js.requireBearerAuth({
|
|
1110
|
+
verifier,
|
|
1111
|
+
resourceMetadataUrl
|
|
1112
|
+
})
|
|
1113
|
+
};
|
|
1114
|
+
this.trimCacheToFit(this.runtimeAuthCache, RUNTIME_AUTH_CACHE_MAX_ENTRIES);
|
|
1115
|
+
this.runtimeAuthCache.set(key, runtimeAuth);
|
|
1116
|
+
return runtimeAuth;
|
|
1117
|
+
}
|
|
1118
|
+
getRuntimeAuthCacheKey(rsUrl, customVerifier) {
|
|
1119
|
+
if (!customVerifier) {
|
|
1120
|
+
return `${rsUrl.href}\0default`;
|
|
1121
|
+
}
|
|
1122
|
+
let verifierId = this.runtimeVerifierIds.get(customVerifier);
|
|
1123
|
+
if (verifierId === void 0) {
|
|
1124
|
+
verifierId = ++this.runtimeVerifierIdCounter;
|
|
1125
|
+
this.runtimeVerifierIds.set(customVerifier, verifierId);
|
|
1126
|
+
}
|
|
1127
|
+
return `${rsUrl.href}\0custom:${verifierId}`;
|
|
1128
|
+
}
|
|
1129
|
+
respondMetadataInitError(res, error) {
|
|
1130
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1131
|
+
console.error(`[kontext] Failed to fetch AS metadata: ${message}`);
|
|
1132
|
+
if (res.headersSent) return;
|
|
1133
|
+
res.status(503).json({
|
|
1134
|
+
error: "service_unavailable",
|
|
1135
|
+
error_description: "Failed to fetch authorization server metadata. Retry later."
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
evictExpiredCredentials(now) {
|
|
1139
|
+
for (const [key, value] of this.credentialCache.entries()) {
|
|
1140
|
+
if (value.expiresAt <= now) {
|
|
1141
|
+
this.credentialCache.delete(key);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
evictExpiredResolvedCredentials(now) {
|
|
1146
|
+
for (const [key, value] of this.resolvedCredentialCache.entries()) {
|
|
1147
|
+
if (value.expiresAt <= now) {
|
|
1148
|
+
this.resolvedCredentialCache.delete(key);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
trimCacheToFit(cache, maxEntries) {
|
|
1153
|
+
while (cache.size >= maxEntries) {
|
|
1154
|
+
const oldestKey = cache.keys().next().value;
|
|
1155
|
+
if (!oldestKey) break;
|
|
1156
|
+
cache.delete(oldestKey);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
// ===========================================================================
|
|
1160
|
+
// Private: token verifier
|
|
1161
|
+
// ===========================================================================
|
|
1162
|
+
createTokenVerifier(metadata, resourceUrl) {
|
|
1163
|
+
const metadataRaw = metadata;
|
|
1164
|
+
const jwksUri = metadataRaw.jwks_uri ?? `${this.apiUrl}/.well-known/jwks.json`;
|
|
1165
|
+
const clientId = this.clientId;
|
|
1166
|
+
const issuers = Array.from(
|
|
1167
|
+
new Set(
|
|
1168
|
+
[metadata.issuer, ...this.tokenIssuers].filter(
|
|
1169
|
+
(issuer2) => typeof issuer2 === "string" && !!issuer2
|
|
1170
|
+
)
|
|
1171
|
+
)
|
|
1172
|
+
);
|
|
1173
|
+
if (!issuers.length) {
|
|
1174
|
+
throw new Error("OAuth metadata missing issuer");
|
|
1175
|
+
}
|
|
1176
|
+
const issuer = issuers.length === 1 ? issuers[0] : issuers;
|
|
1177
|
+
const verifier = new KontextTokenVerifier({
|
|
1178
|
+
jwksUrl: jwksUri,
|
|
1179
|
+
issuer,
|
|
1180
|
+
audience: resourceUrl.href
|
|
1181
|
+
});
|
|
1182
|
+
return {
|
|
1183
|
+
async verifyAccessToken(token) {
|
|
1184
|
+
const result = await verifier.verify(token);
|
|
1185
|
+
if (!result.success) {
|
|
1186
|
+
throw new errors_js.InvalidTokenError(
|
|
1187
|
+
`Token verification failed: ${result.error.message}`
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
const { claims } = result;
|
|
1191
|
+
const payload = claims.payload;
|
|
1192
|
+
const ext = payload.ext ?? {};
|
|
1193
|
+
return {
|
|
1194
|
+
token,
|
|
1195
|
+
clientId: claims.clientId ?? clientId,
|
|
1196
|
+
scopes: claims.scopes,
|
|
1197
|
+
expiresAt: Math.floor(claims.expiresAt.getTime() / 1e3),
|
|
1198
|
+
extra: {
|
|
1199
|
+
...ext,
|
|
1200
|
+
sub: claims.sub,
|
|
1201
|
+
email: payload.email ?? ext.email
|
|
1202
|
+
}
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
// ===========================================================================
|
|
1208
|
+
// Private: telemetry
|
|
1209
|
+
// ===========================================================================
|
|
1210
|
+
async getServiceToken() {
|
|
1211
|
+
if (this.serviceToken && Date.now() < this.serviceTokenExp - 3e4) {
|
|
1212
|
+
return this.serviceToken;
|
|
1213
|
+
}
|
|
1214
|
+
if (this.serviceTokenPromise) {
|
|
1215
|
+
return this.serviceTokenPromise;
|
|
1216
|
+
}
|
|
1217
|
+
this.serviceTokenPromise = (async () => {
|
|
1218
|
+
const res = await fetch(`${this.apiUrl}/oauth2/token`, {
|
|
1219
|
+
method: "POST",
|
|
1220
|
+
headers: {
|
|
1221
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1222
|
+
Authorization: `Basic ${Buffer.from(this.clientId + ":" + this.clientSecret).toString("base64")}`
|
|
1223
|
+
},
|
|
1224
|
+
body: "grant_type=client_credentials"
|
|
1225
|
+
});
|
|
1226
|
+
if (!res.ok) {
|
|
1227
|
+
const text = await res.text().catch(() => "");
|
|
1228
|
+
throw new Error(
|
|
1229
|
+
`[kontext:telemetry] client_credentials grant failed: HTTP ${res.status} ${text}`
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
const data = await res.json();
|
|
1233
|
+
this.serviceToken = data.access_token;
|
|
1234
|
+
this.serviceTokenExp = Date.now() + data.expires_in * 1e3;
|
|
1235
|
+
return data.access_token;
|
|
1236
|
+
})();
|
|
1237
|
+
try {
|
|
1238
|
+
return await this.serviceTokenPromise;
|
|
1239
|
+
} finally {
|
|
1240
|
+
this.serviceTokenPromise = null;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
reportEvent(event) {
|
|
1244
|
+
if (!this.clientSecret || !event.sessionId) return;
|
|
1245
|
+
this.getServiceToken().then(
|
|
1246
|
+
(token) => fetch(`${this.apiUrl}/api/v1/mcp-events`, {
|
|
1247
|
+
method: "POST",
|
|
1248
|
+
headers: {
|
|
1249
|
+
"Content-Type": "application/json",
|
|
1250
|
+
Authorization: `Bearer ${token}`
|
|
1251
|
+
},
|
|
1252
|
+
body: JSON.stringify({
|
|
1253
|
+
...event,
|
|
1254
|
+
agentId: this.clientId,
|
|
1255
|
+
clientId: this.clientId,
|
|
1256
|
+
clientVersion: SDK_VERSION
|
|
1257
|
+
})
|
|
1258
|
+
}).then((res) => {
|
|
1259
|
+
if (!res.ok) {
|
|
1260
|
+
console.warn(
|
|
1261
|
+
`[kontext:telemetry] event report failed: HTTP ${res.status}`
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
})
|
|
1265
|
+
).catch((err) => {
|
|
1266
|
+
console.warn(
|
|
1267
|
+
`[kontext:telemetry] error:`,
|
|
1268
|
+
err instanceof Error ? err.message : String(err)
|
|
1269
|
+
);
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
// ===========================================================================
|
|
1273
|
+
// Private: session lifecycle
|
|
1274
|
+
// ===========================================================================
|
|
1275
|
+
createAgentSession(userToken, mcpSessionId, metadata) {
|
|
1276
|
+
if (!this.clientSecret || !userToken) return;
|
|
1277
|
+
const tokenIdentifier = crypto$1.createHash("sha256").update(userToken).digest("hex");
|
|
1278
|
+
this.getServiceToken().then(
|
|
1279
|
+
(token) => fetch(`${this.apiUrl}/api/v1/agent-sessions`, {
|
|
1280
|
+
method: "POST",
|
|
1281
|
+
headers: {
|
|
1282
|
+
"Content-Type": "application/json",
|
|
1283
|
+
Authorization: `Bearer ${token}`
|
|
1284
|
+
},
|
|
1285
|
+
body: JSON.stringify({
|
|
1286
|
+
tokenIdentifier,
|
|
1287
|
+
hostname: metadata?.hostname,
|
|
1288
|
+
userAgent: metadata?.userAgent,
|
|
1289
|
+
clientInfo: metadata?.clientInfo,
|
|
1290
|
+
tokenExpiresAt: metadata?.tokenExpiresAt ? new Date(metadata.tokenExpiresAt * 1e3).toISOString() : void 0
|
|
1291
|
+
})
|
|
1292
|
+
}).then(async (res) => {
|
|
1293
|
+
if (res.ok) {
|
|
1294
|
+
const data = await res.json();
|
|
1295
|
+
if (this.pendingSessionDisconnects.delete(mcpSessionId)) {
|
|
1296
|
+
this.disconnectAgentSessionByAgentSessionId(
|
|
1297
|
+
data.sessionId,
|
|
1298
|
+
token
|
|
1299
|
+
);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
this.agentSessionIds.set(mcpSessionId, data.sessionId);
|
|
1303
|
+
} else {
|
|
1304
|
+
this.pendingSessionDisconnects.delete(mcpSessionId);
|
|
1305
|
+
console.warn(
|
|
1306
|
+
`[kontext:sessions] create failed: HTTP ${res.status}`
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
})
|
|
1310
|
+
).catch((err) => {
|
|
1311
|
+
this.pendingSessionDisconnects.delete(mcpSessionId);
|
|
1312
|
+
console.warn(
|
|
1313
|
+
`[kontext:sessions] error:`,
|
|
1314
|
+
err instanceof Error ? err.message : String(err)
|
|
1315
|
+
);
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
disconnectAgentSessionByAgentSessionId(agentSessionId, serviceToken) {
|
|
1319
|
+
if (!this.clientSecret) return;
|
|
1320
|
+
const tokenPromise = serviceToken ? Promise.resolve(serviceToken) : this.getServiceToken();
|
|
1321
|
+
tokenPromise.then(
|
|
1322
|
+
(token) => fetch(
|
|
1323
|
+
`${this.apiUrl}/api/v1/agent-sessions/${agentSessionId}/disconnect`,
|
|
1324
|
+
{
|
|
1325
|
+
method: "POST",
|
|
1326
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
1327
|
+
}
|
|
1328
|
+
)
|
|
1329
|
+
).catch(() => {
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
disconnectAgentSession(mcpSessionId) {
|
|
1333
|
+
if (!this.clientSecret) return;
|
|
1334
|
+
const agentSessionId = this.agentSessionIds.get(mcpSessionId);
|
|
1335
|
+
this.agentSessionIds.delete(mcpSessionId);
|
|
1336
|
+
if (!agentSessionId) {
|
|
1337
|
+
this.pendingSessionDisconnects.add(mcpSessionId);
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
this.pendingSessionDisconnects.delete(mcpSessionId);
|
|
1341
|
+
this.disconnectAgentSessionByAgentSessionId(agentSessionId);
|
|
1342
|
+
}
|
|
1343
|
+
async disconnectAllSessions() {
|
|
1344
|
+
if (!this.clientSecret) return;
|
|
1345
|
+
if (this.agentSessionIds.size === 0) {
|
|
1346
|
+
this.pendingSessionDisconnects.clear();
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
try {
|
|
1350
|
+
const token = await this.getServiceToken();
|
|
1351
|
+
await Promise.allSettled(
|
|
1352
|
+
[...this.agentSessionIds.values()].map(
|
|
1353
|
+
(agentSessionId) => fetch(
|
|
1354
|
+
`${this.apiUrl}/api/v1/agent-sessions/${agentSessionId}/disconnect`,
|
|
1355
|
+
{
|
|
1356
|
+
method: "POST",
|
|
1357
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
1358
|
+
}
|
|
1359
|
+
)
|
|
1360
|
+
)
|
|
1361
|
+
);
|
|
1362
|
+
} catch {
|
|
1363
|
+
}
|
|
1364
|
+
this.agentSessionIds.clear();
|
|
1365
|
+
this.pendingSessionDisconnects.clear();
|
|
1366
|
+
}
|
|
1367
|
+
// ===========================================================================
|
|
1368
|
+
// Private: MCP transport handlers
|
|
1369
|
+
// ===========================================================================
|
|
1370
|
+
async runBearerAuth(bearerAuth, req, res) {
|
|
1371
|
+
await new Promise((resolve, reject) => {
|
|
1372
|
+
let settled = false;
|
|
1373
|
+
let nextCalled = false;
|
|
1374
|
+
const cleanup = () => {
|
|
1375
|
+
res.removeListener("finish", onResponseDone);
|
|
1376
|
+
res.removeListener("close", onResponseDone);
|
|
1377
|
+
};
|
|
1378
|
+
const settleResolve = () => {
|
|
1379
|
+
if (settled) return;
|
|
1380
|
+
settled = true;
|
|
1381
|
+
cleanup();
|
|
1382
|
+
resolve();
|
|
1383
|
+
};
|
|
1384
|
+
const settleReject = (err) => {
|
|
1385
|
+
if (settled) return;
|
|
1386
|
+
settled = true;
|
|
1387
|
+
cleanup();
|
|
1388
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
1389
|
+
};
|
|
1390
|
+
const onResponseDone = () => {
|
|
1391
|
+
settleResolve();
|
|
1392
|
+
};
|
|
1393
|
+
res.once("finish", onResponseDone);
|
|
1394
|
+
res.once("close", onResponseDone);
|
|
1395
|
+
let middlewareResult;
|
|
1396
|
+
try {
|
|
1397
|
+
middlewareResult = bearerAuth(req, res, (err) => {
|
|
1398
|
+
nextCalled = true;
|
|
1399
|
+
if (err) {
|
|
1400
|
+
settleReject(err);
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
settleResolve();
|
|
1404
|
+
});
|
|
1405
|
+
} catch (err) {
|
|
1406
|
+
settleReject(err);
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
void Promise.resolve(middlewareResult).then(
|
|
1410
|
+
() => {
|
|
1411
|
+
if (!nextCalled && res.headersSent) {
|
|
1412
|
+
settleResolve();
|
|
1413
|
+
}
|
|
1414
|
+
},
|
|
1415
|
+
(err) => {
|
|
1416
|
+
settleReject(err);
|
|
1417
|
+
}
|
|
1418
|
+
);
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
createMcpHandler(server, sessionManager, getRuntimeAuth, options) {
|
|
1422
|
+
const callbacks = {
|
|
1423
|
+
onSessionClosed: (sessionId) => {
|
|
1424
|
+
options?.onSessionClosed?.(sessionId);
|
|
1425
|
+
this.disconnectAgentSession(sessionId);
|
|
1426
|
+
}
|
|
1427
|
+
};
|
|
1428
|
+
const post = async (req, res) => {
|
|
1429
|
+
const traceId = crypto.randomUUID();
|
|
1430
|
+
const authReq = req;
|
|
1431
|
+
if (getRuntimeAuth) {
|
|
1432
|
+
let bearerAuth;
|
|
1433
|
+
try {
|
|
1434
|
+
const runtimeAuth = await getRuntimeAuth(req);
|
|
1435
|
+
bearerAuth = runtimeAuth.bearerAuth;
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
this.respondMetadataInitError(res, error);
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
await this.runBearerAuth(bearerAuth, req, res);
|
|
1441
|
+
const sessionId2 = req.headers["mcp-session-id"];
|
|
1442
|
+
if (sessionId2) {
|
|
1443
|
+
if (res.headersSent) {
|
|
1444
|
+
this.reportEvent({
|
|
1445
|
+
eventType: "auth_error",
|
|
1446
|
+
traceId,
|
|
1447
|
+
sessionId: sessionId2,
|
|
1448
|
+
durationMs: 0,
|
|
1449
|
+
status: "error_auth"
|
|
1450
|
+
});
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
if (authReq.auth) {
|
|
1454
|
+
this.reportEvent({
|
|
1455
|
+
eventType: "auth_ok",
|
|
1456
|
+
traceId,
|
|
1457
|
+
ownerUserId: authReq.auth.extra?.sub,
|
|
1458
|
+
sessionId: sessionId2,
|
|
1459
|
+
durationMs: 0,
|
|
1460
|
+
status: "ok"
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
} else if (res.headersSent) {
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
1468
|
+
if (sessionId) {
|
|
1469
|
+
const transport2 = sessionManager.getTransport(sessionId);
|
|
1470
|
+
if (transport2) {
|
|
1471
|
+
sessionManager.touchSession(sessionId);
|
|
1472
|
+
await transport2.handleRequest(req, res, req.body);
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
if (!types_js.isInitializeRequest(req.body)) {
|
|
1477
|
+
res.status(400).json({
|
|
1478
|
+
jsonrpc: "2.0",
|
|
1479
|
+
error: {
|
|
1480
|
+
code: -32e3,
|
|
1481
|
+
message: sessionId ? `Session ${sessionId} not found` : "No valid session ID provided"
|
|
1482
|
+
},
|
|
1483
|
+
id: null
|
|
1484
|
+
});
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
const authInfo = authReq.auth;
|
|
1488
|
+
const transport = new streamableHttp_js.StreamableHTTPServerTransport({
|
|
1489
|
+
sessionIdGenerator: () => crypto.randomUUID(),
|
|
1490
|
+
onsessioninitialized: (sid) => {
|
|
1491
|
+
sessionManager.registerSession(
|
|
1492
|
+
sid,
|
|
1493
|
+
transport,
|
|
1494
|
+
callbacks,
|
|
1495
|
+
authInfo?.expiresAt
|
|
1496
|
+
);
|
|
1497
|
+
options?.onSessionInitialized?.(sid, authInfo, transport);
|
|
1498
|
+
this.reportEvent({
|
|
1499
|
+
eventType: "initialize",
|
|
1500
|
+
traceId,
|
|
1501
|
+
sessionId: sid,
|
|
1502
|
+
ownerUserId: authInfo?.extra?.sub,
|
|
1503
|
+
durationMs: 0,
|
|
1504
|
+
status: "ok"
|
|
1505
|
+
});
|
|
1506
|
+
this.createAgentSession(authInfo?.token, sid, {
|
|
1507
|
+
hostname: req.headers["x-forwarded-for"],
|
|
1508
|
+
userAgent: req.headers["user-agent"],
|
|
1509
|
+
tokenExpiresAt: authInfo?.expiresAt
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
});
|
|
1513
|
+
const originalHandle = transport.handleRequest.bind(transport);
|
|
1514
|
+
transport.handleRequest = async (wrappedReq, wrappedRes, parsedBody) => {
|
|
1515
|
+
const reqTraceId = wrappedReq === req ? traceId : crypto.randomUUID();
|
|
1516
|
+
const sid = wrappedReq.headers["mcp-session-id"] ?? transport.sessionId;
|
|
1517
|
+
const start = Date.now();
|
|
1518
|
+
try {
|
|
1519
|
+
await originalHandle(wrappedReq, wrappedRes, parsedBody);
|
|
1520
|
+
if (parsedBody?.method === "tools/call") {
|
|
1521
|
+
this.reportEvent({
|
|
1522
|
+
eventType: "execute_tool",
|
|
1523
|
+
traceId: reqTraceId,
|
|
1524
|
+
toolName: parsedBody.params?.name,
|
|
1525
|
+
durationMs: Date.now() - start,
|
|
1526
|
+
sessionId: sid,
|
|
1527
|
+
ownerUserId: authInfo?.extra?.sub,
|
|
1528
|
+
status: "ok",
|
|
1529
|
+
requestJson: parsedBody.params
|
|
1530
|
+
});
|
|
1531
|
+
} else if (parsedBody?.method === "tools/list") {
|
|
1532
|
+
this.reportEvent({
|
|
1533
|
+
eventType: "search_tools",
|
|
1534
|
+
traceId: reqTraceId,
|
|
1535
|
+
durationMs: Date.now() - start,
|
|
1536
|
+
sessionId: sid,
|
|
1537
|
+
ownerUserId: authInfo?.extra?.sub,
|
|
1538
|
+
status: "ok"
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
} catch (err) {
|
|
1542
|
+
if (parsedBody?.method === "tools/call") {
|
|
1543
|
+
this.reportEvent({
|
|
1544
|
+
eventType: "execute_tool",
|
|
1545
|
+
traceId: reqTraceId,
|
|
1546
|
+
toolName: parsedBody.params?.name,
|
|
1547
|
+
durationMs: Date.now() - start,
|
|
1548
|
+
sessionId: sid,
|
|
1549
|
+
ownerUserId: authInfo?.extra?.sub,
|
|
1550
|
+
status: "error_remote",
|
|
1551
|
+
errorMessage: err instanceof Error ? err.message : String(err)
|
|
1552
|
+
});
|
|
1553
|
+
} else if (parsedBody?.method === "tools/list") {
|
|
1554
|
+
this.reportEvent({
|
|
1555
|
+
eventType: "search_tools",
|
|
1556
|
+
traceId: reqTraceId,
|
|
1557
|
+
durationMs: Date.now() - start,
|
|
1558
|
+
sessionId: sid,
|
|
1559
|
+
ownerUserId: authInfo?.extra?.sub,
|
|
1560
|
+
status: "error_remote",
|
|
1561
|
+
errorMessage: err instanceof Error ? err.message : String(err)
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
throw err;
|
|
1565
|
+
}
|
|
1566
|
+
};
|
|
1567
|
+
const mcpServer = typeof server === "function" ? server() : server;
|
|
1568
|
+
await mcpServer.connect(transport);
|
|
1569
|
+
await transport.handleRequest(req, res, req.body);
|
|
1570
|
+
};
|
|
1571
|
+
const get = async (req, res) => {
|
|
1572
|
+
if (getRuntimeAuth) {
|
|
1573
|
+
let bearerAuth;
|
|
1574
|
+
try {
|
|
1575
|
+
const runtimeAuth = await getRuntimeAuth(req);
|
|
1576
|
+
bearerAuth = runtimeAuth.bearerAuth;
|
|
1577
|
+
} catch (error) {
|
|
1578
|
+
this.respondMetadataInitError(res, error);
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
await this.runBearerAuth(bearerAuth, req, res);
|
|
1582
|
+
if (res.headersSent) {
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
const sessionId = req.headers["mcp-session-id"] || req.headers["Mcp-Session-Id"];
|
|
1587
|
+
if (!sessionId) {
|
|
1588
|
+
res.status(400).json({ error: "Missing Mcp-Session-Id header" });
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
const transport = sessionManager.getTransport(sessionId);
|
|
1592
|
+
if (!transport) {
|
|
1593
|
+
res.status(400).json({ error: "Session not found" });
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
sessionManager.touchSession(sessionId);
|
|
1597
|
+
await transport.handleRequest(req, res);
|
|
1598
|
+
};
|
|
1599
|
+
const del = async (req, res) => {
|
|
1600
|
+
if (getRuntimeAuth) {
|
|
1601
|
+
let bearerAuth;
|
|
1602
|
+
try {
|
|
1603
|
+
const runtimeAuth = await getRuntimeAuth(req);
|
|
1604
|
+
bearerAuth = runtimeAuth.bearerAuth;
|
|
1605
|
+
} catch (error) {
|
|
1606
|
+
this.respondMetadataInitError(res, error);
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
await this.runBearerAuth(bearerAuth, req, res);
|
|
1610
|
+
if (res.headersSent) {
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
const sessionId = req.headers["mcp-session-id"] || req.headers["Mcp-Session-Id"];
|
|
1615
|
+
if (!sessionId) {
|
|
1616
|
+
res.status(400).json({ error: "Missing Mcp-Session-Id header" });
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
const transport = sessionManager.getTransport(sessionId);
|
|
1620
|
+
if (!transport) {
|
|
1621
|
+
res.status(400).json({ error: "Session not found" });
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
await transport.handleRequest(req, res);
|
|
1625
|
+
};
|
|
1626
|
+
return { post, get, delete: del };
|
|
1627
|
+
}
|
|
1628
|
+
};
|
|
1629
|
+
|
|
1630
|
+
exports.IntegrationConnectionRequiredError = IntegrationConnectionRequiredError;
|
|
1631
|
+
exports.Kontext = Kontext;
|
|
1632
|
+
exports.KontextTokenVerifier = KontextTokenVerifier;
|
|
1633
|
+
//# sourceMappingURL=index.cjs.map
|
|
1634
|
+
//# sourceMappingURL=index.cjs.map
|