Files
oh-my-opencode/packages/omo-claude/plugin/scripts/sync-mcp.mjs
T
YeonGyu-Kim e20c51c77f 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>
2026-05-29 13:40:11 +09:00

194 lines
7.6 KiB
JavaScript

#!/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;
});
}