feat: migrate MCP runtime to TypeScript

This commit is contained in:
nac
2026-08-15 05:02:28 +00:00
parent 7eb416ded1
commit 27672a0940
83 changed files with 6559 additions and 12057 deletions
+196
View File
@@ -0,0 +1,196 @@
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { loadConfig } from "./config.js";
import {
credentialsPath,
loadCredentials,
normalizeApiBaseUrl,
readHidden,
saveCredentials,
} from "./credentials.js";
import { CrmService } from "./crm-service.js";
import { NocoBaseClient } from "./nocobase-client.js";
import { ProjectService } from "./project-service.js";
import { createServer } from "./tools.js";
import { VERSION } from "./version.js";
interface ParsedArgs {
command: "serve" | "setup" | "doctor" | "client-config" | "help" | "version";
apiBaseUrl?: string;
config?: string;
enableWrites: boolean;
json: boolean;
}
export async function runCli(argv = process.argv.slice(2)): Promise<void> {
const args = parseArgs(argv);
if (args.command === "help") {
process.stdout.write(helpText());
return;
}
if (args.command === "version") {
process.stdout.write(`${VERSION}\n`);
return;
}
if (args.command === "setup") {
await setup(args);
return;
}
const configPath = resolveConfigPath(args.config);
if (args.command === "client-config") {
printResult(clientConfig(configPath, args.enableWrites), args.json);
return;
}
const runtime = createRuntime(configPath);
if (args.command === "doctor") {
await runtime.client.verifyCredentials();
const health = await runtime.crm.crmHealth();
printResult({ ok: true, credentials: "verified", health }, args.json);
return;
}
const server = createServer({
crmService: runtime.crm,
projectService: runtime.projects,
readonly: !args.enableWrites,
enableConfigTools: process.env.NOCOBASE_MCP_ENABLE_CONFIG_TOOLS?.trim() === "1",
});
const close = async (): Promise<void> => {
await server.close();
process.exit(0);
};
process.once("SIGINT", close);
process.once("SIGTERM", close);
await server.connect(new StdioServerTransport());
}
export function createRuntime(configPath: string): {
client: NocoBaseClient;
crm: CrmService;
projects: ProjectService;
} {
const config = loadConfig(configPath);
const client = new NocoBaseClient(() => loadCredentials(), { dataSource: config.dataSource });
return {
client,
crm: new CrmService(config, client),
projects: new ProjectService(config.project, client),
};
}
async function setup(args: ParsedArgs): Promise<void> {
if (!args.apiBaseUrl) throw new Error("setup requires --api-base-url <https://nocobase.example>");
if (!process.stdin.isTTY || !process.stderr.isTTY) {
throw new Error("Setup requires a user-controlled local terminal; no files were changed");
}
const apiBaseUrl = normalizeApiBaseUrl(args.apiBaseUrl);
process.stderr.write(
"Create a revocable personal Authorize Key in NocoBase for your own business account.\n" +
"Do not paste the Key into Agent chat. Enter it only in the hidden prompt below.\n",
);
const authorizeKey = (await readHidden("Personal NocoBase Authorize Key: ")).trim();
if (!authorizeKey) throw new Error("Authorize Key must not be blank; no files were changed");
const verificationClient = new NocoBaseClient(() => ({ apiBaseUrl, authorizeKey }));
await verificationClient.verifyCredentials();
const configPath = ensureConfig(args.config);
saveCredentials({ apiBaseUrl, authorizeKey });
const runtime = createRuntime(configPath);
const health = await runtime.crm.crmHealth();
printResult(
{
ok: true,
apiBaseUrl,
credentialsPath: credentialsPath(),
keyExposure: "private-tty-and-0600-local-file-only",
configPath,
mode: args.enableWrites ? "fixed-project-writes" : "read-only",
clientConfig: clientConfig(configPath, args.enableWrites),
health,
},
args.json,
);
}
function ensureConfig(explicit?: string): string {
if (explicit) {
const path = resolve(explicit);
if (!existsSync(path)) throw new Error(`Config file does not exist: ${path}`);
return path;
}
const destination = join(dirname(credentialsPath()), "config.json");
if (existsSync(destination)) return destination;
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const template = join(packageRoot, "config.example.json");
if (!existsSync(template)) throw new Error(`Bundled config template is missing: ${template}`);
copyFileSync(template, destination, 0);
return destination;
}
function resolveConfigPath(explicit?: string): string {
const path = explicit ?? process.env.NOCOBASE_MCP_CONFIG ?? join(dirname(credentialsPath()), "config.json");
const resolved = resolve(path);
if (!existsSync(resolved)) throw new Error(`CRM config is missing: ${resolved}. Run setup first.`);
return resolved;
}
export function clientConfig(configPath: string, enableWrites = false): Record<string, unknown> {
const entrypoint = resolve(process.argv[1] ?? fileURLToPath(import.meta.url));
return {
mcpServers: {
"nocobase-crm": {
command: process.execPath,
args: [entrypoint, "--config", configPath, ...(enableWrites ? ["--enable-writes"] : [])],
},
},
};
}
function parseArgs(argv: string[]): ParsedArgs {
const first = argv[0];
let command: ParsedArgs["command"] = "serve";
let index = 0;
if (first && !first.startsWith("-")) {
if (!["serve", "setup", "doctor", "client-config", "help", "version"].includes(first)) {
throw new Error(`Unknown command: ${first}`);
}
command = first as ParsedArgs["command"];
index = 1;
}
const parsed: ParsedArgs = { command, enableWrites: false, json: false };
while (index < argv.length) {
const flag = argv[index];
if (flag === "--help" || flag === "-h") parsed.command = "help";
else if (flag === "--version" || flag === "-v") parsed.command = "version";
else if (flag === "--enable-writes") parsed.enableWrites = true;
else if (flag === "--json") parsed.json = true;
else if (flag === "--api-base-url" || flag === "--config") {
const value = argv[index + 1];
if (!value || value.startsWith("-")) throw new Error(`${flag} requires a value`);
if (flag === "--api-base-url") parsed.apiBaseUrl = value;
else parsed.config = value;
index += 1;
} else throw new Error(`Unknown option: ${flag}`);
index += 1;
}
return parsed;
}
function printResult(value: unknown, json: boolean): void {
process.stdout.write(`${JSON.stringify(value, null, json ? 0 : 2)}\n`);
}
function helpText(): string {
return `NocoBase CRM MCP ${VERSION}
Usage:
nocobase-crm-mcp setup --api-base-url <url> [--config <path>] [--json]
nocobase-crm-mcp [serve] [--config <path>] [--enable-writes]
nocobase-crm-mcp doctor [--config <path>] [--json]
nocobase-crm-mcp client-config [--config <path>] [--enable-writes] [--json]
The setup command accepts the personal Authorize Key only through a hidden local TTY prompt.
The default stdio server is read-only.
`;
}