feat: add Finance MCP workflow
Some checks failed
Deploy Finance MCP Service / build-mcp (push) Successful in 5m21s
Deploy to Production / Build and Test (push) Successful in 10m12s
Deploy Finance MCP Service / deploy-mcp (push) Failing after 4s
Deploy to Production / Deploy to Server (push) Successful in 6m24s

This commit is contained in:
你的用户名
2025-11-08 19:39:10 +08:00
parent 8469cd8d83
commit 076b9fac5f
16 changed files with 2069 additions and 1268 deletions

View File

@@ -1,12 +1,23 @@
{
"name": "@vben/finance-mcp-service",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"description": "MCP service exposing Finwise Pro finance APIs",
"scripts": {
"start": "node src/index.js"
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {},
"devDependencies": {}
"dependencies": {
"p-queue": "^9.0.0",
"pino": "^10.1.0",
"zod": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"tsx": "^4.20.6",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,394 @@
import { Buffer } from 'node:buffer';
interface FinanceEnvelope<T> {
code: number;
message?: string;
data: T;
}
interface BasicCredentials {
username: string;
password: string;
}
export interface FinanceClientConfig {
baseUrl: string;
apiKey?: string;
basicAuth?: BasicCredentials;
timeoutMs?: number;
}
export interface ListTransactionsParams {
type?: string;
statuses?: string[];
includeDeleted?: boolean;
}
export interface ListReimbursementsParams {
type?: string;
statuses?: string[];
includeDeleted?: boolean;
}
export interface ListMediaParams {
limit?: number;
fileTypes?: string[];
}
export interface ListExchangeRatesParams {
fromCurrency?: string;
toCurrency?: string;
date?: string;
}
export interface TelegramConfigPayload {
name?: string;
botToken?: string;
chatId?: string;
notificationTypes?: string[];
isEnabled?: boolean;
}
export class FinanceClient {
private readonly apiKey?: string;
private readonly baseUrl: string;
private readonly basicAuth?: BasicCredentials;
private readonly timeoutMs?: number;
constructor(config: FinanceClientConfig) {
if (!config?.baseUrl) {
throw new Error('FinanceClient requires a baseUrl');
}
this.baseUrl = config.baseUrl.replace(/\/$/, '');
this.apiKey = config.apiKey;
this.basicAuth = validateBasicAuth(config.basicAuth);
this.timeoutMs = config.timeoutMs;
}
createBudget(payload: unknown) {
return this.post('/api/finance/budgets', payload);
}
createCategory(payload: unknown) {
return this.post('/api/finance/categories', payload);
}
createReimbursement(payload: unknown) {
return this.post('/api/finance/reimbursements', payload);
}
createReimbursementMedia(payload: unknown) {
return this.post('/api/finance/media', payload);
}
createTelegramConfig(payload: TelegramConfigPayload) {
return this.post('/api/telegram/notifications', payload);
}
createTransaction(payload: unknown) {
return this.post('/api/finance/transactions', payload);
}
deleteBudget(id: number) {
return this.delete(`/api/finance/budgets/${id}`);
}
deleteCategory(id: number) {
return this.delete(`/api/finance/categories/${id}`);
}
deleteTelegramConfig(id: number) {
return this.delete(`/api/telegram/notifications/${id}`);
}
deleteTransaction(id: number) {
return this.delete(`/api/finance/transactions/${id}`);
}
downloadMedia(id: number) {
return this.download(`/api/finance/media/${id}/download`);
}
getMediaById(id: number) {
return this.get(`/api/finance/media/${id}`);
}
listAccounts(params: { currency?: string } = {}) {
return this.get('/api/finance/accounts', params);
}
listBudgets() {
return this.get('/api/finance/budgets');
}
listCategories(params: { type?: string } = {}) {
return this.get('/api/finance/categories', params);
}
listCurrencies() {
return this.get('/api/finance/currencies');
}
listExchangeRates(params: ListExchangeRatesParams = {}) {
const query: Record<string, string> = {};
if (params.fromCurrency) query.from = params.fromCurrency;
if (params.toCurrency) query.to = params.toCurrency;
if (params.date) query.date = params.date;
return this.get('/api/finance/exchange-rates', query);
}
listMedia(params: ListMediaParams = {}) {
const query: Record<string, number | string> = {};
if (typeof params.limit === 'number') query.limit = params.limit;
if (params.fileTypes?.length) query.types = params.fileTypes.join(',');
return this.get('/api/finance/media', query);
}
listReimbursements(params: ListReimbursementsParams = {}) {
const query: Record<string, boolean | string> = {};
if (params.type) query.type = params.type;
if (params.statuses?.length) query.statuses = params.statuses.join(',');
if (params.includeDeleted !== undefined)
query.includeDeleted = params.includeDeleted;
return this.get('/api/finance/reimbursements', query);
}
listTelegramConfigs() {
return this.get('/api/telegram/notifications');
}
listTransactions(params: ListTransactionsParams = {}) {
const query: Record<string, boolean | string> = {};
if (params.type) query.type = params.type;
if (params.statuses?.length) query.statuses = params.statuses.join(',');
if (params.includeDeleted !== undefined)
query.includeDeleted = params.includeDeleted;
return this.get('/api/finance/transactions', query);
}
testTelegramConfig(payload: { botToken: string; chatId: string }) {
return this.post('/api/telegram/test', payload);
}
updateBudget(id: number, payload: unknown) {
return this.put(`/api/finance/budgets/${id}`, payload);
}
updateCategory(id: number, payload: unknown) {
return this.put(`/api/finance/categories/${id}`, payload);
}
updateReimbursement(id: number, payload: unknown) {
return this.put(`/api/finance/reimbursements/${id}`, payload);
}
updateTelegramConfig(id: number, payload: TelegramConfigPayload) {
return this.put(`/api/telegram/notifications/${id}`, payload);
}
updateTransaction(id: number, payload: unknown) {
return this.put(`/api/finance/transactions/${id}`, payload);
}
private buildHeaders(json: boolean) {
const headers: Record<string, string> = { Accept: 'application/json' };
if (json) headers['Content-Type'] = 'application/json';
if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
else if (this.basicAuth)
headers.Authorization = `Basic ${createBasicToken(this.basicAuth)}`;
return headers;
}
private createUrl(path: string) {
const normalized = path.startsWith('/') ? path : `/${path}`;
return new URL(normalized, this.baseUrl);
}
private async delete(path: string) {
return this.request('DELETE', path);
}
private async download(path: string) {
const url = this.createUrl(path);
const response = await this.performFetch(url, {
method: 'GET',
headers: this.buildHeaders(false),
});
if (!response.ok) {
const payload =
await this.safeParseEnvelope<FinanceEnvelope<unknown>>(response);
if (payload) {
throw new Error(payload.message || 'Failed to download media file');
}
throw new Error(
`Failed to download media file (HTTP ${response.status})`,
);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return {
fileName: this.extractFileName(
response.headers.get('content-disposition'),
),
mimeType:
response.headers.get('content-type') ?? 'application/octet-stream',
size: buffer.byteLength,
base64: buffer.toString('base64'),
};
}
private extractFileName(contentDisposition: null | string) {
if (!contentDisposition) return undefined;
const filenameStar = contentDisposition.match(/filename\*=([^;]+)/i);
if (filenameStar?.[1]) {
const value = filenameStar[1].replace(/^UTF-8''/, '');
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
const filename = contentDisposition.match(/filename="?([^";]+)"?/i);
return filename?.[1];
}
private async get(path: string, query?: Record<string, unknown>) {
return this.request('GET', path, { query });
}
private async parseEnvelope(response: Response, path: string) {
const payload =
await this.safeParseEnvelope<FinanceEnvelope<unknown>>(response);
if (!payload) {
const text = await response.text();
throw new Error(
`Unexpected response from ${path}: ${text || response.statusText}`,
);
}
if (!response.ok) {
throw new Error(
payload.message ||
`Finance API request failed (HTTP ${response.status})`,
);
}
return payload;
}
private async performFetch(url: URL, init: RequestInit) {
const controller = this.timeoutMs ? new AbortController() : undefined;
let timer: NodeJS.Timeout | undefined;
if (controller) {
init.signal = controller.signal;
timer = setTimeout(() => controller.abort(), this.timeoutMs);
}
try {
return await fetch(url, init);
} catch (error: unknown) {
if ((error as Error)?.name === 'AbortError') {
throw new Error(`Request to ${url.pathname} timed out`);
}
throw error;
} finally {
if (timer) clearTimeout(timer);
}
}
private async post(path: string, body?: unknown) {
return this.request('POST', path, { body });
}
private async put(path: string, body?: unknown) {
return this.request('PUT', path, { body });
}
private async request(
method: 'DELETE' | 'GET' | 'POST' | 'PUT',
path: string,
options: {
body?: unknown;
query?: Record<string, unknown>;
} = {},
) {
const url = this.createUrl(path);
if (options.query) {
for (const [key, value] of Object.entries(options.query)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
if (value.length > 0) url.searchParams.set(key, value.join(','));
} else if (typeof value === 'boolean') {
url.searchParams.set(key, value ? 'true' : 'false');
} else {
url.searchParams.set(key, String(value));
}
}
}
const response = await this.performFetch(url, {
method,
headers: this.buildHeaders(method !== 'GET' && method !== 'DELETE'),
body: options.body ? JSON.stringify(options.body) : undefined,
});
const payload = await this.parseEnvelope(response, path);
if (payload.code !== 0) {
throw new Error(payload.message || 'Finance API returned an error');
}
return payload.data;
}
private async safeParseEnvelope<T>(response: Response) {
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) return null;
try {
return (await response.clone().json()) as T;
} catch {
return null;
}
}
}
type BasicAuthLike = Partial<
BasicCredentials & {
login: string;
pass: string;
user: string;
}
>;
const validateBasicAuth = (
credentials?: BasicAuthLike | null,
): BasicCredentials | undefined => {
if (!credentials) return undefined;
const username =
credentials.username ?? credentials.user ?? credentials.login;
const password = credentials.password ?? credentials.pass;
if (!username && !password) return undefined;
if (!username || !password) {
throw new Error(
'FinanceClient basicAuth requires both username and password',
);
}
return { username: String(username), password: String(password) };
};
const createBasicToken = ({ username, password }: BasicCredentials) =>
Buffer.from(`${username}:${password}`, 'utf8').toString('base64');

View File

@@ -0,0 +1,57 @@
import process from 'node:process';
import { z } from 'zod';
const DEFAULT_BASE_URL = 'http://172.16.74.149:5666';
const EnvSchema = z.object({
FINANCE_API_BASE_URL: z.string().trim().optional(),
FINANCE_API_KEY: z.string().trim().optional(),
FINANCE_API_TIMEOUT: z.string().trim().optional(),
FINANCE_BASIC_USERNAME: z.string().optional(),
FINANCE_BASIC_PASSWORD: z.string().optional(),
FINANCE_BASIC_USER: z.string().optional(),
FINANCE_USERNAME: z.string().optional(),
FINANCE_PASSWORD: z.string().optional(),
FINANCE_MCP_MAX_CONCURRENCY: z.string().trim().optional(),
});
const parsed = EnvSchema.parse(process.env);
const baseUrl =
parsed.FINANCE_API_BASE_URL && parsed.FINANCE_API_BASE_URL.length > 0
? parsed.FINANCE_API_BASE_URL
: DEFAULT_BASE_URL;
const timeoutMs = parsed.FINANCE_API_TIMEOUT
? Number.parseInt(parsed.FINANCE_API_TIMEOUT, 10)
: undefined;
const maxConcurrencyRaw = parsed.FINANCE_MCP_MAX_CONCURRENCY
? Number.parseInt(parsed.FINANCE_MCP_MAX_CONCURRENCY, 10)
: undefined;
const maxConcurrency =
Number.isFinite(maxConcurrencyRaw ?? Number.NaN) &&
(maxConcurrencyRaw ?? 0) > 0
? (maxConcurrencyRaw as number)
: 4;
const username =
parsed.FINANCE_BASIC_USERNAME ??
parsed.FINANCE_BASIC_USER ??
parsed.FINANCE_USERNAME ??
null;
const password =
parsed.FINANCE_BASIC_PASSWORD ?? parsed.FINANCE_PASSWORD ?? null;
export const config = {
baseUrl,
apiKey: parsed.FINANCE_API_KEY,
timeoutMs:
Number.isFinite(timeoutMs ?? Number.NaN) && (timeoutMs ?? 0) > 0
? (timeoutMs as number)
: undefined,
maxConcurrency,
basicAuth: username && password ? { username, password } : undefined,
};

View File

@@ -1,285 +0,0 @@
import { Buffer } from 'node:buffer';
export class FinanceClient {
constructor(config) {
if (!config?.baseUrl) {
throw new Error('FinanceClient requires a baseUrl');
}
this.baseUrl = config.baseUrl.replace(/\/$/, '');
this.apiKey = config.apiKey;
this.basicAuth = validateBasicAuth(config.basicAuth);
this.timeoutMs = config.timeoutMs;
}
async listAccounts(params = {}) {
return this.get('/api/finance/accounts', params);
}
async listBudgets() {
return this.get('/api/finance/budgets');
}
async createBudget(payload) {
return this.post('/api/finance/budgets', payload);
}
async updateBudget(id, payload) {
return this.put(`/api/finance/budgets/${id}`, payload);
}
async deleteBudget(id) {
return this.delete(`/api/finance/budgets/${id}`);
}
async listCategories(params = {}) {
return this.get('/api/finance/categories', params);
}
async createCategory(payload) {
return this.post('/api/finance/categories', payload);
}
async updateCategory(id, payload) {
return this.put(`/api/finance/categories/${id}`, payload);
}
async deleteCategory(id) {
return this.delete(`/api/finance/categories/${id}`);
}
async listCurrencies() {
return this.get('/api/finance/currencies');
}
async listExchangeRates(params = {}) {
const query = {};
if (params.fromCurrency) query.from = params.fromCurrency;
if (params.toCurrency) query.to = params.toCurrency;
if (params.date) query.date = params.date;
return this.get('/api/finance/exchange-rates', query);
}
async listTransactions(params = {}) {
const query = {};
if (params.type) query.type = params.type;
if (params.statuses?.length) {
query.statuses = params.statuses.join(',');
}
if (params.includeDeleted !== undefined) {
query.includeDeleted = params.includeDeleted;
}
return this.get('/api/finance/transactions', query);
}
async createTransaction(payload) {
return this.post('/api/finance/transactions', payload);
}
async updateTransaction(id, payload) {
return this.put(`/api/finance/transactions/${id}`, payload);
}
async deleteTransaction(id) {
return this.delete(`/api/finance/transactions/${id}`);
}
async listReimbursements(params = {}) {
const query = {};
if (params.type) query.type = params.type;
if (params.statuses?.length) {
query.statuses = params.statuses.join(',');
}
if (params.includeDeleted !== undefined) {
query.includeDeleted = params.includeDeleted;
}
return this.get('/api/finance/reimbursements', query);
}
async createReimbursement(payload) {
return this.post('/api/finance/reimbursements', payload);
}
async updateReimbursement(id, payload) {
return this.put(`/api/finance/reimbursements/${id}`, payload);
}
async listMedia(params = {}) {
const query = {};
if (params.limit !== undefined) query.limit = params.limit;
if (params.fileTypes?.length) query.types = params.fileTypes.join(',');
return this.get('/api/finance/media', query);
}
async getMediaById(id) {
return this.get(`/api/finance/media/${id}`);
}
async downloadMedia(id) {
const url = this.createUrl(`/api/finance/media/${id}/download`);
const response = await this.performFetch(url, {
method: 'GET',
headers: this.buildHeaders(false),
});
if (!response.ok) {
const payload = await this.safeParseEnvelope(response);
if (payload) {
throw new Error(payload.message || 'Failed to download media file');
}
throw new Error(`Failed to download media file (HTTP ${response.status})`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return {
fileName: this.extractFileName(response.headers.get('content-disposition')),
mimeType: response.headers.get('content-type') ?? 'application/octet-stream',
size: buffer.byteLength,
base64: buffer.toString('base64'),
};
}
async get(path, query) {
return this.request('GET', path, { query });
}
async post(path, body) {
return this.request('POST', path, { body });
}
async put(path, body) {
return this.request('PUT', path, { body });
}
async delete(path) {
return this.request('DELETE', path);
}
async request(method, path, options = {}) {
const url = this.createUrl(path);
if (options.query) {
for (const [key, value] of Object.entries(options.query)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
if (value.length > 0) url.searchParams.set(key, value.join(','));
} else if (typeof value === 'boolean') {
url.searchParams.set(key, value ? 'true' : 'false');
} else {
url.searchParams.set(key, String(value));
}
}
}
const response = await this.performFetch(url, {
method,
headers: this.buildHeaders(method !== 'GET' && method !== 'DELETE'),
body: options.body ? JSON.stringify(options.body) : undefined,
});
const payload = await this.parseEnvelope(response, path);
if (payload.code !== 0) {
throw new Error(payload.message || 'Finance API returned an error');
}
return payload.data;
}
createUrl(path) {
if (!path.startsWith('/')) {
path = `/${path}`;
}
return new URL(path, this.baseUrl);
}
buildHeaders(json) {
const headers = { Accept: 'application/json' };
if (json) headers['Content-Type'] = 'application/json';
if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
else if (this.basicAuth) headers.Authorization = `Basic ${createBasicToken(this.basicAuth)}`;
return headers;
}
async performFetch(url, init) {
const controller = this.timeoutMs ? new AbortController() : undefined;
let timer;
if (controller) {
init.signal = controller.signal;
timer = setTimeout(() => controller.abort(), this.timeoutMs);
}
try {
return await fetch(url, init);
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`Request to ${url.pathname} timed out`);
}
throw error;
} finally {
if (timer) clearTimeout(timer);
}
}
async parseEnvelope(response, path) {
const payload = await this.safeParseEnvelope(response);
if (!payload) {
const text = await response.text();
throw new Error(`Unexpected response from ${path}: ${text || response.statusText}`);
}
if (!response.ok) {
throw new Error(payload.message || `Finance API request failed (HTTP ${response.status})`);
}
return payload;
}
async safeParseEnvelope(response) {
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) return null;
try {
return await response.clone().json();
} catch {
return null;
}
}
extractFileName(contentDisposition) {
if (!contentDisposition) return undefined;
const filenameStar = contentDisposition.match(/filename\*=([^;]+)/i);
if (filenameStar?.[1]) {
const value = filenameStar[1].replace(/^UTF-8''/, '');
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
const filename = contentDisposition.match(/filename="?([^";]+)"?/i);
return filename?.[1];
}
}
const validateBasicAuth = (credentials) => {
if (!credentials) return undefined;
const username = credentials.username ?? credentials.user ?? credentials.login;
const password = credentials.password ?? credentials.pass;
if (!username && !password) return undefined;
if (!username || !password) {
throw new Error('FinanceClient basicAuth requires both username and password');
}
return { username: String(username), password: String(password) };
};
const createBasicToken = ({ username, password }) =>
Buffer.from(`${username}:${password}`, 'utf8').toString('base64');

