feat(omo-claude): re-vendor MCP servers self-contained with CLAUDE_PLUGIN_ROOT

ast_grep + lsp servers vendored under plugin/mcp via sync-mcp.mjs; .mcp.json uses
${CLAUDE_PLUGIN_ROOT} (no ../). Both boot from a node_modules-free tree (verified
JSON-RPC initialize handshakes). lsp hook bundled to inline lsp-tools-mcp. mcp/
dist is gitignored (regenerated by build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
YeonGyu-Kim
2026-05-29 13:23:40 +09:00
parent 186fade2e5
commit e20c51c77f
5 changed files with 273 additions and 1 deletions
+3
View File
@@ -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/
+12
View File
@@ -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"]
}
}
}
+2 -1
View File
@@ -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"
}
}
@@ -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;
});
}
@@ -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",
);
});