#!/usr/bin/env bun import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import pkg from "../package.json" with { type: "json" }; import type { AppConfig, MerchantProfile } from "./config.js"; import { loadConfig } from "./config.js"; import { WfpAuthError, WfpError } from "./errors.js"; import { type LlmFormat, type ProjectionConfig, applyProjection, encodeForLlm } from "./llm-encode.js"; import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, paginate } from "./paginate.js"; import { BALANCE_LEAN, MERCHANT_INFO_LEAN, REGULAR_STATUS_LEAN, TRANSACTION_LIST_LEAN, TRANSACTION_STATUS_LEAN, } from "./projections.js"; import { registerDailyMonitoringPrompt } from "./prompts/daily-monitoring.js"; import { registerFinancialReportPrompt } from "./prompts/financial-report.js"; import { registerFindTransactionPrompt } from "./prompts/find-transaction.js"; import { registerReconciliationPrompt } from "./prompts/reconciliation.js"; import { registerRevenueComparisonPrompt } from "./prompts/revenue-comparison.js"; import { registerSubscriptionOverviewPrompt } from "./prompts/subscription-overview.js"; import { getReasonDescription, getStatusDescription } from "./response-codes.js"; import { type TransactionRow, filterTransactions, hasAnyTransactionFilter, summarizeTransactions, } from "./transactions.js"; import { WayForPayClient } from "./wfp-client.js"; /** * Cap for the live `wfp_get_transaction_list` tool. Lower than `DEFAULT_PAGE_LIMIT` * (50) because raw transaction rows are dense — for "show all the rows" workflows * an agent should narrow with `wfp_search_transactions` or aggregate with * `wfp_summarize_transactions` instead of paging blindly. */ const RAW_LIST_DEFAULT_LIMIT = 20; const VERBOSE_RAW_LIMIT = 20; const INSTRUMENT_PREFIXES = { social_invoice: "WFP-SOC-", payment_button: "WFP-BTN-", } as const; const PACKAGE_NAME = "wayforpay-mcp"; const PACKAGE_VERSION = pkg.version; interface OkOptions { format?: LlmFormat; } /** * MCP result builder. The `text` channel uses TOON by default (compact for * LLM consumption); `structuredContent` keeps a plain JS-object shape so * programmatic downstream consumers stay happy. */ function ok(data: unknown, opts: OkOptions = {}) { const payload = data && typeof data === "object" ? (data as Record) : { value: data }; return { content: [{ type: "text" as const, text: encodeForLlm(payload, { format: opts.format }) }], structuredContent: payload, }; } /** Apply projection unless verbose is on. */ function project(value: T, projection: ProjectionConfig, verbose: boolean | undefined): T { return verbose ? value : (applyProjection(value, projection) as T); } function fail(error: unknown) { const message = error instanceof Error ? error.message : String(error); const detail: Record = { error: message }; if (error instanceof WfpError) { detail.transactionType = error.transactionType; detail.status = error.status; detail.apiError = error.apiError; detail.kind = error instanceof WfpAuthError ? "auth" : "api"; } return { isError: true, content: [{ type: "text" as const, text: JSON.stringify(detail, null, 2) }], structuredContent: detail, }; } async function safeHandle(fn: () => Promise, opts: OkOptions = {}) { try { return ok(await fn(), opts); } catch (e) { return fail(e); } } function requireConfirm(confirm: unknown, action: string) { if (confirm !== true) return fail(new Error(`confirm must be true to ${action}`)); return null; } const merchantSchema = z .string() .optional() .describe("Merchant alias from wfp_list_profiles. Defaults to WFP_DEFAULT_MERCHANT."); const formatSchema = z .enum(["toon", "json"]) .optional() .describe( "Output format for the text channel. 'toon' (default) is ~40% fewer tokens than JSON; pass 'json' if you need standard JSON.", ); const verboseSchema = z .boolean() .optional() .describe("Return all fields (default false → lean projection with the most useful fields only)."); const limitSchema = z .number() .int() .min(1) .max(MAX_PAGE_LIMIT) .optional() .describe(`Page size (1–${MAX_PAGE_LIMIT}, default ${DEFAULT_PAGE_LIMIT}).`); const offsetSchema = z .number() .int() .min(0) .optional() .describe("Page offset (default 0). Use `next_offset` from a prior response to paginate."); export function buildServer(config: AppConfig): McpServer { const merchantsByAlias = new Map(config.merchants.map((merchant) => [merchant.alias, merchant])); function resolveProfile(alias?: string): MerchantProfile { if (alias) { const merchant = merchantsByAlias.get(alias); if (!merchant) throw new Error(`Unknown merchant alias "${alias}"`); return merchant; } if (config.defaultMerchantAlias) { return merchantsByAlias.get(config.defaultMerchantAlias)!; } if (config.merchants.length === 1) { return config.merchants[0]!; } throw new Error("Merchant alias is required because multiple WayForPay profiles are configured"); } function getClient(alias?: string): WayForPayClient { return new WayForPayClient(resolveProfile(alias), { timeoutMs: config.requestTimeoutMs }); } const server = new McpServer({ name: PACKAGE_NAME, version: PACKAGE_VERSION }); // ---------------------------------------------------------------------- // Discovery — what merchants exist in this MCP // ---------------------------------------------------------------------- server.registerTool( "wfp_list_profiles", { title: "List WayForPay profiles", description: "List configured WayForPay merchant aliases available to this MCP server. Read-only. " + "Call this first to discover which `merchant` values to pass to other tools. " + "Output is TOON; pass `format: 'json'` for JSON.", inputSchema: { format: formatSchema }, }, async ({ format }) => safeHandle( async () => ({ defaultMerchantAlias: config.defaultMerchantAlias, enableWriteTools: config.enableWriteTools, profiles: config.merchants.map((merchant) => ({ alias: merchant.alias, merchantAccount: merchant.merchantAccount, hasMerchantPassword: Boolean(merchant.merchantPassword), domainName: merchant.domainName ?? null, defaultCurrency: merchant.defaultCurrency, serviceUrl: merchant.serviceUrl ?? null, })), }), { format }, ), ); // ---------------------------------------------------------------------- // Merchant + balance reads // ---------------------------------------------------------------------- server.registerTool( "wfp_get_merchant_info", { title: "Get merchant info", description: "Fetch merchant metadata for a configured WayForPay merchant. Lean projection by " + "default — pass `verbose: true` for the full payload, `format: 'json'` for JSON.", inputSchema: { merchant: merchantSchema, verbose: verboseSchema, format: formatSchema, }, }, async ({ merchant, verbose, format }) => safeHandle( async () => { const profile = resolveProfile(merchant); const data = await getClient(merchant).getMerchantInfo(); return { merchantAlias: profile.alias, data: project(data, MERCHANT_INFO_LEAN, verbose) }; }, { format }, ), ); server.registerTool( "wfp_get_balance", { title: "Get merchant balance", description: "Fetch current balance or balance at the start of a specific day. Lean projection by " + "default. Useful before issuing refunds or checking available-for-withdraw amounts.", inputSchema: { merchant: merchantSchema, toDate: z.string().optional().describe('Optional date in "DD.MM.YYYY" format'), verbose: verboseSchema, format: formatSchema, }, }, async ({ merchant, toDate, verbose, format }) => safeHandle( async () => { const profile = resolveProfile(merchant); const data = await getClient(merchant).getBalance(toDate); return { merchantAlias: profile.alias, data: project(data, BALANCE_LEAN, verbose) }; }, { format }, ), ); server.registerTool( "wfp_get_currency_rates", { title: "Get currency rates", description: "Fetch WayForPay currency rates for a specific date. Output is TOON; pass " + "`format: 'json'` for JSON.", inputSchema: { merchant: merchantSchema, date: z.string().describe("YYYY-MM-DD"), currency: z.string().optional(), format: formatSchema, }, }, async ({ merchant, date, currency, format }) => safeHandle( async () => { const profile = resolveProfile(merchant); const data = await getClient(merchant).getCurrencyRates({ date, currency }); return { merchantAlias: profile.alias, date, currency: currency ?? null, data }; }, { format }, ), ); // ---------------------------------------------------------------------- // Transactions // ---------------------------------------------------------------------- server.registerTool( "wfp_get_transaction_list", { title: "Get transaction list (raw rows)", description: `Fetch TRANSACTION_LIST for up to 31 inclusive days and return raw rows, paginated. Default page size ${RAW_LIST_DEFAULT_LIMIT}, max ${MAX_PAGE_LIMIT}. Lean projection by default — pass \`verbose: true\` for raw rows (capped at ${VERBOSE_RAW_LIMIT}). PREFER \`wfp_summarize_transactions\` for "how many / what's the total" — it never returns rows, so it's safe on busy merchants. PREFER \`wfp_search_transactions\` when looking for specific transactions (filter by status / amount / payment system). Use this raw tool only when you actually need the row payload.`, inputSchema: { merchant: merchantSchema, dateFrom: z.string().describe("YYYY-MM-DD"), dateTo: z.string().describe("YYYY-MM-DD"), apiVersion: z.union([z.literal(1), z.literal(2)]).optional(), limit: limitSchema, offset: offsetSchema, verbose: verboseSchema, format: formatSchema, }, }, async ({ merchant, dateFrom, dateTo, apiVersion, limit, offset, verbose, format }) => safeHandle( async () => { if (verbose && (limit ?? RAW_LIST_DEFAULT_LIMIT) > VERBOSE_RAW_LIMIT) { throw new Error( `verbose: true is capped at limit=${VERBOSE_RAW_LIMIT} on this tool to protect the LLM context window. Lower \`limit\`, drop \`verbose\`, or use wfp_summarize_transactions / wfp_search_transactions.`, ); } const profile = resolveProfile(merchant); const raw = await getClient(merchant).getTransactionList({ dateFrom, dateTo, apiVersion }); const rows = extractTransactionRows(raw); const effectiveLimit = limit ?? RAW_LIST_DEFAULT_LIMIT; const page = paginate(rows, { limit: effectiveLimit, offset }); return { merchantAlias: profile.alias, dateFrom, dateTo, total: page.total, limit: page.limit, offset: page.offset, has_more: page.has_more, next_offset: page.next_offset, transactions: project(page.items, TRANSACTION_LIST_LEAN, verbose), hint: page.total > 100 && !verbose ? "Large window. wfp_summarize_transactions returns counts/totals without rows; wfp_search_transactions filters." : undefined, }; }, { format }, ), ); server.registerTool( "wfp_summarize_transactions", { title: "Summarize transactions (aggregates only, no rows)", description: "Aggregate TRANSACTION_LIST into a single small response — total count, total amounts " + "by currency, status breakdown (Approved / Declined / Refunded / …), payment system " + "breakdown, and per-day counts. Never returns raw rows, so it's safe on busy merchants " + "(response stays ~1–2KB regardless of underlying volume). Use this FIRST for any " + "\"how many / what's the revenue / how's it trending\" question.", inputSchema: { merchant: merchantSchema, dateFrom: z.string().describe("YYYY-MM-DD"), dateTo: z.string().describe("YYYY-MM-DD"), apiVersion: z.union([z.literal(1), z.literal(2)]).optional(), format: formatSchema, }, }, async ({ merchant, dateFrom, dateTo, apiVersion, format }) => safeHandle( async () => { const profile = resolveProfile(merchant); const raw = await getClient(merchant).getTransactionList({ dateFrom, dateTo, apiVersion }); const rows = extractTransactionRows(raw) as TransactionRow[]; return { merchantAlias: profile.alias, dateFrom, dateTo, summary: summarizeTransactions(rows), }; }, { format }, ), ); server.registerTool( "wfp_search_transactions", { title: "Search transactions (filter + paginate)", description: "Filter the TRANSACTION_LIST window by status / amount range / payment system / " + "orderReference substring / email substring / cardPan substring, then paginate. " + "REQUIRES at least one filter parameter — refuses an unfiltered call to prevent " + 'context-window blowups. Use this for "find all declined transactions over 5000 UAH" ' + 'or "show me payments from acme@example.com".', inputSchema: { merchant: merchantSchema, dateFrom: z.string().describe("YYYY-MM-DD"), dateTo: z.string().describe("YYYY-MM-DD"), apiVersion: z.union([z.literal(1), z.literal(2)]).optional(), status: z .string() .optional() .describe("Match `transactionStatus` exactly (e.g. 'Approved', 'Declined', 'Refunded')."), amount_min: z.number().optional().describe("Minimum transaction amount."), amount_max: z.number().optional().describe("Maximum transaction amount."), payment_system: z .string() .optional() .describe("Match `paymentSystem` exactly (e.g. 'card', 'apple_pay')."), orderReference_contains: z .string() .optional() .describe("Case-insensitive substring on `orderReference`."), email_contains: z.string().optional().describe("Case-insensitive substring on `email`."), card_pan_contains: z .string() .optional() .describe("Case-insensitive substring on `cardPan` (masked PAN)."), limit: limitSchema, offset: offsetSchema, verbose: verboseSchema, format: formatSchema, }, }, async ({ merchant, dateFrom, dateTo, apiVersion, status, amount_min, amount_max, payment_system, orderReference_contains, email_contains, card_pan_contains, limit, offset, verbose, format, }) => safeHandle( async () => { const filters = { status, amount_min, amount_max, payment_system, orderReference_contains, email_contains, card_pan_contains, }; if (!hasAnyTransactionFilter(filters)) { throw new Error( "wfp_search_transactions requires at least one filter (status, amount_min/max, payment_system, " + "orderReference_contains, email_contains, or card_pan_contains). For unfiltered overviews " + "call wfp_summarize_transactions; to dump rows raw, call wfp_get_transaction_list.", ); } const profile = resolveProfile(merchant); const raw = await getClient(merchant).getTransactionList({ dateFrom, dateTo, apiVersion }); const allRows = extractTransactionRows(raw) as TransactionRow[]; const matched = filterTransactions(allRows, filters); const page = paginate(matched, { limit, offset }); return { merchantAlias: profile.alias, dateFrom, dateTo, filters_applied: Object.fromEntries(Object.entries(filters).filter(([, v]) => v !== undefined)), matched_count: matched.length, scanned_count: allRows.length, total: page.total, limit: page.limit, offset: page.offset, has_more: page.has_more, next_offset: page.next_offset, transactions: project(page.items, TRANSACTION_LIST_LEAN, verbose), }; }, { format }, ), ); server.registerTool( "wfp_list_active_instruments", { title: "List active payment instruments", description: "Analyze payment instruments recognized by orderReference prefixes and return summary " + "stats for each one. Read-only. Uses convention-based prefixes: WFP-SOC-* for " + "social invoices and WFP-BTN-* for payment buttons.", inputSchema: { merchant: merchantSchema, dateFrom: z.string().describe("YYYY-MM-DD"), dateTo: z.string().describe("YYYY-MM-DD"), apiVersion: z.union([z.literal(1), z.literal(2)]).optional(), format: formatSchema, }, }, async ({ merchant, dateFrom, dateTo, apiVersion, format }) => safeHandle( async () => { const profile = resolveProfile(merchant); const raw = await getClient(merchant).getTransactionList({ dateFrom, dateTo, apiVersion }); const rows = extractTransactionRows(raw) as TransactionRow[]; const instruments = Object.entries(INSTRUMENT_PREFIXES) .map(([type, prefix]) => { const matched = rows.filter((row) => String(row.orderReference ?? "").startsWith(prefix)); return matched.length > 0 ? buildInstrumentSummary(type, matched) : null; }) .filter((value): value is InstrumentSummary => value !== null); return { merchantAlias: profile.alias, dateFrom, dateTo, total_scanned: rows.length, instruments, }; }, { format }, ), ); server.registerTool( "wfp_instrument_transactions", { title: "Get instrument transactions", description: "Return transactions for a payment instrument recognized by orderReference prefix. " + "Includes instrument-level summary plus paginated rows. Read-only.", inputSchema: { merchant: merchantSchema, instrumentType: z .enum(["social_invoice", "payment_button"]) .describe("Instrument type inferred from orderReference prefix."), dateFrom: z.string().describe("YYYY-MM-DD"), dateTo: z.string().describe("YYYY-MM-DD"), apiVersion: z.union([z.literal(1), z.literal(2)]).optional(), statusFilter: z.string().optional().describe("Optional exact status filter, case-insensitive."), limit: limitSchema, offset: offsetSchema, verbose: verboseSchema, format: formatSchema, }, }, async ({ merchant, instrumentType, dateFrom, dateTo, apiVersion, statusFilter, limit, offset, verbose, format, }) => safeHandle( async () => { if (verbose && (limit ?? RAW_LIST_DEFAULT_LIMIT) > VERBOSE_RAW_LIMIT) { throw new Error( `verbose: true is capped at limit=${VERBOSE_RAW_LIMIT} on this tool to protect the LLM context window.`, ); } const profile = resolveProfile(merchant); const raw = await getClient(merchant).getTransactionList({ dateFrom, dateTo, apiVersion }); const rows = extractTransactionRows(raw) as TransactionRow[]; const prefix = INSTRUMENT_PREFIXES[instrumentType]; const matched = rows.filter((row) => String(row.orderReference ?? "").startsWith(prefix)); const filtered = statusFilter === undefined ? matched : matched.filter( (row) => String(row.transactionStatus ?? "").toLowerCase() === statusFilter.toLowerCase(), ); const page = paginate(filtered, { limit: limit ?? RAW_LIST_DEFAULT_LIMIT, offset }); return { merchantAlias: profile.alias, dateFrom, dateTo, instrumentType, statusFilter: statusFilter ?? null, matched_count: filtered.length, scanned_count: rows.length, total: page.total, limit: page.limit, offset: page.offset, has_more: page.has_more, next_offset: page.next_offset, summary: buildInstrumentSummary(instrumentType, filtered), transactions: project(page.items, TRANSACTION_LIST_LEAN, verbose), }; }, { format }, ), ); server.registerTool( "wfp_check_status", { title: "Check payment status", description: "Call CHECK_STATUS for an orderReference. Returns transactionStatus, amount, currency, " + "reasonCode, and signatureCheck. Lean projection by default.", inputSchema: { merchant: merchantSchema, orderReference: z.string().min(1), apiVersion: z.union([z.literal(1), z.literal(2)]).optional(), verbose: verboseSchema, format: formatSchema, }, }, async ({ merchant, orderReference, apiVersion, verbose, format }) => safeHandle( async () => { const profile = resolveProfile(merchant); const data = await getClient(merchant).checkStatus(orderReference, apiVersion ?? 2); return { merchantAlias: profile.alias, orderReference, data: project(data, TRANSACTION_STATUS_LEAN, verbose), }; }, { format }, ), ); server.registerTool( "wfp_get_regular_status", { title: "Get regular payment status", description: "Call regularApi STATUS for a configured merchant that has merchantPassword. " + "Returns the current state of a recurring subscription.", inputSchema: { merchant: merchantSchema, orderReference: z.string().min(1), verbose: verboseSchema, format: formatSchema, }, }, async ({ merchant, orderReference, verbose, format }) => safeHandle( async () => { const profile = resolveProfile(merchant); const data = await getClient(merchant).getRegularStatus(orderReference); return { merchantAlias: profile.alias, orderReference, data: project(data, REGULAR_STATUS_LEAN, verbose), }; }, { format }, ), ); // ---------------------------------------------------------------------- // Signature helpers (no remote call) // ---------------------------------------------------------------------- server.registerTool( "wfp_verify_payment_signature", { title: "Verify WayForPay payment signature", description: "Verify merchantSignature for WayForPay payment responses or webhook notifications. " + "No remote call — pure local HMAC-MD5 check using the merchant's secretKey.", inputSchema: { merchant: merchantSchema, merchantAccount: z.string().min(1), orderReference: z.string().min(1), amount: z.union([z.string(), z.number()]), currency: z.string().min(1), authCode: z.string().default(""), cardPan: z.string().default(""), transactionStatus: z.string().min(1), reasonCode: z.union([z.string(), z.number()]), merchantSignature: z.string().min(1), format: formatSchema, }, }, async ({ merchant, merchantAccount, orderReference, amount, currency, authCode, cardPan, transactionStatus, reasonCode, merchantSignature, format, }) => safeHandle( async () => { const profile = resolveProfile(merchant); const data = getClient(merchant).verifyPaymentSignature({ merchantAccount, orderReference, amount, currency, authCode, cardPan, transactionStatus, reasonCode, merchantSignature, }); return { merchantAlias: profile.alias, merchantAccount, orderReference, data }; }, { format }, ), ); server.registerTool( "wfp_build_service_ack", { title: "Build webhook acknowledgement", description: "Build a standard WayForPay webhook acknowledgement payload with signature. No remote " + "call — for use in your own webhook handler when you need to ack a notification.", inputSchema: { merchant: merchantSchema, orderReference: z.string().min(1), status: z.string().default("accept"), time: z.number().int().positive().optional(), format: formatSchema, }, }, async ({ merchant, orderReference, status, time, format }) => safeHandle( async () => { const profile = resolveProfile(merchant); const data = getClient(merchant).buildServiceAck(orderReference, status, time); return { merchantAlias: profile.alias, data }; }, { format }, ), ); // ---------------------------------------------------------------------- // Mutations — destructive, require confirm: true // ---------------------------------------------------------------------- if (config.enableWriteTools) { const lineItemSchema = z.object({ name: z.string().min(1), price: z.number().positive(), count: z.number().positive(), }); server.registerTool( "wfp_create_invoice", { title: "Create invoice (DESTRUCTIVE — touches money)", description: "Create a WayForPay invoice. DESTRUCTIVE — generates a payment URL the client can " + "actually pay through. Confirm with the human in chat first. The response includes " + "`invoiceUrl` for redirecting the customer.", inputSchema: { merchant: merchantSchema, orderReference: z.string().min(1), amount: z.number().positive(), currency: z.string().optional(), orderDate: z.number().int().positive().optional(), orderTimeoutSeconds: z.number().int().positive().optional(), language: z.union([z.literal("UA"), z.literal("RU"), z.literal("EN")]).optional(), domainName: z.string().optional(), serviceUrl: z.string().url().optional(), paymentSystems: z.array(z.string()).optional(), clientFirstName: z.string().optional(), clientLastName: z.string().optional(), clientEmail: z.string().email().optional(), clientPhone: z.string().optional(), lineItems: z.array(lineItemSchema).min(1), confirm: z.literal(true).describe("Must be exactly true. Forces explicit acknowledgement."), format: formatSchema, }, }, async ({ merchant, orderReference, amount, currency, orderDate, orderTimeoutSeconds, language, domainName, serviceUrl, paymentSystems, clientFirstName, clientLastName, clientEmail, clientPhone, lineItems, confirm, format, }) => requireConfirm(confirm, "create an invoice") ?? safeHandle( async () => { const profile = resolveProfile(merchant); const data = await getClient(merchant).createInvoice({ orderReference, amount, currency, orderDate, orderTimeoutSeconds, language, domainName, serviceUrl, paymentSystems, clientFirstName, clientLastName, clientEmail, clientPhone, lineItems, }); return { merchantAlias: profile.alias, orderReference, data }; }, { format }, ), ); server.registerTool( "wfp_refund_payment", { title: "Refund payment (DESTRUCTIVE — touches money)", description: "Create a REFUND request in WayForPay. DESTRUCTIVE — moves real funds back to the " + "customer. Workflow: call wfp_check_status first to confirm the order exists and is " + "refundable; show the human the exact amount/orderReference; only then call with " + "confirm: true. Cannot be undone.", inputSchema: { merchant: merchantSchema, orderReference: z.string().min(1), amount: z.number().positive(), currency: z.string().optional(), comment: z.string().min(1), lineItems: z.array(lineItemSchema).optional(), confirm: z.literal(true).describe("Must be exactly true to refund."), format: formatSchema, }, }, async ({ merchant, orderReference, amount, currency, comment, lineItems, confirm, format }) => requireConfirm(confirm, "refund a payment") ?? safeHandle( async () => { const profile = resolveProfile(merchant); const data = await getClient(merchant).refund({ orderReference, amount, currency, comment, lineItems, }); return { merchantAlias: profile.alias, orderReference, data }; }, { format }, ), ); server.registerTool( "wfp_settle_payment", { title: "Settle hold payment (DESTRUCTIVE — touches money)", description: "Create a SETTLE request for previously AUTH-held funds. DESTRUCTIVE — captures " + "funds that were authorized but not yet charged. Confirm with the human in chat first.", inputSchema: { merchant: merchantSchema, orderReference: z.string().min(1), amount: z.number().positive(), currency: z.string().optional(), lineItems: z.array(lineItemSchema).optional(), confirm: z.literal(true).describe("Must be exactly true to settle."), format: formatSchema, }, }, async ({ merchant, orderReference, amount, currency, lineItems, confirm, format }) => requireConfirm(confirm, "settle a payment") ?? safeHandle( async () => { const profile = resolveProfile(merchant); const data = await getClient(merchant).settle({ orderReference, amount, currency, lineItems, }); return { merchantAlias: profile.alias, orderReference, data }; }, { format }, ), ); server.registerTool( "wfp_suspend_recurring", { title: "Suspend recurring payment", description: "Pause a recurring payment subscription for a merchant configured with " + "merchantPassword. Mutation, but reversible through wfp_resume_recurring.", inputSchema: { merchant: merchantSchema, orderReference: z.string().min(1), format: formatSchema, }, }, async ({ merchant, orderReference, format }) => safeHandle( async () => { const profile = resolveProfile(merchant); const data = await getClient(merchant).suspendRecurring(orderReference); return { merchantAlias: profile.alias, orderReference, data }; }, { format }, ), ); server.registerTool( "wfp_resume_recurring", { title: "Resume recurring payment", description: "Resume a suspended recurring payment subscription for a merchant configured with " + "merchantPassword. Mutation, but reversible via suspend.", inputSchema: { merchant: merchantSchema, orderReference: z.string().min(1), format: formatSchema, }, }, async ({ merchant, orderReference, format }) => safeHandle( async () => { const profile = resolveProfile(merchant); const data = await getClient(merchant).resumeRecurring(orderReference); return { merchantAlias: profile.alias, orderReference, data }; }, { format }, ), ); server.registerTool( "wfp_delete_recurring", { title: "Delete recurring payment (DESTRUCTIVE)", description: "Delete a recurring payment subscription via regularApi REMOVE. DESTRUCTIVE — " + "irreversible cancel. Confirm with the human in chat before passing confirm:true.", inputSchema: { merchant: merchantSchema, orderReference: z.string().min(1), confirm: z.literal(true).describe("Must be exactly true to delete the subscription."), format: formatSchema, }, }, async ({ merchant, orderReference, confirm, format }) => requireConfirm(confirm, "delete a recurring subscription") ?? safeHandle( async () => { const profile = resolveProfile(merchant); const data = await getClient(merchant).deleteRecurring(orderReference); return { merchantAlias: profile.alias, orderReference, data }; }, { format }, ), ); } // ---------------------------------------------------------------------- // Meta — health + capability discovery // ---------------------------------------------------------------------- server.registerTool( "wfp_health", { title: "Health check", description: "Verify the MCP server is alive and the merchant secretKey signs correctly. Performs " + "a `getMerchantInfo` round-trip, times it, returns " + "`{ok, signature_valid, latency_ms, version, merchant_alias, merchant_account}`. " + "Use this BEFORE running a multi-step revenue report or payment workflow.", inputSchema: { merchant: merchantSchema, format: formatSchema }, }, async ({ merchant, format }) => safeHandle( async () => { const startedAt = Date.now(); let profile: MerchantProfile; try { profile = resolveProfile(merchant); } catch (err) { return { ok: false, signature_valid: false, latency_ms: 0, version: PACKAGE_VERSION, merchant_alias: merchant ?? null, merchant_account: null, error: err instanceof Error ? err.message : String(err), }; } try { await getClient(merchant).getMerchantInfo(); return { ok: true, signature_valid: true, latency_ms: Date.now() - startedAt, version: PACKAGE_VERSION, merchant_alias: profile.alias, merchant_account: profile.merchantAccount, }; } catch (err) { return { ok: false, signature_valid: !(err instanceof WfpAuthError), latency_ms: Date.now() - startedAt, version: PACKAGE_VERSION, merchant_alias: profile.alias, merchant_account: profile.merchantAccount, error: err instanceof Error ? err.message : String(err), }; } }, { format }, ), ); server.registerTool( "wfp_capabilities", { title: "List MCP capabilities", description: "Discover the tools this server offers without trial-and-error. Returns version, " + "every registered tool with its title and a one-line summary, plus output-format " + "hints. Foreign coding agents should call this FIRST to plan multi-step workflows.", inputSchema: { format: formatSchema }, }, async ({ format }) => safeHandle( async () => ({ version: PACKAGE_VERSION, name: PACKAGE_NAME, output_format: { default: "toon", alternative: "json", note: "Pass `format: 'json'` on any read tool to switch the text channel back to JSON.", }, destructive_safety: "Destructive mutations require `confirm: true` (create_invoice / refund / " + "settle / delete_recurring). Set WFP_ENABLE_WRITE_TOOLS=false to remove " + "all mutation tools, including recurring suspend/resume/delete, from the registry.", merchants: config.merchants.map((m) => m.alias), default_merchant: config.defaultMerchantAlias, tools: describeRegisteredTools(server), }), { format }, ), ); registerFinancialReportPrompt(server); registerDailyMonitoringPrompt(server); registerReconciliationPrompt(server); registerSubscriptionOverviewPrompt(server); registerFindTransactionPrompt(server); registerRevenueComparisonPrompt(server); return server; } /** * WayForPay's TRANSACTION_LIST returns rows under various keys depending on * apiVersion. Surface them as a flat array regardless of shape so the page * slicer can work uniformly. */ function extractTransactionRows(raw: unknown): unknown[] { if (Array.isArray(raw)) return raw; if (raw && typeof raw === "object") { const obj = raw as Record; for (const key of ["transactionList", "transactions", "data", "list"]) { const value = obj[key]; if (Array.isArray(value)) return value; } } return []; } /** * Introspect McpServer's internal tool registry. The SDK doesn't expose a * public accessor; reach into `_registeredTools` and degrade gracefully if * the shape changes. */ function describeRegisteredTools(server: McpServer): Array> { const internal = server as unknown as { _registeredTools?: Record }; const map = internal._registeredTools; if (!map) return []; return Object.entries(map).map(([name, tool]) => { const description = String(tool.description ?? ""); const keys = inputSchemaKeys(tool.inputSchema); return { name, title: tool.title ?? name, summary: firstSentence(description), destructive: keys.includes("confirm") || /destructive/i.test(description), has_pagination: keys.includes("limit") && keys.includes("offset"), has_format: keys.includes("format"), has_verbose: keys.includes("verbose"), input_keys: keys, }; }); } interface RegisteredToolLike { title?: string; description?: string; inputSchema?: unknown; } function inputSchemaKeys(schema: unknown): string[] { if (!schema || typeof schema !== "object") return []; const withShape = schema as { shape?: Record }; if (withShape.shape && typeof withShape.shape === "object") { return Object.keys(withShape.shape); } return Object.keys(schema as Record); } function firstSentence(text: string): string { const trimmed = text.trim(); if (!trimmed) return ""; const dot = trimmed.indexOf(". "); return dot === -1 ? trimmed : trimmed.slice(0, dot + 1); } interface StatusBreakdown { status: string; statusDescription: string; count: number; } interface ReasonBreakdown { reasonCode: number; reasonDescription: string; count: number; } interface InstrumentSummary { type: string; totalTransactions: number; approved: number; declined: number; expired: number; other: number; totalApprovedAmount: number; currency: string; uniqueAmounts: string[]; lastTransactionDate: string; statusBreakdown: StatusBreakdown[]; declineReasons: ReasonBreakdown[]; } function buildInstrumentSummary(type: string, rows: readonly TransactionRow[]): InstrumentSummary { let approved = 0; let declined = 0; let expired = 0; let other = 0; let totalApprovedAmount = 0; let latest = 0; const uniqueAmounts = new Set(); const statusCounts = new Map(); const reasonCounts = new Map(); for (const row of rows) { const status = String(row.transactionStatus ?? "Unknown"); statusCounts.set(status, (statusCounts.get(status) ?? 0) + 1); if (status === "Approved") { approved += 1; totalApprovedAmount += toNumericAmount(row.amount); } else if (status === "Declined") { declined += 1; const code = Number(row.reasonCode); if (Number.isFinite(code) && code > 0) { reasonCounts.set(code, (reasonCounts.get(code) ?? 0) + 1); } } else if (status === "Expired") { expired += 1; } else { other += 1; } uniqueAmounts.add(String(row.amount ?? "0")); latest = Math.max(latest, toComparableTimestamp(row.processingDate ?? row.createdDate)); } return { type, totalTransactions: rows.length, approved, declined, expired, other, totalApprovedAmount: round2(totalApprovedAmount), currency: String(rows[0]?.currency ?? "UAH"), uniqueAmounts: Array.from(uniqueAmounts).sort((a, b) => Number(b) - Number(a)), lastTransactionDate: latest > 0 ? new Date(latest).toISOString() : "N/A", statusBreakdown: Array.from(statusCounts.entries()) .sort((a, b) => b[1] - a[1]) .map(([status, count]) => ({ status, statusDescription: getStatusDescription(status), count, })), declineReasons: Array.from(reasonCounts.entries()) .sort((a, b) => b[1] - a[1]) .map(([reasonCode, count]) => ({ reasonCode, reasonDescription: getReasonDescription(reasonCode), count, })), }; } function toNumericAmount(value: unknown): number { if (typeof value === "number") { return Number.isFinite(value) ? value : 0; } if (typeof value === "string" && value !== "") { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; } return 0; } function round2(value: number): number { return Math.round(value * 100) / 100; } function toComparableTimestamp(value: unknown): number { if (typeof value === "number") { return value > 1e12 ? value : value * 1000; } if (typeof value === "string") { const numeric = Number(value); if (Number.isFinite(numeric)) { return numeric > 1e12 ? numeric : numeric * 1000; } const parsed = Date.parse(value); return Number.isNaN(parsed) ? 0 : parsed; } return 0; } async function main() { let config: AppConfig; try { config = await loadConfig(); } catch (err) { console.error(`[${PACKAGE_NAME}] config error: ${err instanceof Error ? err.message : String(err)}`); process.exit(2); } const server = buildServer(config); const transport = new StdioServerTransport(); await server.connect(transport); } const isMainModule = import.meta.url === `file://${process.argv[1]}`; if (isMainModule) { main().catch((err) => { console.error(`[${PACKAGE_NAME}] fatal:`, err); process.exit(1); }); }