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.
`;
}
+299
View File
@@ -0,0 +1,299 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { isRecord } from "./types.js";
export const PROJECT_PROGRESS_VALUES: Record<string, string> = {
询价阶段: "fk9xj8akn46",
已报价: "k4w2vcz819w",
送样: "g95vqeetu83",
已定点: "fe0peg505d7",
SOP: "a9t9awhtt3r",
终止: "a9t9awhtsfasg3r",
};
export interface CustomerConfig {
resource: string;
idField: string;
fields: string[] | null;
searchFields: string[] | null;
nameFields: string[] | null;
englishNameField: string;
territorialNameField: string;
statusField: string;
sourceField: string;
ownerField: string;
managerField: string;
detailFields: Record<string, string>;
sort: string[] | null;
}
export interface ContactConfig {
resource: string;
idField: string;
fields: string[] | null;
searchFields: string[] | null;
nameFields: string[] | null;
titleField: string;
phoneField: string;
emailField: string;
customerField: string;
sort: string[] | null;
}
export interface FollowupConfig {
resource: string;
idField: string;
fields: string[] | null;
searchFields: string[] | null;
customerField: string;
contactField: string;
ownerField: string;
ownerFields: string[] | null;
dateField: string;
contentField: string;
typeField: string;
statusField: string;
projectField: string;
locationField: string;
sort: string[] | null;
}
export interface UserConfig {
resource: string;
idField: string;
fields: string[] | null;
matchFields: string[] | null;
labelFields: string[] | null;
}
export interface TeamConfig {
resource: string;
idField: string;
nameField: string;
managerField: string;
memberField: string;
userStatusField: string;
formalUserStatusValue: string;
}
export interface ProjectConfig {
resource: string;
idField: string;
nameField: string;
nameFields: string[] | null;
customerField: string;
customerRelationField: string;
inquiryDateField: string;
progressField: string;
applicationsField: string;
partNumbersField: string;
periodUsageField: string;
detailsField: string;
attachmentField: string;
contactField: string;
followupField: string;
updateField: string;
updateResource: string;
updateProjectField: string;
updateDateField: string;
updateContentField: string;
updateAttachmentField: string;
progressValues: Record<string, string>;
}
export interface CrmConfig {
dataSource: string;
timezone: string;
customer: CustomerConfig;
contact: ContactConfig;
followup: FollowupConfig;
user: UserConfig;
team: TeamConfig;
project: ProjectConfig;
}
export const DEFAULT_CONFIG: CrmConfig = {
dataSource: "main",
timezone: "Asia/Shanghai",
customer: {
resource: "customers",
idField: "id",
fields: ["id", "name", "code", "status", "source", "owner", "createdAt", "updatedAt"],
searchFields: ["name", "code"],
nameFields: ["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name", "name", "title", "code"],
englishNameField: "f_kh_english_name",
territorialNameField: "f_territorial_name",
statusField: "status",
sourceField: "source",
ownerField: "owner",
managerField: "kehu_manage_person",
detailFields: {
address: "f_y8iyf2jhana",
intro: "f_spgoie0arjq",
tags: "kh_tags",
followup_interval_days: "f_kyf51uh0ky7",
},
sort: ["-updatedAt"],
},
contact: {
resource: "contacts",
idField: "id",
fields: ["id", "name", "phone", "email", "title", "customer", "createdAt", "updatedAt"],
searchFields: ["name", "phone", "email"],
nameFields: ["f_2zpg91gtmwp", "name", "title"],
titleField: "f_tn8zdm2f938",
phoneField: "khlxr_phone_number",
emailField: "khlxr_email",
customerField: "customer",
sort: ["-updatedAt"],
},
followup: {
resource: "followups",
idField: "id",
fields: [
"id",
"customer",
"contact",
"owner",
"content",
"followedAt",
"nextFollowAt",
"createdAt",
"updatedAt",
],
searchFields: ["content"],
customerField: "customer",
contactField: "contact",
ownerField: "owner",
ownerFields: ["owner"],
dateField: "followedAt",
contentField: "f_19iplxz0nds",
typeField: "gjjl_followup_type",
statusField: "f_hskl7gjvos9",
projectField: "f_gjjl_projects",
locationField: "f_b5c4hfi5og6",
sort: ["-followedAt", "-createdAt"],
},
user: {
resource: "users",
idField: "id",
fields: ["id", "username", "nickname", "email", "f_yh_english_name"],
matchFields: ["username", "nickname", "email", "f_yh_english_name"],
labelFields: ["nickname", "f_yh_english_name", "username", "email"],
},
team: {
resource: "t_team_management",
idField: "id",
nameField: "f_team_name",
managerField: "tdgl_manager",
memberField: "tdgl_team_members",
userStatusField: "yh_status",
formalUserStatusValue: "nkluuumb1r1",
},
project: {
resource: "t_projects",
idField: "id",
nameField: "f_project_name",
nameFields: ["f_project_name", "name", "title"],
customerField: "client_ID",
customerRelationField: "f_projects_client",
inquiryDateField: "xm_project_inquiry_date",
progressField: "f_projects_progress",
applicationsField: "f_projects_applications",
partNumbersField: "f_projects_part_numbers",
periodUsageField: "f_projects_period_usage",
detailsField: "f_projects_details",
attachmentField: "f_projects_attachments",
contactField: "f_projects_contacts",
followupField: "f_projects_followup_records",
updateField: "f_u28gm9h2uzl",
updateResource: "t_qxeepjvi39s",
updateProjectField: "f_k37rbpi4vye",
updateDateField: "f_6g9fw5oqef9",
updateContentField: "f_ei16g0ihwwd",
updateAttachmentField: "f_6pk5pv17i27",
progressValues: { ...PROJECT_PROGRESS_VALUES },
},
};
function cloneDefaults(): CrmConfig {
return structuredClone(DEFAULT_CONFIG);
}
function assignKnown(target: Record<string, unknown>, source: Record<string, unknown>): void {
for (const [key, value] of Object.entries(source)) {
if (!(key in target) || value === undefined || value === null || value === "") continue;
const current = target[key];
if (Array.isArray(current)) {
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
throw new Error(`CRM config ${key} must be an array of strings`);
}
target[key] = [...value];
continue;
}
if (typeof current === "string") {
if (typeof value !== "string") throw new Error(`CRM config ${key} must be a string`);
target[key] = value;
continue;
}
if (isRecord(current)) {
if (!isRecord(value)) throw new Error(`CRM config ${key} must be an object`);
const stringMap = Object.values(current).every((item) => typeof item === "string");
if (stringMap) {
if (!Object.values(value).every((item) => typeof item === "string")) {
throw new Error(`CRM config ${key} must map strings to strings`);
}
target[key] = { ...value };
} else assignKnown(current, value);
continue;
}
throw new Error(`CRM config ${key} has an unsupported value`);
}
}
function normalizeSection(raw: unknown): Record<string, unknown> {
if (!isRecord(raw)) return {};
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(raw)) {
normalized[key.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase())] = value;
}
return normalized;
}
export function loadConfig(path = process.env.NOCOBASE_MCP_CONFIG): CrmConfig {
const config = cloneDefaults();
if (path) {
const parsed: unknown = JSON.parse(readFileSync(resolve(path), "utf8"));
if (!isRecord(parsed)) throw new Error("CRM config must be a JSON object");
const collections = isRecord(parsed.collections) ? parsed.collections : {};
assignKnown(
config.customer as unknown as Record<string, unknown>,
normalizeSection(collections.customers),
);
assignKnown(config.contact as unknown as Record<string, unknown>, normalizeSection(collections.contacts));
assignKnown(
config.followup as unknown as Record<string, unknown>,
normalizeSection(collections.followups),
);
assignKnown(
config.user as unknown as Record<string, unknown>,
normalizeSection(parsed.users ?? parsed.user),
);
assignKnown(
config.team as unknown as Record<string, unknown>,
normalizeSection(parsed.teams ?? parsed.team),
);
assignKnown(config.project as unknown as Record<string, unknown>, normalizeSection(parsed.projects));
const dataSource = parsed.dataSource ?? parsed.data_source;
if (typeof dataSource === "string" && dataSource.trim()) config.dataSource = dataSource.trim();
if (typeof parsed.timezone === "string" && parsed.timezone.trim())
config.timezone = parsed.timezone.trim();
}
if (process.env.NOCOBASE_MCP_DATA_SOURCE?.trim())
config.dataSource = process.env.NOCOBASE_MCP_DATA_SOURCE.trim();
if (process.env.NOCOBASE_MCP_TIMEZONE?.trim()) config.timezone = process.env.NOCOBASE_MCP_TIMEZONE.trim();
if (!config.followup.ownerFields)
config.followup.ownerFields = config.followup.ownerField ? [config.followup.ownerField] : [];
if (!config.project.nameFields) config.project.nameFields = [config.project.nameField, "name", "title"];
return config;
}
+114
View File
@@ -0,0 +1,114 @@
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { isRecord } from "./types.js";
export interface Credentials {
apiBaseUrl: string;
authorizeKey: string;
}
export function normalizeApiBaseUrl(value: string): string {
const raw = value.trim();
if (!raw) throw new Error("NocoBase URL is required");
const url = new URL(raw);
if (!(["http:", "https:"] as string[]).includes(url.protocol))
throw new Error("NocoBase URL must use HTTP or HTTPS");
if (url.username || url.password || url.search || url.hash) {
throw new Error("NocoBase URL must not contain credentials, a query string, or a fragment");
}
const local = url.hostname === "localhost" || url.hostname === "::1" || url.hostname.startsWith("127.");
if (url.protocol !== "https:" && !local)
throw new Error("A remote Authorize Key may only be sent over HTTPS");
url.pathname = `${url.pathname.replace(/\/+$/, "")}${url.pathname.replace(/\/+$/, "").endsWith("/api") ? "" : "/api"}`;
return url.toString().replace(/\/$/, "");
}
export function credentialsPath(): string {
const override = process.env.NOCOBASE_MCP_CREDENTIALS?.trim();
if (override) return resolve(override);
if (process.platform === "win32") {
const base = process.env.APPDATA?.trim() || join(homedir(), "AppData", "Roaming");
return join(base, "nocobase-crm-mcp", "credentials.json");
}
if (process.platform === "darwin") {
return join(homedir(), "Library", "Application Support", "nocobase-crm-mcp", "credentials.json");
}
const base = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
return join(base, "nocobase-crm-mcp", "credentials.json");
}
export function saveCredentials(credentials: Credentials, path = credentialsPath()): void {
const normalized: Credentials = {
apiBaseUrl: normalizeApiBaseUrl(credentials.apiBaseUrl),
authorizeKey: credentials.authorizeKey.trim(),
};
if (!normalized.authorizeKey) throw new Error("Authorize Key must not be blank");
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
if (process.platform !== "win32") {
try {
const directoryMode = statSync(dirname(path)).mode & 0o777;
if ((directoryMode & 0o077) !== 0)
throw new Error(`Credential directory permissions are too broad: ${directoryMode.toString(8)}`);
} catch (error) {
if (existsSync(dirname(path))) throw error;
}
}
const descriptor = openSync(path, "w", 0o600);
try {
writeFileSync(descriptor, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
} finally {
closeSync(descriptor);
}
}
export function loadCredentials(path = credentialsPath()): Credentials {
if (process.platform !== "win32") {
const mode = statSync(path).mode & 0o777;
if ((mode & 0o077) !== 0)
throw new Error(`Credential file must be private (0600); current mode is ${mode.toString(8)}`);
}
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isRecord(parsed) || typeof parsed.apiBaseUrl !== "string" || typeof parsed.authorizeKey !== "string") {
throw new Error("Credential file is invalid; run setup again");
}
const authorizeKey = parsed.authorizeKey.trim();
if (!authorizeKey) throw new Error("Credential file has no Authorize Key; run setup again");
return { apiBaseUrl: normalizeApiBaseUrl(parsed.apiBaseUrl), authorizeKey };
}
export async function readHidden(prompt: string): Promise<string> {
if (!process.stdin.isTTY || !process.stderr.isTTY || typeof process.stdin.setRawMode !== "function") {
throw new Error("Secure Authorize Key entry requires a user-controlled local terminal");
}
process.stderr.write(prompt);
process.stdin.setEncoding("utf8");
process.stdin.setRawMode(true);
process.stdin.resume();
return await new Promise<string>((resolveValue, reject) => {
let value = "";
const cleanup = (): void => {
process.stdin.off("data", onData);
process.stdin.setRawMode(false);
process.stdin.pause();
process.stderr.write("\n");
};
const onData = (chunk: string): void => {
for (const character of chunk) {
if (character === "\u0003") {
cleanup();
reject(new Error("Authorize Key entry cancelled"));
return;
}
if (character === "\r" || character === "\n") {
cleanup();
resolveValue(value);
return;
}
if (character === "\u007f" || character === "\b") value = value.slice(0, -1);
else if (character >= " ") value += character;
}
};
process.stdin.on("data", onData);
});
}
+324
View File
@@ -0,0 +1,324 @@
import type { ApiResponse, RecordRow } from "./types.js";
import { isRecord } from "./types.js";
export const MAX_PAGE_SIZE = 100;
export const MAX_SCAN_RECORDS = 5_000;
export const PROJECTION_FALLBACK_WARNING =
"NocoBase rejected relation field projection; check nameFields in the CRM config";
export const CUSTOMER_STATUS_LABELS: Record<string, string> = {
kh_p_not_start: "未开始",
kh_p_inprogress: "进行中",
kh_p_ordered: "已下单",
kh_p_audited: "已审核",
};
export const FOLLOWUP_CONTENT_KEYS = ["f_19iplxz0nds", "content", "description", "remark"];
export const FOLLOWUP_DATE_KEYS = ["f_srysmjkwfq1", "followedAt", "createdAt", "updatedAt"];
export const FOLLOWUP_TYPE_LABELS: Record<string, string> = {
tdwfk5h1ohv: "邮件",
s48pnyhutwc: "电话",
mwyy8xwk66m: "视频会议",
m34aocz3ebo: "拜访/请客",
"0lmj78zpdnm": "审厂/来访",
o7rjlg4guzh: "论坛/大会",
"2csix8r3h0f": "代理跟进情况代填",
};
export const FOLLOWUP_STATUS_LABELS: Record<string, string> = {
"2rowk1bc8vc": "审批中",
"3ge6geal2u1": "OK",
"68vqz8raus6": "迟交",
b5pf5njqgp6: "无效",
};
export const INVALID_FOLLOWUP_STATUS = "b5pf5njqgp6";
export interface LabelSpec {
idField: string;
nameFields: string[];
}
export function normalizePagination(limit: unknown, offset: unknown): [number, number] {
return [Math.min(Math.max(1, integer(limit, 20)), MAX_PAGE_SIZE), Math.max(0, integer(offset, 0))];
}
export function integer(value: unknown, fallback: number): number {
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
}
export function dataRows(response: ApiResponse): RecordRow[] {
if (Array.isArray(response.data)) return response.data.filter(isRecord);
if (isRecord(response.data)) {
for (const key of ["data", "rows", "results"]) {
const nested = response.data[key];
if (Array.isArray(nested)) return nested.filter(isRecord);
}
}
return [];
}
export function singleRow(response: ApiResponse): RecordRow | undefined {
if (Array.isArray(response.data)) return response.data.find(isRecord);
return isRecord(response.data) ? response.data : undefined;
}
export function responseTotal(response: ApiResponse, fallback: number): number {
for (const container of [response.meta, response.data]) {
if (!isRecord(container)) continue;
for (const key of ["count", "total", "totalCount"]) {
const value = container[key];
if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value);
}
}
return fallback;
}
export function paginatedRows(
rows: unknown[],
total: number,
limit: number,
offset: number,
): Record<string, unknown> {
const nextOffset = offset + rows.length;
return {
results: rows,
total,
returned: rows.length,
limit,
offset,
has_more: rows.length > 0 && nextOffset < total,
next_offset: nextOffset < total ? nextOffset : null,
};
}
export function paginationMetadata(result: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
["total", "returned", "limit", "offset", "has_more", "next_offset"].map((key) => [key, result[key]]),
);
}
export function uniqueStrings(values: Array<string | null | undefined>): string[] {
return [...new Set(values.map((value) => String(value ?? "").trim()).filter(Boolean))];
}
export function compactFieldList(configured: string[] | null, candidates: string[]): string[] {
const available = new Set(configured ?? []);
return uniqueStrings(candidates).filter((candidate) => available.size === 0 || available.has(candidate));
}
export function fieldsWithProjections(scalars: string[], projections: string[]): string[] {
const roots = new Set(projections.map((path) => path.split(".", 1)[0]));
return [...uniqueStrings(scalars).filter((field) => !roots.has(field)), ...uniqueStrings(projections)];
}
export function firstValue(row: RecordRow, fields: string[]): unknown {
for (const field of uniqueStrings(fields)) {
const value = row[field];
if (value !== undefined && value !== null && value !== "") return value;
}
return undefined;
}
export function firstText(row: RecordRow, fields: string[]): string | undefined {
const value = firstValue(row, fields);
return typeof value === "object" ? undefined : value === undefined ? undefined : String(value);
}
export function firstNumeric(rows: RecordRow[], preferredKey: string): number {
for (const row of rows) {
const preferred = row[preferredKey];
if (typeof preferred === "number") return Math.trunc(preferred);
const nested = Object.values(row).find((value) => typeof value === "number");
if (typeof nested === "number") return Math.trunc(nested);
}
return 0;
}
export function prune(mapping: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(mapping).filter(([key, value]) => key === "id" || !isEmpty(value)),
);
}
export function relationRows(value: unknown): RecordRow[] {
if (Array.isArray(value)) return value.filter(isRecord);
if (isRecord(value)) {
if (Array.isArray(value.data)) return value.data.filter(isRecord);
return [value];
}
return [];
}
export function refFromRow(row: RecordRow, spec: LabelSpec): Record<string, unknown> {
const id = row[spec.idField];
const found = firstText(row, spec.nameFields);
return prune({ id, name: found ?? (id !== undefined && id !== null ? `#${String(id)}` : undefined) });
}
export function relationRefs(
row: RecordRow,
fields: string[],
spec: LabelSpec,
): Array<Record<string, unknown>> {
const result: Array<Record<string, unknown>> = [];
const seen = new Set<string>();
for (const field of uniqueStrings(fields)) {
for (const related of relationRows(row[field])) {
const ref = refFromRow(related, spec);
const key = JSON.stringify(ref.id ?? ref.name);
if (Object.keys(ref).length === 0 || seen.has(key)) continue;
seen.add(key);
result.push(ref);
}
}
return result;
}
export function relationIds(value: unknown, idField = "id"): unknown[] {
return relationRows(value)
.map((row) => row[idField])
.filter((value) => value !== undefined && value !== null);
}
export function refIds(refs: Array<Record<string, unknown>>): unknown[] {
return refs.map((ref) => ref.id).filter((value) => value !== undefined && value !== null);
}
export function refNames(refs: Array<Record<string, unknown>>): string[] {
return refs
.map((ref) => ref.name)
.filter((value): value is string => typeof value === "string" && value.length > 0);
}
export function formatUserCandidate(row: RecordRow, idField: string): Record<string, unknown> {
const label = firstText(row, ["nickname", "username", "email", "f_yh_english_name", "id"]) ?? "<unnamed>";
return prune({
id: row[idField],
label,
username: row.username,
nickname: row.nickname,
email: row.email,
f_yh_english_name: row.f_yh_english_name,
});
}
export function pickUserMatches(rows: RecordRow[], query: string, matchFields: string[]): RecordRow[] {
const normalized = query.toLocaleLowerCase();
const exact = rows.filter((row) =>
matchFields.some(
(field) =>
String(row[field] ?? "")
.trim()
.toLocaleLowerCase() === normalized,
),
);
if (exact.length > 0) return exact;
return rows.filter((row) =>
matchFields.some((field) =>
String(row[field] ?? "")
.trim()
.toLocaleLowerCase()
.includes(normalized),
),
);
}
export function decorateDimensionCounts(
rows: RecordRow[],
preferredKeys: string[],
labels: Record<string, string>,
): RecordRow[] {
const decorated = rows.map((row) => {
const [value, key] = dimensionValue(row, preferredKeys);
const item = { ...row };
if (value !== undefined && value !== null) {
if (key && key !== "value" && key !== "label") delete item[key];
item.value = value;
item.label = labels[String(value)] ?? String(value);
}
return item;
});
return decorated.sort(
(left, right) =>
Number(isBlank(left.value)) - Number(isBlank(right.value)) ||
countValue(right) - countValue(left) ||
String(left.label ?? "").localeCompare(String(right.label ?? "")),
);
}
export function decorateIdCounts(rows: RecordRow[], preferredKeys: string[], outputKey: string): RecordRow[] {
return rows
.map((row) => {
const [value, key] = dimensionValue(row, preferredKeys);
const item = { ...row };
if (value !== undefined && value !== null) {
if (key && key !== outputKey) delete item[key];
item[outputKey] = value;
}
return item;
})
.sort(
(left, right) =>
countValue(right) - countValue(left) ||
String(left[outputKey] ?? "").localeCompare(String(right[outputKey] ?? "")),
);
}
export function toZonedIso(value: unknown, timezone: string): unknown {
if (typeof value !== "string" || !value.trim()) return value;
const normalized = value.trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return normalized;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return value;
try {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
});
const parts = Object.fromEntries(formatter.formatToParts(date).map((part) => [part.type, part.value]));
const localUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute),
Number(parts.second),
);
const offsetMinutes = Math.round((localUtc - date.getTime()) / 60_000);
const sign = offsetMinutes >= 0 ? "+" : "-";
const absolute = Math.abs(offsetMinutes);
const offset = `${sign}${String(Math.floor(absolute / 60)).padStart(2, "0")}:${String(absolute % 60).padStart(2, "0")}`;
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}${offset}`;
} catch {
return date.toISOString();
}
}
export function parseDate(value: unknown): Date | undefined {
if (typeof value !== "string") return undefined;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
}
function dimensionValue(row: RecordRow, preferredKeys: string[]): [unknown, string | undefined] {
for (const key of preferredKeys) if (key in row) return [row[key], key];
const entry = Object.entries(row).find(([key, value]) => key !== "count" && typeof value !== "object");
return entry ? [entry[1], entry[0]] : [undefined, undefined];
}
function countValue(row: RecordRow): number {
return typeof row.count === "number" ? Math.trunc(row.count) : 0;
}
function isBlank(value: unknown): boolean {
return value === undefined || value === null || String(value).trim() === "";
}
function isEmpty(value: unknown): boolean {
return isBlank(value) || (typeof value === "object" && value !== null && Object.keys(value).length === 0);
}
+1477
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
export type Filter = Record<string, unknown>;
export function buildSearchFilter(
query: string | null | undefined,
fields: Iterable<string> | null | undefined,
): Filter | undefined {
const normalized = query?.trim() ?? "";
const names = [...(fields ?? [])].filter(Boolean);
if (!normalized || names.length === 0) return undefined;
return { $or: names.map((field) => ({ [field]: { $includes: normalized } })) };
}
export function buildEqCondition(field: string | null | undefined, value: unknown): Filter | undefined {
if (!field || value === undefined || value === null || value === "") return undefined;
return { [field]: { $eq: value } };
}
export function buildRelationIdCondition(
field: string | string[] | null | undefined,
value: unknown,
): Filter | undefined {
if (!field || value === undefined || value === null || value === "") return undefined;
const path = relationPath(field);
if (path.length === 0) return undefined;
return nested(path, { $eq: value });
}
export function buildRelationIdInCondition(
field: string | string[] | null | undefined,
values: unknown[] | null | undefined,
): Filter | undefined {
if (!field) return undefined;
const normalized = (values ?? []).filter((value) => value !== undefined && value !== null && value !== "");
const path = relationPath(field);
if (normalized.length === 0 || path.length === 0) return undefined;
return nested(path, { $in: normalized });
}
export function buildDateRangeCondition(
field: string | null | undefined,
dateFrom: string | null | undefined,
dateTo: string | null | undefined,
): Filter | undefined {
if (!field) return undefined;
const lower = dateFrom?.trim() ?? "";
const upper = dateTo?.trim() ?? "";
if (lower && upper) return { [field]: { $dateBetween: [lower, upper] } };
if (lower) return { [field]: { $dateNotBefore: lower } };
if (upper) return { [field]: { $dateNotAfter: upper } };
return undefined;
}
export function buildAndFilter(conditions: Array<Filter | null | undefined>): Filter {
const filtered = conditions.filter((condition): condition is Filter => Boolean(condition));
return filtered.length === 0 ? {} : { $and: filtered };
}
function relationPath(field: string | string[]): string[] {
const parts = (typeof field === "string" ? field.split(".") : field).map(String).filter(Boolean);
return parts.length === 1 ? [parts[0] as string, "id"] : parts;
}
function nested(path: string[], value: unknown): Filter {
let current: unknown = value;
for (const part of [...path].reverse()) current = { [part]: current };
return current as Filter;
}
+7
View File
@@ -0,0 +1,7 @@
import { runCli } from "./cli.js";
runCli().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`nocobase-crm-mcp: ${message}\n`);
process.exitCode = 1;
});
+366
View File
@@ -0,0 +1,366 @@
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import type { Credentials } from "./credentials.js";
import type { ApiResponse, RecordRow } from "./types.js";
import { isRecord } from "./types.js";
const AUTHENTICATION_CODES = [
"EMPTY_TOKEN",
"INVALID_TOKEN",
"EXPIRED_TOKEN",
"BLOCKED_TOKEN",
"EXPIRED_SESSION",
"NOT_EXIST_USER",
];
export class NocoBaseError extends Error {
readonly statusCode: number;
readonly authenticationFailed: boolean;
readonly retryable: boolean;
constructor(
statusCode: number,
message: string,
options: { authenticationFailed?: boolean; retryable?: boolean } = {},
) {
super(message);
this.name = "NocoBaseError";
this.statusCode = statusCode;
this.authenticationFailed = options.authenticationFailed ?? statusCode === 401;
this.retryable = options.retryable ?? (statusCode === 429 || statusCode >= 500);
}
}
export interface ListOptions {
filter?: Record<string, unknown>;
fields?: string[] | null;
appends?: string[] | null;
except?: string[] | null;
sort?: string[] | null;
page?: number;
pageSize?: number;
paginate?: boolean;
}
export interface QueryOptions {
measures?: RecordRow[];
dimensions?: RecordRow[];
orders?: RecordRow[];
filter?: Record<string, unknown>;
having?: Record<string, unknown>;
limit?: number;
offset?: number;
timezone?: string;
}
type CredentialProvider = () => Credentials | Promise<Credentials>;
export class NocoBaseClient {
readonly dataSource: string;
readonly timeoutMs: number;
private readonly credentialProvider: CredentialProvider;
constructor(
credentialProvider: CredentialProvider,
options: { dataSource?: string; timeoutMs?: number } = {},
) {
this.credentialProvider = credentialProvider;
this.dataSource = options.dataSource?.trim() || "main";
this.timeoutMs = options.timeoutMs ?? 30_000;
}
async health(resource: string, idField: string): Promise<Record<string, unknown>> {
const credentials = await this.credentials();
await this.listRecords(resource, { fields: [idField], page: 1, pageSize: 1 });
return { ok: true, endpoint: new URL(credentials.apiBaseUrl).origin, status: "authenticated" };
}
async listRecords(resource: string, options: ListOptions = {}): Promise<ApiResponse> {
return await this.resourceRequest(resource, "list", {
query: {
filter: options.filter,
fields: options.fields,
appends: options.appends,
except: options.except,
sort: options.sort,
page: options.page,
pageSize: options.pageSize,
paginate: options.paginate,
},
});
}
async getRecord(
resource: string,
filterByTk: unknown,
options: Omit<ListOptions, "filter" | "sort" | "page" | "pageSize" | "paginate"> = {},
): Promise<ApiResponse> {
return await this.resourceRequest(resource, "get", {
query: { filterByTk, fields: options.fields, appends: options.appends, except: options.except },
});
}
async createRecord(
resource: string,
values: Record<string, unknown>,
whitelist: string[],
): Promise<ApiResponse> {
validateScalarValues(values);
return await this.resourceRequest(resource, "create", { query: { whitelist }, body: values });
}
async updateRecord(
resource: string,
recordId: unknown,
values: Record<string, unknown>,
whitelist: string[],
): Promise<ApiResponse> {
validateScalarValues(values);
return await this.resourceRequest(resource, "update", {
query: { filterByTk: recordId, whitelist },
body: values,
});
}
async queryRecords(resource: string, options: QueryOptions = {}): Promise<ApiResponse> {
const { timezone, ...body } = options;
return await this.resourceRequest(resource, "query", { body, timezone });
}
async getCollection(
collectionName: string,
options: Pick<ListOptions, "appends" | "except"> = {},
): Promise<ApiResponse> {
return await this.resourceRequest("collections", "get", {
query: { filterByTk: collectionName, appends: options.appends, except: options.except },
});
}
async listCollectionFields(
collectionName: string,
options: Omit<ListOptions, "filter" | "sort"> = {},
): Promise<ApiResponse> {
validateResource(collectionName);
return await this.request(`/collections/${encodeURIComponent(collectionName)}/fields:list`, {
query: {
fields: options.fields,
appends: options.appends,
except: options.except,
page: options.page,
pageSize: options.pageSize,
paginate: options.paginate,
},
});
}
async addAttachmentIds(projectId: unknown, attachmentIds: unknown[]): Promise<void> {
await this.attachmentRelation(projectId, attachmentIds, "add");
}
async removeAttachmentIds(projectId: unknown, attachmentIds: unknown[]): Promise<void> {
await this.attachmentRelation(projectId, attachmentIds, "remove");
}
async uploadAttachment(
path: string,
filename?: string,
mimeType = "application/octet-stream",
): Promise<RecordRow> {
await this.ensureProjectWriteAccess();
const content = await readFile(path);
const form = new FormData();
form.append("file", new Blob([content], { type: mimeType }), filename || basename(path));
const payload = await this.request("/attachments:create", {
query: { attachmentField: "t_projects.f_projects_attachments" },
body: form,
});
if (!isRecord(payload.data)) throw new NocoBaseError(502, "NocoBase did not return an attachment object");
return payload.data;
}
async verifyCredentials(): Promise<void> {
await this.request("/roles:check", { method: "GET" });
}
private async resourceRequest(
resource: string,
action: string,
options: { query?: Record<string, unknown>; body?: unknown; timezone?: string },
): Promise<ApiResponse> {
validateResource(resource);
return await this.request(`/${resource}:${action}`, options);
}
private async attachmentRelation(
projectId: unknown,
attachmentIds: unknown[],
action: "add" | "remove",
): Promise<void> {
const segment = encodeURIComponent(validateIdentifier(projectId, "project_id"));
await this.request(`/t_projects/${segment}/f_projects_attachments:${action}`, {
body: { tk: attachmentIds },
});
}
private async ensureProjectWriteAccess(): Promise<void> {
const payload = await this.request("/roles:check", { method: "GET" });
if (!isRecord(payload.data))
throw new NocoBaseError(502, "NocoBase roles:check did not return role data");
const effective = { ...payload.data };
if (
isRecord(payload.meta) &&
isRecord(payload.meta.dataSources) &&
isRecord(payload.meta.dataSources.main)
) {
Object.assign(effective, payload.meta.dataSources.main);
}
if (roleAllows(effective, "t_projects", "create") || roleAllows(effective, "t_projects", "update"))
return;
throw new NocoBaseError(403, "Current NocoBase role cannot create or update projects");
}
private async credentials(): Promise<Credentials> {
try {
const credentials = await this.credentialProvider();
if (!credentials.apiBaseUrl.trim() || !credentials.authorizeKey.trim())
throw new Error("missing credentials");
return {
apiBaseUrl: credentials.apiBaseUrl.replace(/\/+$/, ""),
authorizeKey: credentials.authorizeKey.trim(),
};
} catch (error) {
throw new NocoBaseError(401, `NocoBase credentials are unavailable: ${errorMessage(error)}`, {
authenticationFailed: true,
});
}
}
private async request(
path: string,
options: { method?: string; query?: Record<string, unknown>; body?: unknown; timezone?: string } = {},
): Promise<ApiResponse> {
const credentials = await this.credentials();
const url = new URL(`${credentials.apiBaseUrl}${path}`);
appendQuery(url, options.query ?? {});
const headers = new Headers({
authorization: `Bearer ${credentials.authorizeKey}`,
"x-request-source": "nocobase-crm-mcp",
});
if (this.dataSource !== "main") headers.set("x-data-source", this.dataSource);
if (options.timezone) headers.set("x-timezone", options.timezone);
let body: BodyInit | undefined;
if (options.body instanceof FormData) body = options.body;
else if (options.body !== undefined) {
headers.set("content-type", "application/json");
body = JSON.stringify(options.body);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetchSameOrigin(url, {
method: options.method ?? "POST",
headers,
body,
signal: controller.signal,
});
const text = await response.text();
if (!response.ok) {
const redacted = (text || response.statusText || "NocoBase request failed").replaceAll(
credentials.authorizeKey,
"<redacted>",
);
const upper = text.toUpperCase();
throw new NocoBaseError(response.status, redacted, {
authenticationFailed:
response.status === 401 || AUTHENTICATION_CODES.some((code) => upper.includes(code)),
});
}
if (!text) return {};
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
throw new NocoBaseError(response.status, "NocoBase response was not valid JSON");
}
if (!isRecord(payload))
throw new NocoBaseError(response.status, "NocoBase response was not a JSON object");
return payload;
} catch (error) {
if (error instanceof NocoBaseError) throw error;
if (error instanceof Error && error.name === "AbortError") {
throw new NocoBaseError(408, "NocoBase request timed out", { retryable: true });
}
throw new NocoBaseError(0, `NocoBase request failed: ${errorMessage(error)}`, { retryable: true });
} finally {
clearTimeout(timeout);
}
}
}
function appendQuery(url: URL, query: Record<string, unknown>): void {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
for (const item of value)
url.searchParams.append(key, typeof item === "object" ? JSON.stringify(item) : String(item));
} else {
url.searchParams.set(key, typeof value === "object" ? JSON.stringify(value) : String(value));
}
}
}
async function fetchSameOrigin(initialUrl: URL, init: RequestInit): Promise<Response> {
let url = initialUrl;
for (let redirect = 0; redirect <= 5; redirect += 1) {
const response = await fetch(url, { ...init, redirect: "manual" });
if (![301, 302, 303, 307, 308].includes(response.status)) return response;
const location = response.headers.get("location");
if (!location) return response;
const next = new URL(location, url);
if (next.origin !== initialUrl.origin)
throw new NocoBaseError(502, "NocoBase redirected authentication to another origin");
url = next;
}
throw new NocoBaseError(502, "NocoBase returned too many redirects");
}
function validateResource(resource: string): void {
if (!/^[A-Za-z0-9_.-]+$/.test(resource)) throw new Error(`Invalid NocoBase resource: ${resource}`);
}
export function validateIdentifier(value: unknown, name: string): string {
if ((typeof value !== "string" && typeof value !== "number") || typeof value === "boolean") {
throw new Error(`${name} must be a scalar identifier`);
}
const normalized = String(value);
if (!normalized.trim()) throw new Error(`${name} must not be blank`);
if (normalized === "." || normalized === "..") throw new Error(`${name} must not be a dot path segment`);
return normalized;
}
function validateScalarValues(values: Record<string, unknown>): void {
for (const [key, value] of Object.entries(values)) {
if (value !== null && typeof value === "object") throw new Error(`${key} must be a scalar value`);
}
}
function roleAllows(role: RecordRow, resource: string, action: string): boolean {
if (role.allowAll === true) return true;
const aliases = isRecord(role.actionAlias) ? role.actionAlias : {};
const resolved = typeof aliases[action] === "string" ? aliases[action] : action;
if (Array.isArray(role.resources) && role.resources.includes(resource)) {
if (!isRecord(role.actions)) return false;
const actions = role.actions as RecordRow;
return [`${resource}:${resolved}`, `${resource}:${action}`].some(
(path) => actions[path] !== false && actions[path] != null,
);
}
if (!isRecord(role.strategy) || !Array.isArray(role.strategy.actions)) return false;
return role.strategy.actions.some(
(candidate) => typeof candidate === "string" && candidate.split(":", 1)[0] === resolved,
);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+191
View File
@@ -0,0 +1,191 @@
import type { ProjectConfig } from "./config.js";
import { type NocoBaseClient, NocoBaseError, validateIdentifier } from "./nocobase-client.js";
import { isRecord } from "./types.js";
export class ProjectService {
private readonly config: ProjectConfig;
private readonly client: NocoBaseClient;
private readonly whitelist: string[];
constructor(config: ProjectConfig, client: NocoBaseClient) {
this.config = config;
this.client = client;
this.whitelist = [
config.nameField,
config.customerField,
config.inquiryDateField,
config.progressField,
config.applicationsField,
config.partNumbersField,
config.periodUsageField,
config.detailsField,
];
}
async createProject(options: {
name: string;
customerId?: unknown;
inquiryDate?: string;
progress?: string;
applications?: string;
partNumbers?: string;
periodUsage?: string;
details?: string;
attachmentIds?: unknown[];
}): Promise<Record<string, unknown>> {
let values: Record<string, unknown>;
let attachmentIds: unknown[];
try {
values = this.projectValues(options, true);
attachmentIds = deduplicateIdentifiers(options.attachmentIds, "attachment IDs");
} catch (error) {
return inputError(error);
}
const response = await this.client.createRecord(this.config.resource, values, this.whitelist);
if (!isRecord(response.data) || !(this.config.idField in response.data)) {
throw new Error(`NocoBase create response did not contain ${this.config.idField}`);
}
const projectId = response.data[this.config.idField];
if (attachmentIds.length > 0) {
try {
await this.client.addAttachmentIds(projectId, attachmentIds);
} catch (error) {
return attachmentError(projectId, error);
}
}
return { project_id: projectId };
}
async updateProject(
projectId: unknown,
options: {
name?: string;
customerId?: unknown;
inquiryDate?: string;
progress?: string;
applications?: string;
partNumbers?: string;
periodUsage?: string;
details?: string;
addAttachmentIds?: unknown[];
removeAttachmentIds?: unknown[];
},
): Promise<Record<string, unknown>> {
let values: Record<string, unknown>;
let add: unknown[];
let remove: unknown[];
try {
validateIdentifier(projectId, "project_id");
values = this.projectValues(options, false);
add = deduplicateIdentifiers(options.addAttachmentIds, "attachment IDs");
remove = deduplicateIdentifiers(options.removeAttachmentIds, "attachment IDs");
if (add.some((id) => remove.includes(id)))
throw new Error("attachment IDs cannot be added and removed together");
if (Object.keys(values).length === 0 && add.length === 0 && remove.length === 0) {
throw new Error("update request must include project fields or attachment IDs");
}
} catch (error) {
return inputError(error);
}
if (Object.keys(values).length > 0) {
await this.client.updateRecord(this.config.resource, projectId, values, this.whitelist);
}
try {
if (add.length > 0) await this.client.addAttachmentIds(projectId, add);
if (remove.length > 0) await this.client.removeAttachmentIds(projectId, remove);
} catch (error) {
return attachmentError(projectId, error);
}
return { project_id: projectId };
}
private projectValues(
options: {
name?: string;
customerId?: unknown;
inquiryDate?: string;
progress?: string;
applications?: string;
partNumbers?: string;
periodUsage?: string;
details?: string;
},
requireName: boolean,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
if (options.name !== undefined) {
if (typeof options.name !== "string" || !options.name.trim()) throw new Error("name must not be blank");
values[this.config.nameField] = options.name.trim();
} else if (requireName) throw new Error("name is required");
if (options.customerId !== undefined && options.customerId !== null) {
validateIdentifier(options.customerId, "customer_id");
values[this.config.customerField] = options.customerId;
}
if (options.inquiryDate !== undefined) {
if (!isIsoDate(options.inquiryDate)) throw new Error("inquiry_date must be a YYYY-MM-DD date");
values[this.config.inquiryDateField] = options.inquiryDate;
}
if (options.progress !== undefined) {
const progress = this.config.progressValues[options.progress];
if (!progress) throw new InvalidProgressError();
values[this.config.progressField] = progress;
}
for (const [value, field] of [
[options.applications, this.config.applicationsField],
[options.partNumbers, this.config.partNumbersField],
[options.periodUsage, this.config.periodUsageField],
[options.details, this.config.detailsField],
] as const) {
if (value !== undefined) {
if (typeof value !== "string") throw new Error(`${field} must be a scalar value`);
values[field] = value;
}
}
return values;
}
}
class InvalidProgressError extends Error {}
function inputError(error: unknown): Record<string, unknown> {
if (error instanceof InvalidProgressError) {
return {
error: {
code: "invalid_progress",
message: "progress must be one of the configured project progress labels",
},
};
}
return {
error: { code: "invalid_project_input", message: error instanceof Error ? error.message : String(error) },
};
}
function deduplicateIdentifiers(values: unknown[] | undefined, name: string): unknown[] {
if (values === undefined) return [];
if (!Array.isArray(values)) throw new Error(`${name} must be a list`);
const unique: unknown[] = [];
for (const value of values) {
validateIdentifier(value, name);
if (!unique.includes(value)) unique.push(value);
}
return unique;
}
function attachmentError(projectId: unknown, error: unknown): Record<string, unknown> {
let code = "nb_upload_failed";
if (error instanceof NocoBaseError) {
if (error.statusCode === 404) code = "attachment_not_found";
else if (error.authenticationFailed) code = "nb_upload_unauthorized";
}
return {
project_id: projectId,
error: { code, message: error instanceof Error ? error.message : String(error) },
};
}
function isIsoDate(value: unknown): value is string {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const parsed = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
}
+447
View File
@@ -0,0 +1,447 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import type { CrmService } from "./crm-service.js";
import { NocoBaseError } from "./nocobase-client.js";
import type { ProjectService } from "./project-service.js";
import { VERSION } from "./version.js";
type ToolPayload = Record<string, unknown>;
const readAnnotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
const writeAnnotations = { readOnlyHint: false, destructiveHint: false, idempotentHint: false };
const pagination = {
limit: z.number().int().describe("Maximum rows to return.").default(20),
offset: z.number().int().describe("Zero-based result offset.").default(0),
};
export function createServer(options: {
crmService: CrmService;
projectService?: ProjectService;
readonly?: boolean;
enableConfigTools?: boolean;
}): McpServer {
const server = new McpServer({ name: "nocobase-crm", version: VERSION });
const crm = options.crmService;
register(
server,
"crm_health",
"Check NocoBase reachability, personal Key authentication, and CRM mapping.",
{},
() => crm.crmHealth(),
);
register(
server,
"crm_schema",
"Return the configured CRM collection and field mapping.",
{ include_fields: z.boolean().describe("Try to include collection field metadata.").default(false) },
({ include_fields }) => crm.crmSchema(include_fields),
);
register(
server,
"crm_overview",
"Return CRM totals, common customer distributions, and recent follow-ups.",
{
date_from: z.string().optional().describe("Optional lower follow-up date bound."),
date_to: z.string().optional().describe("Optional upper follow-up date bound."),
owner: z.string().optional().describe("Optional user ownership and participation filter."),
},
({ date_from, date_to, owner }) => crm.crmOverview({ dateFrom: date_from, dateTo: date_to, owner }),
);
register(
server,
"crm_filter_options",
"Return customer filter values, follow-up codes, and optional owner candidates.",
{ owner_query: z.string().optional().describe("Optional owner search text.") },
({ owner_query }) => crm.crmFilterOptions(owner_query),
);
register(
server,
"resolve_owner",
"Resolve an owner username, nickname, email, or English name.",
{ query: z.string().describe("Owner search text.") },
({ query }) => crm.resolveOwner(query),
);
register(
server,
"list_teams",
"List CRM teams and optionally their formal members.",
{
query: z.string().optional().describe("Optional team name search."),
include_members: z.boolean().default(false),
formal_members_only: z.boolean().default(true),
...pagination,
},
({ query, include_members, formal_members_only, limit, offset }) =>
crm.listTeams({
query,
includeMembers: include_members,
formalMembersOnly: formal_members_only,
limit,
offset,
}),
);
register(
server,
"resolve_team",
"Resolve a team name before using it as a CRM filter.",
{ query: z.string().describe("Team name to resolve.") },
({ query }) => crm.resolveTeam(query),
);
register(
server,
"get_team",
"Get one CRM team, its managers, and optionally its members.",
{
team_id: z.union([z.string(), z.number()]).describe("NocoBase team ID."),
include_members: z.boolean().default(true),
formal_members_only: z.boolean().default(true),
},
({ team_id, include_members, formal_members_only }) =>
crm.getTeam(team_id, { includeMembers: include_members, formalMembersOnly: formal_members_only }),
);
register(
server,
"team_crm_overview",
"Return team totals and member contributions using formal CRM team membership.",
{
team: z.string().describe("Exact or uniquely matching CRM team name."),
date_from: z.string().optional(),
date_to: z.string().optional(),
include_member_breakdown: z.boolean().default(true),
},
({ team, date_from, date_to, include_member_breakdown }) =>
crm.teamCrmOverview(team, {
dateFrom: date_from,
dateTo: date_to,
includeMemberBreakdown: include_member_breakdown,
}),
);
register(
server,
"list_customers",
"List CRM customers with readable compact rows and pagination metadata.",
{
query: z.string().optional(),
status: z.string().optional(),
owner: z.string().optional(),
team: z.string().optional(),
source: z.string().optional(),
...pagination,
include_raw: z.boolean().default(false),
},
(args) =>
withRawPolicy(args.include_raw, () =>
crm.listCustomers({
query: args.query,
status: args.status,
owner: args.owner,
team: args.team,
source: args.source,
limit: args.limit,
offset: args.offset,
includeRaw: args.include_raw,
}),
),
);
register(
server,
"get_customer",
"Get one customer with contacts and recent follow-ups.",
{
customer_id: z.union([z.string(), z.number()]),
include_contacts: z.boolean().default(true),
include_recent_followups: z.boolean().default(true),
followup_limit: z.number().int().default(10),
include_raw: z.boolean().default(false),
},
(args) =>
withRawPolicy(args.include_raw, () =>
crm.getCustomer(args.customer_id, {
includeContacts: args.include_contacts,
includeRecentFollowups: args.include_recent_followups,
followupLimit: args.followup_limit,
includeRaw: args.include_raw,
}),
),
);
register(
server,
"list_contacts",
"List CRM contacts, optionally filtered by customer.",
{
customer_id: z.union([z.string(), z.number()]).optional(),
query: z.string().optional(),
...pagination,
include_details: z.boolean().default(true),
include_raw: z.boolean().default(false),
},
(args) =>
withRawPolicy(args.include_raw, () =>
crm.listContacts({
customerId: args.customer_id,
query: args.query,
limit: args.limit,
offset: args.offset,
includeDetails: args.include_details,
includeRaw: args.include_raw,
}),
),
);
register(
server,
"list_followups",
"List customer follow-up records as compact readable rows.",
{
customer_id: z.union([z.string(), z.number()]).optional(),
contact_id: z.union([z.string(), z.number()]).optional(),
owner: z.string().optional(),
team: z.string().optional(),
project_id: z.union([z.string(), z.number()]).optional(),
followup_type: z.string().optional(),
record_status: z.string().optional(),
date_from: z.string().optional(),
date_to: z.string().optional(),
...pagination,
include_raw: z.boolean().default(false),
},
(args) =>
withRawPolicy(args.include_raw, () =>
crm.listFollowups({
customerId: args.customer_id,
contactId: args.contact_id,
owner: args.owner,
team: args.team,
projectId: args.project_id,
followupType: args.followup_type,
recordStatus: args.record_status,
dateFrom: args.date_from,
dateTo: args.date_to,
limit: args.limit,
offset: args.offset,
includeRaw: args.include_raw,
}),
),
);
register(
server,
"list_projects",
"List CRM projects linked to customers, teams, and project stages.",
{
customer_id: z.union([z.string(), z.number()]).optional(),
team: z.string().optional(),
stage: z.string().optional(),
...pagination,
include_raw: z.boolean().default(false),
},
(args) =>
withRawPolicy(args.include_raw, () =>
crm.listProjects({
customerId: args.customer_id,
team: args.team,
stage: args.stage,
limit: args.limit,
offset: args.offset,
includeRaw: args.include_raw,
}),
),
);
register(
server,
"get_project",
"Get a CRM project with its existing relations.",
{
project_id: z.union([z.string(), z.number()]),
include_contacts: z.boolean().default(true),
include_followups: z.boolean().default(true),
include_updates: z.boolean().default(true),
},
({ project_id, include_contacts, include_followups, include_updates }) =>
crm.getProject(project_id, {
includeContacts: include_contacts,
includeFollowups: include_followups,
includeUpdates: include_updates,
}),
);
register(
server,
"get_project_timeline",
"Return a chronological project timeline from follow-ups and project updates.",
{
project_id: z.union([z.string(), z.number()]),
date_from: z.string().optional(),
date_to: z.string().optional(),
},
({ project_id, date_from, date_to }) =>
crm.getProjectTimeline(project_id, { dateFrom: date_from, dateTo: date_to }),
);
register(
server,
"list_stale_customers",
"List customers whose last valid follow-up is older than the threshold.",
{
team: z.string().optional(),
owner: z.string().optional(),
inactive_days: z.number().int().default(30),
as_of: z.string().optional(),
...pagination,
},
({ team, owner, inactive_days, as_of, limit, offset }) =>
crm.listStaleCustomers({ team, owner, inactiveDays: inactive_days, asOf: as_of, limit, offset }),
);
if (options.enableConfigTools) {
register(server, "crm_config_template", "Build a reviewed config template with field metadata.", {}, () =>
crm.crmConfigTemplate(),
);
}
if (!(options.readonly ?? true)) {
if (!options.projectService) throw new Error("projectService is required when writes are enabled");
registerWriteTools(server, options.projectService);
}
return server;
}
function registerWriteTools(server: McpServer, projects: ProjectService): void {
server.registerTool(
"create_project",
{
description: "Create one fixed-schema project and optionally associate uploaded attachment IDs.",
inputSchema: {
name: z.string().describe("Project name; must not be blank."),
customer_id: z.union([z.string(), z.number()]).optional(),
inquiry_date: z.string().optional(),
progress: z.string().optional(),
applications: z.string().optional(),
part_numbers: z.string().optional(),
period_usage: z.string().optional(),
details: z.string().optional(),
attachment_ids: z.array(z.union([z.string(), z.number()])).optional(),
},
annotations: writeAnnotations,
},
async (args) =>
toolResult(
await safeTool(() =>
projects.createProject({
name: args.name,
customerId: args.customer_id,
inquiryDate: args.inquiry_date,
progress: args.progress,
applications: args.applications,
partNumbers: args.part_numbers,
periodUsage: args.period_usage,
details: args.details,
attachmentIds: args.attachment_ids,
}),
),
),
);
server.registerTool(
"update_project",
{
description: "Update fixed project fields and incrementally add or remove attachment IDs.",
inputSchema: {
project_id: z.union([z.string(), z.number()]),
name: z.string().optional(),
customer_id: z.union([z.string(), z.number()]).optional(),
inquiry_date: z.string().optional(),
progress: z.string().optional(),
applications: z.string().optional(),
part_numbers: z.string().optional(),
period_usage: z.string().optional(),
details: z.string().optional(),
add_attachment_ids: z.array(z.union([z.string(), z.number()])).optional(),
remove_attachment_ids: z.array(z.union([z.string(), z.number()])).optional(),
},
annotations: writeAnnotations,
},
async (args) =>
toolResult(
await safeTool(() =>
projects.updateProject(args.project_id, {
name: args.name,
customerId: args.customer_id,
inquiryDate: args.inquiry_date,
progress: args.progress,
applications: args.applications,
partNumbers: args.part_numbers,
periodUsage: args.period_usage,
details: args.details,
addAttachmentIds: args.add_attachment_ids,
removeAttachmentIds: args.remove_attachment_ids,
}),
),
),
);
}
function register(
server: McpServer,
name: string,
description: string,
inputSchema: z.ZodRawShape,
operation: (args: any) => Promise<ToolPayload>,
): void {
server.registerTool(name, { description, inputSchema, annotations: readAnnotations }, async (args: any) =>
toolResult(await safeTool(() => operation(args))),
);
}
async function withRawPolicy(
includeRaw: boolean,
operation: () => Promise<ToolPayload>,
): Promise<ToolPayload> {
if (includeRaw && process.env.NOCOBASE_MCP_ENABLE_RAW_DATA?.trim() !== "1") {
return {
error: {
code: "raw_data_disabled",
message: "Raw NocoBase rows are disabled for this local MCP installation.",
hint: "Keep include_raw=false, or explicitly approve NOCOBASE_MCP_ENABLE_RAW_DATA=1.",
retryable: false,
},
};
}
return await operation();
}
async function safeTool(operation: () => Promise<ToolPayload>): Promise<ToolPayload> {
try {
return await operation();
} catch (error) {
if (error instanceof NocoBaseError && error.authenticationFailed) {
return {
error: {
code: "nocobase_auth_failed",
message: error.message,
hint: "Ask the local installation Agent to run setup, then enter a personal Authorize Key in the hidden prompt.",
retryable: false,
},
};
}
if (error instanceof NocoBaseError) {
return {
error: {
code: error.statusCode === 408 ? "nocobase_request_timeout" : "nocobase_request_failed",
message: error.message,
hint: "Check the NocoBase URL, ACL, collection mapping, and query parameters.",
retryable: error.retryable,
},
};
}
return {
error: {
code: "tool_error",
message: error instanceof Error ? error.message : String(error),
hint: "Check the local MCP configuration and logs.",
retryable: false,
},
};
}
}
function toolResult(payload: ToolPayload): CallToolResult {
return {
content: [{ type: "text", text: JSON.stringify(payload) }],
structuredContent: payload,
};
}
+9
View File
@@ -0,0 +1,9 @@
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
export type JsonObject = { [key: string]: JsonValue | undefined };
export type RecordRow = Record<string, unknown>;
export type ApiResponse = Record<string, unknown>;
export function isRecord(value: unknown): value is RecordRow {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+3
View File
@@ -0,0 +1,3 @@
import packageMetadata from "../package.json" with { type: "json" };
export const VERSION = packageMetadata.version;