Skip to content

Commit a9a4c26

Browse files
committed
feat: added external mcp integrations
1 parent 39e07bf commit a9a4c26

7 files changed

Lines changed: 872 additions & 3 deletions

File tree

apps/twig/src/main/services/agent/service.ts

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,9 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
347347
return this.currentToken || fallback;
348348
}
349349

350-
private buildMcpServers(credentials: Credentials): AcpMcpServer[] {
350+
private async buildMcpServers(
351+
credentials: Credentials,
352+
): Promise<AcpMcpServer[]> {
351353
const servers: AcpMcpServer[] = [];
352354

353355
const mcpUrl = this.getPostHogMcpUrl(credentials.apiHost);
@@ -366,9 +368,94 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
366368
],
367369
});
368370

371+
// Fetch user-installed MCP servers from the PostHog backend
372+
const installations = await this.fetchMcpInstallations(credentials);
373+
374+
for (const installation of installations) {
375+
// Skip the PostHog MCP server since it's already included above
376+
if (installation.url === mcpUrl) continue;
377+
378+
if (installation.auth_type === "none") {
379+
servers.push({
380+
name:
381+
installation.name || installation.display_name || installation.url,
382+
type: "http",
383+
url: installation.url,
384+
headers: [],
385+
});
386+
} else {
387+
// Authenticated servers go through the PostHog proxy so credentials
388+
// never leave the backend
389+
servers.push({
390+
name:
391+
installation.name || installation.display_name || installation.url,
392+
type: "http",
393+
url: installation.proxy_url,
394+
headers: [{ name: "Authorization", value: `Bearer ${token}` }],
395+
});
396+
}
397+
}
398+
369399
return servers;
370400
}
371401

402+
private async fetchMcpInstallations(credentials: Credentials): Promise<
403+
Array<{
404+
id: string;
405+
url: string;
406+
proxy_url: string;
407+
name: string;
408+
display_name: string;
409+
auth_type: string;
410+
}>
411+
> {
412+
const token = this.getToken(credentials.apiKey);
413+
const baseUrl = this.getPostHogApiBaseUrl(credentials.apiHost);
414+
const url = `${baseUrl}/api/environments/${credentials.projectId}/mcp_server_installations/`;
415+
416+
try {
417+
const response = await fetch(url, {
418+
headers: {
419+
Authorization: `Bearer ${token}`,
420+
"Content-Type": "application/json",
421+
},
422+
});
423+
424+
if (!response.ok) {
425+
log.warn("Failed to fetch MCP installations", {
426+
status: response.status,
427+
});
428+
return [];
429+
}
430+
431+
const data = (await response.json()) as {
432+
results?: Array<{
433+
id: string;
434+
url: string;
435+
proxy_url?: string;
436+
name: string;
437+
display_name: string;
438+
auth_type: string;
439+
pending_oauth: boolean;
440+
needs_reauth: boolean;
441+
}>;
442+
};
443+
const installations = data.results ?? [];
444+
445+
return installations
446+
.filter((i) => !i.pending_oauth && !i.needs_reauth)
447+
.map((i) => ({
448+
...i,
449+
proxy_url:
450+
i.proxy_url ??
451+
`${baseUrl}/api/environments/${credentials.projectId}/mcp_server_installations/${i.id}/proxy/`,
452+
}));
453+
} catch (err) {
454+
log.warn("Error fetching MCP installations", { error: err });
455+
return [];
456+
}
457+
}
458+
372459
private buildPostHogSystemPrompt(credentials: Credentials): {
373460
append: string;
374461
} {
@@ -388,6 +475,11 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
388475
return "https://mcp.posthog.com/mcp";
389476
}
390477

478+
private getPostHogApiBaseUrl(apiHost: string): string {
479+
const host = process.env.POSTHOG_PROXY_BASE_URL || apiHost;
480+
return host.endsWith("/") ? host.slice(0, -1) : host;
481+
}
482+
391483
async startSession(params: StartSessionInput): Promise<SessionResponse> {
392484
this.validateSessionParams(params);
393485
const config = this.toSessionConfig(params);
@@ -524,7 +616,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
524616
});
525617

526618
const mcpServers =
527-
adapter === "codex" ? [] : this.buildMcpServers(credentials);
619+
adapter === "codex" ? [] : await this.buildMcpServers(credentials);
528620

529621
let configOptions: SessionConfigOption[] | undefined;
530622
let agentSessionId: string;

apps/twig/src/renderer/api/posthogClient.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,31 @@ import { createApiClient, type Schemas } from "./generated";
1313

1414
const log = logger.scope("posthog-client");
1515

16+
export interface McpRecommendedServer {
17+
name: string;
18+
url: string;
19+
description: string;
20+
icon_url: string;
21+
auth_type: "none" | "api_key" | "oauth";
22+
oauth_provider_kind?: string;
23+
}
24+
25+
export interface McpServerInstallation {
26+
id: string;
27+
server_id: string | null;
28+
name: string;
29+
display_name: string;
30+
url: string;
31+
description: string;
32+
auth_type: "none" | "api_key" | "oauth";
33+
configuration: Record<string, unknown>;
34+
needs_reauth: boolean;
35+
pending_oauth: boolean;
36+
proxy_url: string;
37+
created_at: string;
38+
updated_at: string;
39+
}
40+
1641
function isObjectRecord(value: unknown): value is Record<string, unknown> {
1742
return typeof value === "object" && value !== null;
1843
}
@@ -766,6 +791,94 @@ export class PostHogAPIClient {
766791
} as RepoAutonomyStatus;
767792
}
768793

