@crscreditapi/finstack-mcp-server 0.1.1-3d2b39b → 0.2.0-fac1a77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -11,3 +11,8 @@ FINSTACK_PROD_BASE_URL=https://portal.example.com
11
11
 
12
12
  # Shared secret for the mware-portal chatbase endpoints (X-Chatbase-Secret header)
13
13
  FINSTACK_CHATBASE_SECRET=
14
+
15
+ # === Vendor contact directory (crs-vendor-contacts) ===
16
+ # Base URL of the already-deployed vendor contacts app. Read-only, no auth.
17
+ # Defaults to the production Vercel URL if unset.
18
+ # VENDOR_CONTACTS_BASE_URL=https://crs-vendor-contacts.vercel.app
package/README.md CHANGED
@@ -10,6 +10,8 @@ MCP server exposing CRS B2B (Finstack) customer data over the [Model Context Pro
10
10
  | `finstack_get_customer_summary` | Non-PII summary for one customer: onboarding progress, products, API credentials, inspection status, recent billing. |
11
11
  | `finstack_get_customer_revenue` | Revenue for one customer over a date range, bucketed by `day` / `week` / `month` / `quarter` / `year`. |
12
12
  | `finstack_get_customer_invoices` | Paginated list of finalized invoices for one customer with full line-item breakdown. |
13
+ | `finstack_get_customer_pricing_config` | Full configured pricing state for one customer: products, pricing addendums, and environment-user productPricing. Use to cross-check invoiced rates against contracted pricing. |
14
+ | `finstack_get_pricing_addendum_text` | Extracted text from a pricing addendum PDF (with or without disclaimer). Server-side parses the PDF and returns plain text. |
13
15
 
14
16
  ## Install
15
17
 
@@ -125,6 +127,22 @@ Range caps depend on `type`: `day` ≤ 92 days, `week` ≤ 2 years, `month`/`qua
125
127
 
126
128
  Identify the customer with either `customer_code` or `user_id`. `page` defaults to `1`, `per_page` defaults to `50` and is silently capped at `100`. Returns `{customer_code, page, per_page, total_count, total_pages, invoices: [...]}`, where each invoice carries its full `items[]` line-item breakdown.
127
129
 
130
+ ### `finstack_get_customer_pricing_config`
131
+
132
+ ```json
133
+ { "customer_code": "CID18068" }
134
+ ```
135
+
136
+ Returns `{products, pricing_addendums, environment_users}`. `pricing_addendums[]` entries carry `id`, `name`, `status`, `quote_id`, timestamps, `has_pdf`, `has_pdf_without_disclaimer`, and `pdf_url` / `pdf_without_disclaimer_url`. The PDF URLs require the chatbase secret to hit directly — use `finstack_get_pricing_addendum_text` to read the contents instead.
137
+
138
+ ### `finstack_get_pricing_addendum_text`
139
+
140
+ ```json
141
+ { "id": 1234, "variant": "with_disclaimer" }
142
+ ```
143
+
144
+ `variant` is optional and defaults to `"with_disclaimer"`. Returns `{addendum_id, variant, page_count, text}`. The server fetches the PDF over the chatbase API and extracts text with `pdf-parse`; complex table layouts may run columns together but remain readable for LLM reasoning.
145
+
128
146
  All tools accept an optional `environment: "prod" | "dev"` (default `"prod"`).
129
147
 
130
148
  ## Development
@@ -18,6 +18,10 @@ export declare class ApiClient {
18
18
  post<T>(path: string, body?: unknown, headers?: Record<string, string>): Promise<T>;
19
19
  put<T>(path: string, body?: unknown, headers?: Record<string, string>): Promise<T>;
20
20
  del<T>(path: string, headers?: Record<string, string>): Promise<T>;
21
+ getBuffer(path: string, headers?: Record<string, string>): Promise<{
22
+ buffer: Buffer;
23
+ contentType: string;
24
+ }>;
21
25
  }
22
26
  export declare function getClient(env?: Environment): ApiClient;
23
27
  export {};
@@ -43,6 +43,18 @@ export class ApiClient {
43
43
  async del(path, headers) {
44
44
  return this.request('DELETE', path, undefined, headers);
45
45
  }
46
+ async getBuffer(path, headers) {
47
+ const response = await this.fetchWithTimeout(`${this.config.baseUrl}${path}`, { method: 'GET', headers: headers ?? {} });
48
+ if (!response.ok) {
49
+ const error = await response.text();
50
+ throw new Error(`GET ${path} failed: ${response.status} - ${error}`);
51
+ }
52
+ const arrayBuffer = await response.arrayBuffer();
53
+ return {
54
+ buffer: Buffer.from(arrayBuffer),
55
+ contentType: response.headers.get('content-type') ?? 'application/octet-stream',
56
+ };
57
+ }
46
58
  }
47
59
  function loadConfigs() {
48
60
  const timeoutMs = process.env.FINSTACK_TIMEOUT_MS
@@ -1,6 +1,3 @@
1
- /**
2
- * Customer-related calls against the mware-portal API.
3
- */
4
1
  import type { ApiClient } from './client.js';
5
2
  export interface CustomerSummary {
6
3
  summary: string;
@@ -117,3 +114,32 @@ export declare function getCustomerInvoices(client: ApiClient, args: {
117
114
  page?: number;
118
115
  perPage?: number;
119
116
  }): Promise<CustomerInvoicesPage>;
117
+ export interface PricingAddendum {
118
+ id: number;
119
+ name: string;
120
+ status: string;
121
+ quote_id: string | number | null;
122
+ created_at: string;
123
+ updated_at: string;
124
+ has_pdf: boolean;
125
+ has_pdf_without_disclaimer: boolean;
126
+ pdf_url: string | null;
127
+ pdf_without_disclaimer_url: string | null;
128
+ }
129
+ export interface CustomerPricingConfig {
130
+ products: Array<Record<string, unknown>>;
131
+ pricing_addendums: PricingAddendum[];
132
+ environment_users: Array<Record<string, unknown>>;
133
+ }
134
+ export declare function getCustomerPricingConfig(client: ApiClient, customerCode: string): Promise<CustomerPricingConfig>;
135
+ export type PricingAddendumVariant = 'with_disclaimer' | 'without_disclaimer';
136
+ export interface PricingAddendumText {
137
+ addendum_id: number;
138
+ variant: PricingAddendumVariant;
139
+ page_count: number;
140
+ text: string;
141
+ }
142
+ export declare function getPricingAddendumText(client: ApiClient, args: {
143
+ id: number;
144
+ variant: PricingAddendumVariant;
145
+ }): Promise<PricingAddendumText>;
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Customer-related calls against the mware-portal API.
3
+ */
4
+ import { PDFParse } from 'pdf-parse';
1
5
  export async function getCustomerSummary(client, customerCode) {
2
6
  const secret = process.env.FINSTACK_CHATBASE_SECRET;
3
7
  if (!secret) {
@@ -69,3 +73,36 @@ export async function getCustomerInvoices(client, args) {
69
73
  }
70
74
  return response.data;
71
75
  }
76
+ export async function getCustomerPricingConfig(client, customerCode) {
77
+ const secret = process.env.FINSTACK_CHATBASE_SECRET;
78
+ if (!secret) {
79
+ throw new Error('FINSTACK_CHATBASE_SECRET must be set');
80
+ }
81
+ const params = new URLSearchParams({ customer_code: customerCode });
82
+ const response = await client.get(`/api/v1/chatbase/customer_pricing_config?${params}`, { 'X-Chatbase-Secret': secret });
83
+ if (response.status !== 'success' || !response.data) {
84
+ throw new Error(response.message || 'Failed to fetch customer pricing config');
85
+ }
86
+ return response.data;
87
+ }
88
+ export async function getPricingAddendumText(client, args) {
89
+ const secret = process.env.FINSTACK_CHATBASE_SECRET;
90
+ if (!secret) {
91
+ throw new Error('FINSTACK_CHATBASE_SECRET must be set');
92
+ }
93
+ const params = new URLSearchParams({ variant: args.variant });
94
+ const { buffer } = await client.getBuffer(`/api/v1/chatbase/pricing_addendum_pdf/${args.id}?${params}`, { 'X-Chatbase-Secret': secret });
95
+ const parser = new PDFParse({ data: new Uint8Array(buffer) });
96
+ try {
97
+ const result = await parser.getText();
98
+ return {
99
+ addendum_id: args.id,
100
+ variant: args.variant,
101
+ page_count: result.total,
102
+ text: result.text,
103
+ };
104
+ }
105
+ finally {
106
+ await parser.destroy();
107
+ }
108
+ }
@@ -0,0 +1,34 @@
1
+ export interface VendorContact {
2
+ id: string;
3
+ vendor: string;
4
+ name: string;
5
+ email: string;
6
+ phone: string;
7
+ role: string;
8
+ notes: string;
9
+ connection: string;
10
+ location: string;
11
+ bestFor: string;
12
+ specialties: string[];
13
+ }
14
+ export type VendorConnection = '1st Degree' | '2nd Degree' | 'all';
15
+ export interface VendorSearchResult {
16
+ query: string | null;
17
+ connection: VendorConnection;
18
+ total_matched: number;
19
+ returned: number;
20
+ contacts: VendorContact[];
21
+ }
22
+ /**
23
+ * Search the vendor contact directory.
24
+ *
25
+ * The connection filter is applied server-side; the keyword query is applied
26
+ * here, OR-matched across every contact field plus specialty tags so that a
27
+ * question like "who handles ECM pricing" matches on role, notes, AND the
28
+ * `pricing` specialty.
29
+ */
30
+ export declare function searchVendorContacts(args: {
31
+ query?: string;
32
+ connection?: VendorConnection;
33
+ limit?: number;
34
+ }): Promise<VendorSearchResult>;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Vendor contact directory calls.
3
+ *
4
+ * Backed by the standalone crs-vendor-contacts web service (the CPO's
5
+ * Slack-powered vendor Rolodex). There is no database — the service stores
6
+ * contacts in a JSON blob and exposes them over a read-only REST endpoint
7
+ * (GET /api/vendors). We fetch the list and filter in-process so callers can
8
+ * match on specialty tags too (the upstream ?q= filter only covers a subset
9
+ * of fields).
10
+ *
11
+ * This calls the vendor app's already-deployed public API, so it needs no
12
+ * redeploy of that project — only the env var VENDOR_CONTACTS_BASE_URL
13
+ * (defaults to the production Vercel URL).
14
+ */
15
+ import { ApiClient } from './client.js';
16
+ const DEFAULT_BASE_URL = 'https://crs-vendor-contacts.vercel.app';
17
+ const DEFAULT_TIMEOUT_MS = 30_000;
18
+ let _vendorClient = null;
19
+ function getVendorClient() {
20
+ if (_vendorClient)
21
+ return _vendorClient;
22
+ const baseUrl = process.env.VENDOR_CONTACTS_BASE_URL ?? DEFAULT_BASE_URL;
23
+ const timeoutMs = process.env.FINSTACK_TIMEOUT_MS
24
+ ? parseInt(process.env.FINSTACK_TIMEOUT_MS, 10)
25
+ : DEFAULT_TIMEOUT_MS;
26
+ _vendorClient = new ApiClient({ baseUrl, timeoutMs });
27
+ return _vendorClient;
28
+ }
29
+ /**
30
+ * Search the vendor contact directory.
31
+ *
32
+ * The connection filter is applied server-side; the keyword query is applied
33
+ * here, OR-matched across every contact field plus specialty tags so that a
34
+ * question like "who handles ECM pricing" matches on role, notes, AND the
35
+ * `pricing` specialty.
36
+ */
37
+ export async function searchVendorContacts(args) {
38
+ const client = getVendorClient();
39
+ const params = new URLSearchParams();
40
+ if (args.connection && args.connection !== 'all') {
41
+ params.set('connection', args.connection);
42
+ }
43
+ const qs = params.toString();
44
+ const all = await client.get(qs ? `/api/vendors?${qs}` : '/api/vendors');
45
+ let matched = all;
46
+ const q = args.query?.trim().toLowerCase();
47
+ if (q) {
48
+ const terms = q.split(/\s+/).filter((t) => t.length > 1);
49
+ if (terms.length > 0) {
50
+ matched = all.filter((v) => {
51
+ const haystack = [
52
+ v.vendor,
53
+ v.name,
54
+ v.email,
55
+ v.role,
56
+ v.notes,
57
+ v.location,
58
+ v.bestFor,
59
+ ...(v.specialties ?? []),
60
+ ]
61
+ .filter(Boolean)
62
+ .join(' ')
63
+ .toLowerCase();
64
+ return terms.some((t) => haystack.includes(t));
65
+ });
66
+ }
67
+ }
68
+ const limit = args.limit && args.limit > 0 ? Math.min(args.limit, 100) : 25;
69
+ return {
70
+ query: args.query ?? null,
71
+ connection: args.connection ?? 'all',
72
+ total_matched: matched.length,
73
+ returned: Math.min(matched.length, limit),
74
+ contacts: matched.slice(0, limit),
75
+ };
76
+ }
@@ -1,4 +1,4 @@
1
- import { type RevenuePeriodType } from '../api/customer.api.js';
1
+ import { type PricingAddendumVariant, type RevenuePeriodType } from '../api/customer.api.js';
2
2
  interface GetCustomerSummaryArgs {
3
3
  customer_code: string;
4
4
  environment?: string;
@@ -26,4 +26,15 @@ interface GetCustomerInvoicesArgs {
26
26
  environment?: string;
27
27
  }
28
28
  export declare function handleGetCustomerInvoices(args: GetCustomerInvoicesArgs): Promise<unknown>;
29
+ interface GetCustomerPricingConfigArgs {
30
+ customer_code: string;
31
+ environment?: string;
32
+ }
33
+ export declare function handleGetCustomerPricingConfig(args: GetCustomerPricingConfigArgs): Promise<unknown>;
34
+ interface GetPricingAddendumTextArgs {
35
+ id: number;
36
+ variant?: PricingAddendumVariant;
37
+ environment?: string;
38
+ }
39
+ export declare function handleGetPricingAddendumText(args: GetPricingAddendumTextArgs): Promise<unknown>;
29
40
  export {};
@@ -2,7 +2,7 @@
2
2
  * Customer tool handlers
3
3
  */
4
4
  import { getClient } from '../api/client.js';
5
- import { getCustomerInvoices, getCustomerRevenue, getCustomerSummary, searchCustomers, } from '../api/customer.api.js';
5
+ import { getCustomerInvoices, getCustomerPricingConfig, getCustomerRevenue, getCustomerSummary, getPricingAddendumText, searchCustomers, } from '../api/customer.api.js';
6
6
  export async function handleGetCustomerSummary(args) {
7
7
  const client = getClient(args.environment ?? 'prod');
8
8
  return getCustomerSummary(client, args.customer_code);
@@ -29,3 +29,14 @@ export async function handleGetCustomerInvoices(args) {
29
29
  perPage: args.per_page,
30
30
  });
31
31
  }
32
+ export async function handleGetCustomerPricingConfig(args) {
33
+ const client = getClient(args.environment ?? 'prod');
34
+ return getCustomerPricingConfig(client, args.customer_code);
35
+ }
36
+ export async function handleGetPricingAddendumText(args) {
37
+ const client = getClient(args.environment ?? 'prod');
38
+ return getPricingAddendumText(client, {
39
+ id: args.id,
40
+ variant: args.variant ?? 'with_disclaimer',
41
+ });
42
+ }
@@ -1,4 +1,5 @@
1
- import { handleGetCustomerInvoices, handleGetCustomerRevenue, handleGetCustomerSummary, handleSearchCustomer, } from './customer.handler.js';
1
+ import { handleGetCustomerInvoices, handleGetCustomerPricingConfig, handleGetCustomerRevenue, handleGetCustomerSummary, handleGetPricingAddendumText, handleSearchCustomer, } from './customer.handler.js';
2
+ import { handleGetVendor } from './vendor.handler.js';
2
3
  const wrap = (fn) => {
3
4
  return async (args) => fn(args);
4
5
  };
@@ -7,4 +8,7 @@ export const handlers = {
7
8
  finstack_search_customer: wrap(handleSearchCustomer),
8
9
  finstack_get_customer_revenue: wrap(handleGetCustomerRevenue),
9
10
  finstack_get_customer_invoices: wrap(handleGetCustomerInvoices),
11
+ finstack_get_customer_pricing_config: wrap(handleGetCustomerPricingConfig),
12
+ finstack_get_pricing_addendum_text: wrap(handleGetPricingAddendumText),
13
+ vendorproject_find_vendor_contact: wrap(handleGetVendor),
10
14
  };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Vendor contact directory handlers
3
+ */
4
+ import { type VendorConnection } from '../api/vendor.api.js';
5
+ interface GetVendorArgs {
6
+ query?: string;
7
+ connection?: VendorConnection;
8
+ limit?: number;
9
+ }
10
+ export declare function handleGetVendor(args: GetVendorArgs): Promise<unknown>;
11
+ export {};
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Vendor contact directory handlers
3
+ */
4
+ import { searchVendorContacts, } from '../api/vendor.api.js';
5
+ export async function handleGetVendor(args) {
6
+ return searchVendorContacts({
7
+ query: args.query,
8
+ connection: args.connection,
9
+ limit: args.limit,
10
+ });
11
+ }
@@ -77,6 +77,49 @@ export const customerTools = [
77
77
  },
78
78
  },
79
79
  },
80
+ {
81
+ name: 'finstack_get_customer_pricing_config',
82
+ description: "Full configured pricing state for a B2B customer: per-product BillingProductCustomer records (tiers, fees, add-ons, override_price), pricing_addendums (id, name, status, quote_id, timestamps, has_pdf flags, pdf URLs), and environment_users with their productPricing config (billingAmount, effectiveDate, pricingTiers, tiered/volume flags; credentials excluded). Use when the user asks what a customer is contracted to pay, to cross-check invoice line-item rates against contracted pricing, or to inspect tier/volume pricing setup. Each pricing addendum's PDF text can be fetched with finstack_get_pricing_addendum_text using the returned id.",
83
+ inputSchema: {
84
+ type: 'object',
85
+ properties: {
86
+ customer_code: {
87
+ type: 'string',
88
+ description: 'Customer code (CID) — e.g. "CID18068"',
89
+ },
90
+ environment: {
91
+ type: 'string',
92
+ description: 'Target environment: "prod" (default) or "dev"',
93
+ enum: ['dev', 'prod'],
94
+ },
95
+ },
96
+ required: ['customer_code'],
97
+ },
98
+ },
99
+ {
100
+ name: 'finstack_get_pricing_addendum_text',
101
+ description: "Extracted text contents of a pricing addendum PDF. Use after finstack_get_customer_pricing_config to read the full body of an addendum (terms, line items, footnotes) when the structured pricing config doesn't answer the question. Pass the addendum's id from pricing_addendums[]. Returns page_count and the concatenated text. Table layouts may come through with run-together columns — readable for LLM reasoning but not pixel-perfect.",
102
+ inputSchema: {
103
+ type: 'object',
104
+ properties: {
105
+ id: {
106
+ type: 'number',
107
+ description: 'Pricing addendum id, from pricing_addendums[].id in finstack_get_customer_pricing_config',
108
+ },
109
+ variant: {
110
+ type: 'string',
111
+ enum: ['with_disclaimer', 'without_disclaimer'],
112
+ description: 'Which attachment to read. Default "with_disclaimer". Use "without_disclaimer" only if pricing_addendums[].has_pdf_without_disclaimer is true.',
113
+ },
114
+ environment: {
115
+ type: 'string',
116
+ description: 'Target environment: "prod" (default) or "dev"',
117
+ enum: ['dev', 'prod'],
118
+ },
119
+ },
120
+ required: ['id'],
121
+ },
122
+ },
80
123
  {
81
124
  name: 'finstack_get_customer_revenue',
82
125
  description: 'Fetch revenue for a B2B customer over a date range, bucketed by period (day/week/month/quarter/year). Use when the user asks how much a customer paid, revenue trends, MRR/ARR-style figures, or billing over time. Returns periods[{period,revenue}] plus a total. Max range: day=92d, week=2y, month/quarter=10y, year=50y; pick the coarsest type that covers the range.',
@@ -1,2 +1,3 @@
1
1
  import { customerTools } from './customer.tools.js';
2
- export const tools = [...customerTools];
2
+ import { vendorTools } from './vendor.tools.js';
3
+ export const tools = [...customerTools, ...vendorTools];
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Vendor contact directory tool definitions
3
+ */
4
+ import type { ToolDefinition } from '../types/tool.types.js';
5
+ export declare const vendorTools: ToolDefinition[];
@@ -0,0 +1,26 @@
1
+ export const vendorTools = [
2
+ {
3
+ name: 'vendorproject_find_vendor_contact',
4
+ description: "Look up WHO to contact at an external vendor or partner — the person to email, call, or get introduced to. This is about CRS's OWN vendors/partners (credit bureaus and data vendors like Equifax, TransUnion, Experian, CIC/Asurint, SentiLink), NOT CRS customers (use the finstack_* tools for customer accounts, billing, and pricing). Use whenever the user asks who to speak to / talk to / contact / reach out to / get an intro to, or who the point of contact or relationship owner is — for a vendor, a product or topic area (e.g. ECM), a role, or a specialty. Examples: 'who should I speak to for new ECM products', 'who is our pricing contact at Equifax', 'who handles compliance at Experian', 'who owns the TransUnion relationship'. Pass the key terms (vendor name, product/topic like 'ECM', role, or specialty) as `query` — it OR-matches across company, contact name, role, notes, location, and specialty tags (pricing, technical, legal, paperwork, champion, compliance, onboarding, sales, support, executive). Each result includes name, vendor, role, email, phone, location, connection degree, and specialties. Omit `query` to list the whole directory. Read-only — it cannot add, edit, or delete contacts.",
5
+ inputSchema: {
6
+ type: 'object',
7
+ properties: {
8
+ query: {
9
+ type: 'string',
10
+ description: "Keywords to match — company, person, role, product area, or specialty. e.g. 'ECM', 'Equifax pricing', 'compliance'. Tokenized and OR-matched across all contact fields and specialty tags. Omit to return the full directory.",
11
+ },
12
+ connection: {
13
+ type: 'string',
14
+ enum: ['1st Degree', '2nd Degree', 'all'],
15
+ description: "Filter by connection strength. '1st Degree' = direct contacts, '2nd Degree' = warm intros. Default 'all'.",
16
+ },
17
+ limit: {
18
+ type: 'number',
19
+ description: 'Max contacts to return. Default 25, capped at 100.',
20
+ minimum: 1,
21
+ maximum: 100,
22
+ },
23
+ },
24
+ },
25
+ },
26
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crscreditapi/finstack-mcp-server",
3
- "version": "0.1.1-3d2b39b",
3
+ "version": "0.2.0-fac1a77",
4
4
  "description": "MCP server for Finstack",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,11 +31,13 @@
31
31
  "dependencies": {
32
32
  "@modelcontextprotocol/sdk": "^1.0.0",
33
33
  "dotenv": "^16.3.0",
34
- "express": "^5.2.1"
34
+ "express": "^5.2.1",
35
+ "pdf-parse": "^2.4.5"
35
36
  },
36
37
  "devDependencies": {
37
38
  "@types/express": "^5.0.6",
38
39
  "@types/node": "^20.11.0",
40
+ "@types/pdf-parse": "^1.1.5",
39
41
  "typescript": "^5.3.0"
40
42
  }
41
43
  }