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.
Files changed (104) hide show
  1. package/ask/index.ts +12 -11
  2. package/ask/picker.ts +356 -76
  3. package/ask/prompt.ts +6 -2
  4. package/ask/tests/picker.test.ts +273 -82
  5. package/codemap.ts +276 -0
  6. package/hotreload.ts +239 -0
  7. package/init.ts +302 -0
  8. package/mcp/CHANGELOG.md +384 -0
  9. package/mcp/LICENSE +21 -0
  10. package/mcp/OAUTH.md +355 -0
  11. package/mcp/README.md +410 -0
  12. package/mcp/__tests__/agent-dir-paths.upstream-test.ts +80 -0
  13. package/mcp/__tests__/cli.upstream-test.ts +97 -0
  14. package/mcp/__tests__/commands-onboarding.upstream-test.ts +157 -0
  15. package/mcp/__tests__/config.upstream-test.ts +303 -0
  16. package/mcp/__tests__/consent-manager.upstream-test.ts +151 -0
  17. package/mcp/__tests__/direct-tools-auto-auth.upstream-test.ts +218 -0
  18. package/mcp/__tests__/direct-tools.upstream-test.ts +299 -0
  19. package/mcp/__tests__/elicitation-handler.upstream-test.ts +346 -0
  20. package/mcp/__tests__/elicitation-sdk-integration.upstream-test.ts +140 -0
  21. package/mcp/__tests__/errors.upstream-test.ts +218 -0
  22. package/mcp/__tests__/fixtures/elicitation-server.mjs +98 -0
  23. package/mcp/__tests__/host-html-template.upstream-test.ts +278 -0
  24. package/mcp/__tests__/index-lifecycle.upstream-test.ts +484 -0
  25. package/mcp/__tests__/init-elicitation.upstream-test.ts +86 -0
  26. package/mcp/__tests__/interactive-visualizer-server.upstream-test.ts +28 -0
  27. package/mcp/__tests__/logger.upstream-test.ts +175 -0
  28. package/mcp/__tests__/mcp-auth-flow-client-credentials.upstream-test.ts +612 -0
  29. package/mcp/__tests__/mcp-auth-storage.upstream-test.ts +47 -0
  30. package/mcp/__tests__/mcp-callback-server-unref.upstream-test.ts +264 -0
  31. package/mcp/__tests__/mcp-oauth-provider.upstream-test.ts +216 -0
  32. package/mcp/__tests__/mcp-panel-auth.upstream-test.ts +206 -0
  33. package/mcp/__tests__/mcp-panel-exclude-tools.upstream-test.ts +69 -0
  34. package/mcp/__tests__/mcp-panel-keybindings.upstream-test.ts +186 -0
  35. package/mcp/__tests__/npx-resolver.upstream-test.ts +54 -0
  36. package/mcp/__tests__/oauth-handler.upstream-test.ts +77 -0
  37. package/mcp/__tests__/onboarding-state.upstream-test.ts +52 -0
  38. package/mcp/__tests__/package-manifest.upstream-test.ts +22 -0
  39. package/mcp/__tests__/proxy-modes-auto-auth.upstream-test.ts +253 -0
  40. package/mcp/__tests__/proxy-modes-discovery.upstream-test.ts +84 -0
  41. package/mcp/__tests__/proxy-modes-manual-auth.upstream-test.ts +91 -0
  42. package/mcp/__tests__/proxy-modes-ui-messages.upstream-test.ts +72 -0
  43. package/mcp/__tests__/sampling-handler.upstream-test.ts +285 -0
  44. package/mcp/__tests__/server-manager-http-auth.upstream-test.ts +144 -0
  45. package/mcp/__tests__/server-manager-sampling.upstream-test.ts +236 -0
  46. package/mcp/__tests__/tool-metadata.upstream-test.ts +111 -0
  47. package/mcp/__tests__/tool-result-renderer.upstream-test.ts +147 -0
  48. package/mcp/__tests__/ui-integration.upstream-test.ts +551 -0
  49. package/mcp/__tests__/ui-resource-handler.upstream-test.ts +303 -0
  50. package/mcp/__tests__/ui-server.upstream-test.ts +967 -0
  51. package/mcp/__tests__/ui-session-messages.upstream-test.ts +72 -0
  52. package/mcp/__tests__/ui-streaming.upstream-test.ts +543 -0
  53. package/mcp/agent-dir.ts +20 -0
  54. package/mcp/app-bridge.bundle.js +67 -0
  55. package/mcp/cli.js +184 -0
  56. package/mcp/commands.ts +422 -0
  57. package/mcp/config.ts +666 -0
  58. package/mcp/consent-manager.ts +64 -0
  59. package/mcp/direct-tools.ts +439 -0
  60. package/mcp/elicitation-handler.ts +347 -0
  61. package/mcp/errors.ts +219 -0
  62. package/mcp/glimpse-ui.ts +80 -0
  63. package/mcp/host-html-template.ts +427 -0
  64. package/mcp/index.ts +362 -0
  65. package/mcp/init.ts +362 -0
  66. package/mcp/lifecycle.ts +93 -0
  67. package/mcp/logger.ts +169 -0
  68. package/mcp/mcp-auth-flow.ts +559 -0
  69. package/mcp/mcp-auth-flow.upstream-test.ts +259 -0
  70. package/mcp/mcp-auth.ts +302 -0
  71. package/mcp/mcp-auth.upstream-test.ts +373 -0
  72. package/mcp/mcp-callback-server.ts +372 -0
  73. package/mcp/mcp-callback-server.upstream-test.ts +416 -0
  74. package/mcp/mcp-oauth-provider.ts +369 -0
  75. package/mcp/mcp-oauth-provider.upstream-test.ts +518 -0
  76. package/mcp/mcp-panel.ts +829 -0
  77. package/mcp/mcp-setup-panel.ts +580 -0
  78. package/mcp/metadata-cache.ts +201 -0
  79. package/mcp/notify.ts +111 -0
  80. package/mcp/npx-resolver.ts +424 -0
  81. package/mcp/oauth-handler.ts +57 -0
  82. package/mcp/onboarding-state.ts +68 -0
  83. package/mcp/package.json +106 -0
  84. package/mcp/panel-keys.ts +37 -0
  85. package/mcp/proxy-modes.ts +949 -0
  86. package/mcp/resource-tools.ts +17 -0
  87. package/mcp/sampling-handler.ts +268 -0
  88. package/mcp/server-manager.ts +545 -0
  89. package/mcp/state.ts +41 -0
  90. package/mcp/tool-metadata.ts +216 -0
  91. package/mcp/tool-registrar.ts +46 -0
  92. package/mcp/tool-result-renderer.ts +161 -0
  93. package/mcp/types.ts +448 -0
  94. package/mcp/ui-resource-handler.ts +146 -0
  95. package/mcp/ui-server.ts +623 -0
  96. package/mcp/ui-session.ts +386 -0
  97. package/mcp/ui-stream-types.ts +89 -0
  98. package/mcp/utils.ts +129 -0
  99. package/mcp/vitest.config.ts +14 -0
  100. package/migrate.ts +258 -0
  101. package/notification.ts +218 -0
  102. package/notifications-log.ts +83 -0
  103. package/package.json +20 -3
  104. package/status.ts +140 -0
