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 { NocoBaseAuthService } from "./auth-service.js"; import { loadConfig } from "./config.js"; import { createCredentialProvider, credentialsPath, loadStoredCredentials, NOCOBASE_API_BASE_URL, readHidden, saveCredentials, saveOAuthCredentials, } from "./credentials.js"; import { CrmService } from "./crm-service.js"; import { NocoBaseClient } from "./nocobase-client.js"; import { loginWithBrowser } from "./oauth.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"; config?: string; enableWrites: boolean; json: boolean; manualKey: boolean; noBrowser: boolean; } export async function runCli(argv = process.argv.slice(2)): Promise { 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 = ensureConfig(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({ authService: new NocoBaseAuthService({ onCredentialsSaved: runtime.reloadCredentials }), crmService: runtime.crm, projectService: runtime.projects, readonly: !args.enableWrites, enableConfigTools: process.env.NOCOBASE_MCP_ENABLE_CONFIG_TOOLS?.trim() === "1", }); const close = async (): Promise => { 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; reloadCredentials: () => void; } { const config = loadConfig(configPath); const credentialProvider = createCredentialProvider(); const client = new NocoBaseClient(credentialProvider, { dataSource: config.dataSource }); return { client, crm: new CrmService(config, client), projects: new ProjectService(config.project, client), reloadCredentials: credentialProvider.reset, }; } async function setup(args: ParsedArgs): Promise { let authorizeKey: string; let authentication: "oauth-browser" | "personal-authorize-key-fallback"; let configPath: string; if (args.manualKey) { if (!process.stdin.isTTY || !process.stderr.isTTY) { throw new Error("Manual Key setup requires a user-controlled local terminal; no files were changed"); } process.stderr.write( `This MCP connects only to ${NOCOBASE_API_BASE_URL}.\n` + "Enter a revocable personal Authorize Key only in the hidden prompt below.\n" + "Do not paste the Key into Agent chat.\n", ); configPath = ensureConfig(args.config); authorizeKey = (await readHidden("Personal NocoBase Authorize Key: ")).trim(); if (!authorizeKey) throw new Error("Authorize Key must not be blank; no files were changed"); authentication = "personal-authorize-key-fallback"; } else { configPath = ensureConfig(args.config); process.stderr.write( `Signing in through ${NOCOBASE_API_BASE_URL} with NocoBase OAuth.\n` + "Your password and browser session stay inside NocoBase.\n", ); const oauthCredentials = await loginWithBrowser({ clientId: reusableOAuthClientId(), openBrowser: args.noBrowser ? async () => false : undefined, onAuthorization: ({ opened, url, userCode, expiresIn }) => { process.stderr.write( `${opened ? "Opened" : "Open"} the NocoBase authorization page in your browser:\n${url}\n` + `Confirmation code: ${userCode}\n` + `Complete sign-in within ${Math.ceil(expiresIn / 60)} minutes. Waiting for approval...\n`, ); }, }); authorizeKey = oauthCredentials.accessToken; authentication = "oauth-browser"; const verificationClient = new NocoBaseClient(() => ({ apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey, })); await verificationClient.verifyCredentials(); saveOAuthCredentials(oauthCredentials); await printSetupResult(args, configPath, authentication); return; } const verificationClient = new NocoBaseClient(() => ({ apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey, })); await verificationClient.verifyCredentials(); saveCredentials({ authorizeKey }); await printSetupResult(args, configPath, authentication); } async function printSetupResult( args: ParsedArgs, configPath: string, authentication: "oauth-browser" | "personal-authorize-key-fallback", ): Promise { const runtime = createRuntime(configPath); const health = await runtime.crm.crmHealth(); printResult( { ok: true, apiBaseUrl: NOCOBASE_API_BASE_URL, authentication, credentialsPath: credentialsPath(), credentialExposure: authentication === "oauth-browser" ? "nocobase-browser-and-0600-local-file-only" : "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 reusableOAuthClientId(): string | undefined { const path = credentialsPath(); if (!existsSync(path)) return undefined; const stored = loadStoredCredentials(path); return stored.type === "oauth" ? stored.clientId : undefined; } function ensureConfig(explicit?: string): string { const configured = explicit ?? process.env.NOCOBASE_MCP_CONFIG?.trim(); if (configured) { const path = resolve(configured); 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; } export function clientConfig(configPath: string, enableWrites = false): Record { 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, manualKey: false, noBrowser: 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 === "--manual-key") parsed.manualKey = true; else if (flag === "--no-browser") parsed.noBrowser = true; else if (flag === "--config") { const value = argv[index + 1]; if (!value || value.startsWith("-")) throw new Error(`${flag} requires a value`); parsed.config = value; index += 1; } else throw new Error(`Unknown option: ${flag}`); index += 1; } if (parsed.command !== "setup" && (parsed.manualKey || parsed.noBrowser)) { throw new Error("--manual-key and --no-browser are available only with setup"); } if (parsed.manualKey && parsed.noBrowser) throw new Error("--manual-key cannot be combined with --no-browser"); 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 [--config ] [--no-browser] [--manual-key] [--json] nocobase-crm-mcp [serve] [--config ] [--enable-writes] nocobase-crm-mcp doctor [--config ] [--json] nocobase-crm-mcp client-config [--config ] [--enable-writes] [--json] The NocoBase API endpoint is fixed to ${NOCOBASE_API_BASE_URL}. The setup command signs in through NocoBase OAuth in the user's browser. Use --no-browser to print the authorization URL without opening it. Use --manual-key only as a hidden local TTY fallback. The stdio server can start before login; call nocobase_login to obtain a user-facing browser URL. The default stdio server is read-only. `; }