@crscreditapi/finstack-mcp-server 0.9.0-6062c00 → 0.10.0-80b4ec3
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 +5 -0
- package/README.md +36 -0
- package/dist/api/product_lookup.api.d.ts +119 -0
- package/dist/api/product_lookup.api.js +79 -0
- package/dist/handlers/index.js +5 -0
- package/dist/handlers/product_lookup.handler.d.ts +18 -0
- package/dist/handlers/product_lookup.handler.js +39 -0
- package/dist/server.js +8 -2
- package/dist/toolLog.d.ts +33 -0
- package/dist/toolLog.js +96 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/product.tools.js +1 -1
- package/dist/tools/product_lookup.tools.d.ts +2 -0
- package/dist/tools/product_lookup.tools.js +102 -0
- package/package.json +4 -2
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
|
+
# Support Autopsy observability: insert one row per tool call into the shared tool_calls table
|
|
16
|
+
# (n8n Postgres). Leave unset to emit a JSON log line instead. TOOL_LOG=off disables both.
|
|
17
|
+
TOOL_CALLS_DATABASE_URL=
|
|
18
|
+
# SSL is on by default (no sslmode needed in the URL). TOOL_CALLS_PGSSL=off for a local DB.
|
package/README.md
CHANGED
|
@@ -14,6 +14,10 @@ MCP server exposing CRS B2B (Finstack) customer data over the [Model Context Pro
|
|
|
14
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. |
|
|
15
15
|
| `finstack_get_customer_users` | Portal users and no-portal-access contacts for one customer, with names, emails, phones, roles (labelled as on the portal Users tab), owner flag, invitation status and lock/MFA state. The only tool that returns people for an account. |
|
|
16
16
|
| `finstack_get_customer_cohort` | Bulk, paginated list of all customers with portal status, account type, and sales opportunities (type + stage). For cohort/tracker refreshes instead of per-customer summary calls. Optional `status` / `stage` filters. |
|
|
17
|
+
| `finstack_get_product_customers` | Which customers hold a product, by billing product code (e.g. `TUC1002`). Paged over customers; each row carries its subscription status and add-ons. |
|
|
18
|
+
| `finstack_get_add_on_customers` | Which customers have an add-on (by add-on code, e.g. `CRS1002`) attached to at least one product, and on which products. Paged. |
|
|
19
|
+
| `finstack_get_customer_products` | The products one customer holds with subscription status, platform, activation date and attached add-ons. No prices. |
|
|
20
|
+
| `finstack_get_customer_add_ons` | The add-ons one customer has, each with the products it is attached to. |
|
|
17
21
|
| `finstack_get_leadiq_analytics` | System-wide LeadsIQ analytics for a trailing window: totals vs previous period, the complete list of customers that pulled (revenue desc), entitled/active/invited split, and enrolled-but-never-pulled accounts. |
|
|
18
22
|
| `finstack_get_leadiq_customer` | One customer's LeadsIQ detail: enrollment state, window/YTD/lifetime stats, match rate and append hit rates, most commonly used pull filters, recent pulls. |
|
|
19
23
|
| `finstack_get_cop_ai_analytics` | Credit Order Pro AI summary & chat usage for a trailing window: totals, per-customer activity (reports, summaries, opens, questions, ratings, Bedrock cost) and who currently has the feature enabled. |
|
|
@@ -166,6 +170,38 @@ All parameters optional. `page` defaults to `1`; `per_page` defaults to `500`, c
|
|
|
166
170
|
|
|
167
171
|
`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.
|
|
168
172
|
|
|
173
|
+
### `finstack_get_product_customers`
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{ "code": "TUC1002", "status": "activated", "page": 1, "per_page": 500 }
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`code` (required) is an exact billing product code. `status` (optional) is one subscription status or a comma list / array of them — `not_categorized`, `activated`, `in_review`, `disabled`, `terminating`, `deactivated`, `quoted`, `archived`; omit it and every status comes back, each row labelled. `page` defaults to `1`; `per_page` defaults to `500`, capped at `1000`. Returns `{product: {code, name, private_name, status}, page, per_page, total_count, total_pages, customers: [...]}` — `total_count` counts customers, not subscriptions, and each customer carries `products[]`, its subscriptions to that product (one per platform, possibly more than one). Each product row has `code`, `name`, `private_name`, `vendor`, `category`, `status`, `enabled`, `platform`, `format`, `activated_at`, `part_of_merge`, `part_of_bundle` and `add_ons[{code, name}]`. Unknown code → 404, unknown status → 400. Backed by the MCP-only `customers_by_product` chatbase endpoint.
|
|
180
|
+
|
|
181
|
+
### `finstack_get_add_on_customers`
|
|
182
|
+
|
|
183
|
+
```json
|
|
184
|
+
{ "code": "CRS1002", "status": "activated" }
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Same parameters and paging as `finstack_get_product_customers`, keyed by add-on code. Returns `{add_on: {code, name, private_name, status, category, category_label}, page, per_page, total_count, total_pages, customers: [...]}`; each customer's `products[]` lists only the products carrying this add-on. `add_on.status` is `active`, `inactive` or `null` (null means active). `status` filters on the subscription status of the product the add-on is attached to.
|
|
188
|
+
|
|
189
|
+
### `finstack_get_customer_products`
|
|
190
|
+
|
|
191
|
+
```json
|
|
192
|
+
{ "customer_code": "CID18068", "status": "activated,in_review" }
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Identify the customer with either `customer_code` or `user_id`. Returns `{customer: {customer_code, name, status}, count, products: [...]}` ordered by product code, with the product rows described above. No prices — use `finstack_get_customer_pricing_config` for those.
|
|
196
|
+
|
|
197
|
+
### `finstack_get_customer_add_ons`
|
|
198
|
+
|
|
199
|
+
```json
|
|
200
|
+
{ "customer_code": "CID18068" }
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Identify the customer with either `customer_code` or `user_id`. Returns `{customer, count, add_ons: [...]}` where `count` is the number of distinct add-ons and each add-on carries `products[]`, the product rows it is attached to (without their own `add_ons` list). `status` filters on the status of the product the add-on is attached to.
|
|
204
|
+
|
|
169
205
|
### `finstack_get_leadiq_analytics`
|
|
170
206
|
|
|
171
207
|
```json
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product / add-on holding lookups against the mware-portal chatbase API.
|
|
3
|
+
*
|
|
4
|
+
* Four read-only endpoints answer "who has what" in both directions:
|
|
5
|
+
* customers_by_product, customers_by_add_on (paged over customers)
|
|
6
|
+
* customer_products, customer_add_ons (one customer)
|
|
7
|
+
*
|
|
8
|
+
* They read BillingProductCustomer rows (a customer's subscription to a
|
|
9
|
+
* product) and the add-ons attached to them. No prices — that is
|
|
10
|
+
* customer_pricing_config.
|
|
11
|
+
*/
|
|
12
|
+
import type { ApiClient } from './client.js';
|
|
13
|
+
/** Product subscription status on a BillingProductCustomer row. */
|
|
14
|
+
export type SubscriptionStatus = 'not_categorized' | 'activated' | 'in_review' | 'disabled' | 'terminating' | 'deactivated' | 'quoted' | 'archived';
|
|
15
|
+
export declare const SUBSCRIPTION_STATUSES: SubscriptionStatus[];
|
|
16
|
+
export interface AddOnRef {
|
|
17
|
+
code: string;
|
|
18
|
+
name: string;
|
|
19
|
+
}
|
|
20
|
+
/** One customer's subscription to a product. */
|
|
21
|
+
export interface ProductSubscription {
|
|
22
|
+
code: string;
|
|
23
|
+
name: string;
|
|
24
|
+
private_name: string | null;
|
|
25
|
+
vendor: string | null;
|
|
26
|
+
category: string | null;
|
|
27
|
+
status: SubscriptionStatus;
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
platform: string | null;
|
|
30
|
+
format: string | null;
|
|
31
|
+
activated_at: string | null;
|
|
32
|
+
/** true = component row of a merge product rather than a product sold on its own */
|
|
33
|
+
part_of_merge: boolean;
|
|
34
|
+
/** true = component row of a bundle */
|
|
35
|
+
part_of_bundle: boolean;
|
|
36
|
+
add_ons?: AddOnRef[];
|
|
37
|
+
}
|
|
38
|
+
export interface LookupCustomer {
|
|
39
|
+
customer_code: string | null;
|
|
40
|
+
name: string;
|
|
41
|
+
status: string | null;
|
|
42
|
+
}
|
|
43
|
+
export interface ProductRef {
|
|
44
|
+
code: string;
|
|
45
|
+
name: string;
|
|
46
|
+
private_name: string | null;
|
|
47
|
+
status: 'active' | 'terminated' | string;
|
|
48
|
+
}
|
|
49
|
+
export interface AddOnDetail {
|
|
50
|
+
code: string;
|
|
51
|
+
name: string;
|
|
52
|
+
private_name: string | null;
|
|
53
|
+
/** active | inactive | null (null = active) */
|
|
54
|
+
status: string | null;
|
|
55
|
+
category: string | null;
|
|
56
|
+
category_label: string | null;
|
|
57
|
+
}
|
|
58
|
+
interface CustomersPage {
|
|
59
|
+
page: number;
|
|
60
|
+
per_page: number;
|
|
61
|
+
/** customers, not subscriptions */
|
|
62
|
+
total_count: number;
|
|
63
|
+
total_pages: number;
|
|
64
|
+
customers: Array<LookupCustomer & {
|
|
65
|
+
products: ProductSubscription[];
|
|
66
|
+
}>;
|
|
67
|
+
}
|
|
68
|
+
export interface ProductCustomersPage extends CustomersPage {
|
|
69
|
+
product: ProductRef;
|
|
70
|
+
}
|
|
71
|
+
export interface AddOnCustomersPage extends CustomersPage {
|
|
72
|
+
add_on: AddOnDetail;
|
|
73
|
+
}
|
|
74
|
+
export interface CustomerProducts {
|
|
75
|
+
customer: LookupCustomer;
|
|
76
|
+
count: number;
|
|
77
|
+
products: ProductSubscription[];
|
|
78
|
+
}
|
|
79
|
+
export interface CustomerAddOns {
|
|
80
|
+
customer: LookupCustomer;
|
|
81
|
+
/** distinct add-ons */
|
|
82
|
+
count: number;
|
|
83
|
+
add_ons: Array<AddOnDetail & {
|
|
84
|
+
products: ProductSubscription[];
|
|
85
|
+
}>;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Normalize the status filter to the comma list the endpoint expects.
|
|
89
|
+
* Accepts a single value, a comma list, or an array; returns undefined when
|
|
90
|
+
* empty so every status comes back.
|
|
91
|
+
*/
|
|
92
|
+
export declare function normalizeStatusFilter(status?: string | string[]): string | undefined;
|
|
93
|
+
/** Which customers hold a product (paged over customers). */
|
|
94
|
+
export declare function getProductCustomers(client: ApiClient, args: {
|
|
95
|
+
code: string;
|
|
96
|
+
status?: string | string[];
|
|
97
|
+
page?: number;
|
|
98
|
+
perPage?: number;
|
|
99
|
+
}): Promise<ProductCustomersPage>;
|
|
100
|
+
/** Which customers have an add-on attached to at least one product (paged). */
|
|
101
|
+
export declare function getAddOnCustomers(client: ApiClient, args: {
|
|
102
|
+
code: string;
|
|
103
|
+
status?: string | string[];
|
|
104
|
+
page?: number;
|
|
105
|
+
perPage?: number;
|
|
106
|
+
}): Promise<AddOnCustomersPage>;
|
|
107
|
+
/** The products one customer holds, each with status and add-ons. */
|
|
108
|
+
export declare function getCustomerProducts(client: ApiClient, args: {
|
|
109
|
+
customerCode?: string;
|
|
110
|
+
userId?: string;
|
|
111
|
+
status?: string | string[];
|
|
112
|
+
}): Promise<CustomerProducts>;
|
|
113
|
+
/** The add-ons one customer has, each with the products it is attached to. */
|
|
114
|
+
export declare function getCustomerAddOns(client: ApiClient, args: {
|
|
115
|
+
customerCode?: string;
|
|
116
|
+
userId?: string;
|
|
117
|
+
status?: string | string[];
|
|
118
|
+
}): Promise<CustomerAddOns>;
|
|
119
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export const SUBSCRIPTION_STATUSES = [
|
|
2
|
+
'not_categorized',
|
|
3
|
+
'activated',
|
|
4
|
+
'in_review',
|
|
5
|
+
'disabled',
|
|
6
|
+
'terminating',
|
|
7
|
+
'deactivated',
|
|
8
|
+
'quoted',
|
|
9
|
+
'archived',
|
|
10
|
+
];
|
|
11
|
+
function chatbaseSecret() {
|
|
12
|
+
const secret = process.env.FINSTACK_CHATBASE_SECRET;
|
|
13
|
+
if (!secret) {
|
|
14
|
+
throw new Error('FINSTACK_CHATBASE_SECRET must be set');
|
|
15
|
+
}
|
|
16
|
+
return secret;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Normalize the status filter to the comma list the endpoint expects.
|
|
20
|
+
* Accepts a single value, a comma list, or an array; returns undefined when
|
|
21
|
+
* empty so every status comes back.
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeStatusFilter(status) {
|
|
24
|
+
const raw = Array.isArray(status) ? status : (status ?? '').split(',');
|
|
25
|
+
const values = raw.map((s) => s.trim()).filter(Boolean);
|
|
26
|
+
return values.length ? values.join(',') : undefined;
|
|
27
|
+
}
|
|
28
|
+
async function lookup(client, action, params, failureMessage) {
|
|
29
|
+
const response = await client.get(`/api/v1/chatbase/${action}?${params}`, { 'X-Chatbase-Secret': chatbaseSecret() });
|
|
30
|
+
if (response.status !== 'success' || !response.data) {
|
|
31
|
+
throw new Error(response.message || failureMessage);
|
|
32
|
+
}
|
|
33
|
+
return response.data;
|
|
34
|
+
}
|
|
35
|
+
function pagedParams(args) {
|
|
36
|
+
const code = args.code?.trim();
|
|
37
|
+
if (!code) {
|
|
38
|
+
throw new Error('code is required');
|
|
39
|
+
}
|
|
40
|
+
const params = new URLSearchParams({ code });
|
|
41
|
+
const status = normalizeStatusFilter(args.status);
|
|
42
|
+
if (status)
|
|
43
|
+
params.set('status', status);
|
|
44
|
+
if (args.page !== undefined)
|
|
45
|
+
params.set('page', String(args.page));
|
|
46
|
+
if (args.perPage !== undefined)
|
|
47
|
+
params.set('per_page', String(args.perPage));
|
|
48
|
+
return params;
|
|
49
|
+
}
|
|
50
|
+
function customerParams(args) {
|
|
51
|
+
if (!args.customerCode && !args.userId) {
|
|
52
|
+
throw new Error('Provide either customer_code or user_id');
|
|
53
|
+
}
|
|
54
|
+
const params = new URLSearchParams();
|
|
55
|
+
if (args.customerCode)
|
|
56
|
+
params.set('customer_code', args.customerCode);
|
|
57
|
+
if (args.userId)
|
|
58
|
+
params.set('user_id', args.userId);
|
|
59
|
+
const status = normalizeStatusFilter(args.status);
|
|
60
|
+
if (status)
|
|
61
|
+
params.set('status', status);
|
|
62
|
+
return params;
|
|
63
|
+
}
|
|
64
|
+
/** Which customers hold a product (paged over customers). */
|
|
65
|
+
export async function getProductCustomers(client, args) {
|
|
66
|
+
return lookup(client, 'customers_by_product', pagedParams(args), 'Failed to fetch customers by product');
|
|
67
|
+
}
|
|
68
|
+
/** Which customers have an add-on attached to at least one product (paged). */
|
|
69
|
+
export async function getAddOnCustomers(client, args) {
|
|
70
|
+
return lookup(client, 'customers_by_add_on', pagedParams(args), 'Failed to fetch customers by add-on');
|
|
71
|
+
}
|
|
72
|
+
/** The products one customer holds, each with status and add-ons. */
|
|
73
|
+
export async function getCustomerProducts(client, args) {
|
|
74
|
+
return lookup(client, 'customer_products', customerParams(args), 'Failed to fetch customer products');
|
|
75
|
+
}
|
|
76
|
+
/** The add-ons one customer has, each with the products it is attached to. */
|
|
77
|
+
export async function getCustomerAddOns(client, args) {
|
|
78
|
+
return lookup(client, 'customer_add_ons', customerParams(args), 'Failed to fetch customer add-ons');
|
|
79
|
+
}
|
package/dist/handlers/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { handleGetCopAiAnalytics } from './copai.handler.js';
|
|
|
2
2
|
import { handleGetCustomerCohort, handleGetCustomerInvoices, handleGetCustomerPricingConfig, handleGetCustomerRevenue, handleGetCustomerSummary, handleGetCustomerUsers, handleGetPricingAddendumText, handleSearchCustomer, } from './customer.handler.js';
|
|
3
3
|
import { handleGetLeadiqAnalytics, handleGetLeadiqCustomer, } from './leadiq.handler.js';
|
|
4
4
|
import { handleGetProductCatalog } from './product.handler.js';
|
|
5
|
+
import { handleGetAddOnCustomers, handleGetCustomerAddOns, handleGetCustomerProducts, handleGetProductCustomers, } from './product_lookup.handler.js';
|
|
5
6
|
import { handleGetVendors } from './vendor.handler.js';
|
|
6
7
|
const wrap = (fn) => {
|
|
7
8
|
return async (args) => fn(args);
|
|
@@ -19,5 +20,9 @@ export const handlers = {
|
|
|
19
20
|
finstack_get_leadiq_customer: wrap(handleGetLeadiqCustomer),
|
|
20
21
|
finstack_get_cop_ai_analytics: wrap(handleGetCopAiAnalytics),
|
|
21
22
|
finstack_get_product_catalog: wrap(handleGetProductCatalog),
|
|
23
|
+
finstack_get_product_customers: wrap(handleGetProductCustomers),
|
|
24
|
+
finstack_get_add_on_customers: wrap(handleGetAddOnCustomers),
|
|
25
|
+
finstack_get_customer_products: wrap(handleGetCustomerProducts),
|
|
26
|
+
finstack_get_customer_add_ons: wrap(handleGetCustomerAddOns),
|
|
22
27
|
finstack_get_vendors: wrap(handleGetVendors),
|
|
23
28
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface ByCodeArgs {
|
|
2
|
+
code: string;
|
|
3
|
+
status?: string | string[];
|
|
4
|
+
page?: number;
|
|
5
|
+
per_page?: number;
|
|
6
|
+
environment?: string;
|
|
7
|
+
}
|
|
8
|
+
interface ByCustomerArgs {
|
|
9
|
+
customer_code?: string;
|
|
10
|
+
user_id?: string;
|
|
11
|
+
status?: string | string[];
|
|
12
|
+
environment?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function handleGetProductCustomers(args: ByCodeArgs): Promise<unknown>;
|
|
15
|
+
export declare function handleGetAddOnCustomers(args: ByCodeArgs): Promise<unknown>;
|
|
16
|
+
export declare function handleGetCustomerProducts(args: ByCustomerArgs): Promise<unknown>;
|
|
17
|
+
export declare function handleGetCustomerAddOns(args: ByCustomerArgs): Promise<unknown>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product / add-on lookup handlers
|
|
3
|
+
*/
|
|
4
|
+
import { getClient } from '../api/client.js';
|
|
5
|
+
import { getAddOnCustomers, getCustomerAddOns, getCustomerProducts, getProductCustomers, } from '../api/product_lookup.api.js';
|
|
6
|
+
export async function handleGetProductCustomers(args) {
|
|
7
|
+
const client = getClient(args.environment ?? 'prod');
|
|
8
|
+
return getProductCustomers(client, {
|
|
9
|
+
code: args.code,
|
|
10
|
+
status: args.status,
|
|
11
|
+
page: args.page,
|
|
12
|
+
perPage: args.per_page,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export async function handleGetAddOnCustomers(args) {
|
|
16
|
+
const client = getClient(args.environment ?? 'prod');
|
|
17
|
+
return getAddOnCustomers(client, {
|
|
18
|
+
code: args.code,
|
|
19
|
+
status: args.status,
|
|
20
|
+
page: args.page,
|
|
21
|
+
perPage: args.per_page,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export async function handleGetCustomerProducts(args) {
|
|
25
|
+
const client = getClient(args.environment ?? 'prod');
|
|
26
|
+
return getCustomerProducts(client, {
|
|
27
|
+
customerCode: args.customer_code,
|
|
28
|
+
userId: args.user_id,
|
|
29
|
+
status: args.status,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export async function handleGetCustomerAddOns(args) {
|
|
33
|
+
const client = getClient(args.environment ?? 'prod');
|
|
34
|
+
return getCustomerAddOns(client, {
|
|
35
|
+
customerCode: args.customer_code,
|
|
36
|
+
userId: args.user_id,
|
|
37
|
+
status: args.status,
|
|
38
|
+
});
|
|
39
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5
5
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
6
6
|
import { resources, getResourceContent } from './resources.js';
|
|
7
|
+
import { logToolCall } from './toolLog.js';
|
|
7
8
|
export function createServer(tools, handlers) {
|
|
8
9
|
const server = new Server({ name: 'finstack-mcp-server', version: '0.1.0' }, { capabilities: { tools: {}, resources: {} } });
|
|
9
10
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -15,16 +16,21 @@ export function createServer(tools, handlers) {
|
|
|
15
16
|
if (!handler) {
|
|
16
17
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
17
18
|
}
|
|
19
|
+
const startedAt = Date.now();
|
|
20
|
+
const safeArgs = (args || {});
|
|
18
21
|
try {
|
|
19
|
-
const result = await handler(
|
|
22
|
+
const result = await handler(safeArgs);
|
|
23
|
+
const text = JSON.stringify(result, null, 2);
|
|
24
|
+
logToolCall({ server: 'finstack', tool: name, args: safeArgs, durationMs: Date.now() - startedAt, ok: true, resultText: text });
|
|
20
25
|
return {
|
|
21
26
|
content: [
|
|
22
|
-
{ type: 'text', text
|
|
27
|
+
{ type: 'text', text },
|
|
23
28
|
],
|
|
24
29
|
};
|
|
25
30
|
}
|
|
26
31
|
catch (error) {
|
|
27
32
|
const message = error instanceof Error ? error.message : String(error);
|
|
33
|
+
logToolCall({ server: 'finstack', tool: name, args: safeArgs, durationMs: Date.now() - startedAt, ok: false, errorMessage: message });
|
|
28
34
|
return {
|
|
29
35
|
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
30
36
|
isError: true,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface ToolLogEntry {
|
|
2
|
+
server: string;
|
|
3
|
+
tool: string;
|
|
4
|
+
args: Record<string, unknown>;
|
|
5
|
+
durationMs: number;
|
|
6
|
+
ok: boolean;
|
|
7
|
+
resultText?: string;
|
|
8
|
+
errorMessage?: string;
|
|
9
|
+
traceId?: string | null;
|
|
10
|
+
}
|
|
11
|
+
export declare function hashArgs(args: Record<string, unknown>): string;
|
|
12
|
+
/** Pull an HTTP status out of upstream error text such as "GET /x failed: 404 - ..." */
|
|
13
|
+
export declare function upstreamStatusFrom(message: string | undefined): number | null;
|
|
14
|
+
export declare function toRecord(e: ToolLogEntry): {
|
|
15
|
+
trace_id: string | null;
|
|
16
|
+
channel: string;
|
|
17
|
+
agent: string;
|
|
18
|
+
server: string;
|
|
19
|
+
tool: string;
|
|
20
|
+
args_hash: string;
|
|
21
|
+
args_summary: {
|
|
22
|
+
keys: string[];
|
|
23
|
+
};
|
|
24
|
+
started_at: string;
|
|
25
|
+
duration_ms: number;
|
|
26
|
+
ok: boolean;
|
|
27
|
+
error_class: string | null;
|
|
28
|
+
error_message: string | null;
|
|
29
|
+
result_bytes: number;
|
|
30
|
+
truncated: boolean;
|
|
31
|
+
upstream_status: number | null;
|
|
32
|
+
};
|
|
33
|
+
export declare function logToolCall(e: ToolLogEntry): void;
|
package/dist/toolLog.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One record per tool call (Support Autopsy observability, CREM-110).
|
|
3
|
+
*
|
|
4
|
+
* If TOOL_CALLS_DATABASE_URL is set, each call is inserted into the shared `tool_calls`
|
|
5
|
+
* table (same table the n8n agents write to), fire-and-forget: a slow or unreachable
|
|
6
|
+
* database never delays or fails a tool call. Without the URL, one JSON line is written
|
|
7
|
+
* instead (stdout in http mode, stderr in stdio mode where stdout is the MCP channel).
|
|
8
|
+
*
|
|
9
|
+
* Argument values are never stored or logged, only a hash and the key names.
|
|
10
|
+
* Disable entirely with TOOL_LOG=off.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import pg from 'pg';
|
|
14
|
+
const ENABLED = (process.env.TOOL_LOG || 'on').toLowerCase() !== 'off';
|
|
15
|
+
// sslmode is stripped from the URL on purpose: pg >= 8.16 treats sslmode=require as
|
|
16
|
+
// verify-full and the RDS certificate chain is not in the container's trust store. SSL is
|
|
17
|
+
// on by default without chain verification (traffic stays inside the VPC);
|
|
18
|
+
// TOOL_CALLS_PGSSL=off disables it for a local database.
|
|
19
|
+
const DB_URL = (process.env.TOOL_CALLS_DATABASE_URL || '').replace(/[?&]sslmode=[^&]+/, '').replace(/\?$/, '');
|
|
20
|
+
const DB_SSL = (process.env.TOOL_CALLS_PGSSL || 'on').toLowerCase() === 'off' ? false : { rejectUnauthorized: false };
|
|
21
|
+
const STREAM = (process.env.MCP_TRANSPORT || 'stdio') === 'http' ? process.stdout : process.stderr;
|
|
22
|
+
let pool = null;
|
|
23
|
+
let lastDbErrorAt = 0;
|
|
24
|
+
function getPool() {
|
|
25
|
+
if (!DB_URL)
|
|
26
|
+
return null;
|
|
27
|
+
if (!pool) {
|
|
28
|
+
pool = new pg.Pool({
|
|
29
|
+
connectionString: DB_URL,
|
|
30
|
+
max: 2,
|
|
31
|
+
idleTimeoutMillis: 30_000,
|
|
32
|
+
connectionTimeoutMillis: 3_000,
|
|
33
|
+
ssl: DB_SSL,
|
|
34
|
+
});
|
|
35
|
+
pool.on('error', (err) => warnDb(err));
|
|
36
|
+
}
|
|
37
|
+
return pool;
|
|
38
|
+
}
|
|
39
|
+
function warnDb(err) {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
if (now - lastDbErrorAt < 60_000)
|
|
42
|
+
return; // at most one warning per minute
|
|
43
|
+
lastDbErrorAt = now;
|
|
44
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
45
|
+
process.stderr.write(`[toolLog] tool_calls insert failed: ${msg}\n`);
|
|
46
|
+
}
|
|
47
|
+
export function hashArgs(args) {
|
|
48
|
+
const canonical = JSON.stringify(args, Object.keys(args).sort());
|
|
49
|
+
return createHash('sha1').update(canonical).digest('hex').slice(0, 16);
|
|
50
|
+
}
|
|
51
|
+
/** Pull an HTTP status out of upstream error text such as "GET /x failed: 404 - ..." */
|
|
52
|
+
export function upstreamStatusFrom(message) {
|
|
53
|
+
if (!message)
|
|
54
|
+
return null;
|
|
55
|
+
const m = message.match(/\b(?:failed|status|HTTP)\D{0,10}(\d{3})\b/i);
|
|
56
|
+
return m ? Number(m[1]) : null;
|
|
57
|
+
}
|
|
58
|
+
export function toRecord(e) {
|
|
59
|
+
const durationMs = Math.round(e.durationMs);
|
|
60
|
+
return {
|
|
61
|
+
trace_id: e.traceId ?? null,
|
|
62
|
+
channel: 'mcp',
|
|
63
|
+
agent: 'mcp_server',
|
|
64
|
+
server: e.server,
|
|
65
|
+
tool: e.tool,
|
|
66
|
+
args_hash: hashArgs(e.args),
|
|
67
|
+
args_summary: { keys: Object.keys(e.args) },
|
|
68
|
+
started_at: new Date(Date.now() - durationMs).toISOString(),
|
|
69
|
+
duration_ms: durationMs,
|
|
70
|
+
ok: e.ok,
|
|
71
|
+
error_class: e.ok ? null : 'tool_error',
|
|
72
|
+
error_message: e.ok ? null : (e.errorMessage || '').slice(0, 200),
|
|
73
|
+
result_bytes: e.resultText ? Buffer.byteLength(e.resultText) : 0,
|
|
74
|
+
truncated: e.resultText ? /"truncated"\s*:\s*true/.test(e.resultText) : false,
|
|
75
|
+
upstream_status: e.ok ? null : upstreamStatusFrom(e.errorMessage),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const INSERT = `INSERT INTO tool_calls
|
|
79
|
+
(trace_id, channel, agent, server, tool, args_hash, args_summary, started_at,
|
|
80
|
+
duration_ms, ok, error_class, error_message, result_bytes, truncated, upstream_status)
|
|
81
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`;
|
|
82
|
+
export function logToolCall(e) {
|
|
83
|
+
if (!ENABLED)
|
|
84
|
+
return;
|
|
85
|
+
const r = toRecord(e);
|
|
86
|
+
const p = getPool();
|
|
87
|
+
if (!p) {
|
|
88
|
+
STREAM.write(JSON.stringify({ ts: new Date().toISOString(), kind: 'tool_call', ...r }) + '\n');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
p.query(INSERT, [
|
|
92
|
+
r.trace_id, r.channel, r.agent, r.server, r.tool, r.args_hash, JSON.stringify(r.args_summary),
|
|
93
|
+
r.started_at, r.duration_ms, r.ok, r.error_class, r.error_message, r.result_bytes,
|
|
94
|
+
r.truncated, r.upstream_status,
|
|
95
|
+
]).catch(warnDb);
|
|
96
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -2,11 +2,13 @@ import { copAiTools } from './copai.tools.js';
|
|
|
2
2
|
import { customerTools } from './customer.tools.js';
|
|
3
3
|
import { leadiqTools } from './leadiq.tools.js';
|
|
4
4
|
import { productTools } from './product.tools.js';
|
|
5
|
+
import { productLookupTools } from './product_lookup.tools.js';
|
|
5
6
|
import { vendorTools } from './vendor.tools.js';
|
|
6
7
|
export const tools = [
|
|
7
8
|
...customerTools,
|
|
8
9
|
...copAiTools,
|
|
9
10
|
...leadiqTools,
|
|
10
11
|
...productTools,
|
|
12
|
+
...productLookupTools,
|
|
11
13
|
...vendorTools,
|
|
12
14
|
];
|
|
@@ -5,7 +5,7 @@ import { PRODUCT_VENDORS } from '../api/product.api.js';
|
|
|
5
5
|
export const productTools = [
|
|
6
6
|
{
|
|
7
7
|
name: 'finstack_get_product_catalog',
|
|
8
|
-
description: "The CRS product catalog — every product CRS sells (not a specific customer's configuration; for what one customer has, use finstack_get_customer_pricing_config). Use when asked what a product code (e.g. EXP1001, TUC2000) is, what products exist for a bureau/vendor, whether a product is hard or soft pull / FCRA, its base cost or default list price, which platforms it is on (mcl, crs, b2c, liq, bureau_direct), its add-ons (score models etc. with bureau service codes and base costs), its permissible purposes, or whether it is active or terminated. Each product includes: code, status (active/terminated) and termination_date, type, category, vendor, public_name and private_name, description, plan_includes, base_cost and default_price (decimal strings; default_price = base_cost x 2), inquiry (hard/soft/not_applicable), fcra, on_path, platforms, parent_codes, product_codes, warning_notice, add_ons[], permissible_purposes[]. Filters: `code` (exact, one or comma-separated), `status`, `vendor` are applied server-side; `query` is a free-text AND-match across code, names, description, labels, add-on names/codes and permissible purposes. With no filters the whole catalog is returned (can be large — prefer a filter). Does not include subscriber codes or internal notes. Backed by the mware-portal chatbase product_catalog endpoint.",
|
|
8
|
+
description: "The CRS product catalog — every product CRS sells (not a specific customer's configuration; for what one customer has, use finstack_get_customer_products or finstack_get_customer_pricing_config; for which customers hold a product, finstack_get_product_customers). Use when asked what a product code (e.g. EXP1001, TUC2000) is, what products exist for a bureau/vendor, whether a product is hard or soft pull / FCRA, its base cost or default list price, which platforms it is on (mcl, crs, b2c, liq, bureau_direct), its add-ons (score models etc. with bureau service codes and base costs), its permissible purposes, or whether it is active or terminated. Each product includes: code, status (active/terminated) and termination_date, type, category, vendor, public_name and private_name, description, plan_includes, base_cost and default_price (decimal strings; default_price = base_cost x 2), inquiry (hard/soft/not_applicable), fcra, on_path, platforms, parent_codes, product_codes, warning_notice, add_ons[], permissible_purposes[]. Filters: `code` (exact, one or comma-separated), `status`, `vendor` are applied server-side; `query` is a free-text AND-match across code, names, description, labels, add-on names/codes and permissible purposes. With no filters the whole catalog is returned (can be large — prefer a filter). Does not include subscriber codes or internal notes. Backed by the mware-portal chatbase product_catalog endpoint.",
|
|
9
9
|
inputSchema: {
|
|
10
10
|
type: 'object',
|
|
11
11
|
properties: {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product / add-on lookup tool definitions
|
|
3
|
+
*/
|
|
4
|
+
import { SUBSCRIPTION_STATUSES } from '../api/product_lookup.api.js';
|
|
5
|
+
const STATUS_LIST = SUBSCRIPTION_STATUSES.join(', ');
|
|
6
|
+
const statusProperty = {
|
|
7
|
+
type: 'string',
|
|
8
|
+
description: `Only subscriptions in these statuses — one value or a comma list, e.g. "activated" or "activated,in_review". Values: ${STATUS_LIST}. Omit for every status (each row carries its own status). For "who currently has X" use "activated".`,
|
|
9
|
+
};
|
|
10
|
+
const environmentProperty = {
|
|
11
|
+
type: 'string',
|
|
12
|
+
description: 'Target environment: "prod" (default) or "dev"',
|
|
13
|
+
enum: ['dev', 'prod'],
|
|
14
|
+
};
|
|
15
|
+
const pageProperty = {
|
|
16
|
+
type: 'number',
|
|
17
|
+
description: 'Page number, 1-indexed. Default 1.',
|
|
18
|
+
minimum: 1,
|
|
19
|
+
};
|
|
20
|
+
const perPageProperty = {
|
|
21
|
+
type: 'number',
|
|
22
|
+
description: 'Customers per page. Default 500, capped at 1000.',
|
|
23
|
+
minimum: 1,
|
|
24
|
+
maximum: 1000,
|
|
25
|
+
};
|
|
26
|
+
const PRODUCT_ROW_DOC = 'Each product row has code, name, private_name, vendor, category, status (subscription status), enabled, platform, format, activated_at, part_of_merge / part_of_bundle (true = component row of a merge or bundle rather than a product sold on its own), and add_ons[{code,name}].';
|
|
27
|
+
export const productLookupTools = [
|
|
28
|
+
{
|
|
29
|
+
name: 'finstack_get_product_customers',
|
|
30
|
+
description: `Which B2B customers hold a given product (by billing product code, e.g. TUC1002 = TransUnion Bankruptcy, EXP1001 = Experian Credit Profile). Use for "which customers have / use / are subscribed to product X" — do NOT loop finstack_get_customer_summary over the cohort for that. Requires the exact product code; if the user gives a product name, resolve the code with finstack_get_product_catalog first and pass it here. Paged over customers (default 500, max 1000): read total_pages and fetch the rest. Returns {product:{code,name,private_name,status active|terminated}, page, per_page, total_count, total_pages, customers[{customer_code,name,status,products[]}]} where products[] are that customer's subscriptions to this product (can be more than one, e.g. per platform). ${PRODUCT_ROW_DOC} Without a status filter every subscription status is returned (quoted, activated, deactivated...), so filter with status="activated" for current holders. Errors: 404 unknown product code, 400 unknown status. No prices.`,
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
code: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
description: 'Billing product code, exact match — e.g. "EXP1001", "TUC1002".',
|
|
37
|
+
},
|
|
38
|
+
status: statusProperty,
|
|
39
|
+
page: pageProperty,
|
|
40
|
+
per_page: perPageProperty,
|
|
41
|
+
environment: environmentProperty,
|
|
42
|
+
},
|
|
43
|
+
required: ['code'],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'finstack_get_add_on_customers',
|
|
48
|
+
description: `Which B2B customers have a given add-on (by add-on code, e.g. CRS1002 = Credit Order Pro) attached to at least one of their products, and on which products. Use for "which customers have add-on X / feature X enabled". Requires the exact add-on code. Paged over customers (default 500, max 1000): read total_pages and fetch the rest. Returns {add_on:{code,name,private_name,status active|inactive|null (null = active),category,category_label}, page, per_page, total_count, total_pages, customers[{customer_code,name,status,products[]}]} where products[] are only the products carrying this add-on. ${PRODUCT_ROW_DOC} status filters on the subscription status of the product the add-on is attached to. Errors: 404 unknown add-on code, 400 unknown status.`,
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: {
|
|
52
|
+
code: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
description: 'Add-on code, exact match — e.g. "CRS1002".',
|
|
55
|
+
},
|
|
56
|
+
status: statusProperty,
|
|
57
|
+
page: pageProperty,
|
|
58
|
+
per_page: perPageProperty,
|
|
59
|
+
environment: environmentProperty,
|
|
60
|
+
},
|
|
61
|
+
required: ['code'],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'finstack_get_customer_products',
|
|
66
|
+
description: `The products one B2B customer holds, each with its subscription status and attached add-ons — the lean answer to "what products does customer X have / is X subscribed to Y / when was Y activated". Prefer this over finstack_get_customer_pricing_config when prices are not needed, and over finstack_get_customer_summary when you need codes, statuses beyond activated/not_activated, activation dates, or add-ons. Returns {customer:{customer_code,name,status}, count, products[]} ordered by product code. ${PRODUCT_ROW_DOC} Without a status filter every status is returned (quoted, deactivated...), so check each row's status before saying the customer "has" a product. Identify the customer with EITHER customer_code OR user_id; an unknown customer returns 401.`,
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
customer_code: {
|
|
71
|
+
type: 'string',
|
|
72
|
+
description: 'Customer code (CID) — e.g. "CID18068". Provide this OR user_id.',
|
|
73
|
+
},
|
|
74
|
+
user_id: {
|
|
75
|
+
type: 'string',
|
|
76
|
+
description: 'mware-portal user ID. Provide this OR customer_code.',
|
|
77
|
+
},
|
|
78
|
+
status: statusProperty,
|
|
79
|
+
environment: environmentProperty,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'finstack_get_customer_add_ons',
|
|
85
|
+
description: `The add-ons one B2B customer has, each with the products it is attached to — the answer to "which add-ons / features does customer X have" or "does X have add-on Y, on which product". Returns {customer:{customer_code,name,status}, count (distinct add-ons), add_ons[{code,name,private_name,status active|inactive|null (null = active),category,category_label,products[]}]} ordered by add-on code; products[] rows are the product rows without their own add_ons list. status filters on the subscription status of the product the add-on is attached to. Identify the customer with EITHER customer_code OR user_id; an unknown customer returns 401.`,
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
customer_code: {
|
|
90
|
+
type: 'string',
|
|
91
|
+
description: 'Customer code (CID) — e.g. "CID18068". Provide this OR user_id.',
|
|
92
|
+
},
|
|
93
|
+
user_id: {
|
|
94
|
+
type: 'string',
|
|
95
|
+
description: 'mware-portal user ID. Provide this OR customer_code.',
|
|
96
|
+
},
|
|
97
|
+
status: statusProperty,
|
|
98
|
+
environment: environmentProperty,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crscreditapi/finstack-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0-80b4ec3",
|
|
4
4
|
"description": "MCP server for Finstack",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -32,12 +32,14 @@
|
|
|
32
32
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
33
33
|
"dotenv": "^16.3.0",
|
|
34
34
|
"express": "^5.2.1",
|
|
35
|
-
"pdf-parse": "^2.4.5"
|
|
35
|
+
"pdf-parse": "^2.4.5",
|
|
36
|
+
"pg": "^8.23.1"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
39
|
"@types/express": "^5.0.6",
|
|
39
40
|
"@types/node": "^20.11.0",
|
|
40
41
|
"@types/pdf-parse": "^1.1.5",
|
|
42
|
+
"@types/pg": "^8.23.1",
|
|
41
43
|
"typescript": "^5.3.0"
|
|
42
44
|
}
|
|
43
45
|
}
|