Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"build-icons": "bash scripts/generate-icns.sh",
"typecheck": "tsc -p tsconfig.node.json --noEmit && tsc -p tsconfig.web.json --noEmit",
"generate-client": "tsx scripts/update-openapi-client.ts",
"scaffold-mcp-tools": "tsx scripts/scaffold-mcp-tools.ts",
"test": "vitest run",
"test:e2e": "playwright test --config=tests/e2e/playwright.config.ts",
"test:e2e:headed": "playwright test --config=tests/e2e/playwright.config.ts --headed",
Expand Down
294 changes: 294 additions & 0 deletions apps/code/scripts/scaffold-mcp-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
#!/usr/bin/env tsx
/**
* Sync `apps/code/src/main/services/posthog-code-internal-mcp/mcp-tools.yaml`
* with the live tRPC router.
*
* Uses the TypeScript compiler API to STATICALLY parse the router and
* sub-router source files — no runtime import, no Electron, no Node ESM/CJS
* interop. Walks `router({ ... })` object literals, detects whether each
* procedure chain ends in `.query`, `.mutation`, or `.subscription`, and
* emits `enabled: false` stubs for procedures missing from the YAML.
*
* Usage:
* pnpm --filter code scaffold-mcp-tools
* pnpm --filter code scaffold-mcp-tools --check # exit 1 if out of date
*/

import * as fs from "node:fs";
import * as path from "node:path";
import * as ts from "typescript";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { McpToolsYamlSchema } from "../src/main/services/posthog-code-internal-mcp/yaml-schema";

const APP_ROOT = path.resolve(__dirname, "..");
const ROUTER_FILE = path.join(APP_ROOT, "src/main/trpc/router.ts");
const ROUTERS_DIR = path.join(APP_ROOT, "src/main/trpc/routers");
const YAML_PATH = path.join(
APP_ROOT,
"src/main/services/posthog-code-internal-mcp/mcp-tools.yaml",
);

const YAML_HEADER = `# Bridge from tRPC procedures to MCP tools exposed to the running agent.
#
# Re-run \`pnpm --filter code scaffold-mcp-tools\` after adding or removing
# tRPC procedures. New entries are scaffolded as enabled: false; the boot-time
# registry hard-fails if an entry references a procedure that no longer
# exists, so stale entries must be deleted by hand.
#
# Default-deny: every enabled tool is callable by the agent. Review carefully
# before flipping enabled: true on anything beyond the curated defaults.
`;

type ProcedureType = "query" | "mutation" | "subscription";

interface Procedure {
path: string;
type: ProcedureType;
}

function parseSource(filePath: string): ts.SourceFile {
const text = fs.readFileSync(filePath, "utf-8");
return ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true);
}

/**
* Find the object literal passed to a `router({...})` call inside the
* given source file. We look for the FIRST top-level `router(...)` call
* expression; that's the canonical pattern in this codebase (one router
* declaration per file).
*/
function findRouterObjectLiteral(
source: ts.SourceFile,
): ts.ObjectLiteralExpression | undefined {
let result: ts.ObjectLiteralExpression | undefined;

function visit(node: ts.Node): void {
if (result) return;
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === "router" &&
node.arguments.length === 1 &&
ts.isObjectLiteralExpression(node.arguments[0])
) {
result = node.arguments[0];
return;
}
ts.forEachChild(node, visit);
}

visit(source);
return result;
}

/**
* Build a map of namespace → router-file-basename by parsing the root
* router.ts. The pattern is:
*
* import { fooRouter } from "./routers/foo";
* export const trpcRouter = router({ foo: fooRouter, ... });
*
* We use the property assignments to map namespace → identifier, then the
* import declarations to map identifier → file path.
*/
function discoverSubRouters(): Map<string, string> {
const source = parseSource(ROUTER_FILE);

const importMap = new Map<string, string>();
for (const stmt of source.statements) {
if (!ts.isImportDeclaration(stmt)) continue;
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
const moduleSpec = stmt.moduleSpecifier.text;
if (!moduleSpec.startsWith("./routers/")) continue;
const basename = moduleSpec.slice("./routers/".length).replace(/\.js$/, "");
const fileAbs = path.join(ROUTERS_DIR, `${basename}.ts`);
if (!stmt.importClause?.namedBindings) continue;
if (!ts.isNamedImports(stmt.importClause.namedBindings)) continue;
for (const spec of stmt.importClause.namedBindings.elements) {
importMap.set(spec.name.text, fileAbs);
}
}

const literal = findRouterObjectLiteral(source);
if (!literal) {
throw new Error(`Could not find router({...}) call in ${ROUTER_FILE}`);
}

const namespaces = new Map<string, string>();
for (const prop of literal.properties) {
if (!ts.isPropertyAssignment(prop)) continue;
const key = propertyName(prop.name);
if (!key) continue;
if (!ts.isIdentifier(prop.initializer)) continue;
const filePath = importMap.get(prop.initializer.text);
if (!filePath) {
throw new Error(
`Router namespace '${key}' maps to identifier '${prop.initializer.text}' which has no matching import in router.ts`,
);
}
namespaces.set(key, filePath);
}

return namespaces;
}

