902 lines
31 KiB
JavaScript
902 lines
31 KiB
JavaScript
import process from 'node:process';
|
|
|
|
import { FinanceClient } from './finance-client.js';
|
|
|
|
process.on('exit', (code) => {
|
|
process.stderr.write(`[finwise-finance] process exit with code ${code}\n`);
|
|
});
|
|
process.on('uncaughtException', (error) => {
|
|
process.stderr.write(`[finwise-finance] uncaughtException: ${error.stack ?? error.message}\n`);
|
|
});
|
|
process.on('unhandledRejection', (reason) => {
|
|
process.stderr.write(`[finwise-finance] unhandledRejection: ${reason}\n`);
|
|
});
|
|
|
|
class McpServer {
|
|
constructor(options) {
|
|
this.options = options;
|
|
this.tools = new Map();
|
|
this.metadata = [];
|
|
this.buffer = '';
|
|
this.expectedLength = null;
|
|
this.initialized = false;
|
|
|
|
for (const tool of options.tools) {
|
|
if (this.tools.has(tool.name)) {
|
|
throw new Error(`Duplicate MCP tool name: ${tool.name}`);
|
|
}
|
|
this.tools.set(tool.name, tool);
|
|
this.metadata.push({
|
|
name: tool.name,
|
|
description: tool.description,
|
|
inputSchema: tool.inputSchema,
|
|
...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
|
|
});
|
|
}
|
|
}
|
|
|
|
start() {
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => {
|
|
this.buffer += chunk;
|
|
void this.drain();
|
|
});
|
|
process.stdin.on('end', () => {
|
|
this.log('stdin ended');
|
|
});
|
|
process.stdin.on('close', () => {
|
|
this.log('stdin closed');
|
|
});
|
|
process.stdin.resume();
|
|
this.log('MCP service ready');
|
|
}
|
|
|
|
write(payload) {
|
|
const json = JSON.stringify(payload);
|
|
const frame = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`;
|
|
process.stdout.write(frame);
|
|
}
|
|
|
|
respond(id, result) {
|
|
this.log(`responding to ${id} with result`);
|
|
if (id === undefined) return;
|
|
this.write({ jsonrpc: '2.0', id, result });
|
|
}
|
|
|
|
respondError(id, code, message) {
|
|
this.log(`responding error to ${id}: [${code}] ${message}`);
|
|
if (id === undefined) return;
|
|
this.write({ jsonrpc: '2.0', id, error: { code, message } });
|
|
}
|
|
|
|
notify(method, params) {
|
|
this.log(`notifying ${method}`);
|
|
this.write({ jsonrpc: '2.0', method, params });
|
|
}
|
|
|
|
async drain() {
|
|
while (true) {
|
|
if (this.expectedLength === null) {
|
|
const headerEnd = this.buffer.indexOf('\r\n\r\n');
|
|
if (headerEnd === -1) return;
|
|
const header = this.buffer.slice(0, headerEnd);
|
|
const match = header.match(/content-length:\s*(\d+)/i);
|
|
if (!match) {
|
|
this.buffer = this.buffer.slice(headerEnd + 4);
|
|
continue;
|
|
}
|
|
this.expectedLength = Number.parseInt(match[1], 10);
|
|
this.buffer = this.buffer.slice(headerEnd + 4);
|
|
}
|
|
|
|
if (this.buffer.length < (this.expectedLength ?? 0)) return;
|
|
|
|
const body = this.buffer.slice(0, this.expectedLength ?? 0);
|
|
this.buffer = this.buffer.slice(this.expectedLength ?? 0);
|
|
this.expectedLength = null;
|
|
|
|
await this.handleMessage(body);
|
|
}
|
|
}
|
|
|
|
async handleMessage(payload) {
|
|
this.log(`received payload: ${payload}`);
|
|
let request;
|
|
try {
|
|
request = JSON.parse(payload);
|
|
} catch {
|
|
this.respondError(null, -32700, 'Parse error');
|
|
return;
|
|
}
|
|
|
|
if (!request || request.jsonrpc !== '2.0' || typeof request.method !== 'string') {
|
|
this.respondError(request?.id, -32600, 'Invalid Request');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.dispatch(request);
|
|
} catch (error) {
|
|
this.log(`Unexpected error: ${error.message}`);
|
|
this.respondError(request.id, -32000, error.message);
|
|
}
|
|
}
|
|
|
|
async dispatch(request) {
|
|
switch (request.method) {
|
|
case 'initialize': {
|
|
if (this.initialized) {
|
|
this.respondError(request.id, -32600, 'Already initialized');
|
|
return;
|
|
}
|
|
this.initialized = true;
|
|
this.respond(request.id, {
|
|
protocolVersion: '2024-10-07',
|
|
capabilities: { tools: { list: true, call: true } },
|
|
service: {
|
|
name: this.options.name,
|
|
version: this.options.version,
|
|
description: this.options.description,
|
|
},
|
|
});
|
|
this.notify('notifications/ready', {});
|
|
return;
|
|
}
|
|
|
|
case 'tools/list': {
|
|
this.assertInitialized('tools/list');
|
|
this.respond(request.id, { tools: this.metadata });
|
|
return;
|
|
}
|
|
|
|
case 'tools/call': {
|
|
this.assertInitialized('tools/call');
|
|
const params = request.params ?? {};
|
|
const toolName = params.name;
|
|
if (!toolName || typeof toolName !== 'string') {
|
|
this.respondError(request.id, -32602, 'Tool name is required');
|
|
return;
|
|
}
|
|
const tool = this.tools.get(toolName);
|
|
if (!tool) {
|
|
this.respondError(request.id, -32601, `Unknown tool: ${toolName}`);
|
|
return;
|
|
}
|
|
try {
|
|
const result = await tool.handler(params.arguments ?? {});
|
|
this.respond(request.id, result);
|
|
} catch (error) {
|
|
this.respondError(request.id, -32001, error.message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
case 'ping': {
|
|
this.respond(request.id, 'pong');
|
|
return;
|
|
}
|
|
|
|
case 'shutdown': {
|
|
this.respond(request.id, null);
|
|
process.nextTick(() => process.exit(0));
|
|
return;
|
|
}
|
|
|
|
default: {
|
|
this.respondError(request.id, -32601, `Method not found: ${request.method}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
assertInitialized(method) {
|
|
if (!this.initialized) {
|
|
throw new Error(`Received ${method} before initialize`);
|
|
}
|
|
}
|
|
|
|
log(message) {
|
|
process.stderr.write(`[${this.options.name}] ${message}\n`);
|
|
}
|
|
}
|
|
|
|
const jsonResult = (data) => ({
|
|
content: [
|
|
{
|
|
type: 'application/json',
|
|
data,
|
|
},
|
|
],
|
|
});
|
|
|
|
const ensureNumber = (value, field) => {
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
if (typeof value === 'string' && value.trim()) {
|
|
const parsed = Number(value);
|
|
if (!Number.isNaN(parsed)) return parsed;
|
|
}
|
|
throw new Error(`${field} must be a number`);
|
|
};
|
|
|
|
const optionalNumber = (value, field) => {
|
|
if (value === undefined || value === null) return undefined;
|
|
return ensureNumber(value, field);
|
|
};
|
|
|
|
const optionalNullableNumber = (value, field) => {
|
|
if (value === undefined) return undefined;
|
|
if (value === null) return null;
|
|
if (typeof value === 'string') {
|
|
const normalized = value.trim().toLowerCase();
|
|
if (!normalized || normalized === 'null') return null;
|
|
}
|
|
return ensureNumber(value, field);
|
|
};
|
|
|
|
const ensureString = (value, field) => {
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) throw new Error(`${field} cannot be empty`);
|
|
return trimmed;
|
|
}
|
|
if (value === undefined || value === null) throw new Error(`${field} is required`);
|
|
return ensureString(String(value), field);
|
|
};
|
|
|
|
const optionalString = (value) => {
|
|
if (value === undefined || value === null) return undefined;
|
|
return String(value);
|
|
};
|
|
|
|
const optionalNullableString = (value) => {
|
|
if (value === undefined) return undefined;
|
|
if (value === null) return null;
|
|
const normalized = String(value).trim();
|
|
if (normalized.toLowerCase() === 'null') return null;
|
|
return normalized;
|
|
};
|
|
|
|
const optionalBoolean = (value, field) => {
|
|
if (value === undefined || value === null) return undefined;
|
|
if (typeof value === 'boolean') return value;
|
|
if (typeof value === 'number') return value !== 0;
|
|
if (typeof value === 'string') {
|
|
const normalized = value.trim().toLowerCase();
|
|
if (['true', '1', 'yes', 'y'].includes(normalized)) return true;
|
|
if (['false', '0', 'no', 'n'].includes(normalized)) return false;
|
|
}
|
|
throw new Error(`${field} must be boolean`);
|
|
};
|
|
|
|
const parseStringArray = (value) => {
|
|
if (value === undefined || value === null) return undefined;
|
|
let items = [];
|
|
if (Array.isArray(value)) {
|
|
items = value.map((item) => String(item).trim()).filter(Boolean);
|
|
} else if (typeof value === 'string') {
|
|
items = value.split(',').map((item) => item.trim()).filter(Boolean);
|
|
} else {
|
|
items = [String(value).trim()].filter(Boolean);
|
|
}
|
|
return items.length ? items : undefined;
|
|
};
|
|
|
|
const buildTransactionCreatePayload = (args, options = {}) => {
|
|
const payload = {
|
|
type: optionalString(args?.type) ?? options.defaultType ?? ensureString(args?.type, 'type'),
|
|
amount: ensureNumber(args?.amount, 'amount'),
|
|
currency: optionalString(args?.currency) ?? 'CNY',
|
|
transactionDate: ensureString(args?.transactionDate, 'transactionDate'),
|
|
};
|
|
|
|
const categoryId = optionalNullableNumber(args?.categoryId, 'categoryId');
|
|
if (categoryId !== undefined) payload.categoryId = categoryId;
|
|
|
|
const accountId = optionalNullableNumber(args?.accountId, 'accountId');
|
|
if (accountId !== undefined) payload.accountId = accountId;
|
|
|
|
const description = optionalString(args?.description);
|
|
if (description !== undefined) payload.description = description;
|
|
|
|
const project = optionalNullableString(args?.project);
|
|
if (project !== undefined) payload.project = project;
|
|
|
|
const memo = optionalNullableString(args?.memo);
|
|
if (memo !== undefined) payload.memo = memo;
|
|
|
|
const status = optionalString(args?.status);
|
|
if (status !== undefined) payload.status = status;
|
|
|
|
const reimbursementBatch = optionalNullableString(args?.reimbursementBatch);
|
|
if (reimbursementBatch !== undefined) payload.reimbursementBatch = reimbursementBatch;
|
|
|
|
const reviewNotes = optionalNullableString(args?.reviewNotes);
|
|
if (reviewNotes !== undefined) payload.reviewNotes = reviewNotes;
|
|
|
|
const submittedBy = optionalNullableString(args?.submittedBy);
|
|
if (submittedBy !== undefined) payload.submittedBy = submittedBy;
|
|
|
|
const approvedBy = optionalNullableString(args?.approvedBy);
|
|
if (approvedBy !== undefined) payload.approvedBy = approvedBy;
|
|
|
|
const approvedAt = optionalNullableString(args?.approvedAt);
|
|
if (approvedAt !== undefined) payload.approvedAt = approvedAt;
|
|
|
|
const statusUpdatedAt = optionalNullableString(args?.statusUpdatedAt);
|
|
if (statusUpdatedAt !== undefined) payload.statusUpdatedAt = statusUpdatedAt;
|
|
|
|
const isDeleted = optionalBoolean(args?.isDeleted, 'isDeleted');
|
|
if (isDeleted !== undefined) payload.isDeleted = isDeleted;
|
|
|
|
return payload;
|
|
};
|
|
|
|
const buildTransactionUpdatePayload = (args) => {
|
|
const payload = {};
|
|
|
|
if (args?.type !== undefined) payload.type = ensureString(args.type, 'type');
|
|
if (args?.amount !== undefined) payload.amount = ensureNumber(args.amount, 'amount');
|
|
if (args?.currency !== undefined) payload.currency = ensureString(args.currency, 'currency');
|
|
if (args?.transactionDate !== undefined) payload.transactionDate = ensureString(args.transactionDate, 'transactionDate');
|
|
if (args?.categoryId !== undefined) payload.categoryId = optionalNullableNumber(args.categoryId, 'categoryId');
|
|
if (args?.accountId !== undefined) payload.accountId = optionalNullableNumber(args.accountId, 'accountId');
|
|
if (args?.description !== undefined) payload.description = args.description === null ? '' : String(args.description);
|
|
if (args?.project !== undefined) payload.project = optionalNullableString(args.project) ?? null;
|
|
if (args?.memo !== undefined) payload.memo = optionalNullableString(args.memo) ?? null;
|
|
if (args?.status !== undefined) payload.status = ensureString(args.status, 'status');
|
|
if (args?.statusUpdatedAt !== undefined) payload.statusUpdatedAt = ensureString(args.statusUpdatedAt, 'statusUpdatedAt');
|
|
if (args?.reimbursementBatch !== undefined) payload.reimbursementBatch = optionalNullableString(args.reimbursementBatch) ?? null;
|
|
if (args?.reviewNotes !== undefined) payload.reviewNotes = optionalNullableString(args.reviewNotes) ?? null;
|
|
if (args?.submittedBy !== undefined) payload.submittedBy = optionalNullableString(args.submittedBy) ?? null;
|
|
if (args?.approvedBy !== undefined) payload.approvedBy = optionalNullableString(args.approvedBy) ?? null;
|
|
if (args?.approvedAt !== undefined) payload.approvedAt = optionalNullableString(args.approvedAt) ?? null;
|
|
const isDeleted = optionalBoolean(args?.isDeleted, 'isDeleted');
|
|
if (isDeleted !== undefined) payload.isDeleted = isDeleted;
|
|
|
|
return payload;
|
|
};
|
|
|
|
const createFinanceTools = (client) => {
|
|
const tools = [];
|
|
|
|
tools.push({
|
|
name: 'finance_list_accounts',
|
|
description: '列出账户,可选货币过滤',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
currency: { type: 'string', description: 'ISO 4217 货币代码' },
|
|
},
|
|
},
|
|
handler: async (args) => {
|
|
const currency = optionalString(args?.currency);
|
|
return jsonResult(await client.listAccounts(currency ? { currency } : {}));
|
|
},
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_list_budgets',
|
|
description: '查询预算列表',
|
|
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
|
|
handler: async () => jsonResult(await client.listBudgets()),
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_create_budget',
|
|
description: '创建预算',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['category', 'categoryId', 'limit', 'currency', 'period'],
|
|
properties: {
|
|
category: { type: 'string' },
|
|
categoryId: { type: 'number' },
|
|
emoji: { type: 'string' },
|
|
limit: { type: 'number' },
|
|
spent: { type: 'number' },
|
|
remaining: { type: 'number' },
|
|
percentage: { type: 'number' },
|
|
currency: { type: 'string' },
|
|
period: { type: 'string' },
|
|
alertThreshold: { type: 'number' },
|
|
description: { type: 'string' },
|
|
autoRenew: { type: 'boolean' },
|
|
overspendAlert: { type: 'boolean' },
|
|
dailyReminder: { type: 'boolean' },
|
|
monthlyTrend: { type: 'number' },
|
|
isDeleted: { type: 'boolean' },
|
|
},
|
|
},
|
|
handler: async (args) => {
|
|
const payload = {
|
|
category: ensureString(args?.category, 'category'),
|
|
categoryId: ensureNumber(args?.categoryId, 'categoryId'),
|
|
emoji: optionalString(args?.emoji),
|
|
limit: ensureNumber(args?.limit, 'limit'),
|
|
spent: optionalNumber(args?.spent, 'spent'),
|
|
remaining: optionalNumber(args?.remaining, 'remaining'),
|
|
percentage: optionalNumber(args?.percentage, 'percentage'),
|
|
currency: ensureString(args?.currency, 'currency'),
|
|
period: ensureString(args?.period, 'period'),
|
|
alertThreshold: optionalNumber(args?.alertThreshold, 'alertThreshold'),
|
|
description: optionalString(args?.description),
|
|
autoRenew: optionalBoolean(args?.autoRenew, 'autoRenew'),
|
|
overspendAlert: optionalBoolean(args?.overspendAlert, 'overspendAlert'),
|
|
dailyReminder: optionalBoolean(args?.dailyReminder, 'dailyReminder'),
|
|
monthlyTrend: optionalNumber(args?.monthlyTrend, 'monthlyTrend'),
|
|
isDeleted: optionalBoolean(args?.isDeleted, 'isDeleted'),
|
|
};
|
|
return jsonResult(await client.createBudget(payload));
|
|
},
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_update_budget',
|
|
description: '更新预算',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['id'],
|
|
properties: {
|
|
id: { type: 'number' },
|
|
category: { type: 'string' },
|
|
categoryId: { type: 'number' },
|
|
emoji: { type: 'string' },
|
|
limit: { type: 'number' },
|
|
spent: { type: 'number' },
|
|
remaining: { type: 'number' },
|
|
percentage: { type: 'number' },
|
|
currency: { type: 'string' },
|
|
period: { type: 'string' },
|
|
alertThreshold: { type: 'number' },
|
|
description: { type: 'string' },
|
|
autoRenew: { type: 'boolean' },
|
|
overspendAlert: { type: 'boolean' },
|
|
dailyReminder: { type: 'boolean' },
|
|
monthlyTrend: { type: 'number' },
|
|
isDeleted: { type: 'boolean' },
|
|
},
|
|
},
|
|
handler: async (args) => {
|
|
const id = ensureNumber(args?.id, 'id');
|
|
const payload = {};
|
|
if (args?.category !== undefined) payload.category = ensureString(args.category, 'category');
|
|
if (args?.categoryId !== undefined) payload.categoryId = ensureNumber(args.categoryId, 'categoryId');
|
|
if (args?.emoji !== undefined) payload.emoji = optionalString(args.emoji);
|
|
if (args?.limit !== undefined) payload.limit = ensureNumber(args.limit, 'limit');
|
|
if (args?.spent !== undefined) payload.spent = ensureNumber(args.spent, 'spent');
|
|
if (args?.remaining !== undefined) payload.remaining = ensureNumber(args.remaining, 'remaining');
|
|
if (args?.percentage !== undefined) payload.percentage = ensureNumber(args.percentage, 'percentage');
|
|
if (args?.currency !== undefined) payload.currency = ensureString(args.currency, 'currency');
|
|
if (args?.period !== undefined) payload.period = ensureString(args.period, 'period');
|
|
if (args?.alertThreshold !== undefined) payload.alertThreshold = ensureNumber(args.alertThreshold, 'alertThreshold');
|
|
if (args?.description !== undefined) payload.description = optionalString(args.description);
|
|
const autoRenew = optionalBoolean(args?.autoRenew, 'autoRenew');
|
|
if (autoRenew !== undefined) payload.autoRenew = autoRenew;
|
|
const overspendAlert = optionalBoolean(args?.overspendAlert, 'overspendAlert');
|
|
if (overspendAlert !== undefined) payload.overspendAlert = overspendAlert;
|
|
const dailyReminder = optionalBoolean(args?.dailyReminder, 'dailyReminder');
|
|
if (dailyReminder !== undefined) payload.dailyReminder = dailyReminder;
|
|
if (args?.monthlyTrend !== undefined) payload.monthlyTrend = ensureNumber(args.monthlyTrend, 'monthlyTrend');
|
|
const isDeleted = optionalBoolean(args?.isDeleted, 'isDeleted');
|
|
if (isDeleted !== undefined) payload.isDeleted = isDeleted;
|
|
return jsonResult(await client.updateBudget(id, payload));
|
|
},
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_delete_budget',
|
|
description: '删除预算(软删)',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['id'],
|
|
properties: { id: { type: 'number' } },
|
|
},
|
|
handler: async (args) => jsonResult(await client.deleteBudget(ensureNumber(args?.id, 'id'))),
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_list_categories',
|
|
description: '查询分类,可按类型过滤',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
type: { type: 'string', description: 'expense / income' },
|
|
},
|
|
},
|
|
handler: async (args) => {
|
|
const type = optionalString(args?.type);
|
|
return jsonResult(await client.listCategories(type ? { type } : {}));
|
|
},
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_create_category',
|
|
description: '创建分类',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['name', 'type'],
|
|
properties: {
|
|
name: { type: 'string' },
|
|
type: { type: 'string' },
|
|
icon: { type: 'string' },
|
|
color: { type: 'string' },
|
|
},
|
|
},
|
|
handler: async (args) => jsonResult(
|
|
await client.createCategory({
|
|
name: ensureString(args?.name, 'name'),
|
|
type: ensureString(args?.type, 'type'),
|
|
icon: optionalString(args?.icon),
|
|
color: optionalString(args?.color),
|
|
}),
|
|
),
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_update_category',
|
|
description: '更新分类',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['id'],
|
|
properties: {
|
|
id: { type: 'number' },
|
|
name: { type: 'string' },
|
|
icon: { type: 'string' },
|
|
color: { type: 'string' },
|
|
isActive: { type: 'boolean' },
|
|
},
|
|
},
|
|
handler: async (args) => {
|
|
const id = ensureNumber(args?.id, 'id');
|
|
const payload = {};
|
|
if (args?.name !== undefined) payload.name = ensureString(args.name, 'name');
|
|
if (args?.icon !== undefined) payload.icon = optionalString(args.icon);
|
|
if (args?.color !== undefined) payload.color = optionalString(args.color);
|
|
const isActive = optionalBoolean(args?.isActive, 'isActive');
|
|
if (isActive !== undefined) payload.isActive = isActive;
|
|
return jsonResult(await client.updateCategory(id, payload));
|
|
},
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_delete_category',
|
|
description: '删除分类(软删)',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['id'],
|
|
properties: { id: { type: 'number' } },
|
|
},
|
|
handler: async (args) => jsonResult(await client.deleteCategory(ensureNumber(args?.id, 'id'))),
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_list_currencies',
|
|
description: '列出可用货币',
|
|
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
|
|
handler: async () => jsonResult(await client.listCurrencies()),
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_list_exchange_rates',
|
|
description: '查询汇率',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
fromCurrency: { type: 'string' },
|
|
toCurrency: { type: 'string' },
|
|
date: { type: 'string' },
|
|
},
|
|
},
|
|
handler: async (args) => {
|
|
const params = {};
|
|
if (args?.fromCurrency !== undefined) params.fromCurrency = ensureString(args.fromCurrency, 'fromCurrency');
|
|
if (args?.toCurrency !== undefined) params.toCurrency = ensureString(args.toCurrency, 'toCurrency');
|
|
if (args?.date !== undefined) params.date = ensureString(args.date, 'date');
|
|
return jsonResult(await client.listExchangeRates(params));
|
|
},
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_list_transactions',
|
|
description: '查询交易列表',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
type: { type: 'string' },
|
|
statuses: { type: ['array', 'string'], items: { type: 'string' } },
|
|
includeDeleted: { type: 'boolean' },
|
|
},
|
|
},
|
|
handler: async (args) => {
|
|
const type = optionalString(args?.type);
|
|
const statuses = parseStringArray(args?.statuses);
|
|
const includeDeleted = optionalBoolean(args?.includeDeleted, 'includeDeleted');
|
|
return jsonResult(
|
|
await client.listTransactions({
|
|
...(type ? { type } : {}),
|
|
...(statuses ? { statuses } : {}),
|
|
...(includeDeleted !== undefined ? { includeDeleted } : {}),
|
|
}),
|
|
);
|
|
},
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_create_transaction',
|
|
description: '创建交易',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['type', 'amount', 'transactionDate'],
|
|
properties: {
|
|
type: { type: 'string' },
|
|
amount: { type: 'number' },
|
|
currency: { type: 'string' },
|
|
categoryId: { type: ['number', 'null'] },
|
|
accountId: { type: ['number', 'null'] },
|
|
transactionDate: { type: 'string' },
|
|
description: { type: 'string' },
|
|
project: { type: ['string', 'null'] },
|
|
memo: { type: ['string', 'null'] },
|
|
status: { type: 'string' },
|
|
reimbursementBatch: { type: ['string', 'null'] },
|
|
reviewNotes: { type: ['string', 'null'] },
|
|
submittedBy: { type: ['string', 'null'] },
|
|
approvedBy: { type: ['string', 'null'] },
|
|
approvedAt: { type: ['string', 'null'] },
|
|
statusUpdatedAt: { type: ['string', 'null'] },
|
|
isDeleted: { type: 'boolean' },
|
|
},
|
|
},
|
|
handler: async (args) => jsonResult(await client.createTransaction(buildTransactionCreatePayload(args))),
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_update_transaction',
|
|
description: '更新交易',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['id'],
|
|
properties: {
|
|
id: { type: 'number' },
|
|
type: { type: 'string' },
|
|
amount: { type: 'number' },
|
|
currency: { type: 'string' },
|
|
categoryId: { type: ['number', 'null'] },
|
|
accountId: { type: ['number', 'null'] },
|
|
transactionDate: { type: 'string' },
|
|
description: { type: ['string', 'null'] },
|
|
project: { type: ['string', 'null'] },
|
|
memo: { type: ['string', 'null'] },
|
|
status: { type: 'string' },
|
|
statusUpdatedAt: { type: 'string' },
|
|
reimbursementBatch: { type: ['string', 'null'] },
|
|
reviewNotes: { type: ['string', 'null'] },
|
|
submittedBy: { type: ['string', 'null'] },
|
|
approvedBy: { type: ['string', 'null'] },
|
|
approvedAt: { type: ['string', 'null'] },
|
|
isDeleted: { type: 'boolean' },
|
|
},
|
|
},
|
|
handler: async (args) => jsonResult(
|
|
await client.updateTransaction(ensureNumber(args?.id, 'id'), buildTransactionUpdatePayload(args)),
|
|
),
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_delete_transaction',
|
|
description: '删除交易(软删)',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['id'],
|
|
properties: { id: { type: 'number' } },
|
|
},
|
|
handler: async (args) => jsonResult(await client.deleteTransaction(ensureNumber(args?.id, 'id'))),
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_list_reimbursements',
|
|
description: '查询报销单',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
type: { type: 'string' },
|
|
statuses: { type: ['array', 'string'], items: { type: 'string' } },
|
|
includeDeleted: { type: 'boolean' },
|
|
},
|
|
},
|
|
handler: async (args) => {
|
|
const type = optionalString(args?.type);
|
|
const statuses = parseStringArray(args?.statuses);
|
|
const includeDeleted = optionalBoolean(args?.includeDeleted, 'includeDeleted');
|
|
return jsonResult(
|
|
await client.listReimbursements({
|
|
...(type ? { type } : {}),
|
|
...(statuses ? { statuses } : {}),
|
|
...(includeDeleted !== undefined ? { includeDeleted } : {}),
|
|
}),
|
|
);
|
|
},
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_create_reimbursement',
|
|
description: '创建报销单',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['amount', 'transactionDate'],
|
|
properties: {
|
|
type: { type: 'string' },
|
|
amount: { type: 'number' },
|
|
currency: { type: 'string' },
|
|
categoryId: { type: ['number', 'null'] },
|
|
accountId: { type: ['number', 'null'] },
|
|
transactionDate: { type: 'string' },
|
|
description: { type: 'string' },
|
|
project: { type: ['string', 'null'] },
|
|
memo: { type: ['string', 'null'] },
|
|
status: { type: 'string' },
|
|
reimbursementBatch: { type: ['string', 'null'] },
|
|
reviewNotes: { type: ['string', 'null'] },
|
|
submittedBy: { type: ['string', 'null'] },
|
|
approvedBy: { type: ['string', 'null'] },
|
|
approvedAt: { type: ['string', 'null'] },
|
|
statusUpdatedAt: { type: ['string', 'null'] },
|
|
isDeleted: { type: 'boolean' },
|
|
},
|
|
},
|
|
handler: async (args) => jsonResult(
|
|
await client.createReimbursement(buildTransactionCreatePayload(args, { defaultType: 'expense' })),
|
|
),
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_update_reimbursement',
|
|
description: '更新报销单',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['id'],
|
|
properties: {
|
|
id: { type: 'number' },
|
|
type: { type: 'string' },
|
|
amount: { type: 'number' },
|
|
currency: { type: 'string' },
|
|
categoryId: { type: ['number', 'null'] },
|
|
accountId: { type: ['number', 'null'] },
|
|
transactionDate: { type: 'string' },
|
|
description: { type: ['string', 'null'] },
|
|
project: { type: ['string', 'null'] },
|
|
memo: { type: ['string', 'null'] },
|
|
status: { type: 'string' },
|
|
statusUpdatedAt: { type: 'string' },
|
|
reimbursementBatch: { type: ['string', 'null'] },
|
|
reviewNotes: { type: ['string', 'null'] },
|
|
submittedBy: { type: ['string', 'null'] },
|
|
approvedBy: { type: ['string', 'null'] },
|
|
approvedAt: { type: ['string', 'null'] },
|
|
isDeleted: { type: 'boolean' },
|
|
},
|
|
},
|
|
handler: async (args) => jsonResult(
|
|
await client.updateReimbursement(
|
|
ensureNumber(args?.id, 'id'),
|
|
buildTransactionUpdatePayload(args),
|
|
),
|
|
),
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_list_media',
|
|
description: '查询媒体消息',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
limit: { type: 'number' },
|
|
fileTypes: { type: ['array', 'string'], items: { type: 'string' } },
|
|
},
|
|
},
|
|
handler: async (args) => {
|
|
const limit = optionalNumber(args?.limit, 'limit');
|
|
const fileTypes = parseStringArray(args?.fileTypes);
|
|
return jsonResult(
|
|
await client.listMedia({
|
|
...(limit !== undefined ? { limit } : {}),
|
|
...(fileTypes ? { fileTypes } : {}),
|
|
}),
|
|
);
|
|
},
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_get_media',
|
|
description: '根据 ID 获取媒体详情',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['id'],
|
|
properties: { id: { type: 'number' } },
|
|
},
|
|
handler: async (args) => jsonResult(await client.getMediaById(ensureNumber(args?.id, 'id'))),
|
|
});
|
|
|
|
tools.push({
|
|
name: 'finance_download_media',
|
|
description: '下载媒体文件并返回 Base64',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['id'],
|
|
properties: {
|
|
id: { type: 'number' },
|
|
includeMetadata: { type: 'boolean', default: true },
|
|
},
|
|
},
|
|
outputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
fileName: { type: ['string', 'null'] },
|
|
mimeType: { type: 'string' },
|
|
size: { type: 'number' },
|
|
base64: { type: 'string' },
|
|
metadata: { type: ['object', 'null'] },
|
|
},
|
|
},
|
|
handler: async (args) => {
|
|
const id = ensureNumber(args?.id, 'id');
|
|
const includeMetadata = optionalBoolean(args?.includeMetadata, 'includeMetadata');
|
|
const file = await client.downloadMedia(id);
|
|
const metadata = includeMetadata === false ? null : await client.getMediaById(id);
|
|
return jsonResult({ ...file, metadata });
|
|
},
|
|
});
|
|
|
|
return tools;
|
|
};
|
|
|
|
const createServer = () => {
|
|
const baseUrl =
|
|
process.env.FINANCE_API_BASE_URL ?? 'http://172.16.74.149:5666';
|
|
const apiKey = process.env.FINANCE_API_KEY;
|
|
const timeoutEnv = process.env.FINANCE_API_TIMEOUT;
|
|
const timeout = timeoutEnv ? Number.parseInt(timeoutEnv, 10) : undefined;
|
|
const basicUsername =
|
|
process.env.FINANCE_BASIC_USERNAME ??
|
|
process.env.FINANCE_BASIC_USER ??
|
|
process.env.FINANCE_USERNAME;
|
|
const basicPassword =
|
|
process.env.FINANCE_BASIC_PASSWORD ??
|
|
process.env.FINANCE_PASSWORD;
|
|
const basicAuth =
|
|
basicUsername && basicPassword ? { username: basicUsername, password: basicPassword } : undefined;
|
|
|
|
const client = new FinanceClient({
|
|
baseUrl,
|
|
apiKey,
|
|
basicAuth,
|
|
timeoutMs: Number.isFinite(timeout ?? NaN) ? timeout : undefined,
|
|
});
|
|
|
|
return new McpServer({
|
|
name: 'finwise-finance',
|
|
version: '0.1.0',
|
|
description: 'Finwise Pro 财务接口 MCP 服务',
|
|
tools: createFinanceTools(client),
|
|
});
|
|
};
|
|
|
|
createServer().start();
|