794+
async getMcpServers(): Promise<McpRecommendedServer[]> {
795+
const teamId = await this.getTeamId();
796+
const url = new URL(
797+
`${this.api.baseUrl}/api/environments/${teamId}/mcp_servers/`,
798+
);
799+
const response = await this.api.fetcher.fetch({
800+
method: "get",
801+
url,
802+
path: `/api/environments/${teamId}/mcp_servers/`,
803+
});
804+
805+
if (!response.ok) {
806+
throw new Error(`Failed to fetch MCP servers: ${response.statusText}`);
807+
}
808+
809+
const data = await response.json();
810+
return data.results ?? data ?? [];
811+
}
812+
813+
async getMcpServerInstallations(): Promise<McpServerInstallation[]> {
814+
const teamId = await this.getTeamId();
815+
const url = new URL(
816+
`${this.api.baseUrl}/api/environments/${teamId}/mcp_server_installations/`,
817+
);
818+
const response = await this.api.fetcher.fetch({
819+
method: "get",
820+
url,
821+
path: `/api/environments/${teamId}/mcp_server_installations/`,
822+
});
823+
824+
if (!response.ok) {
825+
throw new Error(
826+
`Failed to fetch MCP server installations: ${response.statusText}`,
827+
);
828+
}
829+
830+
const data = await response.json();
831+
return data.results ?? data ?? [];
832+
}
833+
834+
async installCustomMcpServer(options: {
835+
name: string;
836+
url: string;
837+
auth_type: "none" | "api_key" | "oauth";
838+
api_key?: string;
839+
description?: string;
840+
oauth_provider_kind?: string;
841+
}): Promise<McpServerInstallation & { redirect_url?: string }> {
842+
const teamId = await this.getTeamId();
843+
const apiUrl = new URL(
844+
`${this.api.baseUrl}/api/environments/${teamId}/mcp_server_installations/install_custom/`,
845+
);
846+
const response = await this.api.fetcher.fetch({
847+
method: "post",
848+
url: apiUrl,
849+
path: `/api/environments/${teamId}/mcp_server_installations/install_custom/`,
850+
overrides: {
851+
body: JSON.stringify(options),
852+
},
853+
});
854+
855+
if (!response.ok) {
856+
const errorData = await response.json().catch(() => ({}));
857+
throw new Error(
858+
(errorData as { detail?: string }).detail ??
859+
`Failed to install MCP server: ${response.statusText}`,
860+
);
861+
}
862+
863+
return await response.json();
864+
}
865+
866+
async uninstallMcpServer(installationId: string): Promise<void> {
867+
const teamId = await this.getTeamId();
868+
const url = new URL(
869+
`${this.api.baseUrl}/api/environments/${teamId}/mcp_server_installations/${installationId}/`,
870+
);
871+
const response = await this.api.fetcher.fetch({
872+
method: "delete",
873+
url,
874+
path: `/api/environments/${teamId}/mcp_server_installations/${installationId}/`,
875+
});
876+
877+
if (!response.ok && response.status !== 204) {
878+
throw new Error(`Failed to uninstall MCP server: ${response.statusText}`);
879+
}
880+
}
881+
769882
/**
770883
* Check if a feature flag is enabled for the current project.
771884
* Returns true if the flag exists and is active, false otherwise.

apps/twig/src/renderer/features/settings/components/SettingsDialog.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Folder,
1111
GearSix,
1212
Keyboard,
13+
Plugs,
1314
User,
1415
Wrench,
1516
} from "@phosphor-icons/react";
@@ -20,6 +21,7 @@ import { AccountSettings } from "./sections/AccountSettings";
2021
import { AdvancedSettings } from "./sections/AdvancedSettings";
2122
import { ClaudeCodeSettings } from "./sections/ClaudeCodeSettings";
2223
import { GeneralSettings } from "./sections/GeneralSettings";
24+
import { McpServersSettings } from "./sections/McpServersSettings";
2325
import { ShortcutsSettings } from "./sections/ShortcutsSettings";
2426
import { UpdatesSettings } from "./sections/UpdatesSettings";
2527
import { WorkspacesSettings } from "./sections/WorkspacesSettings";
@@ -36,6 +38,7 @@ const SIDEBAR_ITEMS: SidebarItem[] = [
3638
{ id: "account", label: "Account", icon: <User size={16} /> },
3739
{ id: "workspaces", label: "Workspaces", icon: <Folder size={16} /> },
3840
{ id: "claude-code", label: "Claude Code", icon: <Code size={16} /> },
41+
{ id: "mcp-servers", label: "MCP Servers", icon: <Plugs size={16} /> },
3942
{ id: "shortcuts", label: "Shortcuts", icon: <Keyboard size={16} /> },
4043
{ id: "updates", label: "Updates", icon: <ArrowsClockwise size={16} /> },
4144
{ id: "advanced", label: "Advanced", icon: <Wrench size={16} /> },
@@ -46,6 +49,7 @@ const CATEGORY_TITLES: Record<SettingsCategory, string> = {
4649
account: "Account",
4750
workspaces: "Workspaces",
4851
"claude-code": "Claude Code",
52+
"mcp-servers": "MCP Servers",
4953
shortcuts: "Shortcuts",
5054
updates: "Updates",
5155
advanced: "Advanced",
@@ -56,6 +60,7 @@ const CATEGORY_COMPONENTS: Record<SettingsCategory, React.ComponentType> = {
5660
account: AccountSettings,
5761
workspaces: WorkspacesSettings,
5862
"claude-code": ClaudeCodeSettings,
63+
"mcp-servers": McpServersSettings,
5964
shortcuts: ShortcutsSettings,
6065
updates: UpdatesSettings,
6166
advanced: AdvancedSettings,

0 commit comments

Comments
 (0)