Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/code/src/main/services/agent/auth-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ describe("AgentAuthAdapter", () => {
);
});

it("identifies as the PostHog Code consumer so the MCP server emits UI-app metadata", async () => {
const { servers } = await adapter.buildMcpServers(baseCredentials);

const posthogServer = servers.find((s) => s.name === "posthog");
expect(posthogServer).toBeDefined();
expect(posthogServer?.headers).toEqual(
expect.arrayContaining([
{ name: "x-posthog-mcp-consumer", value: "posthog-code" },
]),
);
});

it("routes authenticated installed MCP servers through the proxy URL", async () => {
mockFetch.mockResolvedValue({
ok: true,
Expand Down
2 changes: 2 additions & 0 deletions apps/code/src/main/services/agent/auth-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export class AgentAuthAdapter {
value: String(credentials.projectId),
},
{ name: "x-posthog-mcp-version", value: "2" },
// Identify as the PostHog Code UI-apps host.
{ name: "x-posthog-mcp-consumer", value: "posthog-code" },
],
});

Expand Down
45 changes: 45 additions & 0 deletions apps/code/src/main/services/mcp-apps/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("../../utils/logger", () => ({
logger: {
scope: () => ({
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
},
}));

import { McpAppsService } from "./service";

function makeService(): McpAppsService {
const urlLauncher = { launch: vi.fn() };
return new McpAppsService(urlLauncher as never);
}

describe("McpAppsService.getUiResourceByUri", () => {
let service: McpAppsService;

beforeEach(() => {
service = makeService();
});

it("rejects non-ui:// URIs without attempting a fetch", async () => {
await expect(
service.getUiResourceByUri("posthog", "https://evil.example/app.html"),
).resolves.toBeNull();
await expect(
service.getUiResourceByUri("posthog", "file:///etc/passwd"),
).resolves.toBeNull();
});

it("rejects when the server has no connection config", async () => {
// ui:// passes the guard, but with no configured server the lazy connection
// fails. The fetch rethrows rather than caching a permanent null, so the
// caller's query can surface the error and retry once boot populates configs.
await expect(
service.getUiResourceByUri("posthog", "ui://posthog/survey-list.html"),
).rejects.toThrow("No server config for: posthog");
});
});
234 changes: 161 additions & 73 deletions apps/code/src/main/services/mcp-apps/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import type { IUrlLauncher } from "@posthog/platform/url-launcher";
import {
BUILTIN_POSTHOG_SERVER_NAME,
EXEC_TOOL_NAME,
LEGACY_RESOURCE_URI_META_KEY,
type McpAppsDiscoveryCompleteEvent,
McpAppsServiceEvent,
type McpAppsServiceEvents,
Expand All @@ -14,6 +17,8 @@ import {
type McpToolUiAssociation,
type McpToolUiMeta,
type McpUiResource,
POSTHOG_EXEC_TOOL_KEY,
resolveResultResourceUri,
} from "@shared/types/mcp-apps";
import { inject, injectable } from "inversify";
import { MAIN_TOKENS } from "../../di/tokens";
Expand All @@ -22,6 +27,29 @@ import { TypedEventEmitter } from "../../utils/typed-event-emitter";

const log = logger.scope("mcp-apps-service");

/**
* Safe diagnostic snapshot of a tool-call result — surfaces whether/where a UI
* `resourceUri` is hiding in `_meta` without dumping large payloads (HTML, rows).
*/
function summarizeResult(result: unknown): Record<string, unknown> {
if (result == null || typeof result !== "object") {
return { resultType: typeof result };
}
const obj = result as Record<string, unknown>;
const meta = obj._meta;
const hasMeta = meta != null && typeof meta === "object";
const metaObj = hasMeta ? (meta as Record<string, unknown>) : undefined;
return {
resultType: "object",
resultKeys: Object.keys(obj),
hasMeta,
metaKeys: metaObj ? Object.keys(metaObj) : undefined,
metaUi: metaObj?.ui,
legacyResourceUri: metaObj?.[LEGACY_RESOURCE_URI_META_KEY],
resolvedResourceUri: resolveResultResourceUri(result),
};
}

const UI_MIME_TYPE = "text/html;profile=mcp-app";
const MAX_HTML_SIZE = 5 * 1024 * 1024; // 5MB

Expand Down Expand Up @@ -105,7 +133,26 @@ export class McpAppsService extends TypedEventEmitter<McpAppsServiceEvents> {
}),
]);

log.info("discoverServerUiTools: listed tools", {
serverName,
toolNames: toolsList.tools.map((t) => t.name),
hasExecTool:
serverName === BUILTIN_POSTHOG_SERVER_NAME &&
toolsList.tools.some((t) => t.name === EXEC_TOOL_NAME),
resourceUris: resourcesList?.resources.map((r) => r.uri),
});

for (const tool of toolsList.tools) {
// The built-in PostHog `exec` tool carries no registration-time UI
// resource (it resolves per call), but we still cache its definition so
// the app bridge can surface it when a per-call UI is rendered.
if (
serverName === BUILTIN_POSTHOG_SERVER_NAME &&
tool.name === EXEC_TOOL_NAME
) {
this.toolDefinitions.set(POSTHOG_EXEC_TOOL_KEY, tool);
}

const uiMeta = (tool as McpToolUiMeta)._meta?.ui;
if (!uiMeta?.resourceUri) continue;

Expand Down Expand Up @@ -184,7 +231,7 @@ export class McpAppsService extends TypedEventEmitter<McpAppsServiceEvents> {
});

const client = new Client(
{ name: "Twig", version: "1.0.0" },
{ name: "posthog-code", version: "1.0.0" },
{
capabilities: {
extensions: {
Expand All @@ -207,110 +254,145 @@ export class McpAppsService extends TypedEventEmitter<McpAppsServiceEvents> {
}

/**
* Get the UI resource for a tool. Fetches lazily on first access:
* creates an MCP connection if needed, then reads the resource HTML.
* Deduplicates concurrent fetches for the same resource URI.
* Fetch the UI resource for a registration-discovered tool, by its tool key.
*/
async getUiResourceForTool(toolKey: string): Promise<McpUiResource | null> {
const association = this.toolAssociations.get(toolKey);
if (!association) {
log.debug("getUiResourceForTool: no association found", { toolKey });
return null;
}
return this.fetchUiResourceByUri(
association.serverName,
association.resourceUri,
);
}

// Return cached resource immediately
const cached = this.resourceCache.get(association.resourceUri);
/**
* Fetch a UI resource directly by its `ui://` URI. Used by the built-in
* PostHog `exec` path, where the resource URI is resolved per call from the
* tool result's `_meta` (in the renderer) rather than from a registered
* tool->UI association. Because the renderer derives it from the persisted
* conversation, exec UI apps survive app restarts — unlike the old in-memory
* per-call association map.
*/
async getUiResourceByUri(
serverName: string,
resourceUri: string,
): Promise<McpUiResource | null> {
if (!resourceUri.startsWith("ui://")) {
log.warn("getUiResourceByUri: rejecting non-ui:// URI", {
serverName,
resourceUri,
});
return null;
}
return this.fetchUiResourceByUri(serverName, resourceUri);
}

/**
* Lazily fetch + cache a UI resource's HTML, deduplicating concurrent fetches
* for the same URI. Shared by the registration and per-call exec paths.
*/
private async fetchUiResourceByUri(
serverName: string,
resourceUri: string,
): Promise<McpUiResource | null> {
const cached = this.resourceCache.get(resourceUri);
if (cached) {
log.debug("getUiResourceForTool: cache hit", { toolKey });
log.debug("fetchUiResourceByUri: cache hit", { serverName, resourceUri });
return cached;
}

// Deduplicate concurrent fetches for the same resource URI
const pendingFetch = this.pendingFetches.get(association.resourceUri);
const pendingFetch = this.pendingFetches.get(resourceUri);
if (pendingFetch) {
log.debug("getUiResourceForTool: joining pending fetch", {
toolKey,
uri: association.resourceUri,
log.debug("fetchUiResourceByUri: joining pending fetch", {
serverName,
resourceUri,
});
return pendingFetch;
}

// Start the fetch for this resource URI
log.debug("getUiResourceForTool: starting lazy fetch", {
toolKey,
serverName: association.serverName,
uri: association.resourceUri,
log.debug("fetchUiResourceByUri: starting lazy fetch", {
serverName,
resourceUri,
});
const fetchPromise = this.fetchUiResource(association);
this.pendingFetches.set(association.resourceUri, fetchPromise);

const fetchPromise = this.doFetchUiResource(serverName, resourceUri);
this.pendingFetches.set(resourceUri, fetchPromise);
try {
return await fetchPromise;
} finally {
this.pendingFetches.delete(association.resourceUri);
this.pendingFetches.delete(resourceUri);
}
}

private async fetchUiResource(
association: McpToolUiAssociation,
private async doFetchUiResource(
serverName: string,
resourceUri: string,
): Promise<McpUiResource | null> {
let resourceResult: Awaited<ReturnType<Client["readResource"]>>;
try {
const conn = await this.getOrCreateConnection(association.serverName);
const resourceResult = await conn.client.readResource({
uri: association.resourceUri,
const conn = await this.getOrCreateConnection(serverName);
resourceResult = await conn.client.readResource({ uri: resourceUri });
} catch (err) {
// Connection/read failures are transient — most notably "No server config
// for: posthog" during the boot race, before a session populates configs.
// Rethrow so the caller's query surfaces an error and retries, instead of
// caching a permanent `null` for this (shared) resource URI and poisoning
// every later call that reuses it.
log.warn("Failed to fetch UI resource (transient — will retry)", {
serverName,
uri: resourceUri,
error: err instanceof Error ? err.message : String(err),
});
throw err;
}

const textContent = resourceResult.contents.find(
(c) => "text" in c && c.mimeType === UI_MIME_TYPE,
);
if (!textContent || !("text" in textContent)) {
log.warn("UI resource had no matching text content", {
serverName: association.serverName,
uri: association.resourceUri,
contentsCount: resourceResult.contents.length,
});
return null;
}

if (textContent.text.length > MAX_HTML_SIZE) {
log.warn("UI resource HTML exceeds size limit", {
uri: association.resourceUri,
size: textContent.text.length,
limit: MAX_HTML_SIZE,
});
return null;
}

// Use metadata cached during discovery
const resourceMeta = this.resourceMetaCache.get(association.resourceUri);

const resource: McpUiResource = {
uri: association.resourceUri,
name: resourceMeta?.name,
mimeType: UI_MIME_TYPE,
csp: resourceMeta?._meta?.ui?.csp,
permissions: resourceMeta?._meta?.ui?.permissions,
html: textContent.text,
serverName: association.serverName,
};

this.resourceCache.set(association.resourceUri, resource);
log.info("Lazily fetched and cached UI resource", {
serverName: association.serverName,
uri: association.resourceUri,
htmlLength: textContent.text.length,
hasCsp: !!resource.csp,
const textContent = resourceResult.contents.find(
(c) => "text" in c && c.mimeType === UI_MIME_TYPE,
);
if (!textContent || !("text" in textContent)) {
// The server answered but has no usable UI content — a definitive "no UI"
// for this URI, so caching `null` is correct.
log.warn("UI resource had no matching text content", {
serverName,
uri: resourceUri,
contentsCount: resourceResult.contents.length,
});
return null;
}

return resource;
} catch (err) {
log.warn("Failed to lazily fetch UI resource", {
serverName: association.serverName,
uri: association.resourceUri,
error: err instanceof Error ? err.message : String(err),
if (textContent.text.length > MAX_HTML_SIZE) {
log.warn("UI resource HTML exceeds size limit", {
uri: resourceUri,
size: textContent.text.length,
limit: MAX_HTML_SIZE,
});
return null;
}

// Use metadata cached during discovery (CSP/permissions), if available.
const resourceMeta = this.resourceMetaCache.get(resourceUri);

const resource: McpUiResource = {
uri: resourceUri,
name: resourceMeta?.name,
mimeType: UI_MIME_TYPE,
csp: resourceMeta?._meta?.ui?.csp,
permissions: resourceMeta?._meta?.ui?.permissions,
html: textContent.text,
serverName,
};

this.resourceCache.set(resourceUri, resource);
log.info("Lazily fetched and cached UI resource", {
serverName,
uri: resourceUri,
htmlLength: textContent.text.length,
hasCsp: !!resource.csp,
});

return resource;
}

hasUiForTool(toolKey: string): boolean {
Expand Down Expand Up @@ -382,7 +464,13 @@ export class McpAppsService extends TypedEventEmitter<McpAppsServiceEvents> {
result: unknown,
isError?: boolean,
): void {
log.info("notifyToolResult", { toolKey, toolCallId, isError });
log.info("notifyToolResult", {
toolKey,
toolCallId,
isError,
...summarizeResult(result),
});

this.emit(McpAppsServiceEvent.ToolResult, {
toolKey,
toolCallId,
Expand Down
Loading
Loading