@aexol/spectral 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +10 -47
- package/dist/mcp/agent-dir.js +18 -0
- package/dist/mcp/app-bridge.bundle.js +67 -0
- package/dist/mcp/commands.js +263 -0
- package/dist/mcp/config.js +532 -0
- package/dist/mcp/consent-manager.js +59 -0
- package/dist/mcp/direct-tools.js +354 -0
- package/dist/mcp/errors.js +165 -0
- package/dist/mcp/glimpse-ui.js +67 -0
- package/dist/mcp/host-html-template.js +412 -0
- package/dist/mcp/index.js +291 -0
- package/dist/mcp/init.js +280 -0
- package/dist/mcp/lifecycle.js +79 -0
- package/dist/mcp/logger.js +130 -0
- package/dist/mcp/mcp-auth-flow.js +283 -0
- package/dist/mcp/mcp-auth.js +226 -0
- package/dist/mcp/mcp-callback-server.js +225 -0
- package/dist/mcp/mcp-oauth-provider.js +243 -0
- package/dist/mcp/mcp-panel.js +646 -0
- package/dist/mcp/mcp-setup-panel.js +485 -0
- package/dist/mcp/metadata-cache.js +158 -0
- package/dist/mcp/npx-resolver.js +385 -0
- package/dist/mcp/oauth-handler.js +54 -0
- package/dist/mcp/onboarding-state.js +56 -0
- package/dist/mcp/proxy-modes.js +714 -0
- package/dist/mcp/resource-tools.js +14 -0
- package/dist/mcp/sampling-handler.js +206 -0
- package/dist/mcp/server-manager.js +301 -0
- package/dist/mcp/state.js +1 -0
- package/dist/mcp/tool-metadata.js +128 -0
- package/dist/mcp/tool-registrar.js +43 -0
- package/dist/mcp/types.js +93 -0
- package/dist/mcp/ui-resource-handler.js +113 -0
- package/dist/mcp/ui-server.js +522 -0
- package/dist/mcp/ui-session.js +306 -0
- package/dist/mcp/ui-stream-types.js +58 -0
- package/dist/mcp/utils.js +104 -0
- package/dist/mcp/vitest.config.js +13 -0
- package/package.json +6 -3
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { lazyConnect, getFailureAgeSeconds } from "./init.js";
|
|
2
|
+
import { isServerCacheValid } from "./metadata-cache.js";
|
|
3
|
+
import { formatSchema } from "./tool-metadata.js";
|
|
4
|
+
import { transformMcpContent } from "./tool-registrar.js";
|
|
5
|
+
import { maybeStartUiSession } from "./ui-session.js";
|
|
6
|
+
import { formatToolName, isToolExcluded } from "./types.js";
|
|
7
|
+
import { resourceNameToToolName } from "./resource-tools.js";
|
|
8
|
+
import { authenticate, supportsOAuth } from "./mcp-auth-flow.js";
|
|
9
|
+
import { formatAuthRequiredMessage } from "./utils.js";
|
|
10
|
+
const BUILTIN_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
|
|
11
|
+
function getDirectAuthRequiredMessage(state, serverName, defaultMessage = `MCP server "${serverName}" requires OAuth authentication. Run /mcp-auth ${serverName} first.`) {
|
|
12
|
+
return formatAuthRequiredMessage(state.config, serverName, defaultMessage);
|
|
13
|
+
}
|
|
14
|
+
function getDirectAuthFailedMessage(state, serverName, message) {
|
|
15
|
+
const customGuidance = state.config.settings?.authRequiredMessage;
|
|
16
|
+
if (customGuidance) {
|
|
17
|
+
return `OAuth authentication failed for "${serverName}": ${message}. ${getDirectAuthRequiredMessage(state, serverName)}`;
|
|
18
|
+
}
|
|
19
|
+
return `OAuth authentication failed for "${serverName}": ${message}. Run /mcp-auth ${serverName} first.`;
|
|
20
|
+
}
|
|
21
|
+
async function attemptDirectAutoAuth(state, serverName) {
|
|
22
|
+
if (state.config.settings?.autoAuth !== true) {
|
|
23
|
+
return { status: "skipped" };
|
|
24
|
+
}
|
|
25
|
+
const definition = state.config.mcpServers[serverName];
|
|
26
|
+
if (!definition || !supportsOAuth(definition) || !definition.url) {
|
|
27
|
+
return { status: "skipped" };
|
|
28
|
+
}
|
|
29
|
+
const oauthConfig = definition.oauth;
|
|
30
|
+
const grantType = oauthConfig?.grantType ?? "authorization_code";
|
|
31
|
+
if (!state.ui && grantType !== "client_credentials") {
|
|
32
|
+
return {
|
|
33
|
+
status: "failed",
|
|
34
|
+
message: getDirectAuthRequiredMessage(state, serverName, `MCP server "${serverName}" requires OAuth authentication. Run /mcp-auth ${serverName} in an interactive session.`),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
await authenticate(serverName, definition.url, definition);
|
|
39
|
+
return { status: "success" };
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
43
|
+
return {
|
|
44
|
+
status: "failed",
|
|
45
|
+
message: getDirectAuthFailedMessage(state, serverName, message),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function resolveDirectTools(config, cache, prefix, envOverride) {
|
|
50
|
+
const specs = [];
|
|
51
|
+
if (!cache)
|
|
52
|
+
return specs;
|
|
53
|
+
const seenNames = new Set();
|
|
54
|
+
const envServers = new Set();
|
|
55
|
+
const envTools = new Map();
|
|
56
|
+
if (envOverride) {
|
|
57
|
+
for (let item of envOverride) {
|
|
58
|
+
item = item.replace(/\/+$/, "");
|
|
59
|
+
if (item.includes("/")) {
|
|
60
|
+
const [server, tool] = item.split("/", 2);
|
|
61
|
+
if (server && tool) {
|
|
62
|
+
if (!envTools.has(server))
|
|
63
|
+
envTools.set(server, new Set());
|
|
64
|
+
envTools.get(server).add(tool);
|
|
65
|
+
}
|
|
66
|
+
else if (server) {
|
|
67
|
+
envServers.add(server);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else if (item) {
|
|
71
|
+
envServers.add(item);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const globalDirect = config.settings?.directTools;
|
|
76
|
+
for (const [serverName, definition] of Object.entries(config.mcpServers)) {
|
|
77
|
+
const serverCache = cache.servers[serverName];
|
|
78
|
+
if (!serverCache || !isServerCacheValid(serverCache, definition))
|
|
79
|
+
continue;
|
|
80
|
+
let toolFilter = false;
|
|
81
|
+
if (envOverride) {
|
|
82
|
+
if (envServers.has(serverName)) {
|
|
83
|
+
toolFilter = true;
|
|
84
|
+
}
|
|
85
|
+
else if (envTools.has(serverName)) {
|
|
86
|
+
toolFilter = [...envTools.get(serverName)];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
if (definition.directTools !== undefined) {
|
|
91
|
+
toolFilter = definition.directTools;
|
|
92
|
+
}
|
|
93
|
+
else if (globalDirect) {
|
|
94
|
+
toolFilter = globalDirect;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!toolFilter)
|
|
98
|
+
continue;
|
|
99
|
+
for (const tool of serverCache.tools ?? []) {
|
|
100
|
+
if (toolFilter !== true && !toolFilter.includes(tool.name))
|
|
101
|
+
continue;
|
|
102
|
+
if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools))
|
|
103
|
+
continue;
|
|
104
|
+
const prefixedName = formatToolName(tool.name, serverName, prefix);
|
|
105
|
+
if (BUILTIN_NAMES.has(prefixedName)) {
|
|
106
|
+
console.warn(`MCP: skipping direct tool "${prefixedName}" (collides with builtin)`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (seenNames.has(prefixedName)) {
|
|
110
|
+
console.warn(`MCP: skipping duplicate direct tool "${prefixedName}" from "${serverName}"`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
seenNames.add(prefixedName);
|
|
114
|
+
specs.push({
|
|
115
|
+
serverName,
|
|
116
|
+
originalName: tool.name,
|
|
117
|
+
prefixedName,
|
|
118
|
+
description: tool.description ?? "",
|
|
119
|
+
inputSchema: tool.inputSchema,
|
|
120
|
+
uiResourceUri: tool.uiResourceUri,
|
|
121
|
+
uiStreamMode: tool.uiStreamMode,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (definition.exposeResources !== false) {
|
|
125
|
+
for (const resource of serverCache.resources ?? []) {
|
|
126
|
+
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
|
127
|
+
if (toolFilter !== true && !toolFilter.includes(baseName))
|
|
128
|
+
continue;
|
|
129
|
+
if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools))
|
|
130
|
+
continue;
|
|
131
|
+
const prefixedName = formatToolName(baseName, serverName, prefix);
|
|
132
|
+
if (BUILTIN_NAMES.has(prefixedName)) {
|
|
133
|
+
console.warn(`MCP: skipping direct resource tool "${prefixedName}" (collides with builtin)`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (seenNames.has(prefixedName)) {
|
|
137
|
+
console.warn(`MCP: skipping duplicate direct resource tool "${prefixedName}" from "${serverName}"`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
seenNames.add(prefixedName);
|
|
141
|
+
specs.push({
|
|
142
|
+
serverName,
|
|
143
|
+
originalName: baseName,
|
|
144
|
+
prefixedName,
|
|
145
|
+
description: resource.description ?? `Read resource: ${resource.uri}`,
|
|
146
|
+
resourceUri: resource.uri,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return specs;
|
|
152
|
+
}
|
|
153
|
+
export function getMissingConfiguredDirectToolServers(config, cache) {
|
|
154
|
+
const missing = [];
|
|
155
|
+
const globalDirect = config.settings?.directTools;
|
|
156
|
+
for (const [serverName, definition] of Object.entries(config.mcpServers)) {
|
|
157
|
+
const hasDirectTools = definition.directTools !== undefined
|
|
158
|
+
? !!definition.directTools
|
|
159
|
+
: !!globalDirect;
|
|
160
|
+
if (!hasDirectTools)
|
|
161
|
+
continue;
|
|
162
|
+
const serverCache = cache?.servers?.[serverName];
|
|
163
|
+
if (!serverCache || !isServerCacheValid(serverCache, definition)) {
|
|
164
|
+
missing.push(serverName);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return missing;
|
|
168
|
+
}
|
|
169
|
+
export function buildProxyDescription(config, cache, directSpecs) {
|
|
170
|
+
const prefix = config.settings?.toolPrefix ?? "server";
|
|
171
|
+
let desc = `MCP gateway - connect to MCP servers and call their tools. Non-MCP Pi tools should be called directly, not through mcp.\n`;
|
|
172
|
+
const directByServer = new Map();
|
|
173
|
+
for (const spec of directSpecs) {
|
|
174
|
+
directByServer.set(spec.serverName, (directByServer.get(spec.serverName) ?? 0) + 1);
|
|
175
|
+
}
|
|
176
|
+
if (directByServer.size > 0) {
|
|
177
|
+
const parts = [...directByServer.entries()].map(([server, count]) => `${server} (${count})`);
|
|
178
|
+
desc += `\nDirect tools available (call as normal tools): ${parts.join(", ")}\n`;
|
|
179
|
+
}
|
|
180
|
+
const serverSummaries = [];
|
|
181
|
+
for (const serverName of Object.keys(config.mcpServers)) {
|
|
182
|
+
const entry = cache?.servers?.[serverName];
|
|
183
|
+
const definition = config.mcpServers[serverName];
|
|
184
|
+
const toolCount = (entry?.tools ?? []).filter((tool) => !isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)).length;
|
|
185
|
+
const resourceCount = definition?.exposeResources !== false
|
|
186
|
+
? (entry?.resources ?? []).filter((resource) => {
|
|
187
|
+
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
|
188
|
+
return !isToolExcluded(baseName, serverName, prefix, definition.excludeTools);
|
|
189
|
+
}).length
|
|
190
|
+
: 0;
|
|
191
|
+
const totalItems = toolCount + resourceCount;
|
|
192
|
+
if (totalItems === 0)
|
|
193
|
+
continue;
|
|
194
|
+
const directCount = directByServer.get(serverName) ?? 0;
|
|
195
|
+
const proxyCount = totalItems - directCount;
|
|
196
|
+
if (proxyCount > 0) {
|
|
197
|
+
serverSummaries.push(`${serverName} (${proxyCount} tools)`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (serverSummaries.length > 0) {
|
|
201
|
+
desc += `\nServers: ${serverSummaries.join(", ")}\n`;
|
|
202
|
+
}
|
|
203
|
+
desc += `\nUsage:\n`;
|
|
204
|
+
desc += ` mcp({ }) → Show server status\n`;
|
|
205
|
+
desc += ` mcp({ server: "name" }) → List tools from server\n`;
|
|
206
|
+
desc += ` mcp({ search: "query" }) → Search MCP tools by name/description\n`;
|
|
207
|
+
desc += ` mcp({ describe: "tool_name" }) → Show tool details and parameters\n`;
|
|
208
|
+
desc += ` mcp({ connect: "server-name" }) → Connect to a server and refresh metadata\n`;
|
|
209
|
+
desc += ` mcp({ tool: "name", args: '{"key": "value"}' }) → Call a tool (args is JSON string)\n`;
|
|
210
|
+
desc += ` mcp({ action: "ui-messages" }) → Retrieve accumulated messages from completed UI sessions\n`;
|
|
211
|
+
desc += `\nMode: tool (call) > connect > describe > search > server (list) > action > nothing (status)`;
|
|
212
|
+
return desc;
|
|
213
|
+
}
|
|
214
|
+
export function createDirectToolExecutor(getState, getInitPromise, spec) {
|
|
215
|
+
return async function execute(_toolCallId, params) {
|
|
216
|
+
let state = getState();
|
|
217
|
+
const initPromise = getInitPromise();
|
|
218
|
+
if (!state && initPromise) {
|
|
219
|
+
try {
|
|
220
|
+
state = await initPromise;
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
224
|
+
return {
|
|
225
|
+
content: [{ type: "text", text: `MCP initialization failed: ${message}` }],
|
|
226
|
+
details: { error: "init_failed", message },
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!state) {
|
|
231
|
+
return {
|
|
232
|
+
content: [{ type: "text", text: "MCP not initialized" }],
|
|
233
|
+
details: { error: "not_initialized" },
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
let connected = await lazyConnect(state, spec.serverName);
|
|
237
|
+
let autoAuthAttempted = false;
|
|
238
|
+
if (!connected && state.manager.getConnection(spec.serverName)?.status === "needs-auth") {
|
|
239
|
+
autoAuthAttempted = true;
|
|
240
|
+
const autoAuth = await attemptDirectAutoAuth(state, spec.serverName);
|
|
241
|
+
if (autoAuth.status === "failed") {
|
|
242
|
+
return {
|
|
243
|
+
content: [{ type: "text", text: autoAuth.message }],
|
|
244
|
+
details: { error: "auth_required", server: spec.serverName, message: autoAuth.message },
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (autoAuth.status === "success") {
|
|
248
|
+
await state.manager.close(spec.serverName);
|
|
249
|
+
state.failureTracker.delete(spec.serverName);
|
|
250
|
+
connected = await lazyConnect(state, spec.serverName);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (!connected) {
|
|
254
|
+
const authConnection = state.manager.getConnection(spec.serverName);
|
|
255
|
+
if (authConnection?.status === "needs-auth") {
|
|
256
|
+
const message = getDirectAuthRequiredMessage(state, spec.serverName);
|
|
257
|
+
return {
|
|
258
|
+
content: [{ type: "text", text: message }],
|
|
259
|
+
details: { error: "auth_required", server: spec.serverName, message, autoAuthAttempted },
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const failedAgo = getFailureAgeSeconds(state, spec.serverName);
|
|
263
|
+
return {
|
|
264
|
+
content: [{ type: "text", text: `MCP server "${spec.serverName}" not available${failedAgo !== null ? ` (failed ${failedAgo}s ago)` : ""}` }],
|
|
265
|
+
details: { error: "server_unavailable", server: spec.serverName },
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const connection = state.manager.getConnection(spec.serverName);
|
|
269
|
+
if (!connection || connection.status !== "connected") {
|
|
270
|
+
return {
|
|
271
|
+
content: [{ type: "text", text: `MCP server "${spec.serverName}" not connected` }],
|
|
272
|
+
details: { error: "not_connected", server: spec.serverName },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
let uiSession = null;
|
|
276
|
+
try {
|
|
277
|
+
state.manager.touch(spec.serverName);
|
|
278
|
+
state.manager.incrementInFlight(spec.serverName);
|
|
279
|
+
if (spec.resourceUri) {
|
|
280
|
+
const result = await connection.client.readResource({ uri: spec.resourceUri });
|
|
281
|
+
const content = (result.contents ?? []).map(c => ({
|
|
282
|
+
type: "text",
|
|
283
|
+
text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${c.mimeType ?? "unknown"}]` : JSON.stringify(c)),
|
|
284
|
+
}));
|
|
285
|
+
return {
|
|
286
|
+
content: content.length > 0 ? content : [{ type: "text", text: "(empty resource)" }],
|
|
287
|
+
details: { server: spec.serverName, resourceUri: spec.resourceUri },
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const hasUi = !!spec.uiResourceUri;
|
|
291
|
+
uiSession = hasUi
|
|
292
|
+
? await maybeStartUiSession(state, {
|
|
293
|
+
serverName: spec.serverName,
|
|
294
|
+
toolName: spec.originalName,
|
|
295
|
+
toolArgs: (params ?? {}),
|
|
296
|
+
uiResourceUri: spec.uiResourceUri,
|
|
297
|
+
streamMode: spec.uiStreamMode,
|
|
298
|
+
})
|
|
299
|
+
: null;
|
|
300
|
+
const resultPromise = connection.client.callTool({
|
|
301
|
+
name: spec.originalName,
|
|
302
|
+
arguments: (params ?? {}),
|
|
303
|
+
_meta: uiSession?.requestMeta,
|
|
304
|
+
});
|
|
305
|
+
const result = await resultPromise;
|
|
306
|
+
uiSession?.sendToolResult(result);
|
|
307
|
+
const mcpContent = (result.content ?? []);
|
|
308
|
+
const content = transformMcpContent(mcpContent);
|
|
309
|
+
if (result.isError) {
|
|
310
|
+
let errorText = content.filter(c => c.type === "text").map(c => c.text).join("\n") || "Tool execution failed";
|
|
311
|
+
if (spec.inputSchema) {
|
|
312
|
+
errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
content: [{ type: "text", text: `Error: ${errorText}` }],
|
|
316
|
+
details: { error: "tool_error", server: spec.serverName },
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const resultText = content.filter(c => c.type === "text").map(c => c.text).join("\n") || "(empty result)";
|
|
320
|
+
if (hasUi) {
|
|
321
|
+
const uiMessage = uiSession?.reused
|
|
322
|
+
? "Updated the open UI."
|
|
323
|
+
: "📺 Interactive UI is now open in your browser. I'll respond to your prompts and intents as you interact with it.";
|
|
324
|
+
return {
|
|
325
|
+
content: [{ type: "text", text: `${resultText}\n\n${uiMessage}` }],
|
|
326
|
+
details: { server: spec.serverName, tool: spec.originalName, uiOpen: true },
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
content: content.length > 0 ? content : [{ type: "text", text: "(empty result)" }],
|
|
331
|
+
details: { server: spec.serverName, tool: spec.originalName },
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
336
|
+
uiSession?.sendToolCancelled(message);
|
|
337
|
+
let errorText = `Failed to call tool: ${message}`;
|
|
338
|
+
if (spec.inputSchema) {
|
|
339
|
+
errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
content: [{ type: "text", text: errorText }],
|
|
343
|
+
details: { error: "call_failed", server: spec.serverName },
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
if (uiSession?.reused) {
|
|
348
|
+
uiSession.close();
|
|
349
|
+
}
|
|
350
|
+
state.manager.decrementInFlight(spec.serverName);
|
|
351
|
+
state.manager.touch(spec.serverName);
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom error types for MCP UI operations.
|
|
3
|
+
* Provides structured errors with context and recovery hints.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Base error class for MCP UI errors.
|
|
7
|
+
*/
|
|
8
|
+
export class McpUiError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
context;
|
|
11
|
+
recoveryHint;
|
|
12
|
+
cause;
|
|
13
|
+
constructor(message, options) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "McpUiError";
|
|
16
|
+
this.code = options.code;
|
|
17
|
+
this.context = options.context ?? {};
|
|
18
|
+
this.recoveryHint = options.recoveryHint;
|
|
19
|
+
this.cause = options.cause;
|
|
20
|
+
// Maintain proper stack trace
|
|
21
|
+
if (Error.captureStackTrace) {
|
|
22
|
+
Error.captureStackTrace(this, this.constructor);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
toJSON() {
|
|
26
|
+
return {
|
|
27
|
+
name: this.name,
|
|
28
|
+
code: this.code,
|
|
29
|
+
message: this.message,
|
|
30
|
+
context: this.context,
|
|
31
|
+
recoveryHint: this.recoveryHint,
|
|
32
|
+
stack: this.stack,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Error fetching a UI resource from the MCP server.
|
|
38
|
+
*/
|
|
39
|
+
export class ResourceFetchError extends McpUiError {
|
|
40
|
+
constructor(uri, reason, options) {
|
|
41
|
+
super(`Failed to fetch UI resource "${uri}": ${reason}`, {
|
|
42
|
+
code: "RESOURCE_FETCH_ERROR",
|
|
43
|
+
context: { uri, server: options?.server },
|
|
44
|
+
recoveryHint: "Check that the MCP server is connected and the resource URI is valid.",
|
|
45
|
+
cause: options?.cause,
|
|
46
|
+
});
|
|
47
|
+
this.name = "ResourceFetchError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Error parsing or validating UI resource content.
|
|
52
|
+
*/
|
|
53
|
+
export class ResourceParseError extends McpUiError {
|
|
54
|
+
constructor(uri, reason, options) {
|
|
55
|
+
super(`Invalid UI resource "${uri}": ${reason}`, {
|
|
56
|
+
code: "RESOURCE_PARSE_ERROR",
|
|
57
|
+
context: { uri, server: options?.server, mimeType: options?.mimeType },
|
|
58
|
+
recoveryHint: "Ensure the resource returns valid HTML with the correct MIME type.",
|
|
59
|
+
});
|
|
60
|
+
this.name = "ResourceParseError";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Error connecting to the AppBridge.
|
|
65
|
+
*/
|
|
66
|
+
export class BridgeConnectionError extends McpUiError {
|
|
67
|
+
constructor(reason, options) {
|
|
68
|
+
super(`AppBridge connection failed: ${reason}`, {
|
|
69
|
+
code: "BRIDGE_CONNECTION_ERROR",
|
|
70
|
+
context: { session: options?.session },
|
|
71
|
+
recoveryHint: "Check browser console for detailed errors. The iframe may have failed to load.",
|
|
72
|
+
cause: options?.cause,
|
|
73
|
+
});
|
|
74
|
+
this.name = "BridgeConnectionError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Error related to user consent for tool calls.
|
|
79
|
+
*/
|
|
80
|
+
export class ConsentError extends McpUiError {
|
|
81
|
+
denied;
|
|
82
|
+
constructor(server, options) {
|
|
83
|
+
const message = options.denied
|
|
84
|
+
? `Tool calls for "${server}" were denied for this session`
|
|
85
|
+
: `Tool call approval required for "${server}"`;
|
|
86
|
+
super(message, {
|
|
87
|
+
code: options.denied ? "CONSENT_DENIED" : "CONSENT_REQUIRED",
|
|
88
|
+
context: { server },
|
|
89
|
+
recoveryHint: options.denied
|
|
90
|
+
? "The user denied tool access. Start a new session to try again."
|
|
91
|
+
: "Prompt the user for consent before calling tools.",
|
|
92
|
+
});
|
|
93
|
+
this.name = "ConsentError";
|
|
94
|
+
this.denied = options.denied ?? false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Error with UI server session management.
|
|
99
|
+
*/
|
|
100
|
+
export class SessionError extends McpUiError {
|
|
101
|
+
constructor(reason, options) {
|
|
102
|
+
super(`Session error: ${reason}`, {
|
|
103
|
+
code: "SESSION_ERROR",
|
|
104
|
+
context: { session: options?.session },
|
|
105
|
+
recoveryHint: "The session may have expired or been closed. Try opening the UI again.",
|
|
106
|
+
cause: options?.cause,
|
|
107
|
+
});
|
|
108
|
+
this.name = "SessionError";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Error starting or operating the UI server.
|
|
113
|
+
*/
|
|
114
|
+
export class ServerError extends McpUiError {
|
|
115
|
+
constructor(reason, options) {
|
|
116
|
+
super(`UI server error: ${reason}`, {
|
|
117
|
+
code: "SERVER_ERROR",
|
|
118
|
+
context: { port: options?.port },
|
|
119
|
+
recoveryHint: "Check if the port is available. Another process may be using it.",
|
|
120
|
+
cause: options?.cause,
|
|
121
|
+
});
|
|
122
|
+
this.name = "ServerError";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Error communicating with the MCP server.
|
|
127
|
+
*/
|
|
128
|
+
export class McpServerError extends McpUiError {
|
|
129
|
+
constructor(server, reason, options) {
|
|
130
|
+
super(`MCP server "${server}" error: ${reason}`, {
|
|
131
|
+
code: "MCP_SERVER_ERROR",
|
|
132
|
+
context: { server, tool: options?.tool },
|
|
133
|
+
recoveryHint: "Check that the MCP server is running and responsive.",
|
|
134
|
+
cause: options?.cause,
|
|
135
|
+
});
|
|
136
|
+
this.name = "McpServerError";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Wrap an unknown error into an McpUiError.
|
|
141
|
+
*/
|
|
142
|
+
export function wrapError(error, context) {
|
|
143
|
+
if (error instanceof McpUiError) {
|
|
144
|
+
// Merge contexts
|
|
145
|
+
return new McpUiError(error.message, {
|
|
146
|
+
code: error.code,
|
|
147
|
+
context: { ...error.context, ...context },
|
|
148
|
+
recoveryHint: error.recoveryHint,
|
|
149
|
+
cause: error.cause,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const cause = error instanceof Error ? error : undefined;
|
|
153
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
154
|
+
return new McpUiError(message, {
|
|
155
|
+
code: "UNKNOWN_ERROR",
|
|
156
|
+
context,
|
|
157
|
+
cause,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Check if an error is a specific MCP UI error type.
|
|
162
|
+
*/
|
|
163
|
+
export function isErrorCode(error, code) {
|
|
164
|
+
return error instanceof McpUiError && error.code === code;
|
|
165
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { platform } from "node:os";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
let glimpseAvailable = null;
|
|
7
|
+
let resolvedBinaryPath = null;
|
|
8
|
+
export function isGlimpseAvailable() {
|
|
9
|
+
if (glimpseAvailable !== null)
|
|
10
|
+
return glimpseAvailable;
|
|
11
|
+
if (platform() !== "darwin") {
|
|
12
|
+
glimpseAvailable = false;
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
resolvedBinaryPath = getGlimpseBinaryPath();
|
|
16
|
+
glimpseAvailable = resolvedBinaryPath !== null;
|
|
17
|
+
return glimpseAvailable;
|
|
18
|
+
}
|
|
19
|
+
function getGlimpseBinaryPath() {
|
|
20
|
+
if (process.env.GLIMPSE_BINARY && existsSync(process.env.GLIMPSE_BINARY)) {
|
|
21
|
+
return process.env.GLIMPSE_BINARY;
|
|
22
|
+
}
|
|
23
|
+
// Local node_modules
|
|
24
|
+
try {
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const glimpseuiPath = require.resolve("glimpseui");
|
|
27
|
+
const binaryPath = join(dirname(glimpseuiPath), "glimpse");
|
|
28
|
+
if (existsSync(binaryPath))
|
|
29
|
+
return binaryPath;
|
|
30
|
+
}
|
|
31
|
+
catch { }
|
|
32
|
+
// Global npm install
|
|
33
|
+
try {
|
|
34
|
+
const globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf-8" }).trim();
|
|
35
|
+
const binaryPath = join(globalRoot, "glimpseui", "src", "glimpse");
|
|
36
|
+
if (existsSync(binaryPath))
|
|
37
|
+
return binaryPath;
|
|
38
|
+
}
|
|
39
|
+
catch { }
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
export async function openGlimpseWindow(html, options) {
|
|
43
|
+
const modulePath = resolvedBinaryPath
|
|
44
|
+
? join(dirname(resolvedBinaryPath), "glimpse.mjs")
|
|
45
|
+
: "glimpseui";
|
|
46
|
+
const glimpse = await import(modulePath);
|
|
47
|
+
let active = true;
|
|
48
|
+
const win = glimpse.open(html, {
|
|
49
|
+
width: options.width ?? 900,
|
|
50
|
+
height: options.height ?? 700,
|
|
51
|
+
title: options.title,
|
|
52
|
+
});
|
|
53
|
+
win.on("closed", () => {
|
|
54
|
+
if (!active)
|
|
55
|
+
return;
|
|
56
|
+
active = false;
|
|
57
|
+
options.onClosed();
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
close: () => {
|
|
61
|
+
if (!active)
|
|
62
|
+
return;
|
|
63
|
+
active = false;
|
|
64
|
+
win.close();
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|