diff --git a/.gitignore b/.gitignore index 7c01dcc0a..bbd0443f7 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ oauth-success.html .debug-journal*.md session-ses_*.md plans/ + +# omo-claude vendored MCP dist (regenerated by `npm run sync:mcp`) +packages/omo-claude/plugin/mcp/ diff --git a/packages/omo-claude/plugin/.mcp.json b/packages/omo-claude/plugin/.mcp.json new file mode 100644 index 000000000..372a1ebab --- /dev/null +++ b/packages/omo-claude/plugin/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "ast_grep": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/mcp/ast-grep/cli.js", "mcp"] + }, + "lsp": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/mcp/lsp/cli.js", "mcp"] + } + } +} diff --git a/packages/omo-claude/plugin/package.json b/packages/omo-claude/plugin/package.json index 4c86cc582..5308cb931 100644 --- a/packages/omo-claude/plugin/package.json +++ b/packages/omo-claude/plugin/package.json @@ -15,10 +15,11 @@ "components/ultrawork" ], "scripts": { - "build": "node scripts/sync-components.mjs && node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && npm run build --workspaces --if-present", + "build": "node scripts/sync-components.mjs && node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && npm run build --workspaces --if-present && node scripts/sync-mcp.mjs", "check": "npm run build && npm test", "sync:components": "node scripts/sync-components.mjs", "sync:skills": "node scripts/sync-skills.mjs", + "sync:mcp": "node scripts/sync-mcp.mjs", "test": "node --test test/*.test.mjs" } } diff --git a/packages/omo-claude/plugin/scripts/sync-mcp.mjs b/packages/omo-claude/plugin/scripts/sync-mcp.mjs new file mode 100644 index 000000000..e2e51a4ce --- /dev/null +++ b/packages/omo-claude/plugin/scripts/sync-mcp.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +// Vendors the monorepo MCP servers (ast-grep, lsp) and the self-contained lsp +// component hook into the omo-claude (Claude Code) plugin tree so the plugin is +// runnable standalone from a cache (no `../` traversal, no node_modules dance). +// +// What it produces (per plan decision D7/D12): +// plugin/mcp/ast-grep/cli.js the bun-bundled ast-grep MCP server entry +// (single file, node-builtins only; the `sg` +// binary is resolved from the host at run time). +// plugin/mcp/lsp/** the entire lsp-tools-mcp `dist/` tree (cli.js + +// its relative siblings; node-builtins only). +// components/lsp/dist/cli.js the lsp component hook re-bundled with its +// `@code-yeongyu/lsp-tools-mcp` dependency inlined +// so the hook boots from a tree with NO +// node_modules. +// +// Modes: +// node sync-mcp.mjs build the MCP packages, then vendor + bundle. +// node sync-mcp.mjs --no-build vendor + bundle from already-built dist. +// node sync-mcp.mjs --check assert the vendored artifacts exist and that +// .mcp.json is self-contained (no `../`, +// references ${CLAUDE_PLUGIN_ROOT}); exit +// nonzero on any failure. Does not write. +// +// Path resolution mirrors sync-components.mjs / sync-skills.mjs: every root is +// derived from import.meta.url so the script is location-stable. + +import { spawnSync } from "node:child_process"; +import { cp, mkdir, readFile, rm, stat } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_ROOT = dirname(SCRIPT_DIR); +const PACKAGE_ROOT = dirname(PLUGIN_ROOT); +const PACKAGES_ROOT = dirname(PACKAGE_ROOT); + +export const AST_GREP_PACKAGE_ROOT = join(PACKAGES_ROOT, "ast-grep-mcp"); +export const LSP_TOOLS_PACKAGE_ROOT = join(PACKAGES_ROOT, "lsp-tools-mcp"); + +export const MCP_DEST_ROOT = join(PLUGIN_ROOT, "mcp"); +export const AST_GREP_DEST = join(MCP_DEST_ROOT, "ast-grep"); +export const LSP_DEST = join(MCP_DEST_ROOT, "lsp"); + +export const LSP_COMPONENT_HOOK = join(PLUGIN_ROOT, "components", "lsp", "dist", "cli.js"); +export const MCP_JSON_PATH = join(PLUGIN_ROOT, ".mcp.json"); + +// Files the boot path of each vendored server actually needs at run time. +const REQUIRED_AST_GREP_FILES = ["cli.js"]; +const REQUIRED_LSP_FILES = ["cli.js", "mcp.js", "tools.js", join("lsp", "manager.js")]; + +async function pathExists(target) { + try { + await stat(target); + return true; + } catch (error) { + if (error && error.code === "ENOENT") return false; + throw error; + } +} + +function run(command, args, cwd) { + const result = spawnSync(command, args, { cwd, stdio: "inherit", encoding: "utf8" }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed in ${cwd} (exit ${result.status})`); + } +} + +// Bundle a node entry into a single self-contained file with `bun build`. +function bundle(entry, outfile) { + run("bun", ["build", entry, "--target=node", "--format=esm", "--outfile", outfile], PLUGIN_ROOT); +} + +export async function buildMcpPackages() { + // ast-grep-mcp: bun-bundled single-file CLI (workspace deps inlined). + run("bun", ["run", "build"], AST_GREP_PACKAGE_ROOT); + // lsp-tools-mcp: tsc to dist/ (node-builtins-only runtime). + run("npm", ["run", "build"], LSP_TOOLS_PACKAGE_ROOT); +} + +export async function vendorMcp() { + await rm(MCP_DEST_ROOT, { recursive: true, force: true }); + await mkdir(MCP_DEST_ROOT, { recursive: true }); + + // ast-grep: the built cli.js is a self-contained bundle (node-builtins only; + // the `sg`/`ast-grep` binary is located on the host at run time). Vendor the + // single file — do NOT ship the ~46MB platform-specific binary. + await mkdir(AST_GREP_DEST, { recursive: true }); + await cp(join(AST_GREP_PACKAGE_ROOT, "dist", "cli.js"), join(AST_GREP_DEST, "cli.js")); + + // lsp: the whole dist/ tree (cli.js imports ./mcp.js + ./lsp/manager.js + // relatively; the closure imports only node builtins). Copy it verbatim so + // the relative imports resolve from the cache. + await mkdir(LSP_DEST, { recursive: true }); + await cp(join(LSP_TOOLS_PACKAGE_ROOT, "dist"), LSP_DEST, { recursive: true }); +} + +// Re-bundle the lsp COMPONENT hook with its @code-yeongyu/lsp-tools-mcp +// dependency inlined so `node components/lsp/dist/cli.js hook post-tool-use` +// boots from a tree with NO node_modules. The tsc-built dist/cli.js imports the +// bare specifier "@code-yeongyu/lsp-tools-mcp/dist/*.js" which is unresolvable in +// a plugin cache; bundling inlines it (result: node-builtins only). +export async function bundleLspComponentHook() { + if (!(await pathExists(LSP_COMPONENT_HOOK))) { + throw new Error( + `lsp component hook missing at ${LSP_COMPONENT_HOOK}; build the lsp component first`, + ); + } + const tmp = `${LSP_COMPONENT_HOOK}.bundle.mjs`; + bundle(LSP_COMPONENT_HOOK, tmp); + await rm(LSP_COMPONENT_HOOK, { force: true }); + await cp(tmp, LSP_COMPONENT_HOOK); + await rm(tmp, { force: true }); +} + +export async function checkVendored() { + const problems = []; + + for (const rel of REQUIRED_AST_GREP_FILES) { + if (!(await pathExists(join(AST_GREP_DEST, rel)))) { + problems.push(`missing plugin/mcp/ast-grep/${rel}`); + } + } + for (const rel of REQUIRED_LSP_FILES) { + if (!(await pathExists(join(LSP_DEST, rel)))) { + problems.push(`missing plugin/mcp/lsp/${rel}`); + } + } + + if (!(await pathExists(MCP_JSON_PATH))) { + problems.push("missing plugin/.mcp.json"); + } else { + const raw = await readFile(MCP_JSON_PATH, "utf8"); + if (raw.includes("../")) problems.push(".mcp.json contains a '../' path"); + if (!raw.includes("${CLAUDE_PLUGIN_ROOT}")) { + problems.push(".mcp.json does not reference ${CLAUDE_PLUGIN_ROOT}"); + } + if (raw.includes('"cwd"')) problems.push('.mcp.json still declares a "cwd"'); + const parsed = JSON.parse(raw); + const servers = parsed.mcpServers ?? {}; + for (const name of ["ast_grep", "lsp"]) { + if (!servers[name]) problems.push(`.mcp.json missing mcpServers.${name}`); + } + } + + // The hook must be self-contained: no bare @code-yeongyu/lsp-tools-mcp import. + if (await pathExists(LSP_COMPONENT_HOOK)) { + const hook = await readFile(LSP_COMPONENT_HOOK, "utf8"); + if (hook.includes("@code-yeongyu/lsp-tools-mcp")) { + problems.push("components/lsp/dist/cli.js still imports @code-yeongyu/lsp-tools-mcp (not bundled)"); + } + } else { + problems.push("missing components/lsp/dist/cli.js"); + } + + return problems; +} + +export async function syncMcp(options = {}) { + const build = options.build ?? true; + if (build) await buildMcpPackages(); + await vendorMcp(); + await bundleLspComponentHook(); +} + +async function main() { + const args = process.argv.slice(2); + const check = args.includes("--check"); + const noBuild = args.includes("--no-build"); + + if (check) { + const problems = await checkVendored(); + if (problems.length > 0) { + console.error("MCP vendoring out of sync:"); + for (const line of problems) console.error(` - ${line}`); + process.exitCode = 1; + return; + } + console.log("MCP vendoring in sync (ast-grep + lsp servers + lsp hook)"); + return; + } + + await syncMcp({ build: !noBuild }); + console.log("vendored MCP servers: ast-grep, lsp; bundled lsp component hook"); +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ""; +if (invokedPath === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/packages/omo-claude/plugin/scripts/sync-mcp.test.mjs b/packages/omo-claude/plugin/scripts/sync-mcp.test.mjs new file mode 100644 index 000000000..e62ce8a43 --- /dev/null +++ b/packages/omo-claude/plugin/scripts/sync-mcp.test.mjs @@ -0,0 +1,63 @@ +import { spawnSync } from "node:child_process"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + AST_GREP_DEST, + LSP_COMPONENT_HOOK, + LSP_DEST, + MCP_JSON_PATH, + checkVendored, +} from "./sync-mcp.mjs"; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const SCRIPT_PATH = join(SCRIPT_DIR, "sync-mcp.mjs"); + +function runCli(args) { + return spawnSync(process.execPath, [SCRIPT_PATH, ...args], { encoding: "utf8" }); +} + +test("--check is GREEN against the vendored tree", () => { + const check = runCli(["--check"]); + assert.equal(check.status, 0, `--check should be green but got: ${check.stderr}`); + assert.match(check.stdout, /in sync/); +}); + +test("checkVendored reports no problems", async () => { + const problems = await checkVendored(); + assert.deepEqual(problems, [], `unexpected vendoring problems: ${problems.join("; ")}`); +}); + +test(".mcp.json is self-contained (no ../, uses ${CLAUDE_PLUGIN_ROOT}, no cwd)", async () => { + const raw = await readFile(MCP_JSON_PATH, "utf8"); + assert.ok(!raw.includes("../"), ".mcp.json must not contain '../' paths"); + assert.ok(raw.includes("${CLAUDE_PLUGIN_ROOT}"), ".mcp.json must reference ${CLAUDE_PLUGIN_ROOT}"); + assert.ok(!raw.includes('"cwd"'), '.mcp.json must not declare a "cwd"'); + + const parsed = JSON.parse(raw); + assert.ok(parsed.mcpServers.ast_grep, "ast_grep server present"); + assert.ok(parsed.mcpServers.lsp, "lsp server present"); + assert.deepEqual(parsed.mcpServers.ast_grep.args, [ + "${CLAUDE_PLUGIN_ROOT}/mcp/ast-grep/cli.js", + "mcp", + ]); + assert.deepEqual(parsed.mcpServers.lsp.args, ["${CLAUDE_PLUGIN_ROOT}/mcp/lsp/cli.js", "mcp"]); +}); + +test("vendored server entrypoints exist", async () => { + for (const f of [join(AST_GREP_DEST, "cli.js"), join(LSP_DEST, "cli.js")]) { + const raw = await readFile(f, "utf8"); + assert.ok(raw.length > 0, `${f} should be non-empty`); + } +}); + +test("lsp component hook is bundled (no bare @code-yeongyu/lsp-tools-mcp import)", async () => { + const hook = await readFile(LSP_COMPONENT_HOOK, "utf8"); + assert.ok( + !hook.includes("@code-yeongyu/lsp-tools-mcp"), + "the lsp component hook must inline lsp-tools-mcp so it resolves from a cache", + ); +});