pi-soly 1.9.3 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ask/index.ts +12 -11
- package/ask/picker.ts +356 -76
- package/ask/prompt.ts +6 -2
- package/ask/tests/picker.test.ts +273 -82
- package/codemap.ts +276 -0
- package/hotreload.ts +239 -0
- package/init.ts +302 -0
- package/mcp/CHANGELOG.md +384 -0
- package/mcp/LICENSE +21 -0
- package/mcp/OAUTH.md +355 -0
- package/mcp/README.md +410 -0
- package/mcp/__tests__/agent-dir-paths.upstream-test.ts +80 -0
- package/mcp/__tests__/cli.upstream-test.ts +97 -0
- package/mcp/__tests__/commands-onboarding.upstream-test.ts +157 -0
- package/mcp/__tests__/config.upstream-test.ts +303 -0
- package/mcp/__tests__/consent-manager.upstream-test.ts +151 -0
- package/mcp/__tests__/direct-tools-auto-auth.upstream-test.ts +218 -0
- package/mcp/__tests__/direct-tools.upstream-test.ts +299 -0
- package/mcp/__tests__/elicitation-handler.upstream-test.ts +346 -0
- package/mcp/__tests__/elicitation-sdk-integration.upstream-test.ts +140 -0
- package/mcp/__tests__/errors.upstream-test.ts +218 -0
- package/mcp/__tests__/fixtures/elicitation-server.mjs +98 -0
- package/mcp/__tests__/host-html-template.upstream-test.ts +278 -0
- package/mcp/__tests__/index-lifecycle.upstream-test.ts +484 -0
- package/mcp/__tests__/init-elicitation.upstream-test.ts +86 -0
- package/mcp/__tests__/interactive-visualizer-server.upstream-test.ts +28 -0
- package/mcp/__tests__/logger.upstream-test.ts +175 -0
- package/mcp/__tests__/mcp-auth-flow-client-credentials.upstream-test.ts +612 -0
- package/mcp/__tests__/mcp-auth-storage.upstream-test.ts +47 -0
- package/mcp/__tests__/mcp-callback-server-unref.upstream-test.ts +264 -0
- package/mcp/__tests__/mcp-oauth-provider.upstream-test.ts +216 -0
- package/mcp/__tests__/mcp-panel-auth.upstream-test.ts +206 -0
- package/mcp/__tests__/mcp-panel-exclude-tools.upstream-test.ts +69 -0
- package/mcp/__tests__/mcp-panel-keybindings.upstream-test.ts +186 -0
- package/mcp/__tests__/npx-resolver.upstream-test.ts +54 -0
- package/mcp/__tests__/oauth-handler.upstream-test.ts +77 -0
- package/mcp/__tests__/onboarding-state.upstream-test.ts +52 -0
- package/mcp/__tests__/package-manifest.upstream-test.ts +22 -0
- package/mcp/__tests__/proxy-modes-auto-auth.upstream-test.ts +253 -0
- package/mcp/__tests__/proxy-modes-discovery.upstream-test.ts +84 -0
- package/mcp/__tests__/proxy-modes-manual-auth.upstream-test.ts +91 -0
- package/mcp/__tests__/proxy-modes-ui-messages.upstream-test.ts +72 -0
- package/mcp/__tests__/sampling-handler.upstream-test.ts +285 -0
- package/mcp/__tests__/server-manager-http-auth.upstream-test.ts +144 -0
- package/mcp/__tests__/server-manager-sampling.upstream-test.ts +236 -0
- package/mcp/__tests__/tool-metadata.upstream-test.ts +111 -0
- package/mcp/__tests__/tool-result-renderer.upstream-test.ts +147 -0
- package/mcp/__tests__/ui-integration.upstream-test.ts +551 -0
- package/mcp/__tests__/ui-resource-handler.upstream-test.ts +303 -0
- package/mcp/__tests__/ui-server.upstream-test.ts +967 -0
- package/mcp/__tests__/ui-session-messages.upstream-test.ts +72 -0
- package/mcp/__tests__/ui-streaming.upstream-test.ts +543 -0
- package/mcp/agent-dir.ts +20 -0
- package/mcp/app-bridge.bundle.js +67 -0
- package/mcp/cli.js +184 -0
- package/mcp/commands.ts +422 -0
- package/mcp/config.ts +666 -0
- package/mcp/consent-manager.ts +64 -0
- package/mcp/direct-tools.ts +439 -0
- package/mcp/elicitation-handler.ts +347 -0
- package/mcp/errors.ts +219 -0
- package/mcp/glimpse-ui.ts +80 -0
- package/mcp/host-html-template.ts +427 -0
- package/mcp/index.ts +362 -0
- package/mcp/init.ts +362 -0
- package/mcp/lifecycle.ts +93 -0
- package/mcp/logger.ts +169 -0
- package/mcp/mcp-auth-flow.ts +559 -0
- package/mcp/mcp-auth-flow.upstream-test.ts +259 -0
- package/mcp/mcp-auth.ts +302 -0
- package/mcp/mcp-auth.upstream-test.ts +373 -0
- package/mcp/mcp-callback-server.ts +372 -0
- package/mcp/mcp-callback-server.upstream-test.ts +416 -0
- package/mcp/mcp-oauth-provider.ts +369 -0
- package/mcp/mcp-oauth-provider.upstream-test.ts +518 -0
- package/mcp/mcp-panel.ts +829 -0
- package/mcp/mcp-setup-panel.ts +580 -0
- package/mcp/metadata-cache.ts +201 -0
- package/mcp/notify.ts +111 -0
- package/mcp/npx-resolver.ts +424 -0
- package/mcp/oauth-handler.ts +57 -0
- package/mcp/onboarding-state.ts +68 -0
- package/mcp/package.json +106 -0
- package/mcp/panel-keys.ts +37 -0
- package/mcp/proxy-modes.ts +949 -0
- package/mcp/resource-tools.ts +17 -0
- package/mcp/sampling-handler.ts +268 -0
- package/mcp/server-manager.ts +545 -0
- package/mcp/state.ts +41 -0
- package/mcp/tool-metadata.ts +216 -0
- package/mcp/tool-registrar.ts +46 -0
- package/mcp/tool-result-renderer.ts +161 -0
- package/mcp/types.ts +448 -0
- package/mcp/ui-resource-handler.ts +146 -0
- package/mcp/ui-server.ts +623 -0
- package/mcp/ui-session.ts +386 -0
- package/mcp/ui-stream-types.ts +89 -0
- package/mcp/utils.ts +129 -0
- package/mcp/vitest.config.ts +14 -0
- package/migrate.ts +258 -0
- package/notification.ts +218 -0
- package/notifications-log.ts +83 -0
- package/package.json +20 -3
- package/status.ts +140 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for MCP UI flow
|
|
3
|
+
*
|
|
4
|
+
* These tests exercise the full flow from tool call with UI resource
|
|
5
|
+
* through browser communication back to agent message retrieval.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
8
|
+
import http from "node:http";
|
|
9
|
+
import { startUiServer, type UiServerHandle } from "../ui-server.ts";
|
|
10
|
+
import { UiResourceHandler } from "../ui-resource-handler.ts";
|
|
11
|
+
import { ConsentManager } from "../consent-manager.ts";
|
|
12
|
+
import type { McpServerManager } from "../server-manager.ts";
|
|
13
|
+
import type { UiResourceContent, UiSessionMessages } from "../types.ts";
|
|
14
|
+
|
|
15
|
+
// Helper to make HTTP requests
|
|
16
|
+
async function request(
|
|
17
|
+
url: string,
|
|
18
|
+
options: { method?: string; body?: unknown } = {}
|
|
19
|
+
): Promise<{ status: number; body: unknown }> {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const parsed = new URL(url);
|
|
22
|
+
const req = http.request(
|
|
23
|
+
{
|
|
24
|
+
hostname: parsed.hostname,
|
|
25
|
+
port: parsed.port,
|
|
26
|
+
path: parsed.pathname + parsed.search,
|
|
27
|
+
method: options.method ?? "GET",
|
|
28
|
+
headers: { "Content-Type": "application/json" },
|
|
29
|
+
},
|
|
30
|
+
(res) => {
|
|
31
|
+
const chunks: Buffer[] = [];
|
|
32
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
33
|
+
res.on("end", () => {
|
|
34
|
+
const text = Buffer.concat(chunks).toString("utf-8");
|
|
35
|
+
let body: unknown;
|
|
36
|
+
try {
|
|
37
|
+
body = JSON.parse(text);
|
|
38
|
+
} catch {
|
|
39
|
+
body = text;
|
|
40
|
+
}
|
|
41
|
+
resolve({ status: res.statusCode ?? 0, body });
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
req.on("error", reject);
|
|
46
|
+
if (options.body) req.write(JSON.stringify(options.body));
|
|
47
|
+
req.end();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Simulate browser behavior
|
|
52
|
+
class BrowserSimulator {
|
|
53
|
+
private serverUrl: string;
|
|
54
|
+
private sessionToken: string;
|
|
55
|
+
|
|
56
|
+
constructor(handle: UiServerHandle) {
|
|
57
|
+
this.serverUrl = `http://localhost:${handle.port}`;
|
|
58
|
+
this.sessionToken = handle.sessionToken;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async loadPage(): Promise<string> {
|
|
62
|
+
const res = await request(`${this.serverUrl}/?session=${this.sessionToken}`);
|
|
63
|
+
if (res.status !== 200) throw new Error(`Page load failed: ${res.status}`);
|
|
64
|
+
return res.body as string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async sendPrompt(prompt: string): Promise<void> {
|
|
68
|
+
const res = await request(`${this.serverUrl}/proxy/ui/message`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
body: {
|
|
71
|
+
token: this.sessionToken,
|
|
72
|
+
params: { type: "prompt", prompt },
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
if (res.status !== 200) throw new Error(`Send prompt failed: ${res.status}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async sendIntent(intent: string, params?: Record<string, unknown>): Promise<void> {
|
|
79
|
+
const res = await request(`${this.serverUrl}/proxy/ui/message`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
body: {
|
|
82
|
+
token: this.sessionToken,
|
|
83
|
+
params: { type: "intent", intent, params },
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
if (res.status !== 200) throw new Error(`Send intent failed: ${res.status}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async sendNotification(message: string): Promise<void> {
|
|
90
|
+
const res = await request(`${this.serverUrl}/proxy/ui/message`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
body: {
|
|
93
|
+
token: this.sessionToken,
|
|
94
|
+
params: { type: "notify", message },
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
if (res.status !== 200) throw new Error(`Send notification failed: ${res.status}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async callTool(name: string, args: Record<string, unknown> = {}): Promise<unknown> {
|
|
101
|
+
const res = await request(`${this.serverUrl}/proxy/tools/call`, {
|
|
102
|
+
method: "POST",
|
|
103
|
+
body: {
|
|
104
|
+
token: this.sessionToken,
|
|
105
|
+
params: { name, arguments: args },
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
if (res.status !== 200) throw new Error(`Tool call failed: ${res.status}`);
|
|
109
|
+
return (res.body as { result: unknown }).result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async grantConsent(): Promise<void> {
|
|
113
|
+
const res = await request(`${this.serverUrl}/proxy/ui/consent`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
body: {
|
|
116
|
+
token: this.sessionToken,
|
|
117
|
+
params: { approved: true },
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
if (res.status !== 200) throw new Error(`Consent failed: ${res.status}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async heartbeat(): Promise<void> {
|
|
124
|
+
const res = await request(`${this.serverUrl}/proxy/ui/heartbeat`, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
body: { token: this.sessionToken, params: {} },
|
|
127
|
+
});
|
|
128
|
+
if (res.status !== 200) throw new Error(`Heartbeat failed: ${res.status}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async complete(reason = "done"): Promise<void> {
|
|
132
|
+
const res = await request(`${this.serverUrl}/proxy/ui/complete`, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
body: {
|
|
135
|
+
token: this.sessionToken,
|
|
136
|
+
params: { reason },
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
if (res.status !== 200) throw new Error(`Complete failed: ${res.status}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Mock manager that simulates real MCP server behavior
|
|
144
|
+
function createIntegrationManager(): McpServerManager {
|
|
145
|
+
const tools = new Map([
|
|
146
|
+
["get_data", { result: { data: [1, 2, 3] } }],
|
|
147
|
+
["save_file", { result: { saved: true, path: "/tmp/file.txt" } }],
|
|
148
|
+
["slow_operation", { result: { completed: true }, delay: 100 }],
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
getConnection: vi.fn().mockReturnValue({
|
|
153
|
+
status: "connected",
|
|
154
|
+
client: {
|
|
155
|
+
callTool: vi.fn().mockImplementation(async ({ name }) => {
|
|
156
|
+
const tool = tools.get(name);
|
|
157
|
+
if (!tool) throw new Error(`Unknown tool: ${name}`);
|
|
158
|
+
if ((tool as { delay?: number }).delay) {
|
|
159
|
+
await new Promise((r) => setTimeout(r, (tool as { delay: number }).delay));
|
|
160
|
+
}
|
|
161
|
+
return { content: [{ type: "text", text: JSON.stringify(tool.result) }] };
|
|
162
|
+
}),
|
|
163
|
+
},
|
|
164
|
+
}),
|
|
165
|
+
touch: vi.fn(),
|
|
166
|
+
incrementInFlight: vi.fn(),
|
|
167
|
+
decrementInFlight: vi.fn(),
|
|
168
|
+
readResource: vi.fn().mockResolvedValue({
|
|
169
|
+
contents: [
|
|
170
|
+
{
|
|
171
|
+
uri: "ui://test/app",
|
|
172
|
+
mimeType: "text/html",
|
|
173
|
+
text: `<!DOCTYPE html>
|
|
174
|
+
<html>
|
|
175
|
+
<head><title>Test App</title></head>
|
|
176
|
+
<body>
|
|
177
|
+
<h1>Test MCP App</h1>
|
|
178
|
+
<button id="getData">Get Data</button>
|
|
179
|
+
<button id="sendPrompt">Ask Agent</button>
|
|
180
|
+
</body>
|
|
181
|
+
</html>`,
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
}),
|
|
185
|
+
} as unknown as McpServerManager;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
describe("MCP UI Integration", () => {
|
|
189
|
+
let handle: UiServerHandle | null = null;
|
|
190
|
+
|
|
191
|
+
afterEach(() => {
|
|
192
|
+
if (handle) {
|
|
193
|
+
handle.close("test-cleanup");
|
|
194
|
+
handle = null;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("Full UI Session Flow", () => {
|
|
199
|
+
it("completes a full agent → browser → agent cycle", async () => {
|
|
200
|
+
// 1. Agent calls tool with UI
|
|
201
|
+
const manager = createIntegrationManager();
|
|
202
|
+
const consentManager = new ConsentManager("never"); // No consent prompts
|
|
203
|
+
|
|
204
|
+
const resource: UiResourceContent = {
|
|
205
|
+
uri: "ui://test/app",
|
|
206
|
+
html: "<h1>Test App</h1>",
|
|
207
|
+
mimeType: "text/html",
|
|
208
|
+
meta: { permissions: [] },
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const receivedMessages: UiSessionMessages = { prompts: [], notifications: [], intents: [] };
|
|
212
|
+
const onMessage = vi.fn().mockImplementation((params) => {
|
|
213
|
+
if (params.type === "prompt") receivedMessages.prompts.push(params.prompt);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
handle = await startUiServer({
|
|
217
|
+
serverName: "test-server",
|
|
218
|
+
toolName: "launch_app",
|
|
219
|
+
toolArgs: { mode: "interactive" },
|
|
220
|
+
resource,
|
|
221
|
+
manager,
|
|
222
|
+
consentManager,
|
|
223
|
+
onMessage,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// 2. Browser loads the page
|
|
227
|
+
const browser = new BrowserSimulator(handle);
|
|
228
|
+
const html = await browser.loadPage();
|
|
229
|
+
expect(html).toContain("test-server");
|
|
230
|
+
expect(html).toContain("launch_app");
|
|
231
|
+
|
|
232
|
+
// 3. Browser calls a tool through the proxy
|
|
233
|
+
const toolResult = await browser.callTool("get_data", { query: "test" });
|
|
234
|
+
expect(toolResult).toBeDefined();
|
|
235
|
+
|
|
236
|
+
// 4. Browser sends a prompt back to the agent
|
|
237
|
+
await browser.sendPrompt("Please analyze this data and summarize");
|
|
238
|
+
|
|
239
|
+
// 5. Agent retrieves the messages
|
|
240
|
+
const messages = handle.getSessionMessages();
|
|
241
|
+
expect(messages.prompts).toContain("Please analyze this data and summarize");
|
|
242
|
+
|
|
243
|
+
// 6. Browser completes the session
|
|
244
|
+
await browser.complete("user-done");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("handles multiple messages in conversation", async () => {
|
|
248
|
+
const manager = createIntegrationManager();
|
|
249
|
+
const consentManager = new ConsentManager("never");
|
|
250
|
+
|
|
251
|
+
const resource: UiResourceContent = {
|
|
252
|
+
uri: "ui://test/chat",
|
|
253
|
+
html: "<div id='chat'></div>",
|
|
254
|
+
mimeType: "text/html",
|
|
255
|
+
meta: { permissions: [] },
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
handle = await startUiServer({
|
|
259
|
+
serverName: "chat-server",
|
|
260
|
+
toolName: "open_chat",
|
|
261
|
+
toolArgs: {},
|
|
262
|
+
resource,
|
|
263
|
+
manager,
|
|
264
|
+
consentManager,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const browser = new BrowserSimulator(handle);
|
|
268
|
+
await browser.loadPage();
|
|
269
|
+
|
|
270
|
+
// Simulate a multi-turn conversation
|
|
271
|
+
await browser.sendPrompt("What is the capital of France?");
|
|
272
|
+
await browser.sendNotification("User is typing...");
|
|
273
|
+
await browser.sendPrompt("And what about Germany?");
|
|
274
|
+
await browser.sendIntent("show_map", { countries: ["France", "Germany"] });
|
|
275
|
+
|
|
276
|
+
const messages = handle.getSessionMessages();
|
|
277
|
+
expect(messages.prompts).toHaveLength(2);
|
|
278
|
+
expect(messages.prompts[0]).toBe("What is the capital of France?");
|
|
279
|
+
expect(messages.prompts[1]).toBe("And what about Germany?");
|
|
280
|
+
expect(messages.notifications).toHaveLength(1);
|
|
281
|
+
expect(messages.intents).toHaveLength(1);
|
|
282
|
+
expect(messages.intents[0]).toEqual({
|
|
283
|
+
intent: "show_map",
|
|
284
|
+
params: { countries: ["France", "Germany"] },
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("handles consent flow for tool calls", async () => {
|
|
289
|
+
const manager = createIntegrationManager();
|
|
290
|
+
const consentManager = new ConsentManager("once-per-server");
|
|
291
|
+
|
|
292
|
+
const resource: UiResourceContent = {
|
|
293
|
+
uri: "ui://test/app",
|
|
294
|
+
html: "<h1>App</h1>",
|
|
295
|
+
mimeType: "text/html",
|
|
296
|
+
meta: { permissions: [] },
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
handle = await startUiServer({
|
|
300
|
+
serverName: "sensitive-server",
|
|
301
|
+
toolName: "admin_tool",
|
|
302
|
+
toolArgs: {},
|
|
303
|
+
resource,
|
|
304
|
+
manager,
|
|
305
|
+
consentManager,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const browser = new BrowserSimulator(handle);
|
|
309
|
+
await browser.loadPage();
|
|
310
|
+
|
|
311
|
+
// First tool call should fail - no consent yet
|
|
312
|
+
await expect(browser.callTool("get_data")).rejects.toThrow();
|
|
313
|
+
|
|
314
|
+
// Grant consent
|
|
315
|
+
await browser.grantConsent();
|
|
316
|
+
|
|
317
|
+
// Now tool call should succeed
|
|
318
|
+
const result = await browser.callTool("get_data");
|
|
319
|
+
expect(result).toBeDefined();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("tracks in-flight requests correctly", async () => {
|
|
323
|
+
const manager = createIntegrationManager();
|
|
324
|
+
const consentManager = new ConsentManager("never");
|
|
325
|
+
|
|
326
|
+
const resource: UiResourceContent = {
|
|
327
|
+
uri: "ui://test/app",
|
|
328
|
+
html: "<h1>App</h1>",
|
|
329
|
+
mimeType: "text/html",
|
|
330
|
+
meta: { permissions: [] },
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
handle = await startUiServer({
|
|
334
|
+
serverName: "test-server",
|
|
335
|
+
toolName: "test_tool",
|
|
336
|
+
toolArgs: {},
|
|
337
|
+
resource,
|
|
338
|
+
manager,
|
|
339
|
+
consentManager,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const browser = new BrowserSimulator(handle);
|
|
343
|
+
await browser.loadPage();
|
|
344
|
+
|
|
345
|
+
// Call multiple tools
|
|
346
|
+
await Promise.all([
|
|
347
|
+
browser.callTool("get_data"),
|
|
348
|
+
browser.callTool("save_file"),
|
|
349
|
+
]);
|
|
350
|
+
|
|
351
|
+
// Both should have incremented/decremented
|
|
352
|
+
expect(manager.incrementInFlight).toHaveBeenCalledTimes(2);
|
|
353
|
+
expect(manager.decrementInFlight).toHaveBeenCalledTimes(2);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe("UiResourceHandler + UiServer Integration", () => {
|
|
358
|
+
it("reads resource and starts server with correct content", async () => {
|
|
359
|
+
const manager = createIntegrationManager();
|
|
360
|
+
const handler = new UiResourceHandler(manager);
|
|
361
|
+
|
|
362
|
+
// Read the UI resource
|
|
363
|
+
const resource = await handler.readUiResource("test-server", "ui://test/app");
|
|
364
|
+
expect(resource.html).toContain("Test MCP App");
|
|
365
|
+
expect(resource.mimeType).toBe("text/html");
|
|
366
|
+
|
|
367
|
+
// Start server with the resource
|
|
368
|
+
const consentManager = new ConsentManager("never");
|
|
369
|
+
handle = await startUiServer({
|
|
370
|
+
serverName: "test-server",
|
|
371
|
+
toolName: "launch_app",
|
|
372
|
+
toolArgs: {},
|
|
373
|
+
resource,
|
|
374
|
+
manager,
|
|
375
|
+
consentManager,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Verify page contains the resource content
|
|
379
|
+
const browser = new BrowserSimulator(handle);
|
|
380
|
+
const html = await browser.loadPage();
|
|
381
|
+
// The host page wraps the resource in an iframe
|
|
382
|
+
expect(html).toContain("iframe");
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe("Session Lifecycle", () => {
|
|
387
|
+
it("calls onComplete when session ends", async () => {
|
|
388
|
+
const onComplete = vi.fn();
|
|
389
|
+
const manager = createIntegrationManager();
|
|
390
|
+
const consentManager = new ConsentManager("never");
|
|
391
|
+
|
|
392
|
+
handle = await startUiServer({
|
|
393
|
+
serverName: "test-server",
|
|
394
|
+
toolName: "test_tool",
|
|
395
|
+
toolArgs: {},
|
|
396
|
+
resource: {
|
|
397
|
+
uri: "ui://test/app",
|
|
398
|
+
html: "<h1>App</h1>",
|
|
399
|
+
mimeType: "text/html",
|
|
400
|
+
meta: { permissions: [] },
|
|
401
|
+
},
|
|
402
|
+
manager,
|
|
403
|
+
consentManager,
|
|
404
|
+
onComplete,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const browser = new BrowserSimulator(handle);
|
|
408
|
+
await browser.loadPage();
|
|
409
|
+
await browser.complete("user-finished");
|
|
410
|
+
|
|
411
|
+
// Wait for callback
|
|
412
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
413
|
+
expect(onComplete).toHaveBeenCalledWith("user-finished");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("maintains heartbeat to prevent timeout", async () => {
|
|
417
|
+
const manager = createIntegrationManager();
|
|
418
|
+
const consentManager = new ConsentManager("never");
|
|
419
|
+
|
|
420
|
+
handle = await startUiServer({
|
|
421
|
+
serverName: "test-server",
|
|
422
|
+
toolName: "test_tool",
|
|
423
|
+
toolArgs: {},
|
|
424
|
+
resource: {
|
|
425
|
+
uri: "ui://test/app",
|
|
426
|
+
html: "<h1>App</h1>",
|
|
427
|
+
mimeType: "text/html",
|
|
428
|
+
meta: { permissions: [] },
|
|
429
|
+
},
|
|
430
|
+
manager,
|
|
431
|
+
consentManager,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const browser = new BrowserSimulator(handle);
|
|
435
|
+
await browser.loadPage();
|
|
436
|
+
|
|
437
|
+
// Send heartbeats
|
|
438
|
+
await browser.heartbeat();
|
|
439
|
+
await browser.heartbeat();
|
|
440
|
+
|
|
441
|
+
// Session should still be active
|
|
442
|
+
const html = await browser.loadPage();
|
|
443
|
+
expect(html).toContain("test-server");
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
describe("Error Handling", () => {
|
|
448
|
+
it("handles MCP server errors gracefully", async () => {
|
|
449
|
+
const manager = {
|
|
450
|
+
...createIntegrationManager(),
|
|
451
|
+
getConnection: vi.fn().mockReturnValue({
|
|
452
|
+
status: "connected",
|
|
453
|
+
client: {
|
|
454
|
+
callTool: vi.fn().mockRejectedValue(new Error("MCP server error")),
|
|
455
|
+
},
|
|
456
|
+
}),
|
|
457
|
+
} as unknown as McpServerManager;
|
|
458
|
+
|
|
459
|
+
const consentManager = new ConsentManager("never");
|
|
460
|
+
|
|
461
|
+
handle = await startUiServer({
|
|
462
|
+
serverName: "error-server",
|
|
463
|
+
toolName: "error_tool",
|
|
464
|
+
toolArgs: {},
|
|
465
|
+
resource: {
|
|
466
|
+
uri: "ui://test/app",
|
|
467
|
+
html: "<h1>App</h1>",
|
|
468
|
+
mimeType: "text/html",
|
|
469
|
+
meta: { permissions: [] },
|
|
470
|
+
},
|
|
471
|
+
manager,
|
|
472
|
+
consentManager,
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const browser = new BrowserSimulator(handle);
|
|
476
|
+
await browser.loadPage();
|
|
477
|
+
|
|
478
|
+
// Tool call should fail but not crash the server
|
|
479
|
+
await expect(browser.callTool("failing_tool")).rejects.toThrow();
|
|
480
|
+
|
|
481
|
+
// Server should still be responsive
|
|
482
|
+
await browser.heartbeat();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("handles disconnected server", async () => {
|
|
486
|
+
const manager = {
|
|
487
|
+
...createIntegrationManager(),
|
|
488
|
+
getConnection: vi.fn().mockReturnValue(null),
|
|
489
|
+
} as unknown as McpServerManager;
|
|
490
|
+
|
|
491
|
+
const consentManager = new ConsentManager("never");
|
|
492
|
+
|
|
493
|
+
handle = await startUiServer({
|
|
494
|
+
serverName: "disconnected-server",
|
|
495
|
+
toolName: "test_tool",
|
|
496
|
+
toolArgs: {},
|
|
497
|
+
resource: {
|
|
498
|
+
uri: "ui://test/app",
|
|
499
|
+
html: "<h1>App</h1>",
|
|
500
|
+
mimeType: "text/html",
|
|
501
|
+
meta: { permissions: [] },
|
|
502
|
+
},
|
|
503
|
+
manager,
|
|
504
|
+
consentManager,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const browser = new BrowserSimulator(handle);
|
|
508
|
+
await browser.loadPage();
|
|
509
|
+
|
|
510
|
+
// Tool call should fail with 503
|
|
511
|
+
await expect(browser.callTool("any_tool")).rejects.toThrow("503");
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
describe("Display Mode Changes", () => {
|
|
516
|
+
it("allows switching display modes", async () => {
|
|
517
|
+
const manager = createIntegrationManager();
|
|
518
|
+
const consentManager = new ConsentManager("never");
|
|
519
|
+
|
|
520
|
+
handle = await startUiServer({
|
|
521
|
+
serverName: "test-server",
|
|
522
|
+
toolName: "test_tool",
|
|
523
|
+
toolArgs: {},
|
|
524
|
+
resource: {
|
|
525
|
+
uri: "ui://test/app",
|
|
526
|
+
html: "<h1>App</h1>",
|
|
527
|
+
mimeType: "text/html",
|
|
528
|
+
meta: { permissions: [] },
|
|
529
|
+
},
|
|
530
|
+
manager,
|
|
531
|
+
consentManager,
|
|
532
|
+
hostContext: {
|
|
533
|
+
displayMode: "inline",
|
|
534
|
+
availableDisplayModes: ["inline", "fullscreen", "pip"],
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// Request fullscreen
|
|
539
|
+
const res = await request(`http://localhost:${handle.port}/proxy/ui/request-display-mode`, {
|
|
540
|
+
method: "POST",
|
|
541
|
+
body: {
|
|
542
|
+
token: handle.sessionToken,
|
|
543
|
+
params: { mode: "fullscreen" },
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
expect(res.status).toBe(200);
|
|
548
|
+
expect((res.body as { result: { mode: string } }).result.mode).toBe("fullscreen");
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
});
|