function propertyName(name: ts.PropertyName): string | undefined {
if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
return undefined;
}

/**
* Walk the procedure chain's outermost call to determine its type. The chain
* looks like:
* publicProcedure.input(X).output(Y).query(handler)
* publicProcedure.subscription(handler)
* The OUTERMOST call's property name tells us the type. Anything that
* doesn't end in query/mutation/subscription is skipped (e.g. helpers).
*/
function classifyProcedure(expr: ts.Expression): ProcedureType | undefined {
if (!ts.isCallExpression(expr)) return undefined;
const callee = expr.expression;
if (!ts.isPropertyAccessExpression(callee)) return undefined;
const method = callee.name.text;
if (
method === "query" ||
method === "mutation" ||
method === "subscription"
) {
return method;
}
return undefined;
}

function parseRouterProcedures(
namespace: string,
filePath: string,
): Procedure[] {
const source = parseSource(filePath);
const literal = findRouterObjectLiteral(source);
if (!literal) {
throw new Error(`Could not find router({...}) call in ${filePath}`);
}
const procedures: Procedure[] = [];
for (const prop of literal.properties) {
if (!ts.isPropertyAssignment(prop)) continue;
const key = propertyName(prop.name);
if (!key) continue;
const type = classifyProcedure(prop.initializer);
if (!type) continue;
procedures.push({ path: `${namespace}.${key}`, type });
}
return procedures;
}

function discoverProcedures(): Procedure[] {
const namespaces = discoverSubRouters();
const all: Procedure[] = [];
for (const [namespace, filePath] of namespaces) {
all.push(...parseRouterProcedures(namespace, filePath));
}
return all.filter((p) => p.type !== "subscription");
}

function main(): void {
const check = process.argv.includes("--check");
const procedures = discoverProcedures();

let existing: { tools: Record<string, { operation: string }> } = {
tools: {},
};
if (fs.existsSync(YAML_PATH)) {
const raw = fs.readFileSync(YAML_PATH, "utf-8");
const parsed = parseYaml(raw);
const result = McpToolsYamlSchema.safeParse(parsed);
if (!result.success) {
console.error("Invalid existing mcp-tools.yaml:");
for (const issue of result.error.issues) {
console.error(` ${issue.path.join(".")}: ${issue.message}`);
}
process.exit(1);
}
existing = result.data as { tools: Record<string, { operation: string }> };
}

const proceduresByPath = new Map(procedures.map((p) => [p.path, p]));
const existingByOperation = new Map<string, [string, unknown]>();
for (const [name, config] of Object.entries(existing.tools)) {
existingByOperation.set(config.operation, [name, config]);
}

const mergedTools: Record<string, unknown> = {};
let added = 0;
let unchanged = 0;
const stale: string[] = [];

for (const proc of procedures) {
const existingEntry = existingByOperation.get(proc.path);
if (existingEntry) {
const [name, config] = existingEntry;
mergedTools[name] = config;
unchanged++;
} else {
mergedTools[proc.path] = {
operation: proc.path,
enabled: false,
};
added++;
}
}

for (const [name, config] of Object.entries(existing.tools)) {
if (!proceduresByPath.has(config.operation)) {
mergedTools[name] = config;
stale.push(`${name} → ${config.operation}`);
}
}

const sortedTools = Object.fromEntries(
Object.entries(mergedTools).sort(([a], [b]) => a.localeCompare(b)),
);

const nextContent =
YAML_HEADER + stringifyYaml({ tools: sortedTools }, { lineWidth: 120 });
const currentContent = fs.existsSync(YAML_PATH)
? fs.readFileSync(YAML_PATH, "utf-8")
: "";

const isUpToDate = currentContent === nextContent;

if (check) {
if (!isUpToDate) {
console.error(
"mcp-tools.yaml is out of date with the tRPC router. Run `pnpm --filter code scaffold-mcp-tools` and commit the result.",
);
console.error(
` unchanged=${unchanged} added=${added} stale=${stale.length}`,
);
process.exit(1);
}
console.log(
`mcp-tools.yaml is up to date (${procedures.length} procedures).`,
);
return;
}

if (!isUpToDate) {
fs.writeFileSync(YAML_PATH, nextContent);
}

console.log(
`mcp-tools.yaml: ${procedures.length} procedures total — ${unchanged} unchanged, ${added} added.`,
);
if (stale.length > 0) {
console.warn(
`\n⚠ ${stale.length} stale tool(s) in YAML reference procedures that no longer exist:`,
);
for (const s of stale) console.warn(` - ${s}`);
console.warn(
" These were left in place. Delete them or boot will hard-fail.",
);
process.exitCode = 2;
}
}

main();
10 changes: 10 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { AuthProxyService } from "../services/auth-proxy/service";
import { CloudTaskService } from "../services/cloud-task/service";
import { ConnectivityService } from "../services/connectivity/service";
import { ContextMenuService } from "../services/context-menu/service";
import { CustomInstructionsService } from "../services/custom-instructions/service";
import { DeepLinkService } from "../services/deep-link/service";
import { EnrichmentService } from "../services/enrichment/service";
import { EnvironmentService } from "../services/environment/service";
Expand All @@ -52,10 +53,12 @@ import { LlmGatewayService } from "../services/llm-gateway/service";
import { LocalLogsService } from "../services/local-logs/service";
import { McpAppsService } from "../services/mcp-apps/service";
import { McpCallbackService } from "../services/mcp-callback/service";
import { McpInstallationsService } from "../services/mcp-installations/service";
import { McpProxyService } from "../services/mcp-proxy/service";
import { NewTaskLinkService } from "../services/new-task-link/service";
import { NotificationService } from "../services/notification/service";
import { OAuthService } from "../services/oauth/service";
import { PostHogCodeInternalMcpService } from "../services/posthog-code-internal-mcp/service";
import { PosthogPluginService } from "../services/posthog-plugin/service";
import { ProcessTrackingService } from "../services/process-tracking/service";
import { ProvisioningService } from "../services/provisioning/service";
Expand Down Expand Up @@ -109,7 +112,14 @@ container.bind(MAIN_TOKENS.AgentAuthAdapter).to(AgentAuthAdapter);
container.bind(MAIN_TOKENS.AgentService).to(AgentService);
container.bind(MAIN_TOKENS.AuthService).to(AuthService);
container.bind(MAIN_TOKENS.AuthProxyService).to(AuthProxyService);
container
.bind(MAIN_TOKENS.CustomInstructionsService)
.to(CustomInstructionsService);
container.bind(MAIN_TOKENS.McpInstallationsService).to(McpInstallationsService);
container.bind(MAIN_TOKENS.McpProxyService).to(McpProxyService);
container
.bind(MAIN_TOKENS.PostHogCodeInternalMcpService)
.to(PostHogCodeInternalMcpService);
container.bind(MAIN_TOKENS.ArchiveService).to(ArchiveService);
container.bind(MAIN_TOKENS.SuspensionService).to(SuspensionService);
container.bind(MAIN_TOKENS.AppLifecycleService).to(AppLifecycleService);
Expand Down
5 changes: 5 additions & 0 deletions apps/code/src/main/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ export const MAIN_TOKENS = Object.freeze({
AgentService: Symbol.for("Main.AgentService"),
AuthService: Symbol.for("Main.AuthService"),
AuthProxyService: Symbol.for("Main.AuthProxyService"),
CustomInstructionsService: Symbol.for("Main.CustomInstructionsService"),
McpInstallationsService: Symbol.for("Main.McpInstallationsService"),
McpProxyService: Symbol.for("Main.McpProxyService"),
PostHogCodeInternalMcpService: Symbol.for(
"Main.PostHogCodeInternalMcpService",
),
ArchiveService: Symbol.for("Main.ArchiveService"),
SuspensionService: Symbol.for("Main.SuspensionService"),
AppLifecycleService: Symbol.for("Main.AppLifecycleService"),
Expand Down
6 changes: 6 additions & 0 deletions apps/code/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
initializePostHog,
trackAppEvent,
} from "./services/posthog-analytics";
import type { PostHogCodeInternalMcpService } from "./services/posthog-code-internal-mcp/service";
import type { PosthogPluginService } from "./services/posthog-plugin/service";
import type { SlackIntegrationService } from "./services/slack-integration/service";
import type { SuspensionService } from "./services/suspension/service";
Expand Down Expand Up @@ -159,6 +160,11 @@ async function initializeServices(): Promise<void> {

await authService.initialize();

const internalMcp = container.get<PostHogCodeInternalMcpService>(
MAIN_TOKENS.PostHogCodeInternalMcpService,
);
await internalMcp.start();

// Initialize workspace branch watcher for live branch rename detection
const workspaceService = container.get<WorkspaceService>(
MAIN_TOKENS.WorkspaceService,
Expand Down
7 changes: 7 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 @@ -58,6 +58,12 @@ function createDependencies() {
(id: string) => `http://127.0.0.1:9998/${encodeURIComponent(id)}`,
),
},
internalMcp: {
getUrl: vi.fn().mockReturnValue("http://127.0.0.1:9997/mcp"),
getAuthHeader: vi
.fn()
.mockReturnValue({ name: "authorization", value: "Bearer test" }),
},
};
}

Expand All @@ -77,6 +83,7 @@ describe("AgentAuthAdapter", () => {
deps.authService as never,
deps.authProxy as never,
deps.mcpProxy as never,
deps.internalMcp as never,
);
});

Expand Down
Loading
Loading