@mcpstack/agent-sdk 1.0.0-pr.16.7572c080abaa.13.1
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/LICENSE +21 -0
- package/README.md +254 -0
- package/dist/app/index.d.mts +969 -0
- package/dist/app/index.d.ts +969 -0
- package/dist/app/index.js +4025 -0
- package/dist/app/index.js.map +1 -0
- package/dist/app/index.mjs +3977 -0
- package/dist/app/index.mjs.map +1 -0
- package/dist/index.d.mts +639 -0
- package/dist/index.d.ts +639 -0
- package/dist/index.js +2649 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2620 -0
- package/dist/index.mjs.map +1 -0
- package/dist/react/index.d.mts +982 -0
- package/dist/react/index.d.ts +982 -0
- package/dist/react/index.js +4722 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/index.mjs +4700 -0
- package/dist/react/index.mjs.map +1 -0
- package/dist/react-embed/index.d.mts +361 -0
- package/dist/react-embed/index.d.ts +361 -0
- package/dist/react-embed/index.js +6500 -0
- package/dist/react-embed/index.js.map +1 -0
- package/dist/react-embed/index.mjs +6454 -0
- package/dist/react-embed/index.mjs.map +1 -0
- package/dist/react-native/index.d.mts +982 -0
- package/dist/react-native/index.d.ts +982 -0
- package/dist/react-native/index.js +4075 -0
- package/dist/react-native/index.js.map +1 -0
- package/dist/react-native/index.mjs +4053 -0
- package/dist/react-native/index.mjs.map +1 -0
- package/package.json +96 -0
|
@@ -0,0 +1,3977 @@
|
|
|
1
|
+
// src/core/sse-client.ts
|
|
2
|
+
function* parseEventBlocks(input) {
|
|
3
|
+
const normalized = input.replace(/\r\n/g, "\n");
|
|
4
|
+
const eventBlocks = normalized.split("\n\n");
|
|
5
|
+
for (const eventBlock of eventBlocks) {
|
|
6
|
+
if (!eventBlock.trim()) continue;
|
|
7
|
+
let eventType = "";
|
|
8
|
+
const dataLines = [];
|
|
9
|
+
for (const line of eventBlock.split("\n")) {
|
|
10
|
+
if (line.startsWith("event: ")) {
|
|
11
|
+
eventType = line.slice("event: ".length);
|
|
12
|
+
} else if (line.startsWith("data: ")) {
|
|
13
|
+
dataLines.push(line.slice("data: ".length));
|
|
14
|
+
} else if (line === "data:") {
|
|
15
|
+
dataLines.push("");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (!eventType || dataLines.length === 0) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(dataLines.join("\n"));
|
|
23
|
+
yield { type: eventType, data: parsed };
|
|
24
|
+
} catch {
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function* parseSseStream(response, signal) {
|
|
29
|
+
const reader = response.body?.getReader();
|
|
30
|
+
if (!reader) {
|
|
31
|
+
const text = await response.text().catch(() => "");
|
|
32
|
+
yield* parseEventBlocks(text);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const decoder = new TextDecoder();
|
|
36
|
+
let buffer = "";
|
|
37
|
+
try {
|
|
38
|
+
while (true) {
|
|
39
|
+
if (signal?.aborted) break;
|
|
40
|
+
const { done, value } = await reader.read();
|
|
41
|
+
if (done) break;
|
|
42
|
+
buffer += decoder.decode(value, { stream: true });
|
|
43
|
+
const normalized = buffer.replace(/\r\n/g, "\n");
|
|
44
|
+
const events = normalized.split("\n\n");
|
|
45
|
+
buffer = events.pop() ?? "";
|
|
46
|
+
for (const eventBlock of events) {
|
|
47
|
+
yield* parseEventBlocks(eventBlock);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (buffer.trim()) {
|
|
51
|
+
yield* parseEventBlocks(buffer);
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
reader.releaseLock();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/core/auth/registration.ts
|
|
59
|
+
var REGISTRATION_STORAGE_PREFIX = "mcpstack_oauth_registration_";
|
|
60
|
+
function getStorage(storage) {
|
|
61
|
+
if (storage) {
|
|
62
|
+
return storage;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return typeof localStorage === "undefined" ? null : localStorage;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function encodeCacheKey(raw) {
|
|
71
|
+
return btoa(raw).replace(/[^a-zA-Z0-9]/g, "");
|
|
72
|
+
}
|
|
73
|
+
function getPreferredRegistrationPreference(authConfig) {
|
|
74
|
+
return authConfig?.registrationPreference ?? "auto";
|
|
75
|
+
}
|
|
76
|
+
function getEffectiveCallbackUrl(authConfig, fallbackCallbackUrl) {
|
|
77
|
+
const configuredCallbackUrl = authConfig?.callbackUrl?.trim();
|
|
78
|
+
if (!configuredCallbackUrl) {
|
|
79
|
+
return fallbackCallbackUrl;
|
|
80
|
+
}
|
|
81
|
+
if (isNativeCallbackUrl(fallbackCallbackUrl) && !isNativeCallbackUrl(configuredCallbackUrl)) {
|
|
82
|
+
return fallbackCallbackUrl;
|
|
83
|
+
}
|
|
84
|
+
return configuredCallbackUrl;
|
|
85
|
+
}
|
|
86
|
+
function buildRegistrationCacheKey(authConfig, callbackUrl, requestedMode) {
|
|
87
|
+
const raw = [
|
|
88
|
+
authConfig.authorizationServerUrl ?? "",
|
|
89
|
+
authConfig.authorizationServerMetadataUrl ?? "",
|
|
90
|
+
authConfig.resource ?? "",
|
|
91
|
+
callbackUrl,
|
|
92
|
+
requestedMode
|
|
93
|
+
].join("|");
|
|
94
|
+
return encodeCacheKey(raw);
|
|
95
|
+
}
|
|
96
|
+
function buildTokenCacheKey(authConfig, mcpServerUrl) {
|
|
97
|
+
if (!authConfig) {
|
|
98
|
+
return encodeCacheKey(`legacy|${mcpServerUrl}`);
|
|
99
|
+
}
|
|
100
|
+
const callbackUrl = authConfig.callbackUrl ?? "";
|
|
101
|
+
const clientMode = authConfig.clientMode ?? "manual";
|
|
102
|
+
const raw = [
|
|
103
|
+
authConfig.authorizationServerUrl ?? mcpServerUrl,
|
|
104
|
+
authConfig.resource ?? "",
|
|
105
|
+
callbackUrl,
|
|
106
|
+
clientMode
|
|
107
|
+
].join("|");
|
|
108
|
+
return encodeCacheKey(raw);
|
|
109
|
+
}
|
|
110
|
+
function loadStoredRegistration(cacheKey, storage) {
|
|
111
|
+
const targetStorage = getStorage(storage);
|
|
112
|
+
if (!targetStorage) return null;
|
|
113
|
+
try {
|
|
114
|
+
const raw = targetStorage.getItem(`${REGISTRATION_STORAGE_PREFIX}${cacheKey}`);
|
|
115
|
+
if (!raw) return null;
|
|
116
|
+
return JSON.parse(raw);
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function saveStoredRegistration(registration, storage) {
|
|
122
|
+
const targetStorage = getStorage(storage);
|
|
123
|
+
if (!targetStorage) return;
|
|
124
|
+
try {
|
|
125
|
+
targetStorage.setItem(
|
|
126
|
+
`${REGISTRATION_STORAGE_PREFIX}${registration.key}`,
|
|
127
|
+
JSON.stringify(registration)
|
|
128
|
+
);
|
|
129
|
+
} catch {
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function applyResolvedRegistration(authConfig, registration) {
|
|
133
|
+
return {
|
|
134
|
+
...authConfig,
|
|
135
|
+
callbackUrl: registration.callbackUrl,
|
|
136
|
+
clientId: registration.clientId ?? authConfig.clientId,
|
|
137
|
+
clientMode: registration.mode,
|
|
138
|
+
resource: registration.resource ?? authConfig.resource,
|
|
139
|
+
authorizationServerUrl: registration.authorizationServerUrl ?? authConfig.authorizationServerUrl,
|
|
140
|
+
authorizationServerMetadataUrl: registration.authorizationServerMetadataUrl ?? authConfig.authorizationServerMetadataUrl,
|
|
141
|
+
registrationEndpoint: registration.registrationEndpoint ?? authConfig.registrationEndpoint
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function createResolvedRegistration(authConfig, mode, callbackUrl, clientId, clientMetadataUrl) {
|
|
145
|
+
return {
|
|
146
|
+
cacheKey: buildRegistrationCacheKey(authConfig, callbackUrl, mode),
|
|
147
|
+
mode,
|
|
148
|
+
clientId,
|
|
149
|
+
callbackUrl,
|
|
150
|
+
resource: authConfig.resource,
|
|
151
|
+
authorizationServerUrl: authConfig.authorizationServerUrl,
|
|
152
|
+
authorizationServerMetadataUrl: authConfig.authorizationServerMetadataUrl,
|
|
153
|
+
registrationEndpoint: authConfig.registrationEndpoint,
|
|
154
|
+
clientMetadataUrl
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function shouldAttemptMode(preferred, candidate) {
|
|
158
|
+
return preferred === "auto" || preferred === candidate;
|
|
159
|
+
}
|
|
160
|
+
function isLoopbackHost(hostname) {
|
|
161
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
162
|
+
}
|
|
163
|
+
function isNativeCallbackUrl(callbackUrl) {
|
|
164
|
+
try {
|
|
165
|
+
const url = new URL(callbackUrl);
|
|
166
|
+
return url.protocol !== "http:" && url.protocol !== "https:";
|
|
167
|
+
} catch {
|
|
168
|
+
return !callbackUrl.startsWith("http://") && !callbackUrl.startsWith("https://");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function inferApplicationType(callbackUrl) {
|
|
172
|
+
try {
|
|
173
|
+
const url = new URL(callbackUrl);
|
|
174
|
+
if (url.protocol === "http:" || url.protocol === "https:") {
|
|
175
|
+
return isLoopbackHost(url.hostname) ? "native" : "web";
|
|
176
|
+
}
|
|
177
|
+
return "native";
|
|
178
|
+
} catch {
|
|
179
|
+
return "native";
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function registerPublicClient(authConfig, options) {
|
|
183
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
184
|
+
const callbackUrl = options.callbackUrl;
|
|
185
|
+
const registration = createResolvedRegistration(authConfig, "dcr", callbackUrl);
|
|
186
|
+
if (!authConfig.registrationEndpoint) {
|
|
187
|
+
throw new Error("Dynamic client registration is not available for this server.");
|
|
188
|
+
}
|
|
189
|
+
const existing = loadStoredRegistration(registration.cacheKey, options.storage);
|
|
190
|
+
if (existing?.clientId) {
|
|
191
|
+
return {
|
|
192
|
+
...registration,
|
|
193
|
+
clientId: existing.clientId
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const requestBody = {
|
|
197
|
+
client_name: options.clientName ?? "MCP Stack MCP Client",
|
|
198
|
+
application_type: inferApplicationType(callbackUrl),
|
|
199
|
+
redirect_uris: [callbackUrl],
|
|
200
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
201
|
+
response_types: ["code"],
|
|
202
|
+
token_endpoint_auth_method: "none",
|
|
203
|
+
scope: authConfig.scopes?.join(" "),
|
|
204
|
+
client_uri: options.clientUri
|
|
205
|
+
};
|
|
206
|
+
const response = await fetchImpl(authConfig.registrationEndpoint, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: {
|
|
209
|
+
Accept: "application/json",
|
|
210
|
+
"Content-Type": "application/json"
|
|
211
|
+
},
|
|
212
|
+
body: JSON.stringify(requestBody)
|
|
213
|
+
});
|
|
214
|
+
if (!response.ok) {
|
|
215
|
+
const errorText = await response.text().catch(() => "Dynamic client registration failed.");
|
|
216
|
+
throw new Error(`Dynamic client registration failed (${response.status}): ${errorText}`);
|
|
217
|
+
}
|
|
218
|
+
const payload = await response.json();
|
|
219
|
+
if (!payload.client_id) {
|
|
220
|
+
throw new Error("Dynamic client registration response did not include a client_id.");
|
|
221
|
+
}
|
|
222
|
+
saveStoredRegistration({
|
|
223
|
+
key: registration.cacheKey,
|
|
224
|
+
mode: "dcr",
|
|
225
|
+
authorizationServerUrl: authConfig.authorizationServerUrl ?? "",
|
|
226
|
+
authorizationServerMetadataUrl: authConfig.authorizationServerMetadataUrl,
|
|
227
|
+
registrationEndpoint: authConfig.registrationEndpoint,
|
|
228
|
+
clientId: payload.client_id,
|
|
229
|
+
callbackUrl,
|
|
230
|
+
resource: authConfig.resource,
|
|
231
|
+
createdAt: Date.now(),
|
|
232
|
+
updatedAt: Date.now()
|
|
233
|
+
}, options.storage);
|
|
234
|
+
return {
|
|
235
|
+
...registration,
|
|
236
|
+
clientId: payload.client_id
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
async function resolveOAuthRegistration(authConfig, options) {
|
|
240
|
+
const callbackUrl = getEffectiveCallbackUrl(authConfig, options.callbackUrl);
|
|
241
|
+
const preferred = getPreferredRegistrationPreference(authConfig);
|
|
242
|
+
const preregClientId = authConfig.clientId?.trim();
|
|
243
|
+
if (preregClientId && shouldAttemptMode(preferred, "preregistered")) {
|
|
244
|
+
return createResolvedRegistration(
|
|
245
|
+
authConfig,
|
|
246
|
+
"preregistered",
|
|
247
|
+
callbackUrl,
|
|
248
|
+
preregClientId
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (authConfig.clientIdMetadataDocumentSupported && options.oauthClientMetadataUrl && shouldAttemptMode(preferred, "cimd")) {
|
|
252
|
+
return createResolvedRegistration(
|
|
253
|
+
authConfig,
|
|
254
|
+
"cimd",
|
|
255
|
+
callbackUrl,
|
|
256
|
+
options.oauthClientMetadataUrl,
|
|
257
|
+
options.oauthClientMetadataUrl
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (authConfig.registrationEndpoint && shouldAttemptMode(preferred, "dcr")) {
|
|
261
|
+
return registerPublicClient(authConfig, {
|
|
262
|
+
...options,
|
|
263
|
+
callbackUrl
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
return createResolvedRegistration(
|
|
267
|
+
authConfig,
|
|
268
|
+
preregClientId ? "preregistered" : "manual",
|
|
269
|
+
callbackUrl,
|
|
270
|
+
preregClientId
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// src/core/auth-storage.ts
|
|
275
|
+
var LEGACY_OAUTH_TOKEN_STORAGE_PREFIX = "mcpstack_oauth_";
|
|
276
|
+
var SCOPED_OAUTH_TOKEN_STORAGE_PREFIX = "mcpstack_oauth_session_";
|
|
277
|
+
function hashAuthSessionKey(value) {
|
|
278
|
+
let hash = 2166136261;
|
|
279
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
280
|
+
hash ^= value.charCodeAt(index);
|
|
281
|
+
hash = Math.imul(hash, 16777619);
|
|
282
|
+
}
|
|
283
|
+
return (hash >>> 0).toString(36);
|
|
284
|
+
}
|
|
285
|
+
function normalizeAuthSessionKey(value) {
|
|
286
|
+
if (typeof value !== "string") {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
const normalized = value.trim();
|
|
290
|
+
return normalized || null;
|
|
291
|
+
}
|
|
292
|
+
function resolveExplicitAuthSessionKey(config) {
|
|
293
|
+
return normalizeAuthSessionKey(config.authSessionKey);
|
|
294
|
+
}
|
|
295
|
+
function buildScopedOAuthTokenStoragePrefix(authSessionKey) {
|
|
296
|
+
const normalizedSessionKey = normalizeAuthSessionKey(authSessionKey);
|
|
297
|
+
if (!normalizedSessionKey) {
|
|
298
|
+
return LEGACY_OAUTH_TOKEN_STORAGE_PREFIX;
|
|
299
|
+
}
|
|
300
|
+
return `${SCOPED_OAUTH_TOKEN_STORAGE_PREFIX}${hashAuthSessionKey(normalizedSessionKey)}_`;
|
|
301
|
+
}
|
|
302
|
+
function buildScopedOAuthTokenStorageKey(cacheKey, authSessionKey) {
|
|
303
|
+
return `${buildScopedOAuthTokenStoragePrefix(authSessionKey)}${cacheKey}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// src/core/EmcyAgent.ts
|
|
307
|
+
var DEFAULT_MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
308
|
+
var DEFAULT_LOCAL_PUBLIC_APP_PORT = "3100";
|
|
309
|
+
var DEFAULT_OAUTH_CALLBACK_URL = "https://mcpstack.com/oauth/callback";
|
|
310
|
+
var DEFAULT_OAUTH_CLIENT_METADATA_URL = "https://mcpstack.com/.well-known/oauth-client-metadata.json";
|
|
311
|
+
var DEFAULT_AUDIO_TURN_DETECTION = {
|
|
312
|
+
enabled: true,
|
|
313
|
+
autoSubmit: true,
|
|
314
|
+
silenceDurationMs: 850,
|
|
315
|
+
minSpeechDurationMs: 180,
|
|
316
|
+
noSpeechTimeoutMs: 12e3,
|
|
317
|
+
speechThreshold: 0.012,
|
|
318
|
+
noiseMultiplier: 2.4
|
|
319
|
+
};
|
|
320
|
+
var AUDIO_ACTIVITY_EMIT_INTERVAL_MS = 120;
|
|
321
|
+
var MIN_AUDIO_LEVEL_DELTA_FOR_STATE = 0.03;
|
|
322
|
+
function isLocalhostHost(hostname) {
|
|
323
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
324
|
+
}
|
|
325
|
+
function getDefaultOAuthHelperOrigin(agentServiceUrl) {
|
|
326
|
+
if (!agentServiceUrl) {
|
|
327
|
+
return DEFAULT_OAUTH_CALLBACK_URL.replace(/\/oauth\/callback$/, "");
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
const url = new URL(agentServiceUrl);
|
|
331
|
+
if (isLocalhostHost(url.hostname)) {
|
|
332
|
+
return `${url.protocol}//${url.hostname}:${DEFAULT_LOCAL_PUBLIC_APP_PORT}`;
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
}
|
|
336
|
+
return DEFAULT_OAUTH_CALLBACK_URL.replace(/\/oauth\/callback$/, "");
|
|
337
|
+
}
|
|
338
|
+
function getDefaultOAuthCallbackUrl(agentServiceUrl) {
|
|
339
|
+
return `${getDefaultOAuthHelperOrigin(agentServiceUrl)}/oauth/callback`;
|
|
340
|
+
}
|
|
341
|
+
function getDefaultOAuthClientMetadataUrl(agentServiceUrl) {
|
|
342
|
+
return `${getDefaultOAuthHelperOrigin(agentServiceUrl)}/.well-known/oauth-client-metadata.json`;
|
|
343
|
+
}
|
|
344
|
+
function buildEmbeddedExchangeUrl(mcpServerUrl) {
|
|
345
|
+
const url = new URL(mcpServerUrl);
|
|
346
|
+
if (/\/mcp\/?$/i.test(url.pathname)) {
|
|
347
|
+
url.pathname = url.pathname.replace(/\/mcp\/?$/i, "/embedded/exchange");
|
|
348
|
+
} else {
|
|
349
|
+
url.pathname = `${url.pathname.replace(/\/$/, "")}/embedded/exchange`;
|
|
350
|
+
}
|
|
351
|
+
return url.toString();
|
|
352
|
+
}
|
|
353
|
+
function normalizeOAuthTokenResponse(payload, authConfig) {
|
|
354
|
+
const accessToken = typeof payload.accessToken === "string" ? payload.accessToken : typeof payload.access_token === "string" ? payload.access_token : "";
|
|
355
|
+
if (!accessToken) {
|
|
356
|
+
return void 0;
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
accessToken,
|
|
360
|
+
refreshToken: typeof payload.refreshToken === "string" ? payload.refreshToken : typeof payload.refresh_token === "string" ? payload.refresh_token : void 0,
|
|
361
|
+
expiresIn: typeof payload.expiresIn === "number" ? payload.expiresIn : typeof payload.expires_in === "number" ? payload.expires_in : void 0,
|
|
362
|
+
tokenType: typeof payload.tokenType === "string" ? payload.tokenType : typeof payload.token_type === "string" ? payload.token_type : "Bearer",
|
|
363
|
+
resolvedAuthConfig: authConfig
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function createAppTokenAuthHandler(agentId, auth) {
|
|
367
|
+
return async (mcpServerUrl, authConfig) => {
|
|
368
|
+
const appToken = await auth.getToken();
|
|
369
|
+
const trimmedToken = appToken?.trim();
|
|
370
|
+
if (!trimmedToken) {
|
|
371
|
+
return void 0;
|
|
372
|
+
}
|
|
373
|
+
const headers = {
|
|
374
|
+
"Content-Type": "application/json",
|
|
375
|
+
Authorization: `Bearer ${trimmedToken}`
|
|
376
|
+
};
|
|
377
|
+
if (auth.appId?.trim()) {
|
|
378
|
+
headers["x-mcpstack-embedded-app-id"] = auth.appId.trim();
|
|
379
|
+
}
|
|
380
|
+
const body = {
|
|
381
|
+
agentId,
|
|
382
|
+
resource: authConfig.resource ?? mcpServerUrl,
|
|
383
|
+
scopes: authConfig.scopes
|
|
384
|
+
};
|
|
385
|
+
if (auth.appId?.trim()) {
|
|
386
|
+
body.appId = auth.appId.trim();
|
|
387
|
+
}
|
|
388
|
+
const response = await fetch(buildEmbeddedExchangeUrl(mcpServerUrl), {
|
|
389
|
+
method: "POST",
|
|
390
|
+
headers,
|
|
391
|
+
body: JSON.stringify(body)
|
|
392
|
+
});
|
|
393
|
+
const payload = await response.json().catch(() => null);
|
|
394
|
+
if (!response.ok) {
|
|
395
|
+
const description = typeof payload?.error_description === "string" ? payload.error_description : typeof payload?.error === "string" ? payload.error : `App token exchange failed (${response.status}).`;
|
|
396
|
+
throw new Error(description);
|
|
397
|
+
}
|
|
398
|
+
return normalizeOAuthTokenResponse(payload ?? {}, authConfig);
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
function extractErrorMessage(payload) {
|
|
402
|
+
if (typeof payload === "string") {
|
|
403
|
+
const message = payload.trim();
|
|
404
|
+
return message || null;
|
|
405
|
+
}
|
|
406
|
+
if (!payload || typeof payload !== "object") {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
const record = payload;
|
|
410
|
+
for (const key of ["error", "message", "detail"]) {
|
|
411
|
+
const value = record[key];
|
|
412
|
+
if (typeof value === "string" && value.trim()) {
|
|
413
|
+
return value.trim();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
async function getResponseErrorMessage(response) {
|
|
419
|
+
const fallback = `HTTP ${response.status}`;
|
|
420
|
+
try {
|
|
421
|
+
const payload = await response.clone().json();
|
|
422
|
+
const message = extractErrorMessage(payload);
|
|
423
|
+
if (message) {
|
|
424
|
+
return message;
|
|
425
|
+
}
|
|
426
|
+
} catch {
|
|
427
|
+
}
|
|
428
|
+
const text = await response.text().catch(() => "");
|
|
429
|
+
return text.trim() || fallback;
|
|
430
|
+
}
|
|
431
|
+
function parameterToJsonSchema(parameter) {
|
|
432
|
+
const schema = {
|
|
433
|
+
type: parameter.type
|
|
434
|
+
};
|
|
435
|
+
if (parameter.description) {
|
|
436
|
+
schema.description = parameter.description;
|
|
437
|
+
}
|
|
438
|
+
if (parameter.enum) {
|
|
439
|
+
schema.enum = parameter.enum;
|
|
440
|
+
}
|
|
441
|
+
if (parameter.type === "array") {
|
|
442
|
+
schema.items = parameter.items ? parameterToJsonSchema(parameter.items) : { type: "string" };
|
|
443
|
+
}
|
|
444
|
+
if (parameter.type === "object" && parameter.properties) {
|
|
445
|
+
const properties = {};
|
|
446
|
+
const required = [];
|
|
447
|
+
for (const [key, child] of Object.entries(parameter.properties)) {
|
|
448
|
+
properties[key] = parameterToJsonSchema(child);
|
|
449
|
+
if (child.required) required.push(key);
|
|
450
|
+
}
|
|
451
|
+
schema.properties = properties;
|
|
452
|
+
schema.required = required;
|
|
453
|
+
}
|
|
454
|
+
if (parameter.additionalProperties !== void 0) {
|
|
455
|
+
schema.additionalProperties = typeof parameter.additionalProperties === "boolean" ? parameter.additionalProperties : parameterToJsonSchema(parameter.additionalProperties);
|
|
456
|
+
}
|
|
457
|
+
return schema;
|
|
458
|
+
}
|
|
459
|
+
function parametersToJsonSchema(params) {
|
|
460
|
+
const properties = {};
|
|
461
|
+
const required = [];
|
|
462
|
+
for (const [key, p] of Object.entries(params)) {
|
|
463
|
+
properties[key] = parameterToJsonSchema(p);
|
|
464
|
+
if (p.required) required.push(key);
|
|
465
|
+
}
|
|
466
|
+
return { type: "object", properties, required };
|
|
467
|
+
}
|
|
468
|
+
var McpStackAgent = class {
|
|
469
|
+
constructor(config) {
|
|
470
|
+
this.agentConfig = null;
|
|
471
|
+
this.budgetSnapshot = null;
|
|
472
|
+
this.conversationId = null;
|
|
473
|
+
this.messages = [];
|
|
474
|
+
this.historyCursor = null;
|
|
475
|
+
this.hasOlderMessages = false;
|
|
476
|
+
this.isLoadingHistory = false;
|
|
477
|
+
this.abortController = null;
|
|
478
|
+
this.isLoading = false;
|
|
479
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
480
|
+
/** Per-server MCP session tracking */
|
|
481
|
+
this.mcpSessions = /* @__PURE__ */ new Map();
|
|
482
|
+
/** OAuth tokens per MCP server URL (standalone mode only) */
|
|
483
|
+
this.oauthTokens = /* @__PURE__ */ new Map();
|
|
484
|
+
/** Servers explicitly disconnected by the user and awaiting manual re-auth */
|
|
485
|
+
this.manuallySignedOutServers = /* @__PURE__ */ new Set();
|
|
486
|
+
this.audioState = {
|
|
487
|
+
status: "idle",
|
|
488
|
+
isSupported: false,
|
|
489
|
+
isEnabled: false,
|
|
490
|
+
transcript: "",
|
|
491
|
+
partialTranscript: "",
|
|
492
|
+
error: null
|
|
493
|
+
};
|
|
494
|
+
this.audioStream = null;
|
|
495
|
+
this.audioContext = null;
|
|
496
|
+
this.audioSource = null;
|
|
497
|
+
this.audioProcessor = null;
|
|
498
|
+
this.audioSilentGain = null;
|
|
499
|
+
this.audioSocket = null;
|
|
500
|
+
this.audioSessionTimer = null;
|
|
501
|
+
this.audioSessionStopRequested = false;
|
|
502
|
+
this.audioFinalTranscript = "";
|
|
503
|
+
this.audioTurnState = null;
|
|
504
|
+
const appTokenAuthHandler = config.auth?.mode === "app-token" ? createAppTokenAuthHandler(config.agentId, config.auth) : void 0;
|
|
505
|
+
this.config = {
|
|
506
|
+
...config,
|
|
507
|
+
agentServiceUrl: config.agentServiceUrl ?? "https://api.mcpstack.com",
|
|
508
|
+
oauthCallbackUrl: config.oauthCallbackUrl ?? getDefaultOAuthCallbackUrl(config.agentServiceUrl),
|
|
509
|
+
oauthClientMetadataUrl: config.oauthClientMetadataUrl ?? getDefaultOAuthClientMetadataUrl(config.agentServiceUrl),
|
|
510
|
+
onAuthRequired: config.onAuthRequired ?? appTokenAuthHandler
|
|
511
|
+
};
|
|
512
|
+
this.audioState = this.buildAudioState({ status: "idle" });
|
|
513
|
+
}
|
|
514
|
+
async resolveAuthToken() {
|
|
515
|
+
if (this.config.getAuthToken) {
|
|
516
|
+
const token = await this.config.getAuthToken();
|
|
517
|
+
if (token) return token;
|
|
518
|
+
}
|
|
519
|
+
return this.config.apiKey;
|
|
520
|
+
}
|
|
521
|
+
/** Initialize: fetch agent config (tools, widget settings, MCP servers) */
|
|
522
|
+
async init() {
|
|
523
|
+
const token = await this.resolveAuthToken();
|
|
524
|
+
const response = await fetch(
|
|
525
|
+
`${this.config.agentServiceUrl}/api/v1/agents/${this.config.agentId}/config`,
|
|
526
|
+
{
|
|
527
|
+
headers: {
|
|
528
|
+
Authorization: `Bearer ${token}`
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
);
|
|
532
|
+
if (!response.ok) {
|
|
533
|
+
throw new Error(await getResponseErrorMessage(response));
|
|
534
|
+
}
|
|
535
|
+
this.agentConfig = await response.json();
|
|
536
|
+
this.audioState = this.buildAudioState({ status: "idle" });
|
|
537
|
+
this.emit("audio_state", this.audioState);
|
|
538
|
+
await this.refreshBudgetSnapshot().catch(() => void 0);
|
|
539
|
+
if (this.agentConfig?.mcpServers?.length) {
|
|
540
|
+
await Promise.all(
|
|
541
|
+
this.agentConfig.mcpServers.map(async (server) => {
|
|
542
|
+
server.authConfig = await this.resolveServerAuthConfig(server.url, server.authConfig ?? null);
|
|
543
|
+
})
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
if (this.agentConfig?.mcpServers) {
|
|
547
|
+
for (const server of this.agentConfig.mcpServers) {
|
|
548
|
+
if (!this.mcpSessions.has(server.url)) {
|
|
549
|
+
let authStatus;
|
|
550
|
+
if (server.authConfig?.authType === "oauth2") {
|
|
551
|
+
authStatus = server.authStatus === "connected" || this.hasValidOAuthToken(server.url) ? "connected" : "needs_auth";
|
|
552
|
+
} else {
|
|
553
|
+
authStatus = server.authStatus || "connected";
|
|
554
|
+
}
|
|
555
|
+
this.mcpSessions.set(server.url, { sessionId: null, authStatus });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (this.config.initialConversationId) {
|
|
560
|
+
await this.loadConversation(this.config.initialConversationId);
|
|
561
|
+
}
|
|
562
|
+
return this.agentConfig;
|
|
563
|
+
}
|
|
564
|
+
/** Get current MCP server auth statuses */
|
|
565
|
+
getMcpServers() {
|
|
566
|
+
if (!this.agentConfig?.mcpServers) return [];
|
|
567
|
+
return this.agentConfig.mcpServers.map((server) => ({
|
|
568
|
+
url: server.url,
|
|
569
|
+
name: server.name,
|
|
570
|
+
authStatus: this.mcpSessions.get(server.url)?.authStatus ?? server.authStatus ?? "connected",
|
|
571
|
+
canSignOut: (server.authConfig?.authType ?? "none") !== "none"
|
|
572
|
+
}));
|
|
573
|
+
}
|
|
574
|
+
/** Send a message and process the full orchestration loop (including tool calls) */
|
|
575
|
+
async sendMessage(message) {
|
|
576
|
+
if (!this.agentConfig) {
|
|
577
|
+
await this.init();
|
|
578
|
+
}
|
|
579
|
+
this.isLoading = true;
|
|
580
|
+
this.emit("loading", true);
|
|
581
|
+
const userMsg = {
|
|
582
|
+
id: crypto.randomUUID(),
|
|
583
|
+
role: "user",
|
|
584
|
+
content: message,
|
|
585
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
586
|
+
};
|
|
587
|
+
this.messages.push(userMsg);
|
|
588
|
+
this.emit("message", userMsg);
|
|
589
|
+
try {
|
|
590
|
+
await this.runChatLoop(message);
|
|
591
|
+
} catch (err) {
|
|
592
|
+
const errorMsg = err instanceof Error ? err.message : "Unknown error";
|
|
593
|
+
this.emit("error", { code: "sdk_error", message: errorMsg });
|
|
594
|
+
} finally {
|
|
595
|
+
this.isLoading = false;
|
|
596
|
+
this.emit("loading", false);
|
|
597
|
+
this.emit("thinking", false);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/** Start a new conversation */
|
|
601
|
+
newConversation() {
|
|
602
|
+
this.conversationId = null;
|
|
603
|
+
this.messages = [];
|
|
604
|
+
this.historyCursor = null;
|
|
605
|
+
this.hasOlderMessages = false;
|
|
606
|
+
this.isLoadingHistory = false;
|
|
607
|
+
}
|
|
608
|
+
/** Cancel the current in-flight request */
|
|
609
|
+
cancel() {
|
|
610
|
+
this.abortController?.abort();
|
|
611
|
+
this.abortController = null;
|
|
612
|
+
}
|
|
613
|
+
/** Get all messages in the current conversation */
|
|
614
|
+
getMessages() {
|
|
615
|
+
return [...this.messages];
|
|
616
|
+
}
|
|
617
|
+
/** Get the current conversation ID */
|
|
618
|
+
getConversationId() {
|
|
619
|
+
return this.conversationId;
|
|
620
|
+
}
|
|
621
|
+
getHasOlderMessages() {
|
|
622
|
+
return this.hasOlderMessages;
|
|
623
|
+
}
|
|
624
|
+
getIsLoadingHistory() {
|
|
625
|
+
return this.isLoadingHistory;
|
|
626
|
+
}
|
|
627
|
+
/** Get the loaded agent config */
|
|
628
|
+
getAgentConfig() {
|
|
629
|
+
return this.agentConfig;
|
|
630
|
+
}
|
|
631
|
+
getAudioInputState() {
|
|
632
|
+
return { ...this.audioState };
|
|
633
|
+
}
|
|
634
|
+
async startVoiceInput() {
|
|
635
|
+
if (!this.agentConfig) {
|
|
636
|
+
await this.init();
|
|
637
|
+
}
|
|
638
|
+
if (!this.isAudioInputSupported()) {
|
|
639
|
+
this.setAudioState({
|
|
640
|
+
status: "error",
|
|
641
|
+
error: {
|
|
642
|
+
code: "unsupported_browser",
|
|
643
|
+
message: "Microphone input is not supported in this browser."
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (!this.isAudioInputEnabled()) {
|
|
649
|
+
this.setAudioState({
|
|
650
|
+
status: "error",
|
|
651
|
+
error: {
|
|
652
|
+
code: "audio_not_enabled",
|
|
653
|
+
message: "Microphone input is not enabled for this agent."
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (this.audioState.status === "requesting_permission" || this.audioState.status === "connecting" || this.audioState.status === "listening" || this.audioState.status === "transcribing") {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
this.audioSessionStopRequested = false;
|
|
662
|
+
this.audioFinalTranscript = "";
|
|
663
|
+
this.audioTurnState = null;
|
|
664
|
+
this.setAudioState({
|
|
665
|
+
status: "requesting_permission",
|
|
666
|
+
transcript: "",
|
|
667
|
+
partialTranscript: "",
|
|
668
|
+
error: null,
|
|
669
|
+
sessionId: null,
|
|
670
|
+
conversationId: this.conversationId,
|
|
671
|
+
inputLevel: 0,
|
|
672
|
+
isSpeaking: false,
|
|
673
|
+
speechMs: 0,
|
|
674
|
+
silenceMs: 0,
|
|
675
|
+
autoSubmitEnabled: this.getAudioTurnDetectionConfig().enabled && this.getAudioTurnDetectionConfig().autoSubmit
|
|
676
|
+
});
|
|
677
|
+
let stream = null;
|
|
678
|
+
try {
|
|
679
|
+
stream = await navigator.mediaDevices.getUserMedia({
|
|
680
|
+
audio: {
|
|
681
|
+
channelCount: 1,
|
|
682
|
+
echoCancellation: true,
|
|
683
|
+
noiseSuppression: true,
|
|
684
|
+
autoGainControl: true
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
} catch (error) {
|
|
688
|
+
this.setAudioState({
|
|
689
|
+
status: "error",
|
|
690
|
+
error: {
|
|
691
|
+
code: "microphone_permission_denied",
|
|
692
|
+
message: error instanceof Error ? error.message : "Microphone permission was denied."
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
try {
|
|
698
|
+
this.audioStream = stream;
|
|
699
|
+
this.setAudioState({ status: "connecting" });
|
|
700
|
+
const session = await this.createRealtimeTranscriptionSession();
|
|
701
|
+
this.conversationId = session.conversationId;
|
|
702
|
+
this.setAudioState({
|
|
703
|
+
sessionId: session.sessionId,
|
|
704
|
+
conversationId: session.conversationId,
|
|
705
|
+
maxSessionSeconds: session.maxSessionSeconds
|
|
706
|
+
});
|
|
707
|
+
const socket = await this.openAudioSocket(session.webSocketUrl);
|
|
708
|
+
this.audioSocket = socket;
|
|
709
|
+
this.bindAudioSocket(socket);
|
|
710
|
+
await this.startAudioCapture(stream, socket);
|
|
711
|
+
const maxSessionMs = Math.max(1, session.maxSessionSeconds) * 1e3;
|
|
712
|
+
this.audioSessionTimer = setTimeout(() => {
|
|
713
|
+
void this.commitVoiceInput();
|
|
714
|
+
}, maxSessionMs);
|
|
715
|
+
this.setAudioState({ status: "listening" });
|
|
716
|
+
} catch (error) {
|
|
717
|
+
this.cleanupAudioCapture();
|
|
718
|
+
this.setAudioState({
|
|
719
|
+
status: "error",
|
|
720
|
+
error: {
|
|
721
|
+
code: this.extractErrorCode(error) ?? "audio_start_failed",
|
|
722
|
+
message: error instanceof Error ? error.message : "Could not start microphone input."
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
async stopVoiceInput() {
|
|
728
|
+
await this.commitVoiceInput();
|
|
729
|
+
}
|
|
730
|
+
cancelVoiceInput() {
|
|
731
|
+
this.audioSessionStopRequested = true;
|
|
732
|
+
this.cleanupAudioCapture();
|
|
733
|
+
this.audioTurnState = null;
|
|
734
|
+
this.setAudioState({
|
|
735
|
+
status: "idle",
|
|
736
|
+
transcript: "",
|
|
737
|
+
partialTranscript: "",
|
|
738
|
+
error: null,
|
|
739
|
+
sessionId: null,
|
|
740
|
+
inputLevel: 0,
|
|
741
|
+
isSpeaking: false,
|
|
742
|
+
speechMs: 0,
|
|
743
|
+
silenceMs: 0
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
async loadConversation(conversationId, pageSize = this.config.conversationHistoryPageSize ?? 50) {
|
|
747
|
+
this.isLoadingHistory = true;
|
|
748
|
+
try {
|
|
749
|
+
const page = await this.fetchConversationMessages(conversationId, void 0, pageSize);
|
|
750
|
+
this.applyConversationPage(page, false);
|
|
751
|
+
return page;
|
|
752
|
+
} finally {
|
|
753
|
+
this.isLoadingHistory = false;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
async loadOlderMessages(pageSize = this.config.conversationHistoryPageSize ?? 50) {
|
|
757
|
+
if (!this.conversationId || !this.historyCursor || !this.hasOlderMessages) {
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
this.isLoadingHistory = true;
|
|
761
|
+
try {
|
|
762
|
+
const page = await this.fetchConversationMessages(this.conversationId, this.historyCursor, pageSize);
|
|
763
|
+
this.applyConversationPage(page, true);
|
|
764
|
+
return page;
|
|
765
|
+
} finally {
|
|
766
|
+
this.isLoadingHistory = false;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
async submitFeedback(input) {
|
|
770
|
+
if (!this.conversationId) {
|
|
771
|
+
throw new Error("No active conversation to rate.");
|
|
772
|
+
}
|
|
773
|
+
const token = await this.resolveAuthToken();
|
|
774
|
+
const response = await fetch(
|
|
775
|
+
`${this.config.agentServiceUrl}/api/v1/chat/conversations/${encodeURIComponent(this.conversationId)}/feedback`,
|
|
776
|
+
{
|
|
777
|
+
method: "POST",
|
|
778
|
+
headers: {
|
|
779
|
+
"Content-Type": "application/json",
|
|
780
|
+
Authorization: `Bearer ${token}`
|
|
781
|
+
},
|
|
782
|
+
body: JSON.stringify(input)
|
|
783
|
+
}
|
|
784
|
+
);
|
|
785
|
+
if (!response.ok) {
|
|
786
|
+
throw new Error(await getResponseErrorMessage(response));
|
|
787
|
+
}
|
|
788
|
+
return await response.json();
|
|
789
|
+
}
|
|
790
|
+
/** Convert client tools to the API schema format. */
|
|
791
|
+
clientToolsToSchemas() {
|
|
792
|
+
if (!this.config.clientTools) return [];
|
|
793
|
+
return Object.entries(this.config.clientTools).map(([name, def]) => ({
|
|
794
|
+
name,
|
|
795
|
+
description: def.description,
|
|
796
|
+
inputSchema: parametersToJsonSchema(def.parameters),
|
|
797
|
+
selection: def.selection
|
|
798
|
+
}));
|
|
799
|
+
}
|
|
800
|
+
resolveExternalUserId() {
|
|
801
|
+
const hostIdentity = this.config.embeddedAuth?.hostIdentity;
|
|
802
|
+
return this.config.externalUserId ?? hostIdentity?.subject ?? hostIdentity?.email ?? void 0;
|
|
803
|
+
}
|
|
804
|
+
buildExternalUserContext() {
|
|
805
|
+
const hostIdentity = this.config.embeddedAuth?.hostIdentity;
|
|
806
|
+
const id = this.resolveExternalUserId();
|
|
807
|
+
const externalUser = {};
|
|
808
|
+
if (id) externalUser.id = id;
|
|
809
|
+
if (hostIdentity?.email) externalUser.email = hostIdentity.email;
|
|
810
|
+
if (hostIdentity?.displayName) externalUser.displayName = hostIdentity.displayName;
|
|
811
|
+
if (hostIdentity?.avatarUrl) externalUser.avatarUrl = hostIdentity.avatarUrl;
|
|
812
|
+
if (hostIdentity?.organizationId) externalUser.organizationId = hostIdentity.organizationId;
|
|
813
|
+
return Object.keys(externalUser).length > 0 ? externalUser : void 0;
|
|
814
|
+
}
|
|
815
|
+
/** Latest runtime budget snapshot for the current SDK identity. */
|
|
816
|
+
getBudgetSnapshot() {
|
|
817
|
+
return this.budgetSnapshot;
|
|
818
|
+
}
|
|
819
|
+
/** Refresh the current SDK identity's budget snapshot from the API. */
|
|
820
|
+
async refreshBudgetSnapshot() {
|
|
821
|
+
const token = await this.resolveAuthToken();
|
|
822
|
+
const params = new URLSearchParams();
|
|
823
|
+
const externalUserId = this.resolveExternalUserId();
|
|
824
|
+
if (externalUserId) {
|
|
825
|
+
params.set("externalUserId", externalUserId);
|
|
826
|
+
}
|
|
827
|
+
const query = params.toString();
|
|
828
|
+
const response = await fetch(
|
|
829
|
+
`${this.config.agentServiceUrl}/api/v1/agents/${this.config.agentId}/budget${query ? `?${query}` : ""}`,
|
|
830
|
+
{
|
|
831
|
+
headers: {
|
|
832
|
+
Authorization: `Bearer ${token}`
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
);
|
|
836
|
+
if (!response.ok) {
|
|
837
|
+
throw new Error(await getResponseErrorMessage(response));
|
|
838
|
+
}
|
|
839
|
+
this.budgetSnapshot = await response.json();
|
|
840
|
+
this.emit("budget_snapshot", this.budgetSnapshot);
|
|
841
|
+
return this.budgetSnapshot;
|
|
842
|
+
}
|
|
843
|
+
/** Whether a request is currently in flight */
|
|
844
|
+
getIsLoading() {
|
|
845
|
+
return this.isLoading;
|
|
846
|
+
}
|
|
847
|
+
/** Update the per-turn app context sent with each chat request. */
|
|
848
|
+
setAppContext(context) {
|
|
849
|
+
this.config = {
|
|
850
|
+
...this.config,
|
|
851
|
+
context
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
/** Update the client tools exposed to the agent without recreating the session. */
|
|
855
|
+
setClientTools(clientTools) {
|
|
856
|
+
this.config = {
|
|
857
|
+
...this.config,
|
|
858
|
+
clientTools
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Proactively authenticate with an MCP server before sending a message.
|
|
863
|
+
* If a token response is provided directly, it is stored immediately.
|
|
864
|
+
* Otherwise, this uses the configured OAuth flow and verifies via MCP init.
|
|
865
|
+
*
|
|
866
|
+
* @param mcpServerUrl - The MCP server URL to authenticate with
|
|
867
|
+
* @param tokenResponse - Optional: provide token response directly (from OAuth popup)
|
|
868
|
+
*/
|
|
869
|
+
async authenticate(mcpServerUrl, tokenResponse) {
|
|
870
|
+
const wasManuallySignedOut = this.manuallySignedOutServers.delete(mcpServerUrl);
|
|
871
|
+
try {
|
|
872
|
+
if (tokenResponse?.accessToken) {
|
|
873
|
+
if (tokenResponse.resolvedAuthConfig) {
|
|
874
|
+
this.updateServerAuthConfig(mcpServerUrl, tokenResponse.resolvedAuthConfig);
|
|
875
|
+
}
|
|
876
|
+
this.storeOAuthToken(mcpServerUrl, tokenResponse);
|
|
877
|
+
this.updateMcpAuthStatus(mcpServerUrl, "connected");
|
|
878
|
+
return true;
|
|
879
|
+
}
|
|
880
|
+
const token = await this.resolveToken(mcpServerUrl);
|
|
881
|
+
if (!token) {
|
|
882
|
+
if (wasManuallySignedOut) {
|
|
883
|
+
this.manuallySignedOutServers.add(mcpServerUrl);
|
|
884
|
+
}
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
887
|
+
this.updateMcpAuthStatus(mcpServerUrl, "connected");
|
|
888
|
+
try {
|
|
889
|
+
await this.initMcpSession(mcpServerUrl);
|
|
890
|
+
return true;
|
|
891
|
+
} catch {
|
|
892
|
+
return true;
|
|
893
|
+
}
|
|
894
|
+
} catch {
|
|
895
|
+
if (wasManuallySignedOut) {
|
|
896
|
+
this.manuallySignedOutServers.add(mcpServerUrl);
|
|
897
|
+
}
|
|
898
|
+
this.updateMcpAuthStatus(mcpServerUrl, "needs_auth");
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
/** Disconnect from an MCP server and require explicit re-authentication before reuse. */
|
|
903
|
+
async signOutMcpServer(mcpServerUrl) {
|
|
904
|
+
this.manuallySignedOutServers.add(mcpServerUrl);
|
|
905
|
+
await this.closeMcpSession(mcpServerUrl);
|
|
906
|
+
this.clearOAuthToken(mcpServerUrl);
|
|
907
|
+
this.updateMcpAuthStatus(mcpServerUrl, "needs_auth");
|
|
908
|
+
}
|
|
909
|
+
/** Get the auth config for an MCP server (from agent config) */
|
|
910
|
+
getServerAuthConfig(mcpServerUrl) {
|
|
911
|
+
const server = this.agentConfig?.mcpServers?.find((s) => s.url === mcpServerUrl);
|
|
912
|
+
return server?.authConfig ?? null;
|
|
913
|
+
}
|
|
914
|
+
getOAuthCallbackUrl() {
|
|
915
|
+
return this.config.oauthCallbackUrl ?? DEFAULT_OAUTH_CALLBACK_URL;
|
|
916
|
+
}
|
|
917
|
+
getOAuthClientMetadataUrl() {
|
|
918
|
+
return this.config.oauthClientMetadataUrl ?? DEFAULT_OAUTH_CLIENT_METADATA_URL;
|
|
919
|
+
}
|
|
920
|
+
getDefaultAuthRequiredHandler() {
|
|
921
|
+
return this.config.auth?.mode === "app-token" ? createAppTokenAuthHandler(this.config.agentId, this.config.auth) : void 0;
|
|
922
|
+
}
|
|
923
|
+
setOnAuthRequired(onAuthRequired) {
|
|
924
|
+
this.config = {
|
|
925
|
+
...this.config,
|
|
926
|
+
onAuthRequired: onAuthRequired ?? this.getDefaultAuthRequiredHandler()
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
isAudioInputSupported() {
|
|
930
|
+
const audioContextCtor = this.getAudioContextConstructor();
|
|
931
|
+
return typeof navigator !== "undefined" && Boolean(navigator.mediaDevices?.getUserMedia) && typeof WebSocket !== "undefined" && Boolean(audioContextCtor) && typeof crypto !== "undefined";
|
|
932
|
+
}
|
|
933
|
+
isAudioInputEnabled() {
|
|
934
|
+
const capabilities = this.agentConfig?.modelConfig?.capabilities;
|
|
935
|
+
return Boolean(
|
|
936
|
+
this.agentConfig?.audio?.inputEnabled && (capabilities?.audioInput ?? capabilities?.realtimeAudioInput)
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
isAudioAutoSubmitEnabled() {
|
|
940
|
+
const turnDetection = this.getAudioTurnDetectionConfig();
|
|
941
|
+
return turnDetection.enabled && turnDetection.autoSubmit;
|
|
942
|
+
}
|
|
943
|
+
getAudioTurnDetectionConfig() {
|
|
944
|
+
const override = this.config.audioInput?.turnDetection ?? {};
|
|
945
|
+
return {
|
|
946
|
+
enabled: override.enabled ?? DEFAULT_AUDIO_TURN_DETECTION.enabled,
|
|
947
|
+
autoSubmit: override.autoSubmit ?? DEFAULT_AUDIO_TURN_DETECTION.autoSubmit,
|
|
948
|
+
silenceDurationMs: this.clampNumber(
|
|
949
|
+
override.silenceDurationMs,
|
|
950
|
+
250,
|
|
951
|
+
3e3,
|
|
952
|
+
DEFAULT_AUDIO_TURN_DETECTION.silenceDurationMs
|
|
953
|
+
),
|
|
954
|
+
minSpeechDurationMs: this.clampNumber(
|
|
955
|
+
override.minSpeechDurationMs,
|
|
956
|
+
80,
|
|
957
|
+
1e3,
|
|
958
|
+
DEFAULT_AUDIO_TURN_DETECTION.minSpeechDurationMs
|
|
959
|
+
),
|
|
960
|
+
noSpeechTimeoutMs: this.clampNumber(
|
|
961
|
+
override.noSpeechTimeoutMs,
|
|
962
|
+
0,
|
|
963
|
+
6e4,
|
|
964
|
+
DEFAULT_AUDIO_TURN_DETECTION.noSpeechTimeoutMs
|
|
965
|
+
),
|
|
966
|
+
speechThreshold: this.clampNumber(
|
|
967
|
+
override.speechThreshold,
|
|
968
|
+
2e-3,
|
|
969
|
+
0.2,
|
|
970
|
+
DEFAULT_AUDIO_TURN_DETECTION.speechThreshold
|
|
971
|
+
),
|
|
972
|
+
noiseMultiplier: this.clampNumber(
|
|
973
|
+
override.noiseMultiplier,
|
|
974
|
+
1.2,
|
|
975
|
+
8,
|
|
976
|
+
DEFAULT_AUDIO_TURN_DETECTION.noiseMultiplier
|
|
977
|
+
)
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
clampNumber(value, min, max, fallback) {
|
|
981
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
982
|
+
return fallback;
|
|
983
|
+
}
|
|
984
|
+
return Math.min(max, Math.max(min, value));
|
|
985
|
+
}
|
|
986
|
+
buildAudioState(patch) {
|
|
987
|
+
const hasPatch = (key) => Object.prototype.hasOwnProperty.call(patch, key);
|
|
988
|
+
return {
|
|
989
|
+
status: patch.status ?? this.audioState.status,
|
|
990
|
+
isSupported: this.isAudioInputSupported(),
|
|
991
|
+
isEnabled: this.isAudioInputEnabled(),
|
|
992
|
+
transcript: patch.transcript ?? this.audioState.transcript,
|
|
993
|
+
partialTranscript: patch.partialTranscript ?? this.audioState.partialTranscript,
|
|
994
|
+
error: hasPatch("error") ? patch.error ?? null : this.audioState.error,
|
|
995
|
+
sessionId: hasPatch("sessionId") ? patch.sessionId ?? null : this.audioState.sessionId ?? null,
|
|
996
|
+
conversationId: hasPatch("conversationId") ? patch.conversationId ?? null : this.audioState.conversationId ?? this.conversationId,
|
|
997
|
+
maxSessionSeconds: hasPatch("maxSessionSeconds") ? patch.maxSessionSeconds ?? null : this.audioState.maxSessionSeconds ?? this.agentConfig?.audio?.maxSessionSeconds ?? null,
|
|
998
|
+
inputLevel: hasPatch("inputLevel") ? patch.inputLevel ?? 0 : this.audioState.inputLevel ?? 0,
|
|
999
|
+
isSpeaking: hasPatch("isSpeaking") ? patch.isSpeaking ?? false : this.audioState.isSpeaking ?? false,
|
|
1000
|
+
speechMs: hasPatch("speechMs") ? patch.speechMs ?? 0 : this.audioState.speechMs ?? 0,
|
|
1001
|
+
silenceMs: hasPatch("silenceMs") ? patch.silenceMs ?? 0 : this.audioState.silenceMs ?? 0,
|
|
1002
|
+
autoSubmitEnabled: hasPatch("autoSubmitEnabled") ? patch.autoSubmitEnabled ?? false : this.audioState.autoSubmitEnabled ?? this.isAudioAutoSubmitEnabled()
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
setAudioState(patch) {
|
|
1006
|
+
this.audioState = this.buildAudioState(patch);
|
|
1007
|
+
this.emit("audio_state", this.audioState);
|
|
1008
|
+
}
|
|
1009
|
+
getAudioContextConstructor() {
|
|
1010
|
+
if (typeof window === "undefined") {
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
return window.AudioContext ?? window.webkitAudioContext ?? null;
|
|
1014
|
+
}
|
|
1015
|
+
async createRealtimeTranscriptionSession() {
|
|
1016
|
+
const token = await this.resolveAuthToken();
|
|
1017
|
+
const externalUser = this.buildExternalUserContext();
|
|
1018
|
+
const response = await fetch(
|
|
1019
|
+
`${this.config.agentServiceUrl}/api/v1/agents/${encodeURIComponent(this.config.agentId)}/realtime/transcription-sessions`,
|
|
1020
|
+
{
|
|
1021
|
+
method: "POST",
|
|
1022
|
+
headers: {
|
|
1023
|
+
"Content-Type": "application/json",
|
|
1024
|
+
Authorization: `Bearer ${token}`
|
|
1025
|
+
},
|
|
1026
|
+
body: JSON.stringify({
|
|
1027
|
+
conversationId: this.conversationId,
|
|
1028
|
+
externalUserId: this.resolveExternalUserId(),
|
|
1029
|
+
externalUser
|
|
1030
|
+
})
|
|
1031
|
+
}
|
|
1032
|
+
);
|
|
1033
|
+
if (!response.ok) {
|
|
1034
|
+
throw await this.buildApiError(response);
|
|
1035
|
+
}
|
|
1036
|
+
return await response.json();
|
|
1037
|
+
}
|
|
1038
|
+
async buildApiError(response) {
|
|
1039
|
+
let code = null;
|
|
1040
|
+
let message = null;
|
|
1041
|
+
try {
|
|
1042
|
+
const payload = await response.clone().json();
|
|
1043
|
+
if (payload && typeof payload === "object") {
|
|
1044
|
+
const record = payload;
|
|
1045
|
+
code = typeof record.code === "string" ? record.code : null;
|
|
1046
|
+
message = extractErrorMessage(payload);
|
|
1047
|
+
}
|
|
1048
|
+
} catch {
|
|
1049
|
+
}
|
|
1050
|
+
const error = new Error(message ?? await getResponseErrorMessage(response));
|
|
1051
|
+
if (code) {
|
|
1052
|
+
error.code = code;
|
|
1053
|
+
}
|
|
1054
|
+
return error;
|
|
1055
|
+
}
|
|
1056
|
+
async openAudioSocket(url) {
|
|
1057
|
+
return new Promise((resolve, reject) => {
|
|
1058
|
+
const socket = new WebSocket(this.normalizeAudioSocketUrl(url));
|
|
1059
|
+
const cleanup = () => {
|
|
1060
|
+
socket.removeEventListener("open", handleOpen);
|
|
1061
|
+
socket.removeEventListener("error", handleError);
|
|
1062
|
+
};
|
|
1063
|
+
const handleOpen = () => {
|
|
1064
|
+
cleanup();
|
|
1065
|
+
resolve(socket);
|
|
1066
|
+
};
|
|
1067
|
+
const handleError = () => {
|
|
1068
|
+
cleanup();
|
|
1069
|
+
reject(new Error("Could not connect to the realtime microphone service."));
|
|
1070
|
+
};
|
|
1071
|
+
socket.addEventListener("open", handleOpen);
|
|
1072
|
+
socket.addEventListener("error", handleError);
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
normalizeAudioSocketUrl(url) {
|
|
1076
|
+
if (typeof window === "undefined") {
|
|
1077
|
+
return url;
|
|
1078
|
+
}
|
|
1079
|
+
try {
|
|
1080
|
+
const parsed = new URL(url, window.location.href);
|
|
1081
|
+
if (window.location.protocol === "https:" && parsed.protocol === "ws:") {
|
|
1082
|
+
parsed.protocol = "wss:";
|
|
1083
|
+
}
|
|
1084
|
+
return parsed.toString();
|
|
1085
|
+
} catch {
|
|
1086
|
+
return url;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
bindAudioSocket(socket) {
|
|
1090
|
+
socket.addEventListener("message", (event) => {
|
|
1091
|
+
void this.handleAudioSocketMessage(event);
|
|
1092
|
+
});
|
|
1093
|
+
socket.addEventListener("close", () => {
|
|
1094
|
+
if (this.audioSocket !== socket) {
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
this.audioSocket = null;
|
|
1098
|
+
this.clearAudioSessionTimer();
|
|
1099
|
+
if (this.audioState.status === "listening" || this.audioState.status === "transcribing" || this.audioState.status === "connecting") {
|
|
1100
|
+
this.cleanupAudioCapture(false);
|
|
1101
|
+
this.audioTurnState = null;
|
|
1102
|
+
this.setAudioState({
|
|
1103
|
+
status: "idle",
|
|
1104
|
+
inputLevel: 0,
|
|
1105
|
+
isSpeaking: false,
|
|
1106
|
+
speechMs: 0,
|
|
1107
|
+
silenceMs: 0
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
socket.addEventListener("error", () => {
|
|
1112
|
+
if (this.audioSocket !== socket) {
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
this.cleanupAudioCapture();
|
|
1116
|
+
this.audioTurnState = null;
|
|
1117
|
+
this.setAudioState({
|
|
1118
|
+
status: "error",
|
|
1119
|
+
error: {
|
|
1120
|
+
code: "audio_socket_error",
|
|
1121
|
+
message: "The realtime microphone connection failed."
|
|
1122
|
+
},
|
|
1123
|
+
inputLevel: 0,
|
|
1124
|
+
isSpeaking: false,
|
|
1125
|
+
speechMs: 0,
|
|
1126
|
+
silenceMs: 0
|
|
1127
|
+
});
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
async handleAudioSocketMessage(event) {
|
|
1131
|
+
const raw = typeof event.data === "string" ? event.data : event.data instanceof Blob ? await event.data.text() : "";
|
|
1132
|
+
if (!raw) {
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
let payload;
|
|
1136
|
+
try {
|
|
1137
|
+
payload = JSON.parse(raw);
|
|
1138
|
+
} catch {
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
const type = typeof payload.type === "string" ? payload.type : "";
|
|
1142
|
+
if (type === "transcript_delta") {
|
|
1143
|
+
const text = typeof payload.text === "string" ? payload.text : "";
|
|
1144
|
+
if (!text) {
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
this.audioFinalTranscript += text;
|
|
1148
|
+
this.setAudioState({
|
|
1149
|
+
partialTranscript: this.audioFinalTranscript,
|
|
1150
|
+
transcript: this.audioFinalTranscript
|
|
1151
|
+
});
|
|
1152
|
+
this.emit("audio_transcript_delta", {
|
|
1153
|
+
text,
|
|
1154
|
+
transcript: this.audioFinalTranscript,
|
|
1155
|
+
isFinal: false
|
|
1156
|
+
});
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (type === "transcript_final") {
|
|
1160
|
+
const finalText = (typeof payload.text === "string" && payload.text.trim() ? payload.text : this.audioFinalTranscript).trim();
|
|
1161
|
+
this.cleanupAudioCapture();
|
|
1162
|
+
this.audioTurnState = null;
|
|
1163
|
+
if (!finalText) {
|
|
1164
|
+
this.setAudioState({
|
|
1165
|
+
status: "idle",
|
|
1166
|
+
transcript: "",
|
|
1167
|
+
partialTranscript: "",
|
|
1168
|
+
inputLevel: 0,
|
|
1169
|
+
isSpeaking: false,
|
|
1170
|
+
speechMs: 0,
|
|
1171
|
+
silenceMs: 0
|
|
1172
|
+
});
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
this.setAudioState({
|
|
1176
|
+
status: "sending",
|
|
1177
|
+
transcript: finalText,
|
|
1178
|
+
partialTranscript: "",
|
|
1179
|
+
error: null,
|
|
1180
|
+
inputLevel: 0,
|
|
1181
|
+
isSpeaking: false
|
|
1182
|
+
});
|
|
1183
|
+
this.emit("audio_transcript_final", {
|
|
1184
|
+
text: finalText,
|
|
1185
|
+
transcript: finalText,
|
|
1186
|
+
conversationId: this.conversationId ?? ""
|
|
1187
|
+
});
|
|
1188
|
+
await this.sendMessage(finalText);
|
|
1189
|
+
this.setAudioState({
|
|
1190
|
+
status: "idle",
|
|
1191
|
+
transcript: finalText,
|
|
1192
|
+
partialTranscript: "",
|
|
1193
|
+
sessionId: null,
|
|
1194
|
+
inputLevel: 0,
|
|
1195
|
+
isSpeaking: false,
|
|
1196
|
+
speechMs: 0,
|
|
1197
|
+
silenceMs: 0
|
|
1198
|
+
});
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
if (type === "error") {
|
|
1202
|
+
const code = typeof payload.code === "string" ? payload.code : "audio_error";
|
|
1203
|
+
const message = typeof payload.message === "string" ? payload.message : "Microphone input failed.";
|
|
1204
|
+
this.cleanupAudioCapture();
|
|
1205
|
+
this.audioTurnState = null;
|
|
1206
|
+
this.setAudioState({
|
|
1207
|
+
status: "error",
|
|
1208
|
+
error: { code, message },
|
|
1209
|
+
inputLevel: 0,
|
|
1210
|
+
isSpeaking: false,
|
|
1211
|
+
speechMs: 0,
|
|
1212
|
+
silenceMs: 0
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
async commitVoiceInput() {
|
|
1217
|
+
if (this.audioSessionStopRequested || this.audioState.status !== "listening" && this.audioState.status !== "connecting") {
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
this.audioSessionStopRequested = true;
|
|
1221
|
+
this.cleanupAudioCapture(false);
|
|
1222
|
+
if (this.audioSocket?.readyState === WebSocket.OPEN) {
|
|
1223
|
+
this.audioSocket.send(JSON.stringify({ type: "audio.commit" }));
|
|
1224
|
+
this.setAudioState({
|
|
1225
|
+
status: "transcribing",
|
|
1226
|
+
inputLevel: 0,
|
|
1227
|
+
isSpeaking: false,
|
|
1228
|
+
silenceMs: this.audioTurnState?.silenceMs ?? this.audioState.silenceMs ?? 0,
|
|
1229
|
+
speechMs: this.audioTurnState?.speechMs ?? this.audioState.speechMs ?? 0
|
|
1230
|
+
});
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
this.audioTurnState = null;
|
|
1234
|
+
this.setAudioState({
|
|
1235
|
+
status: "idle",
|
|
1236
|
+
inputLevel: 0,
|
|
1237
|
+
isSpeaking: false,
|
|
1238
|
+
speechMs: 0,
|
|
1239
|
+
silenceMs: 0
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
stopVoiceInputWithoutTranscript(message) {
|
|
1243
|
+
this.audioSessionStopRequested = true;
|
|
1244
|
+
this.cleanupAudioCapture();
|
|
1245
|
+
this.audioTurnState = null;
|
|
1246
|
+
this.setAudioState({
|
|
1247
|
+
status: "idle",
|
|
1248
|
+
transcript: "",
|
|
1249
|
+
partialTranscript: "",
|
|
1250
|
+
sessionId: null,
|
|
1251
|
+
inputLevel: 0,
|
|
1252
|
+
isSpeaking: false,
|
|
1253
|
+
speechMs: 0,
|
|
1254
|
+
silenceMs: 0,
|
|
1255
|
+
error: {
|
|
1256
|
+
code: "no_speech_detected",
|
|
1257
|
+
message
|
|
1258
|
+
}
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
createAudioTurnState(nowMs) {
|
|
1262
|
+
return {
|
|
1263
|
+
startedAtMs: nowMs,
|
|
1264
|
+
lastFrameAtMs: nowMs,
|
|
1265
|
+
speechStartedAtMs: null,
|
|
1266
|
+
lastSpeechAtMs: null,
|
|
1267
|
+
speechMs: 0,
|
|
1268
|
+
silenceMs: 0,
|
|
1269
|
+
noiseFloor: DEFAULT_AUDIO_TURN_DETECTION.speechThreshold / 2,
|
|
1270
|
+
isSpeaking: false,
|
|
1271
|
+
autoCommitted: false,
|
|
1272
|
+
lastActivityEmitMs: 0
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
analyzeAudioFrame(input, durationMs) {
|
|
1276
|
+
if (input.length === 0) {
|
|
1277
|
+
return { rms: 0, peak: 0, inputLevel: 0, durationMs };
|
|
1278
|
+
}
|
|
1279
|
+
let sumSquares = 0;
|
|
1280
|
+
let peak = 0;
|
|
1281
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
1282
|
+
const sample = input[index];
|
|
1283
|
+
const abs = Math.abs(sample);
|
|
1284
|
+
sumSquares += sample * sample;
|
|
1285
|
+
if (abs > peak) {
|
|
1286
|
+
peak = abs;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
const rms = Math.sqrt(sumSquares / input.length);
|
|
1290
|
+
return {
|
|
1291
|
+
rms,
|
|
1292
|
+
peak,
|
|
1293
|
+
inputLevel: Math.min(1, rms * 14),
|
|
1294
|
+
durationMs
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
processAudioTurnActivity(activity, nowMs) {
|
|
1298
|
+
const config = this.getAudioTurnDetectionConfig();
|
|
1299
|
+
if (!config.enabled) {
|
|
1300
|
+
this.emitAudioActivity(activity, nowMs, false, 0, 0, DEFAULT_AUDIO_TURN_DETECTION.speechThreshold / 2);
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
const state = this.audioTurnState ?? this.createAudioTurnState(nowMs);
|
|
1304
|
+
this.audioTurnState = state;
|
|
1305
|
+
const frameGapMs = Math.max(1, nowMs - state.lastFrameAtMs);
|
|
1306
|
+
const frameDurationMs = Math.max(activity.durationMs, frameGapMs);
|
|
1307
|
+
state.lastFrameAtMs = nowMs;
|
|
1308
|
+
const adaptiveThreshold = Math.max(
|
|
1309
|
+
config.speechThreshold,
|
|
1310
|
+
Math.min(0.08, state.noiseFloor * config.noiseMultiplier)
|
|
1311
|
+
);
|
|
1312
|
+
const peakThreshold = Math.max(adaptiveThreshold * 2.2, config.speechThreshold * 2.5);
|
|
1313
|
+
const speechCandidate = activity.rms >= adaptiveThreshold || activity.rms >= adaptiveThreshold * 0.72 && activity.peak >= peakThreshold;
|
|
1314
|
+
if (speechCandidate) {
|
|
1315
|
+
if (state.speechStartedAtMs === null) {
|
|
1316
|
+
state.speechStartedAtMs = nowMs;
|
|
1317
|
+
state.speechMs = 0;
|
|
1318
|
+
}
|
|
1319
|
+
state.lastSpeechAtMs = nowMs;
|
|
1320
|
+
state.speechMs += frameDurationMs;
|
|
1321
|
+
state.silenceMs = 0;
|
|
1322
|
+
state.isSpeaking = true;
|
|
1323
|
+
} else {
|
|
1324
|
+
state.noiseFloor = this.updateNoiseFloor(state.noiseFloor, activity.rms);
|
|
1325
|
+
state.isSpeaking = false;
|
|
1326
|
+
if (state.lastSpeechAtMs !== null) {
|
|
1327
|
+
state.silenceMs = nowMs - state.lastSpeechAtMs;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
if (state.speechStartedAtMs !== null && state.speechMs < config.minSpeechDurationMs && state.silenceMs >= config.silenceDurationMs) {
|
|
1331
|
+
state.speechStartedAtMs = null;
|
|
1332
|
+
state.lastSpeechAtMs = null;
|
|
1333
|
+
state.speechMs = 0;
|
|
1334
|
+
state.silenceMs = 0;
|
|
1335
|
+
}
|
|
1336
|
+
this.emitAudioActivity(
|
|
1337
|
+
activity,
|
|
1338
|
+
nowMs,
|
|
1339
|
+
state.isSpeaking,
|
|
1340
|
+
state.speechMs,
|
|
1341
|
+
state.silenceMs,
|
|
1342
|
+
state.noiseFloor
|
|
1343
|
+
);
|
|
1344
|
+
const noSpeechElapsedMs = nowMs - state.startedAtMs;
|
|
1345
|
+
if (config.noSpeechTimeoutMs > 0 && state.speechStartedAtMs === null && noSpeechElapsedMs >= config.noSpeechTimeoutMs && !state.autoCommitted) {
|
|
1346
|
+
state.autoCommitted = true;
|
|
1347
|
+
this.stopVoiceInputWithoutTranscript("No speech was detected. Try again when you are ready to speak.");
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
if (config.autoSubmit && state.speechStartedAtMs !== null && state.speechMs >= config.minSpeechDurationMs && state.silenceMs >= config.silenceDurationMs && !state.autoCommitted) {
|
|
1351
|
+
state.autoCommitted = true;
|
|
1352
|
+
void this.commitVoiceInput();
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
updateNoiseFloor(currentNoiseFloor, rms) {
|
|
1356
|
+
const sample = Math.max(15e-4, Math.min(0.08, rms));
|
|
1357
|
+
const smoothing = sample > currentNoiseFloor ? 0.02 : 0.12;
|
|
1358
|
+
return currentNoiseFloor * (1 - smoothing) + sample * smoothing;
|
|
1359
|
+
}
|
|
1360
|
+
emitAudioActivity(activity, nowMs, isSpeaking, speechMs, silenceMs, noiseFloor) {
|
|
1361
|
+
const state = this.audioTurnState;
|
|
1362
|
+
const shouldEmitState = !state || nowMs - state.lastActivityEmitMs >= AUDIO_ACTIVITY_EMIT_INTERVAL_MS || isSpeaking !== this.audioState.isSpeaking || Math.abs(activity.inputLevel - (this.audioState.inputLevel ?? 0)) >= MIN_AUDIO_LEVEL_DELTA_FOR_STATE;
|
|
1363
|
+
if (!shouldEmitState) {
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
if (state) {
|
|
1367
|
+
state.lastActivityEmitMs = nowMs;
|
|
1368
|
+
}
|
|
1369
|
+
const roundedLevel = Number(activity.inputLevel.toFixed(3));
|
|
1370
|
+
const roundedNoiseFloor = Number(noiseFloor.toFixed(5));
|
|
1371
|
+
this.setAudioState({
|
|
1372
|
+
inputLevel: roundedLevel,
|
|
1373
|
+
isSpeaking,
|
|
1374
|
+
speechMs: Math.round(speechMs),
|
|
1375
|
+
silenceMs: Math.round(silenceMs),
|
|
1376
|
+
autoSubmitEnabled: this.isAudioAutoSubmitEnabled()
|
|
1377
|
+
});
|
|
1378
|
+
this.emit("audio_activity", {
|
|
1379
|
+
inputLevel: roundedLevel,
|
|
1380
|
+
noiseFloor: roundedNoiseFloor,
|
|
1381
|
+
isSpeaking,
|
|
1382
|
+
speechMs: Math.round(speechMs),
|
|
1383
|
+
silenceMs: Math.round(silenceMs)
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
async startAudioCapture(stream, socket) {
|
|
1387
|
+
const AudioContextCtor = this.getAudioContextConstructor();
|
|
1388
|
+
if (!AudioContextCtor) {
|
|
1389
|
+
throw new Error("Audio capture is not available in this browser.");
|
|
1390
|
+
}
|
|
1391
|
+
const context = new AudioContextCtor();
|
|
1392
|
+
if (context.state === "suspended") {
|
|
1393
|
+
await context.resume();
|
|
1394
|
+
}
|
|
1395
|
+
const source = context.createMediaStreamSource(stream);
|
|
1396
|
+
const processor = context.createScriptProcessor(4096, 1, 1);
|
|
1397
|
+
const silentGain = context.createGain();
|
|
1398
|
+
silentGain.gain.value = 0;
|
|
1399
|
+
this.audioTurnState = this.createAudioTurnState(performance.now());
|
|
1400
|
+
processor.onaudioprocess = (event) => {
|
|
1401
|
+
if (this.audioSessionStopRequested || socket.readyState !== WebSocket.OPEN || this.audioState.status !== "listening") {
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
const input = event.inputBuffer.getChannelData(0);
|
|
1405
|
+
const durationMs = input.length / context.sampleRate * 1e3;
|
|
1406
|
+
this.processAudioTurnActivity(
|
|
1407
|
+
this.analyzeAudioFrame(input, durationMs),
|
|
1408
|
+
performance.now()
|
|
1409
|
+
);
|
|
1410
|
+
if (this.audioSessionStopRequested) {
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
const resampled = this.resampleToPcm16(input, context.sampleRate, 24e3);
|
|
1414
|
+
if (resampled.length === 0) {
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
socket.send(JSON.stringify({
|
|
1418
|
+
type: "audio.append",
|
|
1419
|
+
audio: this.pcm16ToBase64(resampled)
|
|
1420
|
+
}));
|
|
1421
|
+
};
|
|
1422
|
+
source.connect(processor);
|
|
1423
|
+
processor.connect(silentGain);
|
|
1424
|
+
silentGain.connect(context.destination);
|
|
1425
|
+
this.audioContext = context;
|
|
1426
|
+
this.audioSource = source;
|
|
1427
|
+
this.audioProcessor = processor;
|
|
1428
|
+
this.audioSilentGain = silentGain;
|
|
1429
|
+
}
|
|
1430
|
+
resampleToPcm16(input, inputSampleRate, outputSampleRate) {
|
|
1431
|
+
if (inputSampleRate === outputSampleRate) {
|
|
1432
|
+
return this.floatToPcm16(input);
|
|
1433
|
+
}
|
|
1434
|
+
const ratio = inputSampleRate / outputSampleRate;
|
|
1435
|
+
const outputLength = Math.floor(input.length / ratio);
|
|
1436
|
+
const output = new Float32Array(outputLength);
|
|
1437
|
+
for (let index = 0; index < outputLength; index += 1) {
|
|
1438
|
+
const inputIndex = index * ratio;
|
|
1439
|
+
const lower = Math.floor(inputIndex);
|
|
1440
|
+
const upper = Math.min(lower + 1, input.length - 1);
|
|
1441
|
+
const weight = inputIndex - lower;
|
|
1442
|
+
output[index] = input[lower] * (1 - weight) + input[upper] * weight;
|
|
1443
|
+
}
|
|
1444
|
+
return this.floatToPcm16(output);
|
|
1445
|
+
}
|
|
1446
|
+
floatToPcm16(input) {
|
|
1447
|
+
const output = new Int16Array(input.length);
|
|
1448
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
1449
|
+
const sample = Math.max(-1, Math.min(1, input[index]));
|
|
1450
|
+
output[index] = sample < 0 ? sample * 32768 : sample * 32767;
|
|
1451
|
+
}
|
|
1452
|
+
return output;
|
|
1453
|
+
}
|
|
1454
|
+
pcm16ToBase64(input) {
|
|
1455
|
+
const bytes = new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
1456
|
+
let binary = "";
|
|
1457
|
+
const chunkSize = 32768;
|
|
1458
|
+
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
|
1459
|
+
const chunk = bytes.subarray(offset, offset + chunkSize);
|
|
1460
|
+
binary += String.fromCharCode(...chunk);
|
|
1461
|
+
}
|
|
1462
|
+
return btoa(binary);
|
|
1463
|
+
}
|
|
1464
|
+
cleanupAudioCapture(closeSocket = true) {
|
|
1465
|
+
this.clearAudioSessionTimer();
|
|
1466
|
+
this.audioProcessor?.disconnect();
|
|
1467
|
+
this.audioSource?.disconnect();
|
|
1468
|
+
this.audioSilentGain?.disconnect();
|
|
1469
|
+
this.audioProcessor = null;
|
|
1470
|
+
this.audioSource = null;
|
|
1471
|
+
this.audioSilentGain = null;
|
|
1472
|
+
if (this.audioContext) {
|
|
1473
|
+
void this.audioContext.close().catch(() => void 0);
|
|
1474
|
+
this.audioContext = null;
|
|
1475
|
+
}
|
|
1476
|
+
this.audioStream?.getTracks().forEach((track) => track.stop());
|
|
1477
|
+
this.audioStream = null;
|
|
1478
|
+
if (closeSocket && this.audioSocket) {
|
|
1479
|
+
const socket = this.audioSocket;
|
|
1480
|
+
this.audioSocket = null;
|
|
1481
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
1482
|
+
socket.send(JSON.stringify({ type: "audio.close" }));
|
|
1483
|
+
socket.close();
|
|
1484
|
+
} else if (socket.readyState === WebSocket.CONNECTING) {
|
|
1485
|
+
socket.close();
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
clearAudioSessionTimer() {
|
|
1490
|
+
if (!this.audioSessionTimer) {
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
clearTimeout(this.audioSessionTimer);
|
|
1494
|
+
this.audioSessionTimer = null;
|
|
1495
|
+
}
|
|
1496
|
+
extractErrorCode(error) {
|
|
1497
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
1498
|
+
const code = error.code;
|
|
1499
|
+
return typeof code === "string" ? code : null;
|
|
1500
|
+
}
|
|
1501
|
+
return null;
|
|
1502
|
+
}
|
|
1503
|
+
getPersistentStorage() {
|
|
1504
|
+
return this.config.storage ?? null;
|
|
1505
|
+
}
|
|
1506
|
+
async resolveOAuthClientRegistration(authConfig) {
|
|
1507
|
+
if (!authConfig || authConfig.authType !== "oauth2") {
|
|
1508
|
+
return authConfig ?? null;
|
|
1509
|
+
}
|
|
1510
|
+
const registration = await resolveOAuthRegistration(authConfig, {
|
|
1511
|
+
callbackUrl: this.getOAuthCallbackUrl(),
|
|
1512
|
+
oauthClientMetadataUrl: this.config.oauthClientMetadataUrl,
|
|
1513
|
+
clientName: "MCP Stack MCP Client",
|
|
1514
|
+
clientUri: "https://mcpstack.com",
|
|
1515
|
+
storage: this.getPersistentStorage()
|
|
1516
|
+
});
|
|
1517
|
+
return applyResolvedRegistration(authConfig, registration);
|
|
1518
|
+
}
|
|
1519
|
+
/** Subscribe to events */
|
|
1520
|
+
on(event, handler) {
|
|
1521
|
+
if (!this.listeners.has(event)) {
|
|
1522
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
1523
|
+
}
|
|
1524
|
+
this.listeners.get(event).add(handler);
|
|
1525
|
+
}
|
|
1526
|
+
/** Unsubscribe from events */
|
|
1527
|
+
off(event, handler) {
|
|
1528
|
+
this.listeners.get(event)?.delete(handler);
|
|
1529
|
+
}
|
|
1530
|
+
// ================================================================
|
|
1531
|
+
// Private methods
|
|
1532
|
+
// ================================================================
|
|
1533
|
+
buildProtectedResourceMetadataCandidates(mcpServerUrl) {
|
|
1534
|
+
const url = new URL(mcpServerUrl);
|
|
1535
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
1536
|
+
const normalizedPath = url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname;
|
|
1537
|
+
if (!normalizedPath || normalizedPath === "/") {
|
|
1538
|
+
candidates.add(`${url.origin}/.well-known/oauth-protected-resource`);
|
|
1539
|
+
return [...candidates];
|
|
1540
|
+
}
|
|
1541
|
+
candidates.add(`${url.origin}/.well-known/oauth-protected-resource${normalizedPath}`);
|
|
1542
|
+
candidates.add(`${url.origin}${normalizedPath}/.well-known/oauth-protected-resource`);
|
|
1543
|
+
candidates.add(`${url.origin}/.well-known/oauth-protected-resource`);
|
|
1544
|
+
return [...candidates];
|
|
1545
|
+
}
|
|
1546
|
+
hasSameOrigin(left, right) {
|
|
1547
|
+
try {
|
|
1548
|
+
const leftUrl = new URL(left);
|
|
1549
|
+
const rightUrl = new URL(right);
|
|
1550
|
+
return leftUrl.origin === rightUrl.origin;
|
|
1551
|
+
} catch {
|
|
1552
|
+
return false;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
extractQuotedHeaderValue(header, key) {
|
|
1556
|
+
const match = header.match(new RegExp(`${key}="([^"]+)"`, "i"));
|
|
1557
|
+
return match?.[1] ?? null;
|
|
1558
|
+
}
|
|
1559
|
+
buildAuthorizationServerMetadataCandidates(issuerOrMetadataUrl) {
|
|
1560
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
1561
|
+
if (issuerOrMetadataUrl.includes("/.well-known/oauth-authorization-server") || issuerOrMetadataUrl.includes("/.well-known/openid-configuration")) {
|
|
1562
|
+
candidates.add(issuerOrMetadataUrl);
|
|
1563
|
+
return [...candidates];
|
|
1564
|
+
}
|
|
1565
|
+
const url = new URL(issuerOrMetadataUrl);
|
|
1566
|
+
const normalizedPath = url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname;
|
|
1567
|
+
if (!normalizedPath || normalizedPath === "/") {
|
|
1568
|
+
candidates.add(`${url.origin}/.well-known/oauth-authorization-server`);
|
|
1569
|
+
candidates.add(`${url.origin}/.well-known/openid-configuration`);
|
|
1570
|
+
return [...candidates];
|
|
1571
|
+
}
|
|
1572
|
+
candidates.add(`${url.origin}/.well-known/oauth-authorization-server${normalizedPath}`);
|
|
1573
|
+
candidates.add(`${url.origin}/.well-known/openid-configuration${normalizedPath}`);
|
|
1574
|
+
candidates.add(`${url.origin}${normalizedPath}/.well-known/openid-configuration`);
|
|
1575
|
+
return [...candidates];
|
|
1576
|
+
}
|
|
1577
|
+
hasExplicitManualOverride(manualConfig, field) {
|
|
1578
|
+
return manualConfig?.manualOverrides?.includes(field) ?? false;
|
|
1579
|
+
}
|
|
1580
|
+
pickAuthConfigValue(field, manualConfig, manualValue, discoveredValue) {
|
|
1581
|
+
if (this.hasExplicitManualOverride(manualConfig, field) && manualValue != null) {
|
|
1582
|
+
return manualValue;
|
|
1583
|
+
}
|
|
1584
|
+
return discoveredValue ?? manualValue;
|
|
1585
|
+
}
|
|
1586
|
+
pickAuthConfigArrayValue(field, manualConfig, manualValue, discoveredValue) {
|
|
1587
|
+
if (this.hasExplicitManualOverride(manualConfig, field) && manualValue?.length) {
|
|
1588
|
+
return manualValue;
|
|
1589
|
+
}
|
|
1590
|
+
if (discoveredValue?.length) {
|
|
1591
|
+
return discoveredValue;
|
|
1592
|
+
}
|
|
1593
|
+
return manualValue?.length ? manualValue : void 0;
|
|
1594
|
+
}
|
|
1595
|
+
mergeAuthConfigs(manualConfig, discoveredConfig) {
|
|
1596
|
+
if (!manualConfig && !discoveredConfig) return null;
|
|
1597
|
+
const manualOverrides = new Set(manualConfig?.manualOverrides ?? []);
|
|
1598
|
+
const authorizationEndpoint = this.pickAuthConfigValue(
|
|
1599
|
+
"authorizationEndpoint",
|
|
1600
|
+
manualConfig,
|
|
1601
|
+
manualConfig?.authorizationEndpoint ?? manualConfig?.loginUrl,
|
|
1602
|
+
discoveredConfig?.authorizationEndpoint ?? discoveredConfig?.loginUrl
|
|
1603
|
+
);
|
|
1604
|
+
const tokenEndpoint = this.pickAuthConfigValue(
|
|
1605
|
+
"tokenEndpoint",
|
|
1606
|
+
manualConfig,
|
|
1607
|
+
manualConfig?.tokenEndpoint ?? manualConfig?.tokenUrl,
|
|
1608
|
+
discoveredConfig?.tokenEndpoint ?? discoveredConfig?.tokenUrl
|
|
1609
|
+
);
|
|
1610
|
+
const callbackUrl = this.pickAuthConfigValue(
|
|
1611
|
+
"callbackUrl",
|
|
1612
|
+
manualConfig,
|
|
1613
|
+
manualConfig?.callbackUrl,
|
|
1614
|
+
discoveredConfig?.callbackUrl
|
|
1615
|
+
) ?? this.getOAuthCallbackUrl();
|
|
1616
|
+
const merged = {
|
|
1617
|
+
authType: manualConfig?.authType ?? discoveredConfig?.authType ?? "oauth2",
|
|
1618
|
+
issuer: discoveredConfig?.issuer ?? manualConfig?.issuer,
|
|
1619
|
+
authorizationServerUrl: this.pickAuthConfigValue(
|
|
1620
|
+
"authorizationServerUrl",
|
|
1621
|
+
manualConfig,
|
|
1622
|
+
manualConfig?.authorizationServerUrl,
|
|
1623
|
+
discoveredConfig?.authorizationServerUrl
|
|
1624
|
+
),
|
|
1625
|
+
authorizationServerMetadataUrl: this.pickAuthConfigValue(
|
|
1626
|
+
"authorizationServerMetadataUrl",
|
|
1627
|
+
manualConfig,
|
|
1628
|
+
manualConfig?.authorizationServerMetadataUrl,
|
|
1629
|
+
discoveredConfig?.authorizationServerMetadataUrl
|
|
1630
|
+
),
|
|
1631
|
+
authorizationEndpoint,
|
|
1632
|
+
loginUrl: authorizationEndpoint,
|
|
1633
|
+
tokenEndpoint,
|
|
1634
|
+
tokenUrl: tokenEndpoint,
|
|
1635
|
+
registrationEndpoint: this.pickAuthConfigValue(
|
|
1636
|
+
"registrationEndpoint",
|
|
1637
|
+
manualConfig,
|
|
1638
|
+
manualConfig?.registrationEndpoint,
|
|
1639
|
+
discoveredConfig?.registrationEndpoint
|
|
1640
|
+
),
|
|
1641
|
+
clientId: this.pickAuthConfigValue(
|
|
1642
|
+
"clientId",
|
|
1643
|
+
manualConfig,
|
|
1644
|
+
manualConfig?.clientId,
|
|
1645
|
+
discoveredConfig?.clientId
|
|
1646
|
+
),
|
|
1647
|
+
scopes: this.pickAuthConfigArrayValue(
|
|
1648
|
+
"scopes",
|
|
1649
|
+
manualConfig,
|
|
1650
|
+
manualConfig?.scopes,
|
|
1651
|
+
discoveredConfig?.scopes
|
|
1652
|
+
),
|
|
1653
|
+
resource: this.pickAuthConfigValue(
|
|
1654
|
+
"resource",
|
|
1655
|
+
manualConfig,
|
|
1656
|
+
manualConfig?.resource,
|
|
1657
|
+
discoveredConfig?.resource
|
|
1658
|
+
),
|
|
1659
|
+
callbackUrl,
|
|
1660
|
+
protectedResourceMetadataUrl: discoveredConfig?.protectedResourceMetadataUrl ?? manualConfig?.protectedResourceMetadataUrl,
|
|
1661
|
+
clientIdMetadataDocumentSupported: discoveredConfig?.clientIdMetadataDocumentSupported ?? manualConfig?.clientIdMetadataDocumentSupported,
|
|
1662
|
+
resourceParameterSupported: discoveredConfig?.resourceParameterSupported ?? manualConfig?.resourceParameterSupported,
|
|
1663
|
+
registrationPreference: manualConfig?.registrationPreference ?? discoveredConfig?.registrationPreference ?? "auto",
|
|
1664
|
+
clientMode: discoveredConfig?.clientMode ?? manualConfig?.clientMode,
|
|
1665
|
+
authRecipe: manualConfig?.authRecipe ?? discoveredConfig?.authRecipe,
|
|
1666
|
+
manualOverrides: manualOverrides.size ? [...manualOverrides] : void 0,
|
|
1667
|
+
discovered: discoveredConfig?.discovered ?? false
|
|
1668
|
+
};
|
|
1669
|
+
return merged;
|
|
1670
|
+
}
|
|
1671
|
+
async discoverAuthConfig(mcpServerUrl, manualConfig) {
|
|
1672
|
+
let protectedResourceUrl = null;
|
|
1673
|
+
let protectedResource = null;
|
|
1674
|
+
const protectedResourceCandidates = /* @__PURE__ */ new Set();
|
|
1675
|
+
if (manualConfig?.protectedResourceMetadataUrl) {
|
|
1676
|
+
protectedResourceCandidates.add(manualConfig.protectedResourceMetadataUrl);
|
|
1677
|
+
}
|
|
1678
|
+
for (const candidate of this.buildProtectedResourceMetadataCandidates(mcpServerUrl)) {
|
|
1679
|
+
protectedResourceCandidates.add(candidate);
|
|
1680
|
+
}
|
|
1681
|
+
for (const candidate of protectedResourceCandidates) {
|
|
1682
|
+
try {
|
|
1683
|
+
const response = await fetch(candidate, {
|
|
1684
|
+
method: "GET",
|
|
1685
|
+
headers: { Accept: "application/json" }
|
|
1686
|
+
});
|
|
1687
|
+
if (response.ok) {
|
|
1688
|
+
protectedResource = await response.json();
|
|
1689
|
+
protectedResourceUrl = candidate;
|
|
1690
|
+
break;
|
|
1691
|
+
}
|
|
1692
|
+
const authenticateHeader = response.headers.get("www-authenticate");
|
|
1693
|
+
if (authenticateHeader) {
|
|
1694
|
+
const resourceMetadataUrl = this.extractQuotedHeaderValue(authenticateHeader, "resource_metadata");
|
|
1695
|
+
if (resourceMetadataUrl && this.hasSameOrigin(candidate, resourceMetadataUrl)) {
|
|
1696
|
+
const metadataResponse = await fetch(resourceMetadataUrl, {
|
|
1697
|
+
method: "GET",
|
|
1698
|
+
headers: { Accept: "application/json" }
|
|
1699
|
+
});
|
|
1700
|
+
if (metadataResponse.ok) {
|
|
1701
|
+
protectedResource = await metadataResponse.json();
|
|
1702
|
+
protectedResourceUrl = resourceMetadataUrl;
|
|
1703
|
+
break;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
} catch {
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
const authServerUrl = protectedResource?.authorization_servers?.[0] ?? protectedResource?.authorization_server ?? manualConfig?.authorizationServerUrl;
|
|
1711
|
+
try {
|
|
1712
|
+
let metadata = null;
|
|
1713
|
+
let metadataUrl = manualConfig?.authorizationServerMetadataUrl ?? null;
|
|
1714
|
+
const metadataCandidates = /* @__PURE__ */ new Set();
|
|
1715
|
+
if (manualConfig?.authorizationServerMetadataUrl) {
|
|
1716
|
+
metadataCandidates.add(manualConfig.authorizationServerMetadataUrl);
|
|
1717
|
+
}
|
|
1718
|
+
if (authServerUrl) {
|
|
1719
|
+
for (const candidate of this.buildAuthorizationServerMetadataCandidates(authServerUrl)) {
|
|
1720
|
+
metadataCandidates.add(candidate);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
for (const candidate of metadataCandidates) {
|
|
1724
|
+
try {
|
|
1725
|
+
const response = await fetch(candidate, {
|
|
1726
|
+
method: "GET",
|
|
1727
|
+
headers: { Accept: "application/json" }
|
|
1728
|
+
});
|
|
1729
|
+
if (response.ok) {
|
|
1730
|
+
metadata = await response.json();
|
|
1731
|
+
metadataUrl = candidate;
|
|
1732
|
+
break;
|
|
1733
|
+
}
|
|
1734
|
+
} catch {
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
if (!metadata && !protectedResource && !manualConfig) {
|
|
1738
|
+
return null;
|
|
1739
|
+
}
|
|
1740
|
+
return {
|
|
1741
|
+
authType: "oauth2",
|
|
1742
|
+
issuer: metadata?.issuer ?? authServerUrl ?? manualConfig?.issuer,
|
|
1743
|
+
authorizationServerUrl: authServerUrl ?? manualConfig?.authorizationServerUrl,
|
|
1744
|
+
authorizationServerMetadataUrl: metadataUrl ?? void 0,
|
|
1745
|
+
authorizationEndpoint: metadata?.authorization_endpoint ?? manualConfig?.authorizationEndpoint ?? manualConfig?.loginUrl,
|
|
1746
|
+
loginUrl: metadata?.authorization_endpoint ?? manualConfig?.loginUrl ?? manualConfig?.authorizationEndpoint,
|
|
1747
|
+
tokenEndpoint: metadata?.token_endpoint ?? manualConfig?.tokenEndpoint ?? manualConfig?.tokenUrl,
|
|
1748
|
+
tokenUrl: metadata?.token_endpoint ?? manualConfig?.tokenUrl ?? manualConfig?.tokenEndpoint,
|
|
1749
|
+
registrationEndpoint: metadata?.registration_endpoint ?? manualConfig?.registrationEndpoint,
|
|
1750
|
+
clientId: manualConfig?.clientId,
|
|
1751
|
+
scopes: protectedResource?.scopes_supported?.length ? protectedResource.scopes_supported : metadata?.scopes_supported ?? manualConfig?.scopes,
|
|
1752
|
+
resource: protectedResource?.resource ?? manualConfig?.resource,
|
|
1753
|
+
callbackUrl: getEffectiveCallbackUrl(manualConfig, this.getOAuthCallbackUrl()),
|
|
1754
|
+
protectedResourceMetadataUrl: protectedResourceUrl ?? manualConfig?.protectedResourceMetadataUrl,
|
|
1755
|
+
clientIdMetadataDocumentSupported: metadata?.client_id_metadata_document_supported ?? manualConfig?.clientIdMetadataDocumentSupported,
|
|
1756
|
+
resourceParameterSupported: metadata?.resource_parameter_supported ?? manualConfig?.resourceParameterSupported,
|
|
1757
|
+
registrationPreference: manualConfig?.registrationPreference ?? "auto",
|
|
1758
|
+
authRecipe: manualConfig?.authRecipe,
|
|
1759
|
+
discovered: Boolean(protectedResource || metadata)
|
|
1760
|
+
};
|
|
1761
|
+
} catch {
|
|
1762
|
+
return manualConfig ? {
|
|
1763
|
+
...manualConfig,
|
|
1764
|
+
callbackUrl: getEffectiveCallbackUrl(manualConfig, this.getOAuthCallbackUrl()),
|
|
1765
|
+
protectedResourceMetadataUrl: protectedResourceUrl ?? manualConfig.protectedResourceMetadataUrl,
|
|
1766
|
+
resource: protectedResource?.resource ?? manualConfig.resource,
|
|
1767
|
+
scopes: protectedResource?.scopes_supported?.length ? protectedResource.scopes_supported : manualConfig.scopes,
|
|
1768
|
+
discovered: Boolean(protectedResource)
|
|
1769
|
+
} : null;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
async resolveServerAuthConfig(mcpServerUrl, manualConfig) {
|
|
1773
|
+
const discoveredConfig = await this.discoverAuthConfig(mcpServerUrl, manualConfig);
|
|
1774
|
+
return this.mergeAuthConfigs(manualConfig, discoveredConfig);
|
|
1775
|
+
}
|
|
1776
|
+
async ensureServerAuthConfig(mcpServerUrl) {
|
|
1777
|
+
const server = this.agentConfig?.mcpServers?.find((item) => item.url === mcpServerUrl);
|
|
1778
|
+
if (!server) {
|
|
1779
|
+
return null;
|
|
1780
|
+
}
|
|
1781
|
+
if (server.authConfig && (server.authConfig.authType !== "oauth2" || server.authConfig.discovered || !!server.authConfig.authorizationEndpoint || !!server.authConfig.tokenEndpoint || !!server.authConfig.tokenUrl || !!server.authConfig.registrationEndpoint || !!server.authConfig.resource)) {
|
|
1782
|
+
return server.authConfig;
|
|
1783
|
+
}
|
|
1784
|
+
server.authConfig = await this.resolveServerAuthConfig(mcpServerUrl, server.authConfig ?? null);
|
|
1785
|
+
return server.authConfig;
|
|
1786
|
+
}
|
|
1787
|
+
updateServerAuthConfig(mcpServerUrl, authConfig) {
|
|
1788
|
+
const server = this.agentConfig?.mcpServers?.find((item) => item.url === mcpServerUrl);
|
|
1789
|
+
if (server) {
|
|
1790
|
+
server.authConfig = authConfig;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
emit(event, data) {
|
|
1794
|
+
this.listeners.get(event)?.forEach((handler) => handler(data));
|
|
1795
|
+
}
|
|
1796
|
+
// ================================================================
|
|
1797
|
+
// OAuth Token Storage (standalone mode only)
|
|
1798
|
+
// ================================================================
|
|
1799
|
+
/** Generate the legacy token cache suffix used before auth-aware keying. */
|
|
1800
|
+
hashUrl(url) {
|
|
1801
|
+
return btoa(url).replace(/[^a-zA-Z0-9]/g, "").slice(0, 32);
|
|
1802
|
+
}
|
|
1803
|
+
getLegacyTokenStorageKey(mcpServerUrl) {
|
|
1804
|
+
return buildScopedOAuthTokenStorageKey(this.hashUrl(mcpServerUrl));
|
|
1805
|
+
}
|
|
1806
|
+
getAuthSessionKey() {
|
|
1807
|
+
return resolveExplicitAuthSessionKey(this.config);
|
|
1808
|
+
}
|
|
1809
|
+
getTokenStorageKey(mcpServerUrl, authConfig) {
|
|
1810
|
+
return buildScopedOAuthTokenStorageKey(
|
|
1811
|
+
buildTokenCacheKey(authConfig, mcpServerUrl),
|
|
1812
|
+
this.getAuthSessionKey()
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
getTokenStorageCandidates(mcpServerUrl) {
|
|
1816
|
+
const currentAuthConfig = this.getServerAuthConfig(mcpServerUrl);
|
|
1817
|
+
const hasExplicitAuthSessionKey = this.getAuthSessionKey() !== null;
|
|
1818
|
+
if (!currentAuthConfig) {
|
|
1819
|
+
return hasExplicitAuthSessionKey ? [{
|
|
1820
|
+
storageKey: this.getTokenStorageKey(mcpServerUrl, null),
|
|
1821
|
+
authConfig: null
|
|
1822
|
+
}] : [{
|
|
1823
|
+
storageKey: this.getLegacyTokenStorageKey(mcpServerUrl),
|
|
1824
|
+
authConfig: null
|
|
1825
|
+
}];
|
|
1826
|
+
}
|
|
1827
|
+
const callbackUrl = getEffectiveCallbackUrl(currentAuthConfig, this.getOAuthCallbackUrl());
|
|
1828
|
+
const candidates = [currentAuthConfig];
|
|
1829
|
+
if (currentAuthConfig.authType === "oauth2") {
|
|
1830
|
+
candidates.push({
|
|
1831
|
+
...currentAuthConfig,
|
|
1832
|
+
clientMode: "manual",
|
|
1833
|
+
callbackUrl
|
|
1834
|
+
});
|
|
1835
|
+
if (currentAuthConfig.clientId) {
|
|
1836
|
+
candidates.push({
|
|
1837
|
+
...currentAuthConfig,
|
|
1838
|
+
clientMode: "preregistered",
|
|
1839
|
+
callbackUrl
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
if (currentAuthConfig.clientIdMetadataDocumentSupported && this.config.oauthClientMetadataUrl) {
|
|
1843
|
+
candidates.push({
|
|
1844
|
+
...currentAuthConfig,
|
|
1845
|
+
clientId: this.config.oauthClientMetadataUrl,
|
|
1846
|
+
clientMode: "cimd",
|
|
1847
|
+
callbackUrl
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
if (currentAuthConfig.registrationEndpoint) {
|
|
1851
|
+
const cacheKey = buildRegistrationCacheKey(currentAuthConfig, callbackUrl, "dcr");
|
|
1852
|
+
const storedRegistration = loadStoredRegistration(cacheKey, this.getPersistentStorage());
|
|
1853
|
+
if (storedRegistration?.clientId) {
|
|
1854
|
+
candidates.push(applyResolvedRegistration(currentAuthConfig, {
|
|
1855
|
+
cacheKey,
|
|
1856
|
+
mode: "dcr",
|
|
1857
|
+
clientId: storedRegistration.clientId,
|
|
1858
|
+
callbackUrl,
|
|
1859
|
+
resource: currentAuthConfig.resource,
|
|
1860
|
+
authorizationServerUrl: currentAuthConfig.authorizationServerUrl,
|
|
1861
|
+
authorizationServerMetadataUrl: currentAuthConfig.authorizationServerMetadataUrl,
|
|
1862
|
+
registrationEndpoint: currentAuthConfig.registrationEndpoint
|
|
1863
|
+
}));
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1868
|
+
const resolved = candidates.map((candidate) => ({
|
|
1869
|
+
storageKey: this.getTokenStorageKey(mcpServerUrl, candidate),
|
|
1870
|
+
authConfig: candidate
|
|
1871
|
+
})).filter((candidate) => {
|
|
1872
|
+
if (seen.has(candidate.storageKey)) return false;
|
|
1873
|
+
seen.add(candidate.storageKey);
|
|
1874
|
+
return true;
|
|
1875
|
+
});
|
|
1876
|
+
if (!hasExplicitAuthSessionKey) {
|
|
1877
|
+
resolved.push({
|
|
1878
|
+
storageKey: this.getLegacyTokenStorageKey(mcpServerUrl),
|
|
1879
|
+
authConfig: currentAuthConfig
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
return resolved;
|
|
1883
|
+
}
|
|
1884
|
+
/** Load OAuth token from memory/persistent storage using auth-aware cache keying. */
|
|
1885
|
+
loadOAuthToken(mcpServerUrl) {
|
|
1886
|
+
for (const candidate of this.getTokenStorageCandidates(mcpServerUrl)) {
|
|
1887
|
+
const cached = this.oauthTokens.get(candidate.storageKey);
|
|
1888
|
+
if (cached) {
|
|
1889
|
+
if (candidate.authConfig) {
|
|
1890
|
+
this.updateServerAuthConfig(mcpServerUrl, candidate.authConfig);
|
|
1891
|
+
}
|
|
1892
|
+
return cached;
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
try {
|
|
1896
|
+
const storage = this.getPersistentStorage() ?? (typeof localStorage !== "undefined" ? localStorage : null);
|
|
1897
|
+
if (storage) {
|
|
1898
|
+
for (const candidate of this.getTokenStorageCandidates(mcpServerUrl)) {
|
|
1899
|
+
const stored = storage.getItem(candidate.storageKey);
|
|
1900
|
+
if (stored) {
|
|
1901
|
+
const data = JSON.parse(stored);
|
|
1902
|
+
if (data.accessToken && data.expiresAt) {
|
|
1903
|
+
this.oauthTokens.set(candidate.storageKey, data);
|
|
1904
|
+
if (candidate.authConfig) {
|
|
1905
|
+
this.updateServerAuthConfig(mcpServerUrl, candidate.authConfig);
|
|
1906
|
+
}
|
|
1907
|
+
return data;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
} catch {
|
|
1913
|
+
}
|
|
1914
|
+
return null;
|
|
1915
|
+
}
|
|
1916
|
+
/** Check if we have a valid (non-expired) OAuth token */
|
|
1917
|
+
hasValidOAuthToken(mcpServerUrl) {
|
|
1918
|
+
const token = this.loadOAuthToken(mcpServerUrl);
|
|
1919
|
+
if (!token) return false;
|
|
1920
|
+
return token.expiresAt > Date.now() || !!token.refreshToken;
|
|
1921
|
+
}
|
|
1922
|
+
/** Store OAuth token to memory and persistent storage */
|
|
1923
|
+
storeOAuthToken(mcpServerUrl, tokenResponse) {
|
|
1924
|
+
const resolvedAuthConfig = tokenResponse.resolvedAuthConfig ?? this.getServerAuthConfig(mcpServerUrl);
|
|
1925
|
+
const storageKey = this.getTokenStorageKey(mcpServerUrl, resolvedAuthConfig);
|
|
1926
|
+
const expiresIn = tokenResponse.expiresIn ?? 3600;
|
|
1927
|
+
const expiresAt = Date.now() + expiresIn * 1e3 - 60 * 1e3;
|
|
1928
|
+
const data = {
|
|
1929
|
+
accessToken: tokenResponse.accessToken,
|
|
1930
|
+
refreshToken: tokenResponse.refreshToken,
|
|
1931
|
+
expiresAt
|
|
1932
|
+
};
|
|
1933
|
+
this.oauthTokens.set(storageKey, data);
|
|
1934
|
+
try {
|
|
1935
|
+
const storage = this.getPersistentStorage() ?? (typeof localStorage !== "undefined" ? localStorage : null);
|
|
1936
|
+
if (storage) {
|
|
1937
|
+
storage.setItem(storageKey, JSON.stringify(data));
|
|
1938
|
+
}
|
|
1939
|
+
} catch {
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
/** Clear OAuth token from memory and persistent storage */
|
|
1943
|
+
clearOAuthToken(mcpServerUrl) {
|
|
1944
|
+
for (const candidate of this.getTokenStorageCandidates(mcpServerUrl)) {
|
|
1945
|
+
this.oauthTokens.delete(candidate.storageKey);
|
|
1946
|
+
}
|
|
1947
|
+
try {
|
|
1948
|
+
const storage = this.getPersistentStorage() ?? (typeof localStorage !== "undefined" ? localStorage : null);
|
|
1949
|
+
if (storage) {
|
|
1950
|
+
for (const candidate of this.getTokenStorageCandidates(mcpServerUrl)) {
|
|
1951
|
+
storage.removeItem(candidate.storageKey);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
} catch {
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
/** Refresh OAuth token using refresh token */
|
|
1958
|
+
async refreshOAuthToken(mcpServerUrl, refreshToken) {
|
|
1959
|
+
const authConfig = await this.ensureServerAuthConfig(mcpServerUrl);
|
|
1960
|
+
const tokenUrl = authConfig?.tokenEndpoint ?? authConfig?.tokenUrl;
|
|
1961
|
+
if (!tokenUrl) return void 0;
|
|
1962
|
+
try {
|
|
1963
|
+
const body = new URLSearchParams({
|
|
1964
|
+
grant_type: "refresh_token",
|
|
1965
|
+
refresh_token: refreshToken
|
|
1966
|
+
});
|
|
1967
|
+
if (authConfig?.clientId) body.set("client_id", authConfig.clientId);
|
|
1968
|
+
if (authConfig?.resource) body.set("resource", authConfig.resource);
|
|
1969
|
+
const response = await fetch(tokenUrl, {
|
|
1970
|
+
method: "POST",
|
|
1971
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1972
|
+
body
|
|
1973
|
+
});
|
|
1974
|
+
if (response.ok) {
|
|
1975
|
+
const data = await response.json();
|
|
1976
|
+
if (data.access_token) {
|
|
1977
|
+
const tokenResponse = {
|
|
1978
|
+
accessToken: data.access_token,
|
|
1979
|
+
refreshToken: data.refresh_token ?? refreshToken,
|
|
1980
|
+
expiresIn: data.expires_in,
|
|
1981
|
+
resolvedAuthConfig: authConfig ?? void 0
|
|
1982
|
+
};
|
|
1983
|
+
this.storeOAuthToken(mcpServerUrl, tokenResponse);
|
|
1984
|
+
return data.access_token;
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
} catch {
|
|
1988
|
+
}
|
|
1989
|
+
return void 0;
|
|
1990
|
+
}
|
|
1991
|
+
// ================================================================
|
|
1992
|
+
// Token Resolution
|
|
1993
|
+
// ================================================================
|
|
1994
|
+
/**
|
|
1995
|
+
* Resolve the auth token for an MCP server.
|
|
1996
|
+
* - OAuth mode: Checks stored token, refreshes if expired, triggers auth if needed
|
|
1997
|
+
*/
|
|
1998
|
+
async resolveToken(mcpServerUrl) {
|
|
1999
|
+
if (this.manuallySignedOutServers.has(mcpServerUrl)) {
|
|
2000
|
+
return void 0;
|
|
2001
|
+
}
|
|
2002
|
+
const authConfig = await this.ensureServerAuthConfig(mcpServerUrl);
|
|
2003
|
+
const stored = this.loadOAuthToken(mcpServerUrl);
|
|
2004
|
+
if (stored) {
|
|
2005
|
+
if (stored.expiresAt > Date.now()) {
|
|
2006
|
+
return stored.accessToken;
|
|
2007
|
+
}
|
|
2008
|
+
if (stored.refreshToken) {
|
|
2009
|
+
const refreshed = await this.refreshOAuthToken(mcpServerUrl, stored.refreshToken);
|
|
2010
|
+
if (refreshed) return refreshed;
|
|
2011
|
+
}
|
|
2012
|
+
this.clearOAuthToken(mcpServerUrl);
|
|
2013
|
+
}
|
|
2014
|
+
if (this.config.onAuthRequired) {
|
|
2015
|
+
if (authConfig) {
|
|
2016
|
+
const isBuiltInPopupAuth = this.config.onAuthRequired.__mcpStackBuiltinPopupAuth === true;
|
|
2017
|
+
const isAppTokenAuth = this.config.auth?.mode === "app-token";
|
|
2018
|
+
const authConfigForHandler = authConfig.authType === "oauth2" && !isBuiltInPopupAuth && !isAppTokenAuth ? await this.resolveOAuthClientRegistration(authConfig) : authConfig;
|
|
2019
|
+
if (authConfigForHandler) {
|
|
2020
|
+
this.updateServerAuthConfig(mcpServerUrl, authConfigForHandler);
|
|
2021
|
+
}
|
|
2022
|
+
const tokenResponse = await this.config.onAuthRequired(
|
|
2023
|
+
mcpServerUrl,
|
|
2024
|
+
authConfigForHandler ?? authConfig
|
|
2025
|
+
);
|
|
2026
|
+
if (tokenResponse?.accessToken) {
|
|
2027
|
+
if (tokenResponse.resolvedAuthConfig) {
|
|
2028
|
+
this.updateServerAuthConfig(mcpServerUrl, tokenResponse.resolvedAuthConfig);
|
|
2029
|
+
}
|
|
2030
|
+
this.storeOAuthToken(mcpServerUrl, tokenResponse);
|
|
2031
|
+
return tokenResponse.accessToken;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
return void 0;
|
|
2036
|
+
}
|
|
2037
|
+
// ================================================================
|
|
2038
|
+
// Chat Loop
|
|
2039
|
+
// ================================================================
|
|
2040
|
+
/**
|
|
2041
|
+
* The core orchestration loop:
|
|
2042
|
+
* 1. Send message to chat API
|
|
2043
|
+
* 2. Stream response
|
|
2044
|
+
* 3. If tool_call → execute tool via MCP → send result → continue
|
|
2045
|
+
* 4. If message_end → done
|
|
2046
|
+
*/
|
|
2047
|
+
async runChatLoop(message) {
|
|
2048
|
+
this.abortController = new AbortController();
|
|
2049
|
+
this.emit("thinking", true);
|
|
2050
|
+
const chatBody = {
|
|
2051
|
+
agentId: this.config.agentId,
|
|
2052
|
+
conversationId: this.conversationId,
|
|
2053
|
+
message,
|
|
2054
|
+
externalUserId: this.resolveExternalUserId(),
|
|
2055
|
+
context: this.config.context
|
|
2056
|
+
};
|
|
2057
|
+
const externalUser = this.buildExternalUserContext();
|
|
2058
|
+
if (externalUser) {
|
|
2059
|
+
chatBody.externalUser = externalUser;
|
|
2060
|
+
}
|
|
2061
|
+
const clientToolSchemas = this.clientToolsToSchemas();
|
|
2062
|
+
if (clientToolSchemas.length > 0) {
|
|
2063
|
+
chatBody.clientTools = clientToolSchemas;
|
|
2064
|
+
}
|
|
2065
|
+
let response = await this.callChatApi(chatBody, "chat");
|
|
2066
|
+
while (true) {
|
|
2067
|
+
const result = await this.processSseStream(response);
|
|
2068
|
+
if (result.type === "message_end") {
|
|
2069
|
+
break;
|
|
2070
|
+
}
|
|
2071
|
+
if (result.type === "tool_call") {
|
|
2072
|
+
const toolCall = result.data;
|
|
2073
|
+
const startTime = Date.now();
|
|
2074
|
+
try {
|
|
2075
|
+
const toolResult = await this.executeTool(toolCall);
|
|
2076
|
+
const duration = Date.now() - startTime;
|
|
2077
|
+
const toolCallMsg = this.messages.find(
|
|
2078
|
+
(m) => m.role === "tool_call" && m.toolCallId === toolCall.toolCallId
|
|
2079
|
+
);
|
|
2080
|
+
if (toolCallMsg) {
|
|
2081
|
+
toolCallMsg.toolCallStatus = "completed";
|
|
2082
|
+
toolCallMsg.toolCallDuration = duration;
|
|
2083
|
+
toolCallMsg.toolResult = typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult);
|
|
2084
|
+
}
|
|
2085
|
+
this.emit("tool_result", { toolCallId: toolCall.toolCallId, result: toolResult, duration });
|
|
2086
|
+
const toolResultMsg = {
|
|
2087
|
+
id: crypto.randomUUID(),
|
|
2088
|
+
role: "tool_result",
|
|
2089
|
+
content: typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult),
|
|
2090
|
+
toolCallId: toolCall.toolCallId,
|
|
2091
|
+
toolName: toolCall.toolName,
|
|
2092
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
2093
|
+
};
|
|
2094
|
+
this.messages.push(toolResultMsg);
|
|
2095
|
+
this.emit("thinking", true);
|
|
2096
|
+
const toolResultBody = {
|
|
2097
|
+
conversationId: this.conversationId,
|
|
2098
|
+
toolCallId: toolCall.toolCallId,
|
|
2099
|
+
result: toolResult,
|
|
2100
|
+
durationMs: duration,
|
|
2101
|
+
context: this.config.context
|
|
2102
|
+
};
|
|
2103
|
+
const clientToolSchemas2 = this.clientToolsToSchemas();
|
|
2104
|
+
if (clientToolSchemas2.length > 0) {
|
|
2105
|
+
toolResultBody.clientTools = clientToolSchemas2;
|
|
2106
|
+
}
|
|
2107
|
+
response = await this.callChatApi(toolResultBody, "chat/tool-result");
|
|
2108
|
+
} catch (err) {
|
|
2109
|
+
const duration = Date.now() - startTime;
|
|
2110
|
+
const errorMsg = err instanceof Error ? err.message : "Tool execution failed";
|
|
2111
|
+
const toolCallMsg = this.messages.find(
|
|
2112
|
+
(m) => m.role === "tool_call" && m.toolCallId === toolCall.toolCallId
|
|
2113
|
+
);
|
|
2114
|
+
if (toolCallMsg) {
|
|
2115
|
+
toolCallMsg.toolCallStatus = "error";
|
|
2116
|
+
toolCallMsg.toolCallDuration = duration;
|
|
2117
|
+
toolCallMsg.toolError = errorMsg;
|
|
2118
|
+
}
|
|
2119
|
+
this.emit("tool_error", { toolCallId: toolCall.toolCallId, error: errorMsg, duration });
|
|
2120
|
+
const errorResult = `Error: ${errorMsg}`;
|
|
2121
|
+
const toolResultMsg = {
|
|
2122
|
+
id: crypto.randomUUID(),
|
|
2123
|
+
role: "tool_result",
|
|
2124
|
+
content: errorResult,
|
|
2125
|
+
toolCallId: toolCall.toolCallId,
|
|
2126
|
+
toolName: toolCall.toolName,
|
|
2127
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
2128
|
+
};
|
|
2129
|
+
this.messages.push(toolResultMsg);
|
|
2130
|
+
this.emit("thinking", true);
|
|
2131
|
+
try {
|
|
2132
|
+
const toolResultBody = {
|
|
2133
|
+
conversationId: this.conversationId,
|
|
2134
|
+
toolCallId: toolCall.toolCallId,
|
|
2135
|
+
result: errorResult,
|
|
2136
|
+
isError: true,
|
|
2137
|
+
durationMs: duration,
|
|
2138
|
+
context: this.config.context
|
|
2139
|
+
};
|
|
2140
|
+
const clientToolSchemas2 = this.clientToolsToSchemas();
|
|
2141
|
+
if (clientToolSchemas2.length > 0) {
|
|
2142
|
+
toolResultBody.clientTools = clientToolSchemas2;
|
|
2143
|
+
}
|
|
2144
|
+
response = await this.callChatApi(toolResultBody, "chat/tool-result");
|
|
2145
|
+
} catch {
|
|
2146
|
+
this.emit("error", { code: "tool_error", message: errorMsg });
|
|
2147
|
+
break;
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
if (result.type === "error") {
|
|
2152
|
+
break;
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
async callChatApi(body, endpoint) {
|
|
2157
|
+
const token = await this.resolveAuthToken();
|
|
2158
|
+
const response = await fetch(
|
|
2159
|
+
`${this.config.agentServiceUrl}/api/v1/${endpoint}`,
|
|
2160
|
+
{
|
|
2161
|
+
method: "POST",
|
|
2162
|
+
headers: {
|
|
2163
|
+
"Content-Type": "application/json",
|
|
2164
|
+
Authorization: `Bearer ${token}`
|
|
2165
|
+
},
|
|
2166
|
+
body: JSON.stringify(body),
|
|
2167
|
+
signal: this.abortController?.signal
|
|
2168
|
+
}
|
|
2169
|
+
);
|
|
2170
|
+
if (!response.ok) {
|
|
2171
|
+
throw new Error(await getResponseErrorMessage(response));
|
|
2172
|
+
}
|
|
2173
|
+
return response;
|
|
2174
|
+
}
|
|
2175
|
+
async fetchConversationMessages(conversationId, cursor, pageSize = 50) {
|
|
2176
|
+
const token = await this.resolveAuthToken();
|
|
2177
|
+
const params = new URLSearchParams();
|
|
2178
|
+
params.set("pageSize", String(pageSize));
|
|
2179
|
+
if (cursor) {
|
|
2180
|
+
params.set("cursor", cursor);
|
|
2181
|
+
}
|
|
2182
|
+
const response = await fetch(
|
|
2183
|
+
`${this.config.agentServiceUrl}/api/v1/chat/conversations/${encodeURIComponent(conversationId)}/messages?${params.toString()}`,
|
|
2184
|
+
{
|
|
2185
|
+
headers: {
|
|
2186
|
+
Authorization: `Bearer ${token}`
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
);
|
|
2190
|
+
if (!response.ok) {
|
|
2191
|
+
throw new Error(await getResponseErrorMessage(response));
|
|
2192
|
+
}
|
|
2193
|
+
return await response.json();
|
|
2194
|
+
}
|
|
2195
|
+
applyConversationPage(page, prepend) {
|
|
2196
|
+
const mappedMessages = page.messages.map((message) => this.mapReplayMessage(message));
|
|
2197
|
+
this.conversationId = page.conversationId;
|
|
2198
|
+
this.historyCursor = page.nextCursor ?? null;
|
|
2199
|
+
this.hasOlderMessages = page.hasNextPage;
|
|
2200
|
+
this.messages = prepend ? [...mappedMessages, ...this.messages] : mappedMessages;
|
|
2201
|
+
}
|
|
2202
|
+
mapReplayMessage(message) {
|
|
2203
|
+
const timestamp = new Date(message.createdAt);
|
|
2204
|
+
return {
|
|
2205
|
+
id: message.id,
|
|
2206
|
+
role: message.role,
|
|
2207
|
+
content: message.content ?? "",
|
|
2208
|
+
toolName: message.toolName ?? void 0,
|
|
2209
|
+
toolLabel: message.toolLabel ?? void 0,
|
|
2210
|
+
toolCallId: message.toolCallId ?? void 0,
|
|
2211
|
+
timestamp,
|
|
2212
|
+
toolCallStatus: message.toolCallStatus ?? void 0,
|
|
2213
|
+
toolCallStartTime: message.toolCallDurationMs != null ? timestamp.getTime() - message.toolCallDurationMs : void 0,
|
|
2214
|
+
toolCallDuration: message.toolCallDurationMs ?? void 0,
|
|
2215
|
+
toolResult: message.toolResultJson ?? void 0,
|
|
2216
|
+
toolError: message.toolError ?? void 0,
|
|
2217
|
+
errorCode: message.errorCode ?? void 0,
|
|
2218
|
+
metadataJson: message.metadataJson ?? null
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
/**
|
|
2222
|
+
* Process an SSE stream from the chat API.
|
|
2223
|
+
* Returns when the stream ends (either message_end or tool_call).
|
|
2224
|
+
*/
|
|
2225
|
+
async processSseStream(response) {
|
|
2226
|
+
let assistantContent = "";
|
|
2227
|
+
let lastToolCall = null;
|
|
2228
|
+
let emittedThinkingFalse = false;
|
|
2229
|
+
for await (const event of parseSseStream(response, this.abortController?.signal)) {
|
|
2230
|
+
switch (event.type) {
|
|
2231
|
+
case "message_start": {
|
|
2232
|
+
const data = event.data;
|
|
2233
|
+
this.conversationId = data.conversationId;
|
|
2234
|
+
break;
|
|
2235
|
+
}
|
|
2236
|
+
case "content_delta": {
|
|
2237
|
+
const data = event.data;
|
|
2238
|
+
if (!emittedThinkingFalse) {
|
|
2239
|
+
this.emit("thinking", false);
|
|
2240
|
+
emittedThinkingFalse = true;
|
|
2241
|
+
}
|
|
2242
|
+
assistantContent += data.text;
|
|
2243
|
+
this.emit("content_delta", data);
|
|
2244
|
+
break;
|
|
2245
|
+
}
|
|
2246
|
+
case "tool_call": {
|
|
2247
|
+
const data = event.data;
|
|
2248
|
+
if (!emittedThinkingFalse) {
|
|
2249
|
+
this.emit("thinking", false);
|
|
2250
|
+
emittedThinkingFalse = true;
|
|
2251
|
+
}
|
|
2252
|
+
lastToolCall = data;
|
|
2253
|
+
const toolCallMsg = {
|
|
2254
|
+
id: crypto.randomUUID(),
|
|
2255
|
+
role: "tool_call",
|
|
2256
|
+
content: `Calling ${data.toolLabel ?? data.toolName}...`,
|
|
2257
|
+
toolName: data.toolName,
|
|
2258
|
+
toolLabel: data.toolLabel,
|
|
2259
|
+
toolCallId: data.toolCallId,
|
|
2260
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2261
|
+
toolCallStatus: "calling",
|
|
2262
|
+
toolCallStartTime: Date.now()
|
|
2263
|
+
};
|
|
2264
|
+
this.messages.push(toolCallMsg);
|
|
2265
|
+
this.emit("tool_call", data);
|
|
2266
|
+
break;
|
|
2267
|
+
}
|
|
2268
|
+
case "message_end": {
|
|
2269
|
+
const data = event.data;
|
|
2270
|
+
if (assistantContent) {
|
|
2271
|
+
const assistantMsg = {
|
|
2272
|
+
id: crypto.randomUUID(),
|
|
2273
|
+
role: "assistant",
|
|
2274
|
+
content: assistantContent,
|
|
2275
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
2276
|
+
};
|
|
2277
|
+
this.messages.push(assistantMsg);
|
|
2278
|
+
this.emit("message", assistantMsg);
|
|
2279
|
+
}
|
|
2280
|
+
this.emit("message_end", data);
|
|
2281
|
+
return { type: "message_end", data };
|
|
2282
|
+
}
|
|
2283
|
+
case "budget_snapshot": {
|
|
2284
|
+
const data = event.data;
|
|
2285
|
+
this.budgetSnapshot = data;
|
|
2286
|
+
this.emit("budget_snapshot", data);
|
|
2287
|
+
break;
|
|
2288
|
+
}
|
|
2289
|
+
case "error": {
|
|
2290
|
+
const data = event.data;
|
|
2291
|
+
if (data.budget) {
|
|
2292
|
+
this.budgetSnapshot = data.budget;
|
|
2293
|
+
this.emit("budget_snapshot", data.budget);
|
|
2294
|
+
}
|
|
2295
|
+
const errorMsg = {
|
|
2296
|
+
id: crypto.randomUUID(),
|
|
2297
|
+
role: "error",
|
|
2298
|
+
content: data.message,
|
|
2299
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2300
|
+
errorCode: data.code
|
|
2301
|
+
};
|
|
2302
|
+
this.messages.push(errorMsg);
|
|
2303
|
+
this.emit("message", errorMsg);
|
|
2304
|
+
this.emit("error", data);
|
|
2305
|
+
return { type: "error", data };
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
if (lastToolCall) {
|
|
2310
|
+
if (assistantContent) {
|
|
2311
|
+
const assistantMsg = {
|
|
2312
|
+
id: crypto.randomUUID(),
|
|
2313
|
+
role: "assistant",
|
|
2314
|
+
content: assistantContent,
|
|
2315
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
2316
|
+
};
|
|
2317
|
+
this.messages.push(assistantMsg);
|
|
2318
|
+
this.emit("message", assistantMsg);
|
|
2319
|
+
}
|
|
2320
|
+
return { type: "tool_call", data: lastToolCall };
|
|
2321
|
+
}
|
|
2322
|
+
if (assistantContent) {
|
|
2323
|
+
const assistantMsg = {
|
|
2324
|
+
id: crypto.randomUUID(),
|
|
2325
|
+
role: "assistant",
|
|
2326
|
+
content: assistantContent,
|
|
2327
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
2328
|
+
};
|
|
2329
|
+
this.messages.push(assistantMsg);
|
|
2330
|
+
this.emit("message", assistantMsg);
|
|
2331
|
+
}
|
|
2332
|
+
return { type: "message_end" };
|
|
2333
|
+
}
|
|
2334
|
+
// ================================================================
|
|
2335
|
+
// MCP Session Management
|
|
2336
|
+
// ================================================================
|
|
2337
|
+
/** Build headers for MCP server requests */
|
|
2338
|
+
getMcpHeaders(mcpServerUrl) {
|
|
2339
|
+
const headers = {
|
|
2340
|
+
"Content-Type": "application/json",
|
|
2341
|
+
"Accept": "application/json, text/event-stream"
|
|
2342
|
+
};
|
|
2343
|
+
const session = this.mcpSessions.get(mcpServerUrl);
|
|
2344
|
+
if (session?.sessionId) {
|
|
2345
|
+
headers["Mcp-Session-Id"] = session.sessionId;
|
|
2346
|
+
}
|
|
2347
|
+
return headers;
|
|
2348
|
+
}
|
|
2349
|
+
/** Best-effort MCP session teardown so reconnect starts cleanly after sign-out. */
|
|
2350
|
+
async closeMcpSession(mcpServerUrl) {
|
|
2351
|
+
const session = this.mcpSessions.get(mcpServerUrl);
|
|
2352
|
+
if (!session?.sessionId) return;
|
|
2353
|
+
const headers = {
|
|
2354
|
+
"Accept": "application/json, text/event-stream",
|
|
2355
|
+
"Mcp-Session-Id": session.sessionId
|
|
2356
|
+
};
|
|
2357
|
+
const storedToken = this.loadOAuthToken(mcpServerUrl);
|
|
2358
|
+
if (storedToken?.accessToken) {
|
|
2359
|
+
headers["Authorization"] = `Bearer ${storedToken.accessToken}`;
|
|
2360
|
+
}
|
|
2361
|
+
try {
|
|
2362
|
+
await fetch(mcpServerUrl, {
|
|
2363
|
+
method: "DELETE",
|
|
2364
|
+
headers,
|
|
2365
|
+
credentials: this.config.useCookies ? "include" : "omit"
|
|
2366
|
+
});
|
|
2367
|
+
} catch {
|
|
2368
|
+
}
|
|
2369
|
+
this.mcpSessions.set(mcpServerUrl, {
|
|
2370
|
+
sessionId: null,
|
|
2371
|
+
authStatus: session.authStatus
|
|
2372
|
+
});
|
|
2373
|
+
}
|
|
2374
|
+
/** Initialize the MCP session for a specific server (required before tools/call) */
|
|
2375
|
+
async initMcpSession(mcpServerUrl) {
|
|
2376
|
+
const session = this.mcpSessions.get(mcpServerUrl);
|
|
2377
|
+
if (session?.sessionId) return;
|
|
2378
|
+
const headers = this.getMcpHeaders(mcpServerUrl);
|
|
2379
|
+
await this.ensureServerAuthConfig(mcpServerUrl);
|
|
2380
|
+
const token = await this.resolveToken(mcpServerUrl);
|
|
2381
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
2382
|
+
const initPayload = JSON.stringify({
|
|
2383
|
+
jsonrpc: "2.0",
|
|
2384
|
+
id: 1,
|
|
2385
|
+
method: "initialize",
|
|
2386
|
+
params: {
|
|
2387
|
+
protocolVersion: DEFAULT_MCP_PROTOCOL_VERSION,
|
|
2388
|
+
capabilities: {},
|
|
2389
|
+
clientInfo: { name: "mcpstack-agent-sdk", version: "0.1.0" }
|
|
2390
|
+
}
|
|
2391
|
+
});
|
|
2392
|
+
const initResponse = await fetch(mcpServerUrl, {
|
|
2393
|
+
method: "POST",
|
|
2394
|
+
headers,
|
|
2395
|
+
credentials: this.config.useCookies ? "include" : "omit",
|
|
2396
|
+
body: initPayload
|
|
2397
|
+
});
|
|
2398
|
+
if (initResponse.status === 401) {
|
|
2399
|
+
this.clearOAuthToken(mcpServerUrl);
|
|
2400
|
+
this.updateMcpAuthStatus(mcpServerUrl, "needs_auth");
|
|
2401
|
+
const freshToken = await this.resolveToken(mcpServerUrl);
|
|
2402
|
+
if (freshToken) {
|
|
2403
|
+
headers["Authorization"] = `Bearer ${freshToken}`;
|
|
2404
|
+
const retryResponse = await fetch(mcpServerUrl, {
|
|
2405
|
+
method: "POST",
|
|
2406
|
+
headers,
|
|
2407
|
+
credentials: this.config.useCookies ? "include" : "omit",
|
|
2408
|
+
body: initPayload
|
|
2409
|
+
});
|
|
2410
|
+
if (retryResponse.ok) {
|
|
2411
|
+
const sessionId2 = retryResponse.headers.get("mcp-session-id");
|
|
2412
|
+
this.mcpSessions.set(mcpServerUrl, { sessionId: sessionId2, authStatus: "connected" });
|
|
2413
|
+
this.updateMcpAuthStatus(mcpServerUrl, "connected");
|
|
2414
|
+
await retryResponse.text().catch(() => {
|
|
2415
|
+
});
|
|
2416
|
+
const notifyHeaders2 = this.getMcpHeaders(mcpServerUrl);
|
|
2417
|
+
notifyHeaders2["Authorization"] = `Bearer ${freshToken}`;
|
|
2418
|
+
await fetch(mcpServerUrl, {
|
|
2419
|
+
method: "POST",
|
|
2420
|
+
headers: notifyHeaders2,
|
|
2421
|
+
credentials: this.config.useCookies ? "include" : "omit",
|
|
2422
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })
|
|
2423
|
+
});
|
|
2424
|
+
return;
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
throw new Error("MCP initialization failed (401): Authentication required");
|
|
2428
|
+
}
|
|
2429
|
+
if (!initResponse.ok) {
|
|
2430
|
+
const errorText = await initResponse.text().catch(() => "MCP init error");
|
|
2431
|
+
throw new Error(`MCP initialization failed (${initResponse.status}): ${errorText}`);
|
|
2432
|
+
}
|
|
2433
|
+
const sessionId = initResponse.headers.get("mcp-session-id");
|
|
2434
|
+
this.mcpSessions.set(mcpServerUrl, { sessionId, authStatus: "connected" });
|
|
2435
|
+
this.updateMcpAuthStatus(mcpServerUrl, "connected");
|
|
2436
|
+
await initResponse.text().catch(() => {
|
|
2437
|
+
});
|
|
2438
|
+
const notifyHeaders = this.getMcpHeaders(mcpServerUrl);
|
|
2439
|
+
if (token) notifyHeaders["Authorization"] = `Bearer ${token}`;
|
|
2440
|
+
await fetch(mcpServerUrl, {
|
|
2441
|
+
method: "POST",
|
|
2442
|
+
headers: notifyHeaders,
|
|
2443
|
+
credentials: this.config.useCookies ? "include" : "omit",
|
|
2444
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
/** Update auth status for an MCP server and emit event */
|
|
2448
|
+
updateMcpAuthStatus(mcpServerUrl, authStatus) {
|
|
2449
|
+
const session = this.mcpSessions.get(mcpServerUrl);
|
|
2450
|
+
if (session) {
|
|
2451
|
+
session.authStatus = authStatus;
|
|
2452
|
+
} else {
|
|
2453
|
+
this.mcpSessions.set(mcpServerUrl, { sessionId: null, authStatus });
|
|
2454
|
+
}
|
|
2455
|
+
const serverInfo = this.agentConfig?.mcpServers?.find((s) => s.url === mcpServerUrl);
|
|
2456
|
+
this.emit("mcp_auth_status", {
|
|
2457
|
+
mcpServerUrl,
|
|
2458
|
+
mcpServerName: serverInfo?.name ?? mcpServerUrl,
|
|
2459
|
+
authStatus
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
/** Parse a JSON-RPC response from either JSON or SSE format */
|
|
2463
|
+
async parseMcpResponse(response) {
|
|
2464
|
+
const contentType = (response.headers.get("content-type") || "").toLowerCase();
|
|
2465
|
+
if (contentType.includes("text/event-stream")) {
|
|
2466
|
+
const text = await response.text();
|
|
2467
|
+
const lines = text.split("\n");
|
|
2468
|
+
let jsonData = "";
|
|
2469
|
+
for (const line of lines) {
|
|
2470
|
+
if (line.startsWith("data: ")) {
|
|
2471
|
+
jsonData += line.slice(6);
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
if (!jsonData) {
|
|
2475
|
+
throw new Error("No data received from MCP server SSE response");
|
|
2476
|
+
}
|
|
2477
|
+
return JSON.parse(jsonData);
|
|
2478
|
+
}
|
|
2479
|
+
return response.json();
|
|
2480
|
+
}
|
|
2481
|
+
// ================================================================
|
|
2482
|
+
// Tool Execution
|
|
2483
|
+
// ================================================================
|
|
2484
|
+
async executeTool(toolCall) {
|
|
2485
|
+
const isClientTool = toolCall.source === "client" || !toolCall.mcpServerUrl;
|
|
2486
|
+
if (isClientTool && this.config.clientTools) {
|
|
2487
|
+
const def = this.config.clientTools[toolCall.toolName];
|
|
2488
|
+
if (def) {
|
|
2489
|
+
const result2 = await def.execute(toolCall.arguments ?? {});
|
|
2490
|
+
return result2;
|
|
2491
|
+
}
|
|
2492
|
+
throw new Error(`Unknown client tool: ${toolCall.toolName}`);
|
|
2493
|
+
}
|
|
2494
|
+
const mcpServerUrl = toolCall.mcpServerUrl || this.agentConfig?.mcpServerUrl;
|
|
2495
|
+
if (!mcpServerUrl) {
|
|
2496
|
+
throw new Error("No MCP server URL for tool call");
|
|
2497
|
+
}
|
|
2498
|
+
await this.initMcpSession(mcpServerUrl);
|
|
2499
|
+
const token = await this.resolveToken(mcpServerUrl);
|
|
2500
|
+
const headers = this.getMcpHeaders(mcpServerUrl);
|
|
2501
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
2502
|
+
const toolCallBody = JSON.stringify({
|
|
2503
|
+
jsonrpc: "2.0",
|
|
2504
|
+
id: 2,
|
|
2505
|
+
method: "tools/call",
|
|
2506
|
+
params: { name: toolCall.toolName, arguments: toolCall.arguments }
|
|
2507
|
+
});
|
|
2508
|
+
let response = await fetch(mcpServerUrl, {
|
|
2509
|
+
method: "POST",
|
|
2510
|
+
headers,
|
|
2511
|
+
credentials: this.config.useCookies ? "include" : "omit",
|
|
2512
|
+
body: toolCallBody,
|
|
2513
|
+
signal: this.abortController?.signal
|
|
2514
|
+
});
|
|
2515
|
+
const session = this.mcpSessions.get(mcpServerUrl);
|
|
2516
|
+
if (response.status === 404 && session?.sessionId) {
|
|
2517
|
+
this.mcpSessions.set(mcpServerUrl, { ...session, sessionId: null });
|
|
2518
|
+
await this.initMcpSession(mcpServerUrl);
|
|
2519
|
+
const retryHeaders = this.getMcpHeaders(mcpServerUrl);
|
|
2520
|
+
if (token) retryHeaders["Authorization"] = `Bearer ${token}`;
|
|
2521
|
+
response = await fetch(mcpServerUrl, {
|
|
2522
|
+
method: "POST",
|
|
2523
|
+
headers: retryHeaders,
|
|
2524
|
+
credentials: this.config.useCookies ? "include" : "omit",
|
|
2525
|
+
body: toolCallBody,
|
|
2526
|
+
signal: this.abortController?.signal
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
if (response.status === 401) {
|
|
2530
|
+
this.clearOAuthToken(mcpServerUrl);
|
|
2531
|
+
this.updateMcpAuthStatus(mcpServerUrl, "needs_auth");
|
|
2532
|
+
const freshToken = await this.resolveToken(mcpServerUrl);
|
|
2533
|
+
if (freshToken) {
|
|
2534
|
+
const authHeaders = this.getMcpHeaders(mcpServerUrl);
|
|
2535
|
+
authHeaders["Authorization"] = `Bearer ${freshToken}`;
|
|
2536
|
+
response = await fetch(mcpServerUrl, {
|
|
2537
|
+
method: "POST",
|
|
2538
|
+
headers: authHeaders,
|
|
2539
|
+
credentials: this.config.useCookies ? "include" : "omit",
|
|
2540
|
+
body: toolCallBody,
|
|
2541
|
+
signal: this.abortController?.signal
|
|
2542
|
+
});
|
|
2543
|
+
if (response.ok) {
|
|
2544
|
+
this.updateMcpAuthStatus(mcpServerUrl, "connected");
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
if (!response.ok) {
|
|
2548
|
+
const errorText = await response.text().catch(() => "Authentication required");
|
|
2549
|
+
throw new Error(`Tool execution failed (401): ${errorText}`);
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
if (!response.ok) {
|
|
2553
|
+
const errorText = await response.text().catch(() => "MCP server error");
|
|
2554
|
+
throw new Error(`Tool execution failed (${response.status}): ${errorText}`);
|
|
2555
|
+
}
|
|
2556
|
+
const currentSession = this.mcpSessions.get(mcpServerUrl);
|
|
2557
|
+
if (currentSession?.authStatus === "needs_auth") {
|
|
2558
|
+
this.updateMcpAuthStatus(mcpServerUrl, "connected");
|
|
2559
|
+
}
|
|
2560
|
+
const result = await this.parseMcpResponse(response);
|
|
2561
|
+
if (result.error) {
|
|
2562
|
+
throw new Error(`MCP error (${result.error.code}): ${result.error.message}`);
|
|
2563
|
+
}
|
|
2564
|
+
if (result.result?.content) {
|
|
2565
|
+
const textContent = result.result.content.filter((c) => c.type === "text").map((c) => c.text).join("\n");
|
|
2566
|
+
return textContent || result.result;
|
|
2567
|
+
}
|
|
2568
|
+
return result.result ?? result;
|
|
2569
|
+
}
|
|
2570
|
+
};
|
|
2571
|
+
|
|
2572
|
+
// src/app/types.ts
|
|
2573
|
+
var APP_AGENT_APPROVAL_ACTION = "requestApproval";
|
|
2574
|
+
var APP_AGENT_INPUT_ACTION = "requestInput";
|
|
2575
|
+
|
|
2576
|
+
// src/app/presentation.ts
|
|
2577
|
+
function isAssistantMessage(message) {
|
|
2578
|
+
return message.role === "assistant";
|
|
2579
|
+
}
|
|
2580
|
+
function isUserMessage(message) {
|
|
2581
|
+
return message.role === "user";
|
|
2582
|
+
}
|
|
2583
|
+
function isErrorMessage(message) {
|
|
2584
|
+
return message.role === "error";
|
|
2585
|
+
}
|
|
2586
|
+
function isToolCallMessage(message) {
|
|
2587
|
+
return message.role === "tool_call";
|
|
2588
|
+
}
|
|
2589
|
+
function deriveVisibleMessages(messages) {
|
|
2590
|
+
return messages.filter(
|
|
2591
|
+
(message) => isAssistantMessage(message) || isUserMessage(message) || isErrorMessage(message) || isToolCallMessage(message)
|
|
2592
|
+
);
|
|
2593
|
+
}
|
|
2594
|
+
function deriveConversationMessages(messages) {
|
|
2595
|
+
return messages.filter(
|
|
2596
|
+
(message) => isUserMessage(message) || isAssistantMessage(message) || isErrorMessage(message)
|
|
2597
|
+
);
|
|
2598
|
+
}
|
|
2599
|
+
function deriveToolMessages(messages) {
|
|
2600
|
+
return messages.filter(isToolCallMessage);
|
|
2601
|
+
}
|
|
2602
|
+
function getLatestAssistantMessage(messages) {
|
|
2603
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
2604
|
+
const message = messages[index];
|
|
2605
|
+
if (message && isAssistantMessage(message)) {
|
|
2606
|
+
return message;
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
return null;
|
|
2610
|
+
}
|
|
2611
|
+
function getLatestUserMessage(messages) {
|
|
2612
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
2613
|
+
const message = messages[index];
|
|
2614
|
+
if (message && isUserMessage(message)) {
|
|
2615
|
+
return message;
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
return null;
|
|
2619
|
+
}
|
|
2620
|
+
function getLatestToolMessage(messages) {
|
|
2621
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
2622
|
+
const message = messages[index];
|
|
2623
|
+
if (message && isToolCallMessage(message)) {
|
|
2624
|
+
return message;
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
return null;
|
|
2628
|
+
}
|
|
2629
|
+
function createInlinePendingTurnState(prompt, messages) {
|
|
2630
|
+
return {
|
|
2631
|
+
prompt,
|
|
2632
|
+
baselineAssistantId: getLatestAssistantMessage(messages)?.id ?? null,
|
|
2633
|
+
baselineToolCount: deriveToolMessages(messages).length
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
2636
|
+
function applyUserMessageOverrides(messages, overrides) {
|
|
2637
|
+
return messages.map(
|
|
2638
|
+
(message) => message.role === "user" && overrides[message.id] ? { ...message, content: overrides[message.id] } : message
|
|
2639
|
+
);
|
|
2640
|
+
}
|
|
2641
|
+
function buildRenderedNodes(messages) {
|
|
2642
|
+
const nodes = [];
|
|
2643
|
+
let toolBuffer = [];
|
|
2644
|
+
const flushTools = (id) => {
|
|
2645
|
+
if (toolBuffer.length > 0) {
|
|
2646
|
+
nodes.push({ kind: "tools", id: `tools-${id}`, tools: toolBuffer });
|
|
2647
|
+
toolBuffer = [];
|
|
2648
|
+
}
|
|
2649
|
+
};
|
|
2650
|
+
messages.forEach((message) => {
|
|
2651
|
+
if (isToolCallMessage(message)) {
|
|
2652
|
+
toolBuffer.push(message);
|
|
2653
|
+
return;
|
|
2654
|
+
}
|
|
2655
|
+
if (message.role === "tool_result") {
|
|
2656
|
+
return;
|
|
2657
|
+
}
|
|
2658
|
+
flushTools(message.id);
|
|
2659
|
+
if (message.role === "user") {
|
|
2660
|
+
nodes.push({ kind: "user", id: message.id, content: message.content });
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
if (message.role === "assistant") {
|
|
2664
|
+
nodes.push({ kind: "assistant", id: message.id, content: message.content });
|
|
2665
|
+
}
|
|
2666
|
+
});
|
|
2667
|
+
if (toolBuffer.length > 0) {
|
|
2668
|
+
nodes.push({ kind: "tools", id: "tools-tail", tools: toolBuffer });
|
|
2669
|
+
}
|
|
2670
|
+
return nodes;
|
|
2671
|
+
}
|
|
2672
|
+
function deriveToolStatusPresentation(status) {
|
|
2673
|
+
if (status === "completed") {
|
|
2674
|
+
return { glyph: "\u2713", tone: "success", badgeLabel: "Completed" };
|
|
2675
|
+
}
|
|
2676
|
+
if (status === "error") {
|
|
2677
|
+
return { glyph: "\u2715", tone: "error", badgeLabel: "Failed" };
|
|
2678
|
+
}
|
|
2679
|
+
return { glyph: "\u2026", tone: "active", badgeLabel: "Running" };
|
|
2680
|
+
}
|
|
2681
|
+
function summarizeConversationId(value) {
|
|
2682
|
+
if (!value) {
|
|
2683
|
+
return "Waiting";
|
|
2684
|
+
}
|
|
2685
|
+
return `${value.slice(0, 6)}\u2026${value.slice(-4)}`;
|
|
2686
|
+
}
|
|
2687
|
+
function formatStructuredText(value) {
|
|
2688
|
+
if (!value) {
|
|
2689
|
+
return "No payload captured.";
|
|
2690
|
+
}
|
|
2691
|
+
try {
|
|
2692
|
+
return JSON.stringify(JSON.parse(value), null, 2);
|
|
2693
|
+
} catch {
|
|
2694
|
+
return value;
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
function deriveConversationStatusLabel(options) {
|
|
2698
|
+
const {
|
|
2699
|
+
isReady,
|
|
2700
|
+
isLoading,
|
|
2701
|
+
isThinking,
|
|
2702
|
+
streamingContent,
|
|
2703
|
+
hasError = false,
|
|
2704
|
+
hasIssue = false,
|
|
2705
|
+
hasAttention = false,
|
|
2706
|
+
readyLabel = "Ready",
|
|
2707
|
+
loadingLabel = "Working\u2026",
|
|
2708
|
+
thinkingLabel = "Thinking\u2026",
|
|
2709
|
+
streamingLabel = "Responding\u2026",
|
|
2710
|
+
attentionLabel = "Needs auth",
|
|
2711
|
+
issueLabel = "Needs attention",
|
|
2712
|
+
reconnectingLabel = "Reconnecting\u2026",
|
|
2713
|
+
connectingLabel = "Connecting\u2026"
|
|
2714
|
+
} = options;
|
|
2715
|
+
if (isReady) {
|
|
2716
|
+
if (isLoading) {
|
|
2717
|
+
return loadingLabel;
|
|
2718
|
+
}
|
|
2719
|
+
if (isThinking) {
|
|
2720
|
+
return thinkingLabel;
|
|
2721
|
+
}
|
|
2722
|
+
if (streamingContent && streamingContent.length > 0) {
|
|
2723
|
+
return streamingLabel;
|
|
2724
|
+
}
|
|
2725
|
+
if (hasAttention) {
|
|
2726
|
+
return attentionLabel;
|
|
2727
|
+
}
|
|
2728
|
+
if (hasIssue || hasError) {
|
|
2729
|
+
return issueLabel;
|
|
2730
|
+
}
|
|
2731
|
+
return readyLabel;
|
|
2732
|
+
}
|
|
2733
|
+
return hasIssue || hasError ? reconnectingLabel : connectingLabel;
|
|
2734
|
+
}
|
|
2735
|
+
function deriveLastTurnSummary(messages) {
|
|
2736
|
+
const lastUser = getLatestUserMessage(messages);
|
|
2737
|
+
if (!lastUser) {
|
|
2738
|
+
return null;
|
|
2739
|
+
}
|
|
2740
|
+
const lastUserIndex = messages.findIndex((message) => message.id === lastUser.id);
|
|
2741
|
+
if (lastUserIndex < 0) {
|
|
2742
|
+
return null;
|
|
2743
|
+
}
|
|
2744
|
+
const after = messages.slice(lastUserIndex + 1);
|
|
2745
|
+
const tools = deriveToolMessages(after);
|
|
2746
|
+
const lastToolEndedAt = tools.map((tool) => (tool.toolCallDuration ?? 0) + (tool.timestamp ? tool.timestamp.getTime() : 0)).reduce((max, value) => max === null || value > max ? value : max, null);
|
|
2747
|
+
const userStartedAt = lastUser.timestamp ? lastUser.timestamp.getTime() : null;
|
|
2748
|
+
return {
|
|
2749
|
+
prompt: lastUser.content.trim(),
|
|
2750
|
+
toolsUsed: tools.length,
|
|
2751
|
+
durationMs: lastToolEndedAt && userStartedAt ? Math.max(0, lastToolEndedAt - userStartedAt) : null
|
|
2752
|
+
};
|
|
2753
|
+
}
|
|
2754
|
+
function deriveInlineFeedState(options) {
|
|
2755
|
+
const {
|
|
2756
|
+
pendingTurn,
|
|
2757
|
+
toolMessages,
|
|
2758
|
+
latestAssistantMessage,
|
|
2759
|
+
streamingContent = "",
|
|
2760
|
+
isLoading,
|
|
2761
|
+
isThinking,
|
|
2762
|
+
maxRecentTools = 4
|
|
2763
|
+
} = options;
|
|
2764
|
+
const startIndex = pendingTurn ? Math.max(
|
|
2765
|
+
pendingTurn.baselineToolCount,
|
|
2766
|
+
toolMessages.length - maxRecentTools
|
|
2767
|
+
) : Math.max(toolMessages.length - maxRecentTools, 0);
|
|
2768
|
+
const recentTools = toolMessages.slice(startIndex);
|
|
2769
|
+
const hasFreshAssistantReply = Boolean(
|
|
2770
|
+
latestAssistantMessage && (!pendingTurn || latestAssistantMessage.id !== pendingTurn.baselineAssistantId)
|
|
2771
|
+
);
|
|
2772
|
+
const previewText = streamingContent || (hasFreshAssistantReply ? latestAssistantMessage.content : null);
|
|
2773
|
+
const waitingForActivity = Boolean(pendingTurn) && recentTools.length === 0 && !streamingContent && (isLoading || isThinking);
|
|
2774
|
+
const isActive = Boolean(isLoading || isThinking || streamingContent.length > 0);
|
|
2775
|
+
return {
|
|
2776
|
+
isActive,
|
|
2777
|
+
pendingPrompt: pendingTurn?.prompt ?? null,
|
|
2778
|
+
recentTools,
|
|
2779
|
+
previewText,
|
|
2780
|
+
waitingForActivity
|
|
2781
|
+
};
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
// src/app/oauth.ts
|
|
2785
|
+
function normalizeOptionalValue(value) {
|
|
2786
|
+
if (!value) {
|
|
2787
|
+
return void 0;
|
|
2788
|
+
}
|
|
2789
|
+
const trimmed = value.trim();
|
|
2790
|
+
return trimmed || void 0;
|
|
2791
|
+
}
|
|
2792
|
+
function isGatewayAuthorizeUrl(authorizationEndpoint) {
|
|
2793
|
+
try {
|
|
2794
|
+
const url = new URL(authorizationEndpoint);
|
|
2795
|
+
return /\/api\/v1\/gateway\/[^/]+\/authorize$/i.test(url.pathname);
|
|
2796
|
+
} catch {
|
|
2797
|
+
return false;
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
function base64UrlEncode(bytes) {
|
|
2801
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2802
|
+
}
|
|
2803
|
+
async function randomToken(length) {
|
|
2804
|
+
const bytes = new Uint8Array(length);
|
|
2805
|
+
crypto.getRandomValues(bytes);
|
|
2806
|
+
return base64UrlEncode(bytes);
|
|
2807
|
+
}
|
|
2808
|
+
async function createCodeChallenge(verifier) {
|
|
2809
|
+
const encoder = new TextEncoder();
|
|
2810
|
+
const data = encoder.encode(verifier);
|
|
2811
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
2812
|
+
return base64UrlEncode(new Uint8Array(hash));
|
|
2813
|
+
}
|
|
2814
|
+
function createPlatformAuthHandler(options) {
|
|
2815
|
+
return async (_mcpServerUrl, authConfig) => {
|
|
2816
|
+
if (!options.platform.auth) {
|
|
2817
|
+
return void 0;
|
|
2818
|
+
}
|
|
2819
|
+
const authorizationEndpoint = authConfig.authorizationEndpoint ?? authConfig.loginUrl;
|
|
2820
|
+
const tokenEndpoint = authConfig.tokenEndpoint ?? authConfig.tokenUrl;
|
|
2821
|
+
const clientId = authConfig.clientId;
|
|
2822
|
+
const redirectUri = authConfig.callbackUrl ?? options.oauthCallbackUrl;
|
|
2823
|
+
if (!authorizationEndpoint || !tokenEndpoint || !clientId || !redirectUri) {
|
|
2824
|
+
return void 0;
|
|
2825
|
+
}
|
|
2826
|
+
const verifier = await randomToken(48);
|
|
2827
|
+
const state = await randomToken(24);
|
|
2828
|
+
const challenge = await createCodeChallenge(verifier);
|
|
2829
|
+
const authorizeUrl = new URL(authorizationEndpoint);
|
|
2830
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
2831
|
+
authorizeUrl.searchParams.set("client_id", clientId);
|
|
2832
|
+
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
|
2833
|
+
authorizeUrl.searchParams.set("state", state);
|
|
2834
|
+
authorizeUrl.searchParams.set("code_challenge", challenge);
|
|
2835
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
2836
|
+
if (authConfig.scopes?.length) {
|
|
2837
|
+
authorizeUrl.searchParams.set("scope", authConfig.scopes.join(" "));
|
|
2838
|
+
}
|
|
2839
|
+
if (authConfig.resource) {
|
|
2840
|
+
authorizeUrl.searchParams.set("resource", authConfig.resource);
|
|
2841
|
+
}
|
|
2842
|
+
if (options.userIdentity && isGatewayAuthorizeUrl(authorizationEndpoint)) {
|
|
2843
|
+
const subject = normalizeOptionalValue(options.userIdentity.subject);
|
|
2844
|
+
const email = normalizeOptionalValue(options.userIdentity.email);
|
|
2845
|
+
const organizationId = normalizeOptionalValue(options.userIdentity.organizationId);
|
|
2846
|
+
const displayName = normalizeOptionalValue(options.userIdentity.displayName);
|
|
2847
|
+
if (subject) {
|
|
2848
|
+
authorizeUrl.searchParams.set("mcpstack_host_subject", subject);
|
|
2849
|
+
}
|
|
2850
|
+
if (email) {
|
|
2851
|
+
authorizeUrl.searchParams.set("mcpstack_host_email", email);
|
|
2852
|
+
}
|
|
2853
|
+
if (organizationId) {
|
|
2854
|
+
authorizeUrl.searchParams.set("mcpstack_host_organization_id", organizationId);
|
|
2855
|
+
}
|
|
2856
|
+
if (displayName) {
|
|
2857
|
+
authorizeUrl.searchParams.set("mcpstack_host_display_name", displayName);
|
|
2858
|
+
}
|
|
2859
|
+
authorizeUrl.searchParams.set("mcpstack_mismatch_policy", "block_with_switch");
|
|
2860
|
+
}
|
|
2861
|
+
const result = await options.platform.auth.openOAuthSession({
|
|
2862
|
+
authorizeUrl: authorizeUrl.toString(),
|
|
2863
|
+
redirectUri,
|
|
2864
|
+
preferEphemeralSession: true
|
|
2865
|
+
});
|
|
2866
|
+
if (result.type !== "success" || !result.url) {
|
|
2867
|
+
return void 0;
|
|
2868
|
+
}
|
|
2869
|
+
const callback = new URL(result.url);
|
|
2870
|
+
const returnedState = callback.searchParams.get("state");
|
|
2871
|
+
const code = callback.searchParams.get("code");
|
|
2872
|
+
if (returnedState !== state || !code) {
|
|
2873
|
+
const errorDescription = callback.searchParams.get("error_description");
|
|
2874
|
+
const errorCode = callback.searchParams.get("error");
|
|
2875
|
+
throw new Error(errorDescription ?? (errorCode ? `OAuth error: ${errorCode}` : "Could not complete OAuth login."));
|
|
2876
|
+
}
|
|
2877
|
+
const tokenResponse = await fetch(tokenEndpoint, {
|
|
2878
|
+
method: "POST",
|
|
2879
|
+
headers: {
|
|
2880
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
2881
|
+
},
|
|
2882
|
+
body: new URLSearchParams({
|
|
2883
|
+
grant_type: "authorization_code",
|
|
2884
|
+
client_id: clientId,
|
|
2885
|
+
redirect_uri: redirectUri,
|
|
2886
|
+
code,
|
|
2887
|
+
code_verifier: verifier,
|
|
2888
|
+
...authConfig.resource ? { resource: authConfig.resource } : {}
|
|
2889
|
+
}).toString()
|
|
2890
|
+
});
|
|
2891
|
+
const payload = await tokenResponse.json().catch(() => null);
|
|
2892
|
+
if (!tokenResponse.ok) {
|
|
2893
|
+
throw new Error(
|
|
2894
|
+
payload?.error_description ?? payload?.error ?? `OAuth sign in failed (${tokenResponse.status}).`
|
|
2895
|
+
);
|
|
2896
|
+
}
|
|
2897
|
+
return {
|
|
2898
|
+
accessToken: payload?.access_token ?? payload?.accessToken,
|
|
2899
|
+
refreshToken: payload?.refresh_token ?? payload?.refreshToken,
|
|
2900
|
+
expiresIn: payload?.expires_in ?? payload?.expiresIn,
|
|
2901
|
+
tokenType: payload?.token_type ?? payload?.tokenType,
|
|
2902
|
+
resolvedAuthConfig: { ...authConfig, callbackUrl: redirectUri }
|
|
2903
|
+
};
|
|
2904
|
+
};
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
// src/app/platform.ts
|
|
2908
|
+
function createBrowserKeyValueStore(storage) {
|
|
2909
|
+
const resolvedStorage = storage ?? (() => {
|
|
2910
|
+
try {
|
|
2911
|
+
return typeof localStorage === "undefined" ? null : localStorage;
|
|
2912
|
+
} catch {
|
|
2913
|
+
return null;
|
|
2914
|
+
}
|
|
2915
|
+
})();
|
|
2916
|
+
return {
|
|
2917
|
+
getItem(key) {
|
|
2918
|
+
return resolvedStorage?.getItem(key) ?? null;
|
|
2919
|
+
},
|
|
2920
|
+
setItem(key, value) {
|
|
2921
|
+
resolvedStorage?.setItem(key, value);
|
|
2922
|
+
},
|
|
2923
|
+
removeItem(key) {
|
|
2924
|
+
resolvedStorage?.removeItem(key);
|
|
2925
|
+
}
|
|
2926
|
+
};
|
|
2927
|
+
}
|
|
2928
|
+
function createBrowserAppAgentPlatform(storage) {
|
|
2929
|
+
return {
|
|
2930
|
+
storage: {
|
|
2931
|
+
durable: createBrowserKeyValueStore(storage)
|
|
2932
|
+
}
|
|
2933
|
+
};
|
|
2934
|
+
}
|
|
2935
|
+
function createMemoryKeyValueStore(seed = {}) {
|
|
2936
|
+
const map = new Map(Object.entries(seed));
|
|
2937
|
+
return {
|
|
2938
|
+
getItem(key) {
|
|
2939
|
+
return map.has(key) ? map.get(key) : null;
|
|
2940
|
+
},
|
|
2941
|
+
setItem(key, value) {
|
|
2942
|
+
map.set(key, value);
|
|
2943
|
+
},
|
|
2944
|
+
removeItem(key) {
|
|
2945
|
+
map.delete(key);
|
|
2946
|
+
}
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2949
|
+
async function resolveStoreValue(value) {
|
|
2950
|
+
return await Promise.resolve(value);
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
// src/app/controller.ts
|
|
2954
|
+
function createId(prefix) {
|
|
2955
|
+
return `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
|
|
2956
|
+
}
|
|
2957
|
+
function normalizeSteps(value) {
|
|
2958
|
+
return Array.isArray(value) ? value.filter((entry) => typeof entry === "string" && entry.trim().length > 0) : [];
|
|
2959
|
+
}
|
|
2960
|
+
function normalizeInputFields(value) {
|
|
2961
|
+
if (!Array.isArray(value)) {
|
|
2962
|
+
return [];
|
|
2963
|
+
}
|
|
2964
|
+
return value.flatMap((entry) => {
|
|
2965
|
+
if (!entry || typeof entry !== "object") {
|
|
2966
|
+
return [];
|
|
2967
|
+
}
|
|
2968
|
+
const record = entry;
|
|
2969
|
+
const key = typeof record.key === "string" ? record.key.trim() : "";
|
|
2970
|
+
const label = typeof record.label === "string" ? record.label.trim() : "";
|
|
2971
|
+
const kind = typeof record.kind === "string" ? record.kind : "text";
|
|
2972
|
+
if (!key || !label) {
|
|
2973
|
+
return [];
|
|
2974
|
+
}
|
|
2975
|
+
return [{
|
|
2976
|
+
key,
|
|
2977
|
+
label,
|
|
2978
|
+
kind: kind === "textarea" || kind === "number" || kind === "select" || kind === "boolean" ? kind : "text",
|
|
2979
|
+
required: Boolean(record.required),
|
|
2980
|
+
placeholder: typeof record.placeholder === "string" ? record.placeholder : void 0,
|
|
2981
|
+
options: Array.isArray(record.options) ? record.options.flatMap((option) => {
|
|
2982
|
+
if (!option || typeof option !== "object") {
|
|
2983
|
+
return [];
|
|
2984
|
+
}
|
|
2985
|
+
const optionRecord = option;
|
|
2986
|
+
const optionLabel = typeof optionRecord.label === "string" ? optionRecord.label.trim() : "";
|
|
2987
|
+
const optionValue = typeof optionRecord.value === "string" ? optionRecord.value : "";
|
|
2988
|
+
return optionLabel && optionValue ? [{ label: optionLabel, value: optionValue }] : [];
|
|
2989
|
+
}) : void 0
|
|
2990
|
+
}];
|
|
2991
|
+
});
|
|
2992
|
+
}
|
|
2993
|
+
function isToolRoutingResetError(error) {
|
|
2994
|
+
return error.code === "tool_routing_error" && /tool not found/i.test(error.message);
|
|
2995
|
+
}
|
|
2996
|
+
function mapConnections(agent) {
|
|
2997
|
+
return agent.getMcpServers().map((server) => ({
|
|
2998
|
+
url: server.url,
|
|
2999
|
+
name: server.name,
|
|
3000
|
+
authStatus: server.authStatus,
|
|
3001
|
+
canSignOut: server.canSignOut
|
|
3002
|
+
}));
|
|
3003
|
+
}
|
|
3004
|
+
function normalizeConnections(connections) {
|
|
3005
|
+
if (!connections?.length) {
|
|
3006
|
+
return [];
|
|
3007
|
+
}
|
|
3008
|
+
const byUrl = /* @__PURE__ */ new Map();
|
|
3009
|
+
for (const connection of connections) {
|
|
3010
|
+
const url = connection.url?.trim();
|
|
3011
|
+
if (!url) {
|
|
3012
|
+
continue;
|
|
3013
|
+
}
|
|
3014
|
+
byUrl.set(url, {
|
|
3015
|
+
...connection,
|
|
3016
|
+
url,
|
|
3017
|
+
name: connection.name?.trim() || url
|
|
3018
|
+
});
|
|
3019
|
+
}
|
|
3020
|
+
return Array.from(byUrl.values());
|
|
3021
|
+
}
|
|
3022
|
+
function mergeConnections(runtimeConnections, existingConnections) {
|
|
3023
|
+
const byUrl = /* @__PURE__ */ new Map();
|
|
3024
|
+
for (const connection of existingConnections) {
|
|
3025
|
+
byUrl.set(connection.url, connection);
|
|
3026
|
+
}
|
|
3027
|
+
for (const connection of runtimeConnections) {
|
|
3028
|
+
byUrl.set(connection.url, connection);
|
|
3029
|
+
}
|
|
3030
|
+
return Array.from(byUrl.values());
|
|
3031
|
+
}
|
|
3032
|
+
var AppAgentController = class {
|
|
3033
|
+
constructor(config) {
|
|
3034
|
+
this.config = config;
|
|
3035
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
3036
|
+
this.recoveredConversationIds = /* @__PURE__ */ new Set();
|
|
3037
|
+
this.approvalResolvers = /* @__PURE__ */ new Map();
|
|
3038
|
+
this.inputResolvers = /* @__PURE__ */ new Map();
|
|
3039
|
+
this.pendingToolCallsByAction = /* @__PURE__ */ new Map();
|
|
3040
|
+
this.lifecycleUnsubscribers = [];
|
|
3041
|
+
this.userMessageDisplayOverrides = /* @__PURE__ */ new Map();
|
|
3042
|
+
this.started = false;
|
|
3043
|
+
this.disposed = false;
|
|
3044
|
+
this.initializationPromise = null;
|
|
3045
|
+
this.pendingDisplayText = null;
|
|
3046
|
+
this.getSnapshot = () => this.snapshot;
|
|
3047
|
+
this.handleMessage = (message) => {
|
|
3048
|
+
if (message.role === "user" && this.pendingDisplayText) {
|
|
3049
|
+
this.userMessageDisplayOverrides.set(message.id, this.pendingDisplayText);
|
|
3050
|
+
this.pendingDisplayText = null;
|
|
3051
|
+
}
|
|
3052
|
+
this.setState((current) => ({
|
|
3053
|
+
...current,
|
|
3054
|
+
conversation: {
|
|
3055
|
+
...current.conversation,
|
|
3056
|
+
messages: [...current.conversation.messages, message],
|
|
3057
|
+
id: this.agent.getConversationId(),
|
|
3058
|
+
streamingContent: "",
|
|
3059
|
+
hasOlderMessages: this.agent.getHasOlderMessages(),
|
|
3060
|
+
pendingTurnSawActivity: current.conversation.pendingTurn ? true : current.conversation.pendingTurnSawActivity
|
|
3061
|
+
}
|
|
3062
|
+
}));
|
|
3063
|
+
void this.persistResumeRecord();
|
|
3064
|
+
this.finalizePendingTurnIfSettled();
|
|
3065
|
+
};
|
|
3066
|
+
this.handleContentDelta = (delta) => {
|
|
3067
|
+
this.setState((current) => ({
|
|
3068
|
+
...current,
|
|
3069
|
+
conversation: {
|
|
3070
|
+
...current.conversation,
|
|
3071
|
+
streamingContent: current.conversation.streamingContent + delta.text,
|
|
3072
|
+
pendingTurnSawActivity: current.conversation.pendingTurn ? true : current.conversation.pendingTurnSawActivity
|
|
3073
|
+
}
|
|
3074
|
+
}));
|
|
3075
|
+
};
|
|
3076
|
+
this.handleToolCall = (toolCall) => {
|
|
3077
|
+
if (toolCall.toolName === APP_AGENT_APPROVAL_ACTION || toolCall.toolName === APP_AGENT_INPUT_ACTION) {
|
|
3078
|
+
const queue = this.pendingToolCallsByAction.get(toolCall.toolName) ?? [];
|
|
3079
|
+
queue.push(toolCall.toolCallId);
|
|
3080
|
+
this.pendingToolCallsByAction.set(toolCall.toolName, queue);
|
|
3081
|
+
}
|
|
3082
|
+
this.setState((current) => ({
|
|
3083
|
+
...current,
|
|
3084
|
+
conversation: {
|
|
3085
|
+
...current.conversation,
|
|
3086
|
+
id: this.agent.getConversationId(),
|
|
3087
|
+
messages: [
|
|
3088
|
+
...current.conversation.messages,
|
|
3089
|
+
{
|
|
3090
|
+
id: crypto.randomUUID(),
|
|
3091
|
+
role: "tool_call",
|
|
3092
|
+
content: `Calling ${toolCall.toolLabel ?? toolCall.toolName}...`,
|
|
3093
|
+
toolName: toolCall.toolName,
|
|
3094
|
+
toolLabel: toolCall.toolLabel,
|
|
3095
|
+
toolCallId: toolCall.toolCallId,
|
|
3096
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
3097
|
+
toolCallStatus: "calling",
|
|
3098
|
+
toolCallStartTime: Date.now()
|
|
3099
|
+
}
|
|
3100
|
+
],
|
|
3101
|
+
pendingTurnSawActivity: current.conversation.pendingTurn ? true : current.conversation.pendingTurnSawActivity
|
|
3102
|
+
}
|
|
3103
|
+
}));
|
|
3104
|
+
};
|
|
3105
|
+
this.handleToolResult = (data) => {
|
|
3106
|
+
this.setState((current) => ({
|
|
3107
|
+
...current,
|
|
3108
|
+
conversation: {
|
|
3109
|
+
...current.conversation,
|
|
3110
|
+
messages: current.conversation.messages.map(
|
|
3111
|
+
(message) => message.role === "tool_call" && message.toolCallId === data.toolCallId ? {
|
|
3112
|
+
...message,
|
|
3113
|
+
toolCallStatus: "completed",
|
|
3114
|
+
toolCallDuration: data.duration,
|
|
3115
|
+
toolResult: typeof data.result === "string" ? data.result : JSON.stringify(data.result)
|
|
3116
|
+
} : message
|
|
3117
|
+
)
|
|
3118
|
+
}
|
|
3119
|
+
}));
|
|
3120
|
+
this.finalizePendingTurnIfSettled();
|
|
3121
|
+
};
|
|
3122
|
+
this.handleToolError = (data) => {
|
|
3123
|
+
this.setState((current) => ({
|
|
3124
|
+
...current,
|
|
3125
|
+
conversation: {
|
|
3126
|
+
...current.conversation,
|
|
3127
|
+
messages: current.conversation.messages.map(
|
|
3128
|
+
(message) => message.role === "tool_call" && message.toolCallId === data.toolCallId ? {
|
|
3129
|
+
...message,
|
|
3130
|
+
toolCallStatus: "error",
|
|
3131
|
+
toolCallDuration: data.duration,
|
|
3132
|
+
toolError: data.error
|
|
3133
|
+
} : message
|
|
3134
|
+
)
|
|
3135
|
+
}
|
|
3136
|
+
}));
|
|
3137
|
+
this.finalizePendingTurnIfSettled();
|
|
3138
|
+
};
|
|
3139
|
+
this.handleThinking = (thinking) => {
|
|
3140
|
+
this.setState((current) => ({
|
|
3141
|
+
...current,
|
|
3142
|
+
conversation: {
|
|
3143
|
+
...current.conversation,
|
|
3144
|
+
isThinking: thinking
|
|
3145
|
+
}
|
|
3146
|
+
}));
|
|
3147
|
+
this.finalizePendingTurnIfSettled();
|
|
3148
|
+
};
|
|
3149
|
+
this.handleLoading = (loading) => {
|
|
3150
|
+
this.setState((current) => ({
|
|
3151
|
+
...current,
|
|
3152
|
+
conversation: {
|
|
3153
|
+
...current.conversation,
|
|
3154
|
+
isLoading: loading,
|
|
3155
|
+
error: loading ? null : current.conversation.error,
|
|
3156
|
+
streamingContent: loading ? "" : current.conversation.streamingContent
|
|
3157
|
+
}
|
|
3158
|
+
}));
|
|
3159
|
+
this.finalizePendingTurnIfSettled();
|
|
3160
|
+
};
|
|
3161
|
+
this.handleError = (error) => {
|
|
3162
|
+
this.setState((current) => ({
|
|
3163
|
+
...current,
|
|
3164
|
+
budget: error.budget ?? current.budget,
|
|
3165
|
+
conversation: {
|
|
3166
|
+
...current.conversation,
|
|
3167
|
+
error,
|
|
3168
|
+
pendingTurn: null,
|
|
3169
|
+
pendingTurnSawActivity: false
|
|
3170
|
+
}
|
|
3171
|
+
}));
|
|
3172
|
+
if (isToolRoutingResetError(error)) {
|
|
3173
|
+
const conversationId = this.agent.getConversationId();
|
|
3174
|
+
if (conversationId && !this.recoveredConversationIds.has(conversationId)) {
|
|
3175
|
+
this.recoveredConversationIds.add(conversationId);
|
|
3176
|
+
void this.handleStaleConversation(error);
|
|
3177
|
+
return;
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
this.setState((current) => ({
|
|
3181
|
+
...current,
|
|
3182
|
+
conversation: {
|
|
3183
|
+
...current.conversation,
|
|
3184
|
+
issue: {
|
|
3185
|
+
code: "runtime_error",
|
|
3186
|
+
message: error.message,
|
|
3187
|
+
recoverable: false
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
}));
|
|
3191
|
+
};
|
|
3192
|
+
this.handleBudgetSnapshot = (budget) => {
|
|
3193
|
+
this.setState((current) => ({
|
|
3194
|
+
...current,
|
|
3195
|
+
budget
|
|
3196
|
+
}));
|
|
3197
|
+
};
|
|
3198
|
+
this.handleMcpAuthStatus = () => {
|
|
3199
|
+
this.setState((current) => ({
|
|
3200
|
+
...current,
|
|
3201
|
+
connections: {
|
|
3202
|
+
items: mergeConnections(mapConnections(this.agent), current.connections.items)
|
|
3203
|
+
}
|
|
3204
|
+
}));
|
|
3205
|
+
};
|
|
3206
|
+
this.handleAudioState = (voice) => {
|
|
3207
|
+
this.setState((current) => ({
|
|
3208
|
+
...current,
|
|
3209
|
+
voice
|
|
3210
|
+
}));
|
|
3211
|
+
};
|
|
3212
|
+
this.platform = config.platform ?? createBrowserAppAgentPlatform();
|
|
3213
|
+
const onAuthRequired = config.onAuthRequired ?? (!config.auth && this.platform.auth ? createPlatformAuthHandler({
|
|
3214
|
+
platform: this.platform,
|
|
3215
|
+
userIdentity: config.userIdentity,
|
|
3216
|
+
oauthCallbackUrl: config.oauthCallbackUrl ?? ""
|
|
3217
|
+
}) : void 0);
|
|
3218
|
+
this.agent = new McpStackAgent({
|
|
3219
|
+
apiKey: config.apiKey,
|
|
3220
|
+
agentId: config.agentId,
|
|
3221
|
+
agentServiceUrl: config.serviceUrl,
|
|
3222
|
+
oauthCallbackUrl: config.oauthCallbackUrl,
|
|
3223
|
+
oauthClientMetadataUrl: config.oauthClientMetadataUrl,
|
|
3224
|
+
getAuthToken: config.getAuthToken,
|
|
3225
|
+
authSessionKey: config.appSessionKey,
|
|
3226
|
+
auth: config.auth,
|
|
3227
|
+
embeddedAuth: config.userIdentity ? {
|
|
3228
|
+
hostIdentity: config.userIdentity,
|
|
3229
|
+
mismatchPolicy: "block_with_switch"
|
|
3230
|
+
} : void 0,
|
|
3231
|
+
useCookies: config.useCookies,
|
|
3232
|
+
onAuthRequired,
|
|
3233
|
+
externalUserId: config.externalUserId ?? config.userIdentity?.subject ?? config.userIdentity?.email,
|
|
3234
|
+
context: config.appContext,
|
|
3235
|
+
clientTools: this.buildClientTools(config.clientTools),
|
|
3236
|
+
conversationHistoryPageSize: config.conversation?.historyPageSize ?? 50,
|
|
3237
|
+
storage: config.storage
|
|
3238
|
+
});
|
|
3239
|
+
this.state = {
|
|
3240
|
+
runtime: {
|
|
3241
|
+
agentConfig: null
|
|
3242
|
+
},
|
|
3243
|
+
conversation: {
|
|
3244
|
+
id: null,
|
|
3245
|
+
messages: [],
|
|
3246
|
+
streamingContent: "",
|
|
3247
|
+
pendingTurn: null,
|
|
3248
|
+
pendingTurnSawActivity: false,
|
|
3249
|
+
statusLabel: "Connecting\u2026",
|
|
3250
|
+
resumeKey: this.getResumeStorageKey(),
|
|
3251
|
+
isReady: false,
|
|
3252
|
+
isLoading: false,
|
|
3253
|
+
isLoadingHistory: false,
|
|
3254
|
+
isThinking: false,
|
|
3255
|
+
hasOlderMessages: false,
|
|
3256
|
+
error: null,
|
|
3257
|
+
issue: null
|
|
3258
|
+
},
|
|
3259
|
+
connections: {
|
|
3260
|
+
items: normalizeConnections(config.initialConnections)
|
|
3261
|
+
},
|
|
3262
|
+
approvals: {
|
|
3263
|
+
pending: []
|
|
3264
|
+
},
|
|
3265
|
+
requests: {
|
|
3266
|
+
pending: []
|
|
3267
|
+
},
|
|
3268
|
+
feedback: {
|
|
3269
|
+
isSubmitting: false,
|
|
3270
|
+
error: null,
|
|
3271
|
+
lastSubmittedAt: null,
|
|
3272
|
+
lastFeedback: null
|
|
3273
|
+
},
|
|
3274
|
+
voice: this.agent.getAudioInputState(),
|
|
3275
|
+
budget: this.agent.getBudgetSnapshot()
|
|
3276
|
+
};
|
|
3277
|
+
this.recomputeDerivedState();
|
|
3278
|
+
this.snapshot = this.buildSnapshot();
|
|
3279
|
+
}
|
|
3280
|
+
getAgent() {
|
|
3281
|
+
return this.agent;
|
|
3282
|
+
}
|
|
3283
|
+
buildSnapshot() {
|
|
3284
|
+
const messages = applyUserMessageOverrides(
|
|
3285
|
+
this.state.conversation.messages,
|
|
3286
|
+
Object.fromEntries(this.userMessageDisplayOverrides)
|
|
3287
|
+
);
|
|
3288
|
+
const visibleMessages = deriveVisibleMessages(messages);
|
|
3289
|
+
const conversationMessages = deriveConversationMessages(messages);
|
|
3290
|
+
const toolMessages = deriveToolMessages(visibleMessages);
|
|
3291
|
+
const latestAssistantMessage = getLatestAssistantMessage(visibleMessages);
|
|
3292
|
+
const latestToolMessage = getLatestToolMessage(toolMessages);
|
|
3293
|
+
const latestUserMessage = getLatestUserMessage(visibleMessages);
|
|
3294
|
+
const lastTurn = deriveLastTurnSummary(messages);
|
|
3295
|
+
const renderedNodes = buildRenderedNodes(messages);
|
|
3296
|
+
const inlineFeed = deriveInlineFeedState({
|
|
3297
|
+
pendingTurn: this.state.conversation.pendingTurn,
|
|
3298
|
+
toolMessages,
|
|
3299
|
+
latestAssistantMessage: latestAssistantMessage ? { id: latestAssistantMessage.id, content: latestAssistantMessage.content } : null,
|
|
3300
|
+
streamingContent: this.state.conversation.streamingContent,
|
|
3301
|
+
isLoading: this.state.conversation.isLoading,
|
|
3302
|
+
isThinking: this.state.conversation.isThinking
|
|
3303
|
+
});
|
|
3304
|
+
return {
|
|
3305
|
+
runtime: {
|
|
3306
|
+
agent: this.agent,
|
|
3307
|
+
agentConfig: this.state.runtime.agentConfig
|
|
3308
|
+
},
|
|
3309
|
+
conversation: {
|
|
3310
|
+
...this.state.conversation,
|
|
3311
|
+
messages,
|
|
3312
|
+
visibleMessages,
|
|
3313
|
+
conversationMessages,
|
|
3314
|
+
toolMessages,
|
|
3315
|
+
renderedNodes,
|
|
3316
|
+
latestAssistantMessage,
|
|
3317
|
+
latestToolMessage,
|
|
3318
|
+
latestUserMessage,
|
|
3319
|
+
lastTurn,
|
|
3320
|
+
pendingTurn: this.state.conversation.pendingTurn,
|
|
3321
|
+
inlineFeed
|
|
3322
|
+
},
|
|
3323
|
+
connections: {
|
|
3324
|
+
...this.state.connections,
|
|
3325
|
+
needsAttention: this.state.connections.items.some((item) => item.authStatus === "needs_auth")
|
|
3326
|
+
},
|
|
3327
|
+
approvals: {
|
|
3328
|
+
pending: this.state.approvals.pending
|
|
3329
|
+
},
|
|
3330
|
+
requests: {
|
|
3331
|
+
pending: this.state.requests.pending
|
|
3332
|
+
},
|
|
3333
|
+
feedback: this.state.feedback,
|
|
3334
|
+
budget: this.state.budget,
|
|
3335
|
+
voice: this.state.voice
|
|
3336
|
+
};
|
|
3337
|
+
}
|
|
3338
|
+
subscribe(listener) {
|
|
3339
|
+
this.listeners.add(listener);
|
|
3340
|
+
return () => {
|
|
3341
|
+
this.listeners.delete(listener);
|
|
3342
|
+
};
|
|
3343
|
+
}
|
|
3344
|
+
start() {
|
|
3345
|
+
if (this.started || this.disposed) {
|
|
3346
|
+
return;
|
|
3347
|
+
}
|
|
3348
|
+
this.started = true;
|
|
3349
|
+
this.bindLifecycleSignals();
|
|
3350
|
+
this.bindRuntimeEvents();
|
|
3351
|
+
this.initializationPromise = this.initialize();
|
|
3352
|
+
}
|
|
3353
|
+
dispose() {
|
|
3354
|
+
if (this.disposed) {
|
|
3355
|
+
return;
|
|
3356
|
+
}
|
|
3357
|
+
this.disposed = true;
|
|
3358
|
+
this.started = false;
|
|
3359
|
+
this.agent.off("message", this.handleMessage);
|
|
3360
|
+
this.agent.off("content_delta", this.handleContentDelta);
|
|
3361
|
+
this.agent.off("tool_call", this.handleToolCall);
|
|
3362
|
+
this.agent.off("tool_result", this.handleToolResult);
|
|
3363
|
+
this.agent.off("tool_error", this.handleToolError);
|
|
3364
|
+
this.agent.off("thinking", this.handleThinking);
|
|
3365
|
+
this.agent.off("loading", this.handleLoading);
|
|
3366
|
+
this.agent.off("error", this.handleError);
|
|
3367
|
+
this.agent.off("mcp_auth_status", this.handleMcpAuthStatus);
|
|
3368
|
+
this.agent.off("audio_state", this.handleAudioState);
|
|
3369
|
+
this.lifecycleUnsubscribers.splice(0).forEach((unsubscribe) => unsubscribe());
|
|
3370
|
+
this.agent.cancelVoiceInput();
|
|
3371
|
+
this.agent.cancel();
|
|
3372
|
+
this.approvalResolvers.clear();
|
|
3373
|
+
this.inputResolvers.clear();
|
|
3374
|
+
this.listeners.clear();
|
|
3375
|
+
}
|
|
3376
|
+
updateDynamicConfig(config) {
|
|
3377
|
+
this.config = {
|
|
3378
|
+
...this.config,
|
|
3379
|
+
...config
|
|
3380
|
+
};
|
|
3381
|
+
this.agent.setAppContext(this.config.appContext);
|
|
3382
|
+
this.agent.setClientTools(this.buildClientTools(this.config.clientTools));
|
|
3383
|
+
}
|
|
3384
|
+
setAuthRequiredHandler(onAuthRequired) {
|
|
3385
|
+
this.config = {
|
|
3386
|
+
...this.config,
|
|
3387
|
+
onAuthRequired
|
|
3388
|
+
};
|
|
3389
|
+
this.agent.setOnAuthRequired(onAuthRequired);
|
|
3390
|
+
}
|
|
3391
|
+
async send(prompt, options) {
|
|
3392
|
+
const trimmed = prompt.trim();
|
|
3393
|
+
if (!trimmed) {
|
|
3394
|
+
return;
|
|
3395
|
+
}
|
|
3396
|
+
const displayText = (options?.displayText ?? trimmed).trim() || trimmed;
|
|
3397
|
+
this.pendingDisplayText = displayText;
|
|
3398
|
+
this.setState((current) => ({
|
|
3399
|
+
...current,
|
|
3400
|
+
conversation: {
|
|
3401
|
+
...current.conversation,
|
|
3402
|
+
pendingTurn: createInlinePendingTurnState(displayText, current.conversation.messages),
|
|
3403
|
+
pendingTurnSawActivity: false,
|
|
3404
|
+
issue: null
|
|
3405
|
+
}
|
|
3406
|
+
}));
|
|
3407
|
+
try {
|
|
3408
|
+
await this.agent.sendMessage(trimmed);
|
|
3409
|
+
} catch (error) {
|
|
3410
|
+
this.pendingDisplayText = null;
|
|
3411
|
+
const message = error instanceof Error ? error.message : "Could not send message.";
|
|
3412
|
+
this.setState((current) => ({
|
|
3413
|
+
...current,
|
|
3414
|
+
conversation: {
|
|
3415
|
+
...current.conversation,
|
|
3416
|
+
pendingTurn: null,
|
|
3417
|
+
pendingTurnSawActivity: false,
|
|
3418
|
+
error: {
|
|
3419
|
+
code: "send_message_error",
|
|
3420
|
+
message
|
|
3421
|
+
},
|
|
3422
|
+
issue: {
|
|
3423
|
+
code: "runtime_error",
|
|
3424
|
+
message,
|
|
3425
|
+
recoverable: false
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
}));
|
|
3429
|
+
throw error;
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
cancel() {
|
|
3433
|
+
this.agent.cancel();
|
|
3434
|
+
}
|
|
3435
|
+
async startVoiceInput() {
|
|
3436
|
+
await this.agent.startVoiceInput();
|
|
3437
|
+
}
|
|
3438
|
+
async stopVoiceInput() {
|
|
3439
|
+
await this.agent.stopVoiceInput();
|
|
3440
|
+
}
|
|
3441
|
+
cancelVoiceInput() {
|
|
3442
|
+
this.agent.cancelVoiceInput();
|
|
3443
|
+
}
|
|
3444
|
+
async loadMore() {
|
|
3445
|
+
this.setState((current) => ({
|
|
3446
|
+
...current,
|
|
3447
|
+
conversation: {
|
|
3448
|
+
...current.conversation,
|
|
3449
|
+
isLoadingHistory: true
|
|
3450
|
+
}
|
|
3451
|
+
}));
|
|
3452
|
+
try {
|
|
3453
|
+
const page = await this.agent.loadOlderMessages();
|
|
3454
|
+
if (!page) {
|
|
3455
|
+
return;
|
|
3456
|
+
}
|
|
3457
|
+
this.syncFromRuntime();
|
|
3458
|
+
} finally {
|
|
3459
|
+
this.setState((current) => ({
|
|
3460
|
+
...current,
|
|
3461
|
+
conversation: {
|
|
3462
|
+
...current.conversation,
|
|
3463
|
+
isLoadingHistory: false
|
|
3464
|
+
}
|
|
3465
|
+
}));
|
|
3466
|
+
}
|
|
3467
|
+
}
|
|
3468
|
+
async resetConversation() {
|
|
3469
|
+
this.agent.newConversation();
|
|
3470
|
+
await this.clearResumeRecord();
|
|
3471
|
+
this.recoveredConversationIds.clear();
|
|
3472
|
+
this.pendingToolCallsByAction.clear();
|
|
3473
|
+
this.approvalResolvers.clear();
|
|
3474
|
+
this.inputResolvers.clear();
|
|
3475
|
+
this.userMessageDisplayOverrides.clear();
|
|
3476
|
+
this.pendingDisplayText = null;
|
|
3477
|
+
this.setState((current) => ({
|
|
3478
|
+
...current,
|
|
3479
|
+
conversation: {
|
|
3480
|
+
...current.conversation,
|
|
3481
|
+
id: null,
|
|
3482
|
+
messages: [],
|
|
3483
|
+
streamingContent: "",
|
|
3484
|
+
pendingTurn: null,
|
|
3485
|
+
pendingTurnSawActivity: false,
|
|
3486
|
+
hasOlderMessages: false,
|
|
3487
|
+
error: null,
|
|
3488
|
+
issue: null
|
|
3489
|
+
},
|
|
3490
|
+
approvals: {
|
|
3491
|
+
pending: []
|
|
3492
|
+
},
|
|
3493
|
+
requests: {
|
|
3494
|
+
pending: []
|
|
3495
|
+
}
|
|
3496
|
+
}));
|
|
3497
|
+
}
|
|
3498
|
+
async connect(serverUrl) {
|
|
3499
|
+
await this.ensureInitialized();
|
|
3500
|
+
const result = await this.agent.authenticate(serverUrl);
|
|
3501
|
+
this.setState((current) => ({
|
|
3502
|
+
...current,
|
|
3503
|
+
connections: {
|
|
3504
|
+
items: mergeConnections(mapConnections(this.agent), current.connections.items)
|
|
3505
|
+
},
|
|
3506
|
+
budget: this.agent.getBudgetSnapshot()
|
|
3507
|
+
}));
|
|
3508
|
+
return result;
|
|
3509
|
+
}
|
|
3510
|
+
async disconnect(serverUrl) {
|
|
3511
|
+
await this.agent.signOutMcpServer(serverUrl);
|
|
3512
|
+
this.setState((current) => ({
|
|
3513
|
+
...current,
|
|
3514
|
+
connections: {
|
|
3515
|
+
items: mergeConnections(mapConnections(this.agent), current.connections.items)
|
|
3516
|
+
}
|
|
3517
|
+
}));
|
|
3518
|
+
}
|
|
3519
|
+
async submitFeedback(input) {
|
|
3520
|
+
this.setState((current) => ({
|
|
3521
|
+
...current,
|
|
3522
|
+
feedback: {
|
|
3523
|
+
...current.feedback,
|
|
3524
|
+
isSubmitting: true,
|
|
3525
|
+
error: null
|
|
3526
|
+
}
|
|
3527
|
+
}));
|
|
3528
|
+
try {
|
|
3529
|
+
const feedback = await this.agent.submitFeedback({
|
|
3530
|
+
...input,
|
|
3531
|
+
source: this.config.feedbackSource ?? "app-agent"
|
|
3532
|
+
});
|
|
3533
|
+
this.setState((current) => ({
|
|
3534
|
+
...current,
|
|
3535
|
+
feedback: {
|
|
3536
|
+
isSubmitting: false,
|
|
3537
|
+
error: null,
|
|
3538
|
+
lastSubmittedAt: feedback.createdAt,
|
|
3539
|
+
lastFeedback: feedback
|
|
3540
|
+
}
|
|
3541
|
+
}));
|
|
3542
|
+
return feedback;
|
|
3543
|
+
} catch (error) {
|
|
3544
|
+
const message = error instanceof Error ? error.message : "Could not save feedback.";
|
|
3545
|
+
this.setState((current) => ({
|
|
3546
|
+
...current,
|
|
3547
|
+
feedback: {
|
|
3548
|
+
...current.feedback,
|
|
3549
|
+
isSubmitting: false,
|
|
3550
|
+
error: message
|
|
3551
|
+
}
|
|
3552
|
+
}));
|
|
3553
|
+
throw error;
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
resolveApproval(id, approved) {
|
|
3557
|
+
const resolver = this.approvalResolvers.get(id);
|
|
3558
|
+
if (!resolver) {
|
|
3559
|
+
return;
|
|
3560
|
+
}
|
|
3561
|
+
this.approvalResolvers.delete(id);
|
|
3562
|
+
this.setState((current) => ({
|
|
3563
|
+
...current,
|
|
3564
|
+
approvals: {
|
|
3565
|
+
pending: current.approvals.pending.filter((approval) => approval.id !== id)
|
|
3566
|
+
}
|
|
3567
|
+
}));
|
|
3568
|
+
resolver.resolve({ approved });
|
|
3569
|
+
}
|
|
3570
|
+
submitRequest(id, values) {
|
|
3571
|
+
const resolver = this.inputResolvers.get(id);
|
|
3572
|
+
if (!resolver) {
|
|
3573
|
+
return;
|
|
3574
|
+
}
|
|
3575
|
+
this.inputResolvers.delete(id);
|
|
3576
|
+
this.setState((current) => ({
|
|
3577
|
+
...current,
|
|
3578
|
+
requests: {
|
|
3579
|
+
pending: current.requests.pending.filter((request) => request.id !== id)
|
|
3580
|
+
}
|
|
3581
|
+
}));
|
|
3582
|
+
resolver.resolve({ submitted: true, values });
|
|
3583
|
+
}
|
|
3584
|
+
cancelRequest(id) {
|
|
3585
|
+
const resolver = this.inputResolvers.get(id);
|
|
3586
|
+
if (!resolver) {
|
|
3587
|
+
return;
|
|
3588
|
+
}
|
|
3589
|
+
this.inputResolvers.delete(id);
|
|
3590
|
+
this.setState((current) => ({
|
|
3591
|
+
...current,
|
|
3592
|
+
requests: {
|
|
3593
|
+
pending: current.requests.pending.filter((request) => request.id !== id)
|
|
3594
|
+
}
|
|
3595
|
+
}));
|
|
3596
|
+
resolver.resolve({ submitted: false });
|
|
3597
|
+
}
|
|
3598
|
+
async initialize() {
|
|
3599
|
+
try {
|
|
3600
|
+
const agentConfig = await this.agent.init();
|
|
3601
|
+
if (this.disposed) {
|
|
3602
|
+
return;
|
|
3603
|
+
}
|
|
3604
|
+
this.setState((current) => ({
|
|
3605
|
+
...current,
|
|
3606
|
+
runtime: {
|
|
3607
|
+
agentConfig
|
|
3608
|
+
},
|
|
3609
|
+
conversation: {
|
|
3610
|
+
...current.conversation,
|
|
3611
|
+
isReady: true
|
|
3612
|
+
},
|
|
3613
|
+
connections: {
|
|
3614
|
+
items: mergeConnections(mapConnections(this.agent), current.connections.items)
|
|
3615
|
+
},
|
|
3616
|
+
budget: this.agent.getBudgetSnapshot()
|
|
3617
|
+
}));
|
|
3618
|
+
const resumeRecord = await this.readResumeRecord();
|
|
3619
|
+
if (resumeRecord && resumeRecord.agentId === this.config.agentId && resumeRecord.appSessionKey === (this.config.appSessionKey ?? null) && resumeRecord.conversationResumeVersion === agentConfig.conversationResumeVersion) {
|
|
3620
|
+
this.setState((current) => ({
|
|
3621
|
+
...current,
|
|
3622
|
+
conversation: {
|
|
3623
|
+
...current.conversation,
|
|
3624
|
+
isLoadingHistory: true
|
|
3625
|
+
}
|
|
3626
|
+
}));
|
|
3627
|
+
try {
|
|
3628
|
+
await this.agent.loadConversation(
|
|
3629
|
+
resumeRecord.conversationId,
|
|
3630
|
+
this.config.conversation?.historyPageSize ?? 50
|
|
3631
|
+
);
|
|
3632
|
+
} catch (error) {
|
|
3633
|
+
this.setState((current) => ({
|
|
3634
|
+
...current,
|
|
3635
|
+
conversation: {
|
|
3636
|
+
...current.conversation,
|
|
3637
|
+
error: {
|
|
3638
|
+
code: "conversation_history_error",
|
|
3639
|
+
message: error instanceof Error ? error.message : "Failed to load conversation history."
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
}));
|
|
3643
|
+
await this.clearResumeRecord();
|
|
3644
|
+
} finally {
|
|
3645
|
+
this.syncFromRuntime();
|
|
3646
|
+
this.setState((current) => ({
|
|
3647
|
+
...current,
|
|
3648
|
+
conversation: {
|
|
3649
|
+
...current.conversation,
|
|
3650
|
+
isLoadingHistory: false
|
|
3651
|
+
}
|
|
3652
|
+
}));
|
|
3653
|
+
}
|
|
3654
|
+
} else {
|
|
3655
|
+
this.syncFromRuntime();
|
|
3656
|
+
}
|
|
3657
|
+
} catch (error) {
|
|
3658
|
+
const message = error instanceof Error && error.message.trim() ? error.message.trim() : "Failed to load agent configuration.";
|
|
3659
|
+
this.setState((current) => ({
|
|
3660
|
+
...current,
|
|
3661
|
+
conversation: {
|
|
3662
|
+
...current.conversation,
|
|
3663
|
+
error: {
|
|
3664
|
+
code: /api key|unauthorized|401/i.test(message) ? "agent_config_auth_error" : "agent_config_error",
|
|
3665
|
+
message
|
|
3666
|
+
},
|
|
3667
|
+
issue: {
|
|
3668
|
+
code: "config_error",
|
|
3669
|
+
message,
|
|
3670
|
+
recoverable: false
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
}));
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
async ensureInitialized() {
|
|
3677
|
+
if (this.state.runtime.agentConfig) {
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
this.initializationPromise ?? (this.initializationPromise = this.initialize());
|
|
3681
|
+
await this.initializationPromise;
|
|
3682
|
+
}
|
|
3683
|
+
bindRuntimeEvents() {
|
|
3684
|
+
this.agent.on("message", this.handleMessage);
|
|
3685
|
+
this.agent.on("content_delta", this.handleContentDelta);
|
|
3686
|
+
this.agent.on("tool_call", this.handleToolCall);
|
|
3687
|
+
this.agent.on("tool_result", this.handleToolResult);
|
|
3688
|
+
this.agent.on("tool_error", this.handleToolError);
|
|
3689
|
+
this.agent.on("thinking", this.handleThinking);
|
|
3690
|
+
this.agent.on("loading", this.handleLoading);
|
|
3691
|
+
this.agent.on("error", this.handleError);
|
|
3692
|
+
this.agent.on("mcp_auth_status", this.handleMcpAuthStatus);
|
|
3693
|
+
this.agent.on("audio_state", this.handleAudioState);
|
|
3694
|
+
this.agent.on("budget_snapshot", this.handleBudgetSnapshot);
|
|
3695
|
+
}
|
|
3696
|
+
async handleStaleConversation(error) {
|
|
3697
|
+
await this.clearResumeRecord();
|
|
3698
|
+
this.agent.newConversation();
|
|
3699
|
+
this.pendingToolCallsByAction.clear();
|
|
3700
|
+
this.approvalResolvers.clear();
|
|
3701
|
+
this.inputResolvers.clear();
|
|
3702
|
+
this.userMessageDisplayOverrides.clear();
|
|
3703
|
+
this.pendingDisplayText = null;
|
|
3704
|
+
this.setState((current) => ({
|
|
3705
|
+
...current,
|
|
3706
|
+
conversation: {
|
|
3707
|
+
...current.conversation,
|
|
3708
|
+
id: null,
|
|
3709
|
+
messages: [],
|
|
3710
|
+
streamingContent: "",
|
|
3711
|
+
pendingTurn: null,
|
|
3712
|
+
pendingTurnSawActivity: false,
|
|
3713
|
+
hasOlderMessages: false,
|
|
3714
|
+
issue: {
|
|
3715
|
+
code: "stale_conversation",
|
|
3716
|
+
message: error.message,
|
|
3717
|
+
recoverable: true
|
|
3718
|
+
}
|
|
3719
|
+
},
|
|
3720
|
+
approvals: {
|
|
3721
|
+
pending: []
|
|
3722
|
+
},
|
|
3723
|
+
requests: {
|
|
3724
|
+
pending: []
|
|
3725
|
+
}
|
|
3726
|
+
}));
|
|
3727
|
+
}
|
|
3728
|
+
bindLifecycleSignals() {
|
|
3729
|
+
const lifecycle = this.platform.lifecycle;
|
|
3730
|
+
if (!lifecycle) {
|
|
3731
|
+
return;
|
|
3732
|
+
}
|
|
3733
|
+
const foregroundUnsubscribe = lifecycle.onForegroundChange?.(() => {
|
|
3734
|
+
this.emit();
|
|
3735
|
+
});
|
|
3736
|
+
if (foregroundUnsubscribe) {
|
|
3737
|
+
this.lifecycleUnsubscribers.push(foregroundUnsubscribe);
|
|
3738
|
+
}
|
|
3739
|
+
const connectivityUnsubscribe = lifecycle.onConnectivityChange?.(() => {
|
|
3740
|
+
this.emit();
|
|
3741
|
+
});
|
|
3742
|
+
if (connectivityUnsubscribe) {
|
|
3743
|
+
this.lifecycleUnsubscribers.push(connectivityUnsubscribe);
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
buildClientTools(clientTools) {
|
|
3747
|
+
const approvalAction = {
|
|
3748
|
+
description: "Ask the host app to approve a multi-step plan before you continue.",
|
|
3749
|
+
selection: {
|
|
3750
|
+
categories: ["approval"],
|
|
3751
|
+
alwaysInclude: true,
|
|
3752
|
+
risk: "medium"
|
|
3753
|
+
},
|
|
3754
|
+
parameters: {
|
|
3755
|
+
title: { type: "string", description: "Short title for the approval request.", required: true },
|
|
3756
|
+
rationale: { type: "string", description: "Optional reason for the plan." },
|
|
3757
|
+
steps: { type: "array", description: "Ordered steps that need approval.", required: true },
|
|
3758
|
+
confirmLabel: { type: "string", description: "Optional label for the approve action." },
|
|
3759
|
+
cancelLabel: { type: "string", description: "Optional label for the reject action." }
|
|
3760
|
+
},
|
|
3761
|
+
execute: async (params) => {
|
|
3762
|
+
const approvalId = createId("approval");
|
|
3763
|
+
const toolCallId = this.shiftPendingToolCallId(APP_AGENT_APPROVAL_ACTION);
|
|
3764
|
+
const approval = {
|
|
3765
|
+
id: approvalId,
|
|
3766
|
+
title: typeof params.title === "string" ? params.title : "Approval required",
|
|
3767
|
+
rationale: typeof params.rationale === "string" ? params.rationale : void 0,
|
|
3768
|
+
steps: normalizeSteps(params.steps),
|
|
3769
|
+
toolCallId,
|
|
3770
|
+
confirmLabel: typeof params.confirmLabel === "string" ? params.confirmLabel : void 0,
|
|
3771
|
+
cancelLabel: typeof params.cancelLabel === "string" ? params.cancelLabel : void 0
|
|
3772
|
+
};
|
|
3773
|
+
this.setState((current) => ({
|
|
3774
|
+
...current,
|
|
3775
|
+
approvals: {
|
|
3776
|
+
pending: [...current.approvals.pending, approval]
|
|
3777
|
+
}
|
|
3778
|
+
}));
|
|
3779
|
+
return await new Promise((resolve) => {
|
|
3780
|
+
this.approvalResolvers.set(approvalId, { resolve });
|
|
3781
|
+
});
|
|
3782
|
+
}
|
|
3783
|
+
};
|
|
3784
|
+
return {
|
|
3785
|
+
...clientTools ?? {},
|
|
3786
|
+
[APP_AGENT_APPROVAL_ACTION]: {
|
|
3787
|
+
...approvalAction
|
|
3788
|
+
},
|
|
3789
|
+
[APP_AGENT_INPUT_ACTION]: {
|
|
3790
|
+
description: "Ask the host app for structured user input before you continue.",
|
|
3791
|
+
selection: {
|
|
3792
|
+
categories: ["approval"],
|
|
3793
|
+
alwaysInclude: true,
|
|
3794
|
+
risk: "medium"
|
|
3795
|
+
},
|
|
3796
|
+
parameters: {
|
|
3797
|
+
title: { type: "string", description: "Short title for the input request.", required: true },
|
|
3798
|
+
prompt: { type: "string", description: "Optional explainer shown above the fields." },
|
|
3799
|
+
fields: { type: "array", description: "Structured fields to collect.", required: true },
|
|
3800
|
+
submitLabel: { type: "string", description: "Optional submit button label." },
|
|
3801
|
+
cancelLabel: { type: "string", description: "Optional cancel button label." }
|
|
3802
|
+
},
|
|
3803
|
+
execute: async (params) => {
|
|
3804
|
+
const requestId = createId("input");
|
|
3805
|
+
const toolCallId = this.shiftPendingToolCallId(APP_AGENT_INPUT_ACTION);
|
|
3806
|
+
const request = {
|
|
3807
|
+
id: requestId,
|
|
3808
|
+
title: typeof params.title === "string" ? params.title : "Input required",
|
|
3809
|
+
prompt: typeof params.prompt === "string" ? params.prompt : void 0,
|
|
3810
|
+
fields: normalizeInputFields(params.fields),
|
|
3811
|
+
toolCallId,
|
|
3812
|
+
submitLabel: typeof params.submitLabel === "string" ? params.submitLabel : void 0,
|
|
3813
|
+
cancelLabel: typeof params.cancelLabel === "string" ? params.cancelLabel : void 0
|
|
3814
|
+
};
|
|
3815
|
+
this.setState((current) => ({
|
|
3816
|
+
...current,
|
|
3817
|
+
requests: {
|
|
3818
|
+
pending: [...current.requests.pending, request]
|
|
3819
|
+
}
|
|
3820
|
+
}));
|
|
3821
|
+
return await new Promise((resolve) => {
|
|
3822
|
+
this.inputResolvers.set(requestId, { resolve });
|
|
3823
|
+
});
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
};
|
|
3827
|
+
}
|
|
3828
|
+
shiftPendingToolCallId(actionName) {
|
|
3829
|
+
const queue = this.pendingToolCallsByAction.get(actionName) ?? [];
|
|
3830
|
+
const next = queue.shift() ?? null;
|
|
3831
|
+
if (queue.length === 0) {
|
|
3832
|
+
this.pendingToolCallsByAction.delete(actionName);
|
|
3833
|
+
} else {
|
|
3834
|
+
this.pendingToolCallsByAction.set(actionName, queue);
|
|
3835
|
+
}
|
|
3836
|
+
return next;
|
|
3837
|
+
}
|
|
3838
|
+
syncFromRuntime() {
|
|
3839
|
+
this.setState((current) => ({
|
|
3840
|
+
...current,
|
|
3841
|
+
conversation: {
|
|
3842
|
+
...current.conversation,
|
|
3843
|
+
id: this.agent.getConversationId(),
|
|
3844
|
+
messages: this.agent.getMessages(),
|
|
3845
|
+
hasOlderMessages: this.agent.getHasOlderMessages()
|
|
3846
|
+
},
|
|
3847
|
+
connections: {
|
|
3848
|
+
items: mergeConnections(mapConnections(this.agent), current.connections.items)
|
|
3849
|
+
}
|
|
3850
|
+
}));
|
|
3851
|
+
void this.persistResumeRecord();
|
|
3852
|
+
}
|
|
3853
|
+
finalizePendingTurnIfSettled() {
|
|
3854
|
+
const current = this.state.conversation;
|
|
3855
|
+
if (current.pendingTurn && current.pendingTurnSawActivity && !current.isLoading && !current.isThinking && !current.streamingContent) {
|
|
3856
|
+
this.setState((state) => ({
|
|
3857
|
+
...state,
|
|
3858
|
+
conversation: {
|
|
3859
|
+
...state.conversation,
|
|
3860
|
+
pendingTurn: null,
|
|
3861
|
+
pendingTurnSawActivity: false
|
|
3862
|
+
}
|
|
3863
|
+
}));
|
|
3864
|
+
}
|
|
3865
|
+
}
|
|
3866
|
+
getResumeStorageKey() {
|
|
3867
|
+
const durable = this.platform.storage?.durable;
|
|
3868
|
+
if (!durable) {
|
|
3869
|
+
return null;
|
|
3870
|
+
}
|
|
3871
|
+
const namespace = this.config.conversation?.namespace ?? "mcpstack.app-agent.resume";
|
|
3872
|
+
const sessionKey = this.config.appSessionKey?.trim() || "anonymous";
|
|
3873
|
+
return `${namespace}:${this.config.agentId}:${sessionKey}`;
|
|
3874
|
+
}
|
|
3875
|
+
async readResumeRecord() {
|
|
3876
|
+
const durable = this.platform.storage?.durable;
|
|
3877
|
+
const resumeKey = this.getResumeStorageKey();
|
|
3878
|
+
if (!durable || !resumeKey) {
|
|
3879
|
+
return null;
|
|
3880
|
+
}
|
|
3881
|
+
try {
|
|
3882
|
+
const raw = await resolveStoreValue(durable.getItem(resumeKey));
|
|
3883
|
+
if (!raw) {
|
|
3884
|
+
return null;
|
|
3885
|
+
}
|
|
3886
|
+
return JSON.parse(raw);
|
|
3887
|
+
} catch {
|
|
3888
|
+
return null;
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3891
|
+
async persistResumeRecord() {
|
|
3892
|
+
const durable = this.platform.storage?.durable;
|
|
3893
|
+
const resumeKey = this.getResumeStorageKey();
|
|
3894
|
+
const conversationId = this.agent.getConversationId();
|
|
3895
|
+
const agentConfig = this.state.runtime.agentConfig;
|
|
3896
|
+
if (!durable || !resumeKey || !conversationId || !agentConfig) {
|
|
3897
|
+
return;
|
|
3898
|
+
}
|
|
3899
|
+
const record = {
|
|
3900
|
+
conversationId,
|
|
3901
|
+
agentId: this.config.agentId,
|
|
3902
|
+
appSessionKey: this.config.appSessionKey ?? null,
|
|
3903
|
+
conversationResumeVersion: agentConfig.conversationResumeVersion,
|
|
3904
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3905
|
+
};
|
|
3906
|
+
try {
|
|
3907
|
+
await resolveStoreValue(durable.setItem(resumeKey, JSON.stringify(record)));
|
|
3908
|
+
} catch {
|
|
3909
|
+
}
|
|
3910
|
+
}
|
|
3911
|
+
async clearResumeRecord() {
|
|
3912
|
+
const durable = this.platform.storage?.durable;
|
|
3913
|
+
const resumeKey = this.getResumeStorageKey();
|
|
3914
|
+
if (!durable || !resumeKey) {
|
|
3915
|
+
return;
|
|
3916
|
+
}
|
|
3917
|
+
try {
|
|
3918
|
+
await resolveStoreValue(durable.removeItem(resumeKey));
|
|
3919
|
+
} catch {
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
setState(update) {
|
|
3923
|
+
if (this.disposed) {
|
|
3924
|
+
return;
|
|
3925
|
+
}
|
|
3926
|
+
this.state = update(this.state);
|
|
3927
|
+
this.recomputeDerivedState();
|
|
3928
|
+
this.snapshot = this.buildSnapshot();
|
|
3929
|
+
this.emit();
|
|
3930
|
+
}
|
|
3931
|
+
recomputeDerivedState() {
|
|
3932
|
+
const hasAttention = this.state.connections.items.some((item) => item.authStatus === "needs_auth");
|
|
3933
|
+
this.state.conversation.statusLabel = deriveConversationStatusLabel({
|
|
3934
|
+
isReady: this.state.conversation.isReady,
|
|
3935
|
+
isLoading: this.state.conversation.isLoading,
|
|
3936
|
+
isThinking: this.state.conversation.isThinking,
|
|
3937
|
+
streamingContent: this.state.conversation.streamingContent,
|
|
3938
|
+
hasError: this.state.conversation.error != null,
|
|
3939
|
+
hasIssue: this.state.conversation.issue != null,
|
|
3940
|
+
hasAttention
|
|
3941
|
+
});
|
|
3942
|
+
this.state.conversation.resumeKey = this.getResumeStorageKey();
|
|
3943
|
+
}
|
|
3944
|
+
emit() {
|
|
3945
|
+
for (const listener of this.listeners) {
|
|
3946
|
+
listener();
|
|
3947
|
+
}
|
|
3948
|
+
}
|
|
3949
|
+
};
|
|
3950
|
+
function createAppAgent(config) {
|
|
3951
|
+
return new AppAgentController(config);
|
|
3952
|
+
}
|
|
3953
|
+
export {
|
|
3954
|
+
AppAgentController,
|
|
3955
|
+
applyUserMessageOverrides,
|
|
3956
|
+
buildRenderedNodes,
|
|
3957
|
+
createAppAgent,
|
|
3958
|
+
createBrowserAppAgentPlatform,
|
|
3959
|
+
createBrowserKeyValueStore,
|
|
3960
|
+
createInlinePendingTurnState,
|
|
3961
|
+
createMemoryKeyValueStore,
|
|
3962
|
+
createPlatformAuthHandler,
|
|
3963
|
+
deriveConversationMessages,
|
|
3964
|
+
deriveConversationStatusLabel,
|
|
3965
|
+
deriveInlineFeedState,
|
|
3966
|
+
deriveLastTurnSummary,
|
|
3967
|
+
deriveToolMessages,
|
|
3968
|
+
deriveToolStatusPresentation,
|
|
3969
|
+
deriveVisibleMessages,
|
|
3970
|
+
formatStructuredText,
|
|
3971
|
+
getLatestAssistantMessage,
|
|
3972
|
+
getLatestToolMessage,
|
|
3973
|
+
getLatestUserMessage,
|
|
3974
|
+
resolveStoreValue,
|
|
3975
|
+
summarizeConversationId
|
|
3976
|
+
};
|
|
3977
|
+
//# sourceMappingURL=index.mjs.map
|