270 lines
9.7 KiB
TypeScript
270 lines
9.7 KiB
TypeScript
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<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 = 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<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;
|
|
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<void> {
|
|
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<void> {
|
|
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<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,
|
|
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 <path>] [--no-browser] [--manual-key] [--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 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.
|
|
`;
|
|
}
|