feat: migrate MCP runtime to TypeScript
This commit is contained in:
+196
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import packageMetadata from "../package.json" with { type: "json" };
|
||||
|
||||
export const VERSION = packageMetadata.version;
|
||||
Reference in New Issue
Block a user