@@ -0,0 +1,218 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ McpUiError,
4
+ ResourceFetchError,
5
+ ResourceParseError,
6
+ BridgeConnectionError,
7
+ ConsentError,
8
+ SessionError,
9
+ ServerError,
10
+ McpServerError,
11
+ wrapError,
12
+ isErrorCode,
13
+ } from "../errors.ts";
14
+
15
+ describe("McpUiError", () => {
16
+ it("creates error with required fields", () => {
17
+ const error = new McpUiError("test message", { code: "TEST_ERROR" });
18
+
19
+ expect(error.message).toBe("test message");
20
+ expect(error.code).toBe("TEST_ERROR");
21
+ expect(error.name).toBe("McpUiError");
22
+ expect(error.context).toEqual({});
23
+ });
24
+
25
+ it("includes optional fields", () => {
26
+ const cause = new Error("root cause");
27
+ const error = new McpUiError("test", {
28
+ code: "TEST",
29
+ context: { server: "test-server" },
30
+ recoveryHint: "Try again",
31
+ cause,
32
+ });
33
+
34
+ expect(error.context.server).toBe("test-server");
35
+ expect(error.recoveryHint).toBe("Try again");
36
+ expect(error.cause).toBe(cause);
37
+ });
38
+
39
+ it("serializes to JSON", () => {
40
+ const error = new McpUiError("test", {
41
+ code: "TEST",
42
+ context: { server: "s1" },
43
+ recoveryHint: "hint",
44
+ });
45
+
46
+ const json = error.toJSON();
47
+ expect(json.name).toBe("McpUiError");
48
+ expect(json.code).toBe("TEST");
49
+ expect(json.message).toBe("test");
50
+ expect(json.context).toEqual({ server: "s1" });
51
+ expect(json.recoveryHint).toBe("hint");
52
+ expect(json.stack).toBeDefined();
53
+ });
54
+ });
55
+
56
+ describe("ResourceFetchError", () => {
57
+ it("formats message correctly", () => {
58
+ const error = new ResourceFetchError("ui://test/resource", "not found", {
59
+ server: "test-server",
60
+ });
61
+
62
+ expect(error.message).toBe(
63
+ 'Failed to fetch UI resource "ui://test/resource": not found'
64
+ );
65
+ expect(error.code).toBe("RESOURCE_FETCH_ERROR");
66
+ expect(error.context.uri).toBe("ui://test/resource");
67
+ expect(error.context.server).toBe("test-server");
68
+ expect(error.recoveryHint).toContain("Check that the MCP server");
69
+ });
70
+
71
+ it("includes cause", () => {
72
+ const cause = new Error("network error");
73
+ const error = new ResourceFetchError("ui://test", "failed", { cause });
74
+
75
+ expect(error.cause).toBe(cause);
76
+ });
77
+ });
78
+
79
+ describe("ResourceParseError", () => {
80
+ it("formats message correctly", () => {
81
+ const error = new ResourceParseError("ui://test", "invalid HTML", {
82
+ server: "test-server",
83
+ mimeType: "text/plain",
84
+ });
85
+
86
+ expect(error.message).toBe('Invalid UI resource "ui://test": invalid HTML');
87
+ expect(error.code).toBe("RESOURCE_PARSE_ERROR");
88
+ expect(error.context.mimeType).toBe("text/plain");
89
+ });
90
+ });
91
+
92
+ describe("BridgeConnectionError", () => {
93
+ it("formats message correctly", () => {
94
+ const error = new BridgeConnectionError("timeout", { session: "abc123" });
95
+
96
+ expect(error.message).toBe("AppBridge connection failed: timeout");
97
+ expect(error.code).toBe("BRIDGE_CONNECTION_ERROR");
98
+ expect(error.context.session).toBe("abc123");
99
+ expect(error.recoveryHint).toContain("browser console");
100
+ });
101
+ });
102
+
103
+ describe("ConsentError", () => {
104
+ it("creates denial error", () => {
105
+ const error = new ConsentError("test-server", { denied: true });
106
+
107
+ expect(error.message).toBe(
108
+ 'Tool calls for "test-server" were denied for this session'
109
+ );
110
+ expect(error.code).toBe("CONSENT_DENIED");
111
+ expect(error.denied).toBe(true);
112
+ expect(error.recoveryHint).toContain("Start a new session");
113
+ });
114
+
115
+ it("creates requires approval error", () => {
116
+ const error = new ConsentError("test-server", { requiresApproval: true });
117
+
118
+ expect(error.message).toBe('Tool call approval required for "test-server"');
119
+ expect(error.code).toBe("CONSENT_REQUIRED");
120
+ expect(error.denied).toBe(false);
121
+ expect(error.recoveryHint).toContain("Prompt the user");
122
+ });
123
+ });
124
+
125
+ describe("SessionError", () => {
126
+ it("formats message correctly", () => {
127
+ const error = new SessionError("expired", { session: "xyz789" });
128
+
129
+ expect(error.message).toBe("Session error: expired");
130
+ expect(error.code).toBe("SESSION_ERROR");
131
+ expect(error.context.session).toBe("xyz789");
132
+ });
133
+ });
134
+
135
+ describe("ServerError", () => {
136
+ it("formats message correctly", () => {
137
+ const error = new ServerError("port in use", { port: 3000 });
138
+
139
+ expect(error.message).toBe("UI server error: port in use");
140
+ expect(error.code).toBe("SERVER_ERROR");
141
+ expect(error.context.port).toBe(3000);
142
+ expect(error.recoveryHint).toContain("port is available");
143
+ });
144
+ });
145
+
146
+ describe("McpServerError", () => {
147
+ it("formats message correctly", () => {
148
+ const error = new McpServerError("my-server", "connection lost", {
149
+ tool: "test-tool",
150
+ });
151
+
152
+ expect(error.message).toBe('MCP server "my-server" error: connection lost');
153
+ expect(error.code).toBe("MCP_SERVER_ERROR");
154
+ expect(error.context.server).toBe("my-server");
155
+ expect(error.context.tool).toBe("test-tool");
156
+ });
157
+ });
158
+
159
+ describe("wrapError", () => {
160
+ it("passes through McpUiError with merged context", () => {
161
+ const original = new McpUiError("original", {
162
+ code: "ORIGINAL",
163
+ context: { server: "s1" },
164
+ });
165
+
166
+ const wrapped = wrapError(original, { tool: "t1" });
167
+
168
+ expect(wrapped.code).toBe("ORIGINAL");
169
+ expect(wrapped.context.server).toBe("s1");
170
+ expect(wrapped.context.tool).toBe("t1");
171
+ });
172
+
173
+ it("wraps standard Error", () => {
174
+ const original = new Error("standard error");
175
+ const wrapped = wrapError(original, { server: "test" });
176
+
177
+ expect(wrapped.code).toBe("UNKNOWN_ERROR");
178
+ expect(wrapped.message).toBe("standard error");
179
+ expect(wrapped.cause).toBe(original);
180
+ expect(wrapped.context.server).toBe("test");
181
+ });
182
+
183
+ it("wraps string error", () => {
184
+ const wrapped = wrapError("string error");
185
+
186
+ expect(wrapped.code).toBe("UNKNOWN_ERROR");
187
+ expect(wrapped.message).toBe("string error");
188
+ });
189
+
190
+ it("wraps unknown value", () => {
191
+ const wrapped = wrapError(42);
192
+
193
+ expect(wrapped.code).toBe("UNKNOWN_ERROR");
194
+ expect(wrapped.message).toBe("42");
195
+ });
196
+ });
197
+
198
+ describe("isErrorCode", () => {
199
+ it("returns true for matching code", () => {
200
+ const error = new McpUiError("test", { code: "MY_CODE" });
201
+ expect(isErrorCode(error, "MY_CODE")).toBe(true);
202
+ });
203
+
204
+ it("returns false for non-matching code", () => {
205
+ const error = new McpUiError("test", { code: "MY_CODE" });
206
+ expect(isErrorCode(error, "OTHER_CODE")).toBe(false);
207
+ });
208
+
209
+ it("returns false for non-McpUiError", () => {
210
+ const error = new Error("regular error");
211
+ expect(isErrorCode(error, "ANY_CODE")).toBe(false);
212
+ });
213
+
214
+ it("returns false for null/undefined", () => {
215
+ expect(isErrorCode(null, "ANY_CODE")).toBe(false);
216
+ expect(isErrorCode(undefined, "ANY_CODE")).toBe(false);
217
+ });
218
+ });
@@ -0,0 +1,98 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import {
4
+ CallToolRequestSchema,
5
+ ElicitResultSchema,
6
+ ListResourcesRequestSchema,
7
+ ListToolsRequestSchema,
8
+ ReadResourceRequestSchema,
9
+ UrlElicitationRequiredError,
10
+ } from "@modelcontextprotocol/sdk/types.js";
11
+
12
+ const server = new Server(
13
+ { name: "elicitation-integration-server", version: "1.0.0" },
14
+ { capabilities: { tools: {}, resources: {} } },
15
+ );
16
+
17
+ function urlRequiredError() {
18
+ return new UrlElicitationRequiredError([{
19
+ mode: "url",
20
+ message: "Connect your account",
21
+ elicitationId: "required-1",
22
+ url: "https://example.com/connect",
23
+ }]);
24
+ }
25
+
26
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
27
+ tools: [
28
+ { name: "capabilities", inputSchema: { type: "object", properties: {} } },
29
+ { name: "form", inputSchema: { type: "object", properties: {} } },
30
+ { name: "url", inputSchema: { type: "object", properties: {} } },
31
+ { name: "url-required", inputSchema: { type: "object", properties: {} } },
32
+ ],
33
+ }));
34
+
35
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
36
+ resources: [
37
+ { name: "URL-required resource", uri: "test://url-required" },
38
+ { name: "URL-required UI resource", uri: "ui://url-required" },
39
+ ],
40
+ }));
41
+
42
+ server.setRequestHandler(ReadResourceRequestSchema, async request => {
43
+ if (request.params.uri === "test://url-required" || request.params.uri === "ui://url-required") {
44
+ throw urlRequiredError();
45
+ }
46
+ return { contents: [] };
47
+ });
48
+
49
+ server.setRequestHandler(CallToolRequestSchema, async request => {
50
+ if (request.params.name === "capabilities") {
51
+ return {
52
+ content: [{ type: "text", text: JSON.stringify(server.getClientCapabilities()?.elicitation ?? null) }],
53
+ };
54
+ }
55
+
56
+ if (request.params.name === "url-required") throw urlRequiredError();
57
+
58
+ if (request.params.name === "form") {
59
+ const result = await server.request({
60
+ method: "elicitation/create",
61
+ params: {
62
+ mode: "form",
63
+ message: "Provide a name",
64
+ requestedSchema: {
65
+ type: "object",
66
+ properties: { name: { type: "string", minLength: 1 } },
67
+ required: ["name"],
68
+ },
69
+ },
70
+ }, ElicitResultSchema);
71
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
72
+ }
73
+
74
+ if (request.params.name === "url") {
75
+ const result = await server.request({
76
+ method: "elicitation/create",
77
+ params: {
78
+ mode: "url",
79
+ message: "Connect your account",
80
+ elicitationId: "requested-1",
81
+ url: "https://example.com/authorize",
82
+ },
83
+ }, ElicitResultSchema);
84
+ if (result.action === "accept") {
85
+ for (const elicitationId of ["unknown", "requested-1", "requested-1"]) {
86
+ await server.notification({
87
+ method: "notifications/elicitation/complete",
88
+ params: { elicitationId },
89
+ });
90
+ }
91
+ }
92
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
93
+ }
94
+
95
+ throw new Error(`Unknown tool: ${request.params.name}`);
96
+ });
97
+
98
+ await server.connect(new StdioServerTransport());
@@ -0,0 +1,278 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildHostHtmlTemplate, type HostHtmlTemplateInput } from "../host-html-template.ts";
3
+
4
+ function createMinimalInput(overrides: Partial<HostHtmlTemplateInput> = {}): HostHtmlTemplateInput {
5
+ return {
6
+ sessionToken: "test-token-123",
7
+ serverName: "test-server",
8
+ toolName: "test-tool",
9
+ toolArgs: { arg1: "value1" },
10
+ resource: {
11
+ uri: "ui://test/widget",
12
+ html: "<h1>Test Widget</h1>",
13
+ mimeType: "text/html",
14
+ meta: {},
15
+ },
16
+ allowAttribute: "",
17
+ requireToolConsent: false,
18
+ cacheToolConsent: true,
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describe("buildHostHtmlTemplate", () => {
24
+ describe("structure", () => {
25
+ it("generates valid HTML document", () => {
26
+ const html = buildHostHtmlTemplate(createMinimalInput());
27
+
28
+ expect(html).toContain("<!doctype html>");
29
+ expect(html).toContain("<html lang=\"en\">");
30
+ expect(html).toContain("</html>");
31
+ });
32
+
33
+ it("includes title with server and tool name", () => {
34
+ const html = buildHostHtmlTemplate(
35
+ createMinimalInput({ serverName: "my-server", toolName: "my-tool" })
36
+ );
37
+
38
+ expect(html).toContain("<title>MCP UI - my-server / my-tool</title>");
39
+ });
40
+
41
+ it("includes header with server and tool info", () => {
42
+ const html = buildHostHtmlTemplate(
43
+ createMinimalInput({ serverName: "demo-server", toolName: "widget-tool" })
44
+ );
45
+
46
+ expect(html).toContain('id="server-name"');
47
+ expect(html).toContain('id="tool-name"');
48
+ expect(html).toContain("Sandboxed");
49
+ });
50
+
51
+ it("includes iframe for app content", () => {
52
+ const html = buildHostHtmlTemplate(createMinimalInput());
53
+
54
+ expect(html).toContain('<iframe id="mcp-app"');
55
+ expect(html).toContain('referrerpolicy="no-referrer"');
56
+ });
57
+
58
+ it("includes control buttons", () => {
59
+ const html = buildHostHtmlTemplate(createMinimalInput());
60
+
61
+ expect(html).toContain('id="done-btn"');
62
+ expect(html).toContain('id="cancel-btn"');
63
+ });
64
+ });
65
+
66
+ describe("data injection", () => {
67
+ it("injects session token", () => {
68
+ const html = buildHostHtmlTemplate(
69
+ createMinimalInput({ sessionToken: "secret-token-xyz" })
70
+ );
71
+
72
+ expect(html).toContain('"secret-token-xyz"');
73
+ });
74
+
75
+ it("injects tool arguments", () => {
76
+ const html = buildHostHtmlTemplate(
77
+ createMinimalInput({ toolArgs: { location: "NYC", units: "metric" } })
78
+ );
79
+
80
+ expect(html).toContain('"location"');
81
+ expect(html).toContain('"NYC"');
82
+ expect(html).toContain('"units"');
83
+ expect(html).toContain('"metric"');
84
+ });
85
+
86
+ it("injects host context", () => {
87
+ const html = buildHostHtmlTemplate(
88
+ createMinimalInput({
89
+ hostContext: {
90
+ displayMode: "fullscreen",
91
+ theme: "dark",
92
+ },
93
+ })
94
+ );
95
+
96
+ expect(html).toContain('"displayMode"');
97
+ expect(html).toContain('"fullscreen"');
98
+ expect(html).toContain('"theme"');
99
+ expect(html).toContain('"dark"');
100
+ });
101
+
102
+ it("escapes HTML in injected values", () => {
103
+ const html = buildHostHtmlTemplate(
104
+ createMinimalInput({
105
+ toolArgs: { script: "<script>alert('xss')</script>" },
106
+ })
107
+ );
108
+
109
+ // Should be escaped
110
+ expect(html).not.toContain("<script>alert");
111
+ expect(html).toContain("\\u003cscript\\u003e");
112
+ });
113
+ });
114
+
115
+ describe("consent handling", () => {
116
+ it("injects requireToolConsent=false", () => {
117
+ const html = buildHostHtmlTemplate(
118
+ createMinimalInput({ requireToolConsent: false })
119
+ );
120
+
121
+ expect(html).toContain("const REQUIRE_TOOL_CONSENT = false");
122
+ });
123
+
124
+ it("injects requireToolConsent=true", () => {
125
+ const html = buildHostHtmlTemplate(
126
+ createMinimalInput({ requireToolConsent: true })
127
+ );
128
+
129
+ expect(html).toContain("const REQUIRE_TOOL_CONSENT = true");
130
+ });
131
+
132
+ it("injects cacheToolConsent", () => {
133
+ const html = buildHostHtmlTemplate(
134
+ createMinimalInput({ cacheToolConsent: false })
135
+ );
136
+
137
+ expect(html).toContain("const CACHE_TOOL_CONSENT = false");
138
+ });
139
+
140
+ it("records explicit consent denials", () => {
141
+ const html = buildHostHtmlTemplate(
142
+ createMinimalInput({ requireToolConsent: true })
143
+ );
144
+
145
+ expect(html).toContain('await post("/proxy/ui/consent", { approved: false }).catch(() => {});');
146
+ expect(html).toContain("Tool call denied by user.");
147
+ });
148
+ });
149
+
150
+ describe("iframe permissions", () => {
151
+ it("sets allow attribute when provided", () => {
152
+ const html = buildHostHtmlTemplate(
153
+ createMinimalInput({ allowAttribute: "camera; microphone" })
154
+ );
155
+
156
+ expect(html).toContain('const ALLOW_ATTRIBUTE = "camera; microphone"');
157
+ });
158
+ });
159
+
160
+ describe("CSP handling", () => {
161
+ it("buildCspMetaContent generates correct CSP directives", async () => {
162
+ const { buildCspMetaContent } = await import("../host-html-template.ts");
163
+ const csp = buildCspMetaContent({
164
+ scriptDomains: ["'self'", "cdn.example.com"],
165
+ styleDomains: ["'self'"],
166
+ });
167
+
168
+ expect(csp).toContain("script-src 'self' cdn.example.com");
169
+ expect(csp).toContain("style-src 'self'");
170
+ expect(csp).toContain("default-src 'none'");
171
+ });
172
+
173
+ it("applyCspMeta injects CSP meta into HTML head", async () => {
174
+ const { applyCspMeta } = await import("../host-html-template.ts");
175
+ const html = applyCspMeta(
176
+ "<html><head></head><body>Content</body></html>",
177
+ "default-src 'none'; script-src 'self'"
178
+ );
179
+
180
+ expect(html).toContain("Content-Security-Policy");
181
+ expect(html).toContain("script-src");
182
+ });
183
+
184
+ it("applyCspMeta preserves existing CSP in resource HTML", async () => {
185
+ const { applyCspMeta } = await import("../host-html-template.ts");
186
+ const resourceWithCsp = `<html>
187
+ <head>
188
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
189
+ </head>
190
+ <body>Content</body>
191
+ </html>`;
192
+
193
+ const html = applyCspMeta(resourceWithCsp, "script-src 'self'");
194
+
195
+ // Should not duplicate CSP meta - applyCspMeta should detect existing CSP and skip injection
196
+ const cspMatches = html.match(/Content-Security-Policy/g) ?? [];
197
+ expect(cspMatches.length).toBe(1);
198
+ });
199
+ });
200
+
201
+ describe("module loading", () => {
202
+ it("uses default AppBridge module URL", () => {
203
+ const html = buildHostHtmlTemplate(createMinimalInput());
204
+
205
+ expect(html).toContain("/app-bridge.bundle.js");
206
+ });
207
+
208
+ it("uses custom AppBridge module URL when provided", () => {
209
+ const html = buildHostHtmlTemplate(
210
+ createMinimalInput({
211
+ appBridgeModuleUrl: "https://cdn.example.com/app-bridge.js",
212
+ })
213
+ );
214
+
215
+ expect(html).toContain("https://cdn.example.com/app-bridge.js");
216
+ });
217
+ });
218
+
219
+ describe("stream mode", () => {
220
+ it("registers the stream patch notification method", () => {
221
+ const html = buildHostHtmlTemplate(createMinimalInput());
222
+
223
+ expect(html).toContain('const STREAM_PATCH_METHOD = "notifications/pi-mcp-adapter/ui-result-patch"');
224
+ expect(html).toContain('eventSource.addEventListener("result-patch"');
225
+ expect(html).toContain("bridge.notification({");
226
+ });
227
+
228
+ it("skips initial tool input in stream-first mode", () => {
229
+ const html = buildHostHtmlTemplate(
230
+ createMinimalInput({
231
+ hostContext: {
232
+ "pi-mcp-adapter/stream": {
233
+ mode: "stream-first",
234
+ streamId: "stream-1",
235
+ intermediateResultPatches: true,
236
+ partialInput: false,
237
+ },
238
+ },
239
+ }),
240
+ );
241
+
242
+ expect(html).toContain('const streamMode = initialStreamContext?.mode === "stream-first" ? "stream-first" : "eager";');
243
+ expect(html).toContain('if (streamMode !== "stream-first") {');
244
+ expect(html).toContain('bridge.sendToolInput({ arguments: TOOL_ARGS });');
245
+ });
246
+ });
247
+
248
+ describe("XSS prevention", () => {
249
+ it("escapes server name in title", () => {
250
+ const html = buildHostHtmlTemplate(
251
+ createMinimalInput({ serverName: "<script>evil</script>" })
252
+ );
253
+
254
+ expect(html).toContain("&lt;script&gt;evil&lt;/script&gt;");
255
+ expect(html).not.toContain("<script>evil</script>");
256
+ });
257
+
258
+ it("escapes tool name in title", () => {
259
+ const html = buildHostHtmlTemplate(
260
+ createMinimalInput({ toolName: '<img onerror="alert(1)">' })
261
+ );
262
+
263
+ expect(html).toContain("&lt;img onerror=");
264
+ });
265
+
266
+ it("escapes unicode line/paragraph separators", () => {
267
+ const html = buildHostHtmlTemplate(
268
+ createMinimalInput({
269
+ toolArgs: { text: "line\u2028separator\u2029here" },
270
+ })
271
+ );
272
+
273
+ // Should be escaped to prevent JS parsing issues
274
+ expect(html).toContain("\\u2028");
275
+ expect(html).toContain("\\u2029");
276
+ });
277
+ });
278
+ });