View File

@@ -1,901 +0,0 @@
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();

View File

@@ -0,0 +1,35 @@
import process from 'node:process';
import { FinanceClient } from './client/finance-client.js';
import { config } from './config.js';
import { logger } from './logger.js';
import { McpServer } from './server/mcp-server.js';
import { createFinanceTools } from './tools/finance.js';
process.on('exit', (code) => {
logger.info({ code }, 'process exit');
});
process.on('uncaughtException', (error) => {
logger.error({ err: error }, 'uncaught exception');
});
process.on('unhandledRejection', (reason) => {
logger.error({ reason }, 'unhandled rejection');
});
const client = new FinanceClient({
baseUrl: config.baseUrl,
apiKey: config.apiKey,
basicAuth: config.basicAuth,
timeoutMs: config.timeoutMs,
});
const server = new McpServer({
name: 'finwise-finance',
version: '0.2.0',
description: 'Finwise Pro 财务接口 MCP 服务',
tools: createFinanceTools(client),
logger,
concurrency: config.maxConcurrency,
});
server.start();

View File

@@ -0,0 +1,12 @@
import process from 'node:process';
import pino from 'pino';
const level =
process.env.FINANCE_MCP_LOG_LEVEL ?? process.env.LOG_LEVEL ?? 'info';
export const logger = pino({
name: 'finwise-finance',
level,
});
export type Logger = typeof logger;

View File

@@ -0,0 +1,270 @@
import type { Logger } from '../logger.js';
import type { McpToolDefinition, ToolContext } from '../types.js';
import { Buffer } from 'node:buffer';
import process from 'node:process';
import PQueue from 'p-queue';
interface JsonRpcRequest {
jsonrpc: '2.0';
id?: null | number | string;
method: string;
params?: Record<string, unknown>;
}
interface JsonRpcSuccess {
jsonrpc: '2.0';
id: null | number | string;
result: unknown;
}
interface JsonRpcError {
jsonrpc: '2.0';
id: null | number | string;
error: {
code: number;
message: string;
};
}
export interface McpServerOptions {
name: string;
version: string;
description: string;
tools: McpToolDefinition[];
logger: Logger;
concurrency?: number;
}
export class McpServer {
private buffer = '';
private expectedLength: null | number = null;
private initialized = false;
private readonly metadata: Array<Omit<McpToolDefinition, 'handler'>>;
private readonly options: McpServerOptions;
private readonly queue: PQueue;
private readonly tools: Map<string, McpToolDefinition>;
constructor(options: McpServerOptions) {
this.options = options;
this.tools = new Map();
this.metadata = [];
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 } : {}),
});
}
this.queue = new PQueue({ concurrency: options.concurrency ?? 4 });
}
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');
}
private assertInitialized(method: string) {
if (!this.initialized) {
throw new Error(`Received ${method} before initialize`);
}
}
private async dispatch(request: JsonRpcRequest) {
switch (request.method) {
case 'initialize': {
if (this.initialized) {
this.respondError(request.id ?? null, -32_600, 'Already initialized');
return;
}
this.initialized = true;
this.respond(request.id ?? null, {
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 'ping': {
this.respond(request.id ?? null, 'pong');
return;
}
case 'shutdown': {
this.respond(request.id ?? null, null);
process.exitCode = 0;
process.nextTick(() => {
process.stdin.pause();
this.log('shutdown signal received');
});
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 ?? null,
-32_602,
'Tool name is required',
);
return;
}
const tool = this.tools.get(toolName);
if (!tool) {
this.respondError(
request.id ?? null,
-32_601,
`Unknown tool: ${toolName}`,
);
return;
}
await this.queue.add(async () => {
try {
const args = (params.arguments ?? {}) as Record<string, unknown>;
const context: ToolContext = { logger: this.options.logger };
const result = await tool.handler(args, context);
this.respond(request.id ?? null, result);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
this.respondError(request.id ?? null, -32_001, message);
}
});
return;
}
case 'tools/list': {
this.assertInitialized('tools/list');
this.respond(request.id ?? null, { tools: this.metadata });
return;
}
default: {
this.respondError(
request.id ?? null,
-32_601,
`Method not found: ${request.method}`,
);
}
}
}
private 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;
}
const lengthHeader = match[1];
if (!lengthHeader) {
this.buffer = this.buffer.slice(headerEnd + 4);
continue;
}
this.expectedLength = Number.parseInt(lengthHeader, 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);
}
}
private async handleMessage(payload: string) {
let request: JsonRpcRequest | null = null;
try {
request = JSON.parse(payload) as JsonRpcRequest;
} catch {
this.respondError(null, -32_700, 'Parse error');
return;
}
if (
!request ||
request.jsonrpc !== '2.0' ||
typeof request.method !== 'string'
) {
this.respondError(request?.id ?? null, -32_600, 'Invalid Request');
return;
}
try {
await this.dispatch(request);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log(`Unexpected error: ${message}`);
this.respondError(request.id ?? null, -32_000, message);
}
}
private log(message: string) {
this.options.logger.debug({ scope: 'mcp-server' }, message);
}
private notify(method: string, params: Record<string, unknown>) {
this.write({ jsonrpc: '2.0', method, params });
}
private respond(id: JsonRpcSuccess['id'], result: unknown) {
if (id === undefined) return;
this.write({ jsonrpc: '2.0', id, result });
}
private respondError(id: JsonRpcError['id'], code: number, message: string) {
if (id === undefined) return;
this.write({ jsonrpc: '2.0', id, error: { code, message } });
}
private write(payload: JsonRpcError | JsonRpcRequest | JsonRpcSuccess) {
const json = JSON.stringify(payload);
const frame = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`;
process.stdout.write(frame);
}
}

View File

@@ -0,0 +1,789 @@
import type {
FinanceClient,
ListExchangeRatesParams,
} from '../client/finance-client.js';
import type { McpToolDefinition, ToolContext } from '../types.js';
import { jsonResult } from '../utils/mcp.js';
import {
ensureNumber,
ensureString,
optionalBoolean,
optionalNullableNumber,
optionalNullableString,
optionalNumber,
optionalString,
parseStringArray,
} from '../utils/validation.js';
type ToolArgs = Record<string, unknown>;
interface CreateTransactionOptions {
defaultType?: string;
}
export const createFinanceTools = (
client: FinanceClient,
): McpToolDefinition[] => {
const tools: McpToolDefinition[] = [];
tools.push(
{
name: 'finance_list_accounts',
description: '列出账户,可选货币过滤',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
currency: { type: 'string', description: 'ISO 4217 货币代码' },
},
},
handler: async (args: ToolArgs, _context: ToolContext) => {
const currency = optionalString(args?.currency);
return jsonResult(
await client.listAccounts(currency ? { currency } : {}),
);
},
},
{
name: 'finance_list_budgets',
description: '查询预算列表',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {},
},
handler: async () => jsonResult(await client.listBudgets()),
},
{
name: 'finance_create_budget',
description: '创建预算',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['name', 'amount', 'currency', 'startDate', 'endDate'],
properties: {
name: { type: 'string' },
amount: { type: 'number' },
currency: { type: 'string' },
startDate: { type: 'string' },
endDate: { type: 'string' },
description: { type: 'string' },
categoryId: { type: 'number' },
project: { type: 'string' },
owner: { type: 'string' },
},
},
handler: async (args: ToolArgs, _context: ToolContext) =>
jsonResult(
await client.createBudget({
name: ensureString(args?.name, 'name'),
amount: ensureNumber(args?.amount, 'amount'),
currency: ensureString(args?.currency, 'currency'),
startDate: ensureString(args?.startDate, 'startDate'),
endDate: ensureString(args?.endDate, 'endDate'),
description: optionalString(args?.description),
categoryId: optionalNumber(args?.categoryId, 'categoryId'),
project: optionalString(args?.project),
owner: optionalString(args?.owner),
}),
),
},
{
name: 'finance_update_budget',
description: '更新预算',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['id'],
properties: {
id: { type: 'number' },
name: { type: 'string' },
amount: { type: 'number' },
currency: { type: 'string' },
startDate: { type: 'string' },
endDate: { type: 'string' },
description: { type: 'string' },
categoryId: { type: 'number' },
project: { type: 'string' },
owner: { type: 'string' },
},
},
handler: async (args: ToolArgs, _context: ToolContext) => {
const id = ensureNumber(args?.id, 'id');
const payload: Record<string, unknown> = {};
if (args?.name !== undefined)
payload.name = ensureString(args.name, 'name');
if (args?.amount !== undefined)
payload.amount = ensureNumber(args.amount, 'amount');
if (args?.currency !== undefined)
payload.currency = ensureString(args.currency, 'currency');
if (args?.startDate !== undefined)
payload.startDate = ensureString(args.startDate, 'startDate');
if (args?.endDate !== undefined)
payload.endDate = ensureString(args.endDate, 'endDate');
if (args?.description !== undefined)
payload.description = optionalString(args.description);
if (args?.categoryId !== undefined)
payload.categoryId = optionalNumber(args.categoryId, 'categoryId');
if (args?.project !== undefined)
payload.project = optionalString(args.project);
if (args?.owner !== undefined)
payload.owner = optionalString(args.owner);
return jsonResult(await client.updateBudget(id, payload));
},
},
{
name: 'finance_delete_budget',
description: '删除预算',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['id'],
properties: { id: { type: 'number' } },
},
handler: async (args: ToolArgs, _context: ToolContext) =>
jsonResult(await client.deleteBudget(ensureNumber(args?.id, 'id'))),
},
{
name: 'finance_list_categories',
description: '查询分类,可按类型过滤',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
type: {
type: 'string',
enum: ['expense', 'income', 'transfer'],
},
},
},
handler: async (args: ToolArgs, _context: ToolContext) => {
const type = optionalString(args?.type);
return jsonResult(await client.listCategories(type ? { type } : {}));
},
},
{
name: 'finance_create_category',
description: '创建分类',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['name', 'type'],
properties: {
name: { type: 'string' },
type: { type: 'string', enum: ['expense', 'income', 'transfer'] },
icon: { type: 'string' },
color: { type: 'string' },
userId: { type: 'number' },
isActive: { type: 'boolean' },
},
},
handler: async (args: ToolArgs, _context: ToolContext) =>
jsonResult(
await client.createCategory({
name: ensureString(args?.name, 'name'),
type: ensureString(args?.type, 'type'),
icon: optionalString(args?.icon),
color: optionalString(args?.color),
userId: optionalNumber(args?.userId, 'userId'),
isActive: optionalBoolean(args?.isActive, 'isActive') ?? true,
}),
),
},
{
name: 'finance_update_category',
description: '更新分类',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['id'],
properties: {
id: { type: 'number' },
name: { type: 'string' },
type: { type: 'string' },
icon: { type: 'string' },
color: { type: 'string' },
userId: { type: 'number' },
isActive: { type: 'boolean' },
},
},
handler: async (args: ToolArgs, _context: ToolContext) => {
const id = ensureNumber(args?.id, 'id');
const payload: Record<string, unknown> = {};
if (args?.name !== undefined)
payload.name = ensureString(args.name, 'name');
if (args?.type !== undefined)
payload.type = ensureString(args.type, 'type');
if (args?.icon !== undefined) payload.icon = optionalString(args.icon);
if (args?.color !== undefined)
payload.color = optionalString(args.color);
if (args?.userId !== undefined)
payload.userId = optionalNumber(args.userId, 'userId');
if (args?.isActive !== undefined)
payload.isActive = optionalBoolean(args.isActive, 'isActive');
return jsonResult(await client.updateCategory(id, payload));
},
},
{
name: 'finance_delete_category',
description: '删除分类',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['id'],
properties: { id: { type: 'number' } },
},
handler: async (args: ToolArgs, _context: ToolContext) =>
jsonResult(await client.deleteCategory(ensureNumber(args?.id, 'id'))),
},
{
name: 'finance_list_currencies',
description: '查询货币列表',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {},
},
handler: async () => jsonResult(await client.listCurrencies()),
},
{
name: 'finance_list_exchange_rates',
description: '查询汇率,可按货币/日期过滤',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fromCurrency: { type: 'string' },
toCurrency: { type: 'string' },
date: { type: 'string' },
},
},
handler: async (args: ToolArgs, _context: ToolContext) => {
const params: ListExchangeRatesParams = {};
if (args?.fromCurrency)
params.fromCurrency = ensureString(args.fromCurrency, 'fromCurrency');
if (args?.toCurrency)
params.toCurrency = ensureString(args.toCurrency, 'toCurrency');
if (args?.date) params.date = ensureString(args.date, 'date');
return jsonResult(await client.listExchangeRates(params));
},
},
{
name: 'finance_list_transactions',
description: '查询交易记录',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
type: { type: 'string', enum: ['expense', 'income', 'transfer'] },
statuses: {
type: ['array', 'string'],
items: { type: 'string' },
},
includeDeleted: { type: 'boolean' },
},
},
handler: async (args: ToolArgs, _context: ToolContext) => {
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 }),
}),
);
},
},
{
name: 'finance_create_transaction',
description: '创建交易记录',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['type', 'amount', 'currency', 'transactionDate'],
properties: {
type: { type: 'string', enum: ['expense', 'income', 'transfer'] },
amount: { type: 'number' },
currency: { type: 'string' },
transactionDate: { type: 'string', description: 'ISO 日期' },
categoryId: { type: ['number', 'null'] },
accountId: { type: ['number', 'null'] },
description: { type: 'string' },
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: ToolArgs, _context: ToolContext) =>
jsonResult(
await client.createTransaction(buildTransactionCreatePayload(args)),
),
},
{
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' },
transactionDate: { type: 'string' },
categoryId: { type: ['number', 'null'] },
accountId: { type: ['number', 'null'] },
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: ToolArgs, _context: ToolContext) =>
jsonResult(
await client.updateTransaction(
ensureNumber(args?.id, 'id'),
buildTransactionUpdatePayload(args),
),
),
},
{
name: 'finance_delete_transaction',
description: '删除交易记录',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['id'],
properties: { id: { type: 'number' } },
},
handler: async (args: ToolArgs, _context: ToolContext) =>
jsonResult(
await client.deleteTransaction(ensureNumber(args?.id, 'id')),
),
},
{
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: ToolArgs, _context: ToolContext) => {
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 }),
}),
);
},
},
{
name: 'finance_create_reimbursement',
description: '创建报销记录',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['amount', 'currency', 'transactionDate'],
properties: {
type: { type: 'string', default: 'expense' },
amount: { type: 'number' },
currency: { type: 'string' },
transactionDate: { type: 'string' },
categoryId: { type: ['number', 'null'] },
accountId: { type: ['number', 'null'] },
description: { type: 'string' },
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: ToolArgs, _context: ToolContext) =>
jsonResult(
await client.createReimbursement(
buildTransactionCreatePayload(args, { defaultType: 'expense' }),
),
),
},
{
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' },
transactionDate: { type: 'string' },
categoryId: { type: ['number', 'null'] },
accountId: { type: ['number', 'null'] },
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: ToolArgs, _context: ToolContext) =>
jsonResult(
await client.updateReimbursement(
ensureNumber(args?.id, 'id'),
buildTransactionUpdatePayload(args),
),
),
},
{
name: 'finance_list_media',
description: '查询媒体消息',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
limit: { type: 'number' },
fileTypes: { type: ['array', 'string'], items: { type: 'string' } },
},
},
handler: async (args: ToolArgs, _context: ToolContext) => {
const limit = optionalNumber(args?.limit, 'limit');
const fileTypes = parseStringArray(args?.fileTypes);
return jsonResult(
await client.listMedia({
...(limit === undefined ? {} : { limit }),
...(fileTypes ? { fileTypes } : {}),
}),
);
},
},
{
name: 'finance_get_media',
description: '根据 ID 获取媒体详情',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['id'],
properties: { id: { type: 'number' } },
},
handler: async (args: ToolArgs, _context: ToolContext) =>
jsonResult(await client.getMediaById(ensureNumber(args?.id, 'id'))),
},
{
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: ToolArgs, _context: ToolContext) => {
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 });
},
},
{
name: 'finance_list_telegram_configs',
description: '列出 Telegram 通知配置',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {},
},
handler: async () => jsonResult(await client.listTelegramConfigs()),
},
{
name: 'finance_create_telegram_config',
description: '创建 Telegram 通知配置并自动测试',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['name', 'botToken', 'chatId'],
properties: {
name: { type: 'string' },
botToken: { type: 'string' },
chatId: { type: 'string' },
notificationTypes: {
type: ['array', 'string'],
items: { type: 'string' },
},
isEnabled: { type: 'boolean', default: true },
},
},
handler: async (args: ToolArgs, _context: ToolContext) =>
jsonResult(
await client.createTelegramConfig({
name: ensureString(args?.name, 'name'),
botToken: ensureString(args?.botToken, 'botToken'),
chatId: ensureString(args?.chatId, 'chatId'),
notificationTypes: parseStringArray(args?.notificationTypes) ?? [
'transaction',
],
isEnabled: optionalBoolean(args?.isEnabled, 'isEnabled') ?? true,
}),
),
},
{
name: 'finance_update_telegram_config',
description: '更新 Telegram 通知配置,并在 Token/Chat 变更时重测',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['id'],
properties: {
id: { type: 'number' },
name: { type: 'string' },
botToken: { type: 'string' },
chatId: { type: 'string' },
notificationTypes: {
type: ['array', 'string'],
items: { type: 'string' },
},
isEnabled: { type: 'boolean' },
},
},
handler: async (args: ToolArgs, _context: ToolContext) => {
const id = ensureNumber(args?.id, 'id');
const payload: Record<string, unknown> = {};
if (args?.name !== undefined)
payload.name = ensureString(args.name, 'name');
if (args?.botToken !== undefined)
payload.botToken = ensureString(args.botToken, 'botToken');
if (args?.chatId !== undefined)
payload.chatId = ensureString(args.chatId, 'chatId');
if (args?.notificationTypes !== undefined) {
payload.notificationTypes =
parseStringArray(args.notificationTypes) ?? [];
}
if (args?.isEnabled !== undefined)
payload.isEnabled = optionalBoolean(args.isEnabled, 'isEnabled');
if (Object.keys(payload).length === 0) {
throw new Error('No fields to update');
}
return jsonResult(await client.updateTelegramConfig(id, payload));
},
},
{
name: 'finance_delete_telegram_config',
description: '删除 Telegram 通知配置',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['id'],
properties: { id: { type: 'number' } },
},
handler: async (args: ToolArgs, _context: ToolContext) =>
jsonResult(
await client.deleteTelegramConfig(ensureNumber(args?.id, 'id')),
),
},
{
name: 'finance_test_telegram_config',
description: '实时测试 Telegram Bot 是否可发送消息',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['botToken', 'chatId'],
properties: {
botToken: { type: 'string' },
chatId: { type: 'string' },
},
},
handler: async (args: ToolArgs, _context: ToolContext) =>
jsonResult(
await client.testTelegramConfig({
botToken: ensureString(args?.botToken, 'botToken'),
chatId: ensureString(args?.chatId, 'chatId'),
}),
),
},
);
return tools;
};
const buildTransactionCreatePayload = (
args: Record<string, unknown>,
options: CreateTransactionOptions = {},
) => {
const payload: Record<string, unknown> = {
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: Record<string, unknown>) => {
const payload: Record<string, unknown> = {};
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;
};

View File

@@ -0,0 +1,44 @@
import type { Logger } from 'pino';
export type JsonValue =
| boolean
| JsonValue[]
| null
| number
| string
| { [key: string]: JsonValue };
export interface JsonContent {
type: 'application/json';
data: unknown;
}
export interface TextContent {
type: 'text';
text: string;
}
export type McpContent = JsonContent | TextContent;
export interface McpToolResult {
content: McpContent[];
}
export type JsonSchema = Record<string, unknown>;
export interface ToolContext {
logger: Logger;
}
export type McpToolHandler = (
args: Record<string, unknown>,
context: ToolContext,
) => Promise<McpToolResult>;
export interface McpToolDefinition {
name: string;
description: string;
inputSchema: JsonSchema;
outputSchema?: JsonSchema;
handler: McpToolHandler;
}

View File

@@ -0,0 +1,9 @@
import type { McpToolResult } from '../types.js';
export const jsonResult = (data: unknown): McpToolResult => ({
content: [{ type: 'application/json', data }],
});
export const textResult = (text: string): McpToolResult => ({
content: [{ type: 'text', text }],
});

View File

@@ -0,0 +1,94 @@
const isNil = (value: unknown): value is null | undefined =>
value === null || value === undefined;
export const ensureNumber = (value: unknown, field: string): number => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed) {
const parsed = Number(trimmed);
if (!Number.isNaN(parsed)) return parsed;
}
}
throw new Error(`${field} must be a number`);
};
export const optionalNumber = (
value: unknown,
field: string,
): number | undefined => {
if (isNil(value)) return undefined;
return ensureNumber(value, field);
};
export const optionalNullableNumber = (
value: unknown,
field: string,
): null | number | undefined => {
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);
};
export const ensureString = (value: unknown, field: string): string => {
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) throw new Error(`${field} cannot be empty`);
return trimmed;
}
if (isNil(value)) throw new Error(`${field} is required`);
return ensureString(String(value), field);
};
export const optionalString = (value: unknown): string | undefined => {
if (isNil(value)) return undefined;
return String(value);
};
export const optionalNullableString = (
value: unknown,
): null | string | undefined => {
if (value === undefined) return undefined;
if (value === null) return null;
const normalized = String(value).trim();
if (!normalized || normalized.toLowerCase() === 'null') return null;
return normalized;
};
export const optionalBoolean = (
value: unknown,
field: string,
): boolean | undefined => {
if (isNil(value)) 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 (['1', 'true', 'y', 'yes'].includes(normalized)) return true;
if (['0', 'false', 'n', 'no'].includes(normalized)) return false;
}
throw new Error(`${field} must be boolean`);
};
export const parseStringArray = (value: unknown): string[] | undefined => {
if (isNil(value)) return undefined;
const toItem = (item: unknown) => String(item).trim();
let items: string[] = [];
if (Array.isArray(value)) {
items = value.map((item) => toItem(item)).filter(Boolean);
} else if (typeof value === 'string') {
items = value
.split(',')
.map((item) => toItem(item))
.filter(Boolean);
} else {
items = [toItem(value)].filter(Boolean);
}
return items.length > 0 ? items : undefined;
};

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"rootDir": "src",
"outDir": "dist",
"types": ["node"],
"skipLibCheck": true
},
"include": ["src"]
}