Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 --import ./scripts/scaffold-mcp-tools-preload.mjs 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
78 changes: 78 additions & 0 deletions apps/code/scripts/electron-stub.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Stub module returned in place of `electron` when the scaffold script runs
// outside of Electron. The router walk only needs the procedures' input
// schemas — none of the methods on `app`, `BrowserWindow`, etc. are actually
// called at import time, so a no-op object suffices.
//
// Used by scaffold-mcp-tools-preload.mjs via a Node loader hook.
const noop = () => {};
const emptyObj = new Proxy(
{},
{
get() {
return noop;
},
},
);

const stub = new Proxy(
{
app: emptyObj,
BrowserWindow: () => emptyObj,
ipcMain: emptyObj,
ipcRenderer: emptyObj,
Menu: emptyObj,
MenuItem: emptyObj,
dialog: emptyObj,
shell: emptyObj,
nativeImage: emptyObj,
clipboard: emptyObj,
safeStorage: emptyObj,
powerMonitor: emptyObj,
powerSaveBlocker: emptyObj,
autoUpdater: emptyObj,
crashReporter: emptyObj,
Notification: () => emptyObj,
Tray: () => emptyObj,
nativeTheme: emptyObj,
session: emptyObj,
screen: emptyObj,
protocol: emptyObj,
webContents: emptyObj,
systemPreferences: emptyObj,
contextBridge: emptyObj,
},
{
get(target, prop) {
if (prop in target) {
return target[prop];
}
return noop;
},
},
);

export default stub;
export const app = stub.app;
export const BrowserWindow = stub.BrowserWindow;
export const ipcMain = stub.ipcMain;
export const ipcRenderer = stub.ipcRenderer;
export const Menu = stub.Menu;
export const MenuItem = stub.MenuItem;
export const dialog = stub.dialog;
export const shell = stub.shell;
export const nativeImage = stub.nativeImage;
export const clipboard = stub.clipboard;
export const safeStorage = stub.safeStorage;
export const powerMonitor = stub.powerMonitor;
export const powerSaveBlocker = stub.powerSaveBlocker;
export const autoUpdater = stub.autoUpdater;
export const crashReporter = stub.crashReporter;
export const Notification = stub.Notification;
export const Tray = stub.Tray;
export const nativeTheme = stub.nativeTheme;
export const session = stub.session;
export const screen = stub.screen;
export const protocol = stub.protocol;
export const webContents = stub.webContents;
export const systemPreferences = stub.systemPreferences;
export const contextBridge = stub.contextBridge;
55 changes: 55 additions & 0 deletions apps/code/scripts/scaffold-mcp-tools-loader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { pathToFileURL } from "node:url";

const STUB_URL = pathToFileURL(`${import.meta.dirname}/electron-stub.mjs`).href;
const SRC_DIR = path.resolve(import.meta.dirname, "..", "src");

const PATH_ALIASES = {
"@main/": `${path.join(SRC_DIR, "main")}/`,
"@renderer/": `${path.join(SRC_DIR, "renderer")}/`,
"@shared/": `${path.join(SRC_DIR, "shared")}/`,
"@features/": `${path.join(SRC_DIR, "renderer", "features")}/`,
"@components/": `${path.join(SRC_DIR, "renderer", "components")}/`,
"@stores/": `${path.join(SRC_DIR, "renderer", "stores")}/`,
"@hooks/": `${path.join(SRC_DIR, "renderer", "hooks")}/`,
"@utils/": `${path.join(SRC_DIR, "renderer", "utils")}/`,
"@test/": `${path.join(SRC_DIR, "shared", "test")}/`,
};

// Some import targets exist as BOTH `foo.ts` AND `foo/` (sibling file +
// directory). Node ESM's default resolution picks the directory and looks for
// `index.json` — wrong. `bundler` moduleResolution (which tsconfig sets) and
// Vite prefer the `.ts` sibling. Replicate that by checking for the `.ts`
// file first and short-circuiting if it exists.
function preferFileSibling(absPath) {
for (const ext of [".ts", ".tsx", ".mjs", ".js"]) {
const candidate = `${absPath}${ext}`;
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}

export function resolve(specifier, context, nextResolve) {
if (specifier === "electron") {
return { url: STUB_URL, format: "module", shortCircuit: true };
}
for (const [prefix, target] of Object.entries(PATH_ALIASES)) {
if (specifier.startsWith(prefix)) {
const rel = specifier.slice(prefix.length);
const abs = path.join(target, rel);
const fileSibling = preferFileSibling(abs);
if (fileSibling) {
return {
url: pathToFileURL(fileSibling).href,
format: fileSibling.endsWith(".json") ? "json" : "module",
shortCircuit: true,
};
}
return nextResolve(abs, context);
}
}
return nextResolve(specifier, context);
}
10 changes: 10 additions & 0 deletions apps/code/scripts/scaffold-mcp-tools-preload.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { register } from "node:module";
import { pathToFileURL } from "node:url";

// Redirects `electron` imports to a local stub so the scaffold script can
// import the tRPC router (which transitively touches Electron-bound modules)
// without an Electron runtime present.
register(
"./scaffold-mcp-tools-loader.mjs",
pathToFileURL(`${import.meta.dirname}/`),
);
183 changes: 183 additions & 0 deletions apps/code/scripts/scaffold-mcp-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
#!/usr/bin/env tsx
/**
* Sync `apps/code/src/main/services/posthog-code-internal-mcp/mcp-tools.yaml`
* with the live tRPC router.
*
* - Walks the router via `_def.procedures` and emits an `enabled: false` stub
* for every procedure that isn't already in the YAML.
* - Leaves existing entries untouched — your hand-authored config (title,
* description, annotations, param_overrides) is preserved.
* - Does NOT remove entries whose procedure has disappeared. It prints them
* as warnings; you decide whether to delete. Boot will hard-fail until you
* do, which is the forcing function.
*
* 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 os from "node:os";
import * as path from "node:path";

// Imports below transitively load main-process services that read env vars
// at module-load time (see apps/code/src/main/utils/env.ts). Set defaults
// here so the script works outside an Electron context. Done before any
// dynamic import below.
if (!process.env.POSTHOG_CODE_DATA_DIR) {
process.env.POSTHOG_CODE_DATA_DIR = path.join(
os.tmpdir(),
"posthog-code-scaffold-mcp-tools",
);
}
if (!process.env.POSTHOG_CODE_IS_DEV) process.env.POSTHOG_CODE_IS_DEV = "true";
if (!process.env.POSTHOG_CODE_VERSION) {
process.env.POSTHOG_CODE_VERSION = "0.0.0-scaffold";
}

const YAML_PATH = path.resolve(
__dirname,
"..",
"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.
`;

interface Procedure {
path: string;
type: "query" | "mutation" | "subscription";
}

async function main(): Promise<void> {
const check = process.argv.includes("--check");

// Dynamic import so the env defaults above are in place before the router's
// transitive deps load.
const { trpcRouter } = await import("../src/main/trpc/router");
const { McpToolsYamlSchema } = await import(
"../src/main/services/posthog-code-internal-mcp/yaml-schema"
);
const { parse: parseYaml, stringify: stringifyYaml } = await import("yaml");

const record = trpcRouter._def.procedures as Record<
string,
{ _def: { type: "query" | "mutation" | "subscription" } }
>;
const procedures: Procedure[] = Object.entries(record)
.map(([p, proc]) => ({ path: p, type: proc._def.type }))
.filter((p) => p.type !== "subscription");

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;
}
}

void main().catch((err) => {
console.error(err);
process.exit(1);
});
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
Loading
Loading