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:
@@ -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/
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user