@verifik/mcp 0.0.0-stage → 0.1.2

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/lib/catalog.js ADDED
@@ -0,0 +1,183 @@
1
+ "use strict";
2
+
3
+ const { filterFeatures } = require("./filters");
4
+ const { buildToolRegistry, toMcpToolDefinition } = require("./tool-schema");
5
+ const { fetchGrantedFeatureCodes } = require("./client-features");
6
+
7
+ const CATALOG_PATH = "/v2/app-features/my-list";
8
+ const PAGE_LIMIT = 500;
9
+
10
+ /**
11
+ * @param {import("./types").McpConfig} config
12
+ */
13
+ const fetchCatalogPage = async (config, page = 1) => {
14
+ const url = new URL(`${config.apiBase}${CATALOG_PATH}`);
15
+ url.searchParams.set("page", String(page));
16
+ url.searchParams.set("limit", String(PAGE_LIMIT));
17
+
18
+ const response = await fetch(url, {
19
+ method: "GET",
20
+ headers: {
21
+ Authorization: `Bearer ${config.token}`,
22
+ Accept: "application/json",
23
+ },
24
+ });
25
+
26
+ const body = await readResponseBody(response);
27
+
28
+ if (!response.ok) {
29
+ throw new Error(`Catalog request failed (${response.status}): ${formatBodyForError(body)}`);
30
+ }
31
+
32
+ return normalizeCatalogResponse(body);
33
+ };
34
+
35
+ /**
36
+ * @param {unknown} body
37
+ */
38
+ const normalizeCatalogResponse = (body) => {
39
+ const payload = body && typeof body === "object" ? body : {};
40
+ const data = Array.isArray(payload.data)
41
+ ? payload.data
42
+ : Array.isArray(payload.data?.docs)
43
+ ? payload.data.docs
44
+ : [];
45
+
46
+ return {
47
+ features: data,
48
+ page: Number(payload.page) || 1,
49
+ pages: Number(payload.pages) || 1,
50
+ total: Number(payload.total) || data.length,
51
+ };
52
+ };
53
+
54
+ /**
55
+ * @param {import("./types").McpConfig} config
56
+ */
57
+ const fetchAllCatalogFeatures = async (config) => {
58
+ const firstPage = await fetchCatalogPage(config, 1);
59
+ const features = [...firstPage.features];
60
+
61
+ for (let page = firstPage.page + 1; page <= firstPage.pages; page += 1) {
62
+ const nextPage = await fetchCatalogPage(config, page);
63
+ features.push(...nextPage.features);
64
+ }
65
+
66
+ return features;
67
+ };
68
+
69
+ /**
70
+ * @param {import("./types").McpConfig} config
71
+ * @returns {Promise<Set<string>|null>}
72
+ */
73
+ const loadGrantedFeatureCodes = async (config) => {
74
+ try {
75
+ return await fetchGrantedFeatureCodes(config);
76
+ } catch {
77
+ return null;
78
+ }
79
+ };
80
+
81
+ /**
82
+ * @param {import("./types").McpConfig} config
83
+ */
84
+ const loadCatalogState = async (config) => {
85
+ const [allFeatures, grantedFeatureCodes] = await Promise.all([
86
+ fetchAllCatalogFeatures(config),
87
+ loadGrantedFeatureCodes(config),
88
+ ]);
89
+ const filteredFeatures = filterFeatures(allFeatures, config, grantedFeatureCodes);
90
+ const registry = buildToolRegistry(filteredFeatures);
91
+
92
+ const tools = [];
93
+
94
+ for (const [toolName, feature] of registry.toolNameToFeature.entries()) {
95
+ tools.push(toMcpToolDefinition(feature, toolName));
96
+ }
97
+
98
+ return {
99
+ features: filteredFeatures,
100
+ allFeatures,
101
+ tools,
102
+ registry,
103
+ grantedFeatureCodes,
104
+ loadedAt: new Date().toISOString(),
105
+ };
106
+ };
107
+
108
+ /**
109
+ * @param {Response} response
110
+ */
111
+ const readResponseBody = async (response) => {
112
+ const contentType = response.headers.get("content-type") || "";
113
+
114
+ if (contentType.includes("application/json")) {
115
+ return response.json();
116
+ }
117
+
118
+ return response.text();
119
+ };
120
+
121
+ /**
122
+ * @param {unknown} body
123
+ */
124
+ const formatBodyForError = (body) => {
125
+ if (typeof body === "string") return body.slice(0, 500);
126
+ try {
127
+ return JSON.stringify(body).slice(0, 500);
128
+ } catch {
129
+ return "unknown_error";
130
+ }
131
+ };
132
+
133
+ class CatalogCache {
134
+ /**
135
+ * @param {import("./types").McpConfig} config
136
+ */
137
+ constructor(config) {
138
+ this.config = config;
139
+ this.state = null;
140
+ this.loadingPromise = null;
141
+ }
142
+
143
+ async getState(forceRefresh = false) {
144
+ const shouldRefresh =
145
+ forceRefresh ||
146
+ !this.state ||
147
+ (this.config.catalogRefreshMs > 0 &&
148
+ Date.now() - Date.parse(this.state.loadedAt) >= this.config.catalogRefreshMs);
149
+
150
+ if (!shouldRefresh) return this.state;
151
+ if (this.loadingPromise) return this.loadingPromise;
152
+
153
+ this.loadingPromise = loadCatalogState(this.config)
154
+ .then((state) => {
155
+ this.state = state;
156
+ return state;
157
+ })
158
+ .finally(() => {
159
+ this.loadingPromise = null;
160
+ });
161
+
162
+ return this.loadingPromise;
163
+ }
164
+
165
+ findFeatureByCode(code) {
166
+ if (!this.state) return null;
167
+ return this.state.features.find((feature) => feature.code === code) || null;
168
+ }
169
+
170
+ resolveFeatureByToolName(toolName) {
171
+ if (!this.state) return null;
172
+ return this.state.registry.toolNameToFeature.get(toolName) || null;
173
+ }
174
+ }
175
+
176
+ module.exports = {
177
+ CATALOG_PATH,
178
+ fetchCatalogPage,
179
+ fetchAllCatalogFeatures,
180
+ loadCatalogState,
181
+ CatalogCache,
182
+ normalizeCatalogResponse,
183
+ };
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+
3
+ const CLIENT_FEATURES_PATH = "/v2/client-features";
4
+ const PAGE_LIMIT = 500;
5
+
6
+ /**
7
+ * @param {import("./types").McpConfig} config
8
+ */
9
+ const fetchClientFeaturesPage = async (config, page = 1) => {
10
+ const url = new URL(`${config.apiBase}${CLIENT_FEATURES_PATH}`);
11
+ url.searchParams.set("page", String(page));
12
+ url.searchParams.set("limit", String(PAGE_LIMIT));
13
+
14
+ const response = await fetch(url, {
15
+ method: "GET",
16
+ headers: {
17
+ Authorization: `Bearer ${config.token}`,
18
+ Accept: "application/json",
19
+ },
20
+ });
21
+
22
+ if (!response.ok) {
23
+ throw new Error(`Client features request failed (${response.status})`);
24
+ }
25
+
26
+ const body = await response.json();
27
+ const data = Array.isArray(body?.data)
28
+ ? body.data
29
+ : Array.isArray(body?.data?.docs)
30
+ ? body.data.docs
31
+ : [];
32
+
33
+ return {
34
+ features: data,
35
+ page: Number(body?.page) || 1,
36
+ pages: Number(body?.pages) || 1,
37
+ };
38
+ };
39
+
40
+ /**
41
+ * @param {import("./types").McpConfig} config
42
+ */
43
+ const fetchGrantedFeatureCodes = async (config) => {
44
+ const firstPage = await fetchClientFeaturesPage(config, 1);
45
+ const codes = new Set();
46
+
47
+ for (const feature of firstPage.features) {
48
+ if (feature?.code) codes.add(feature.code);
49
+ }
50
+
51
+ for (let page = firstPage.page + 1; page <= firstPage.pages; page += 1) {
52
+ const nextPage = await fetchClientFeaturesPage(config, page);
53
+ for (const feature of nextPage.features) {
54
+ if (feature?.code) codes.add(feature.code);
55
+ }
56
+ }
57
+
58
+ return codes;
59
+ };
60
+
61
+ module.exports = {
62
+ CLIENT_FEATURES_PATH,
63
+ fetchGrantedFeatureCodes,
64
+ };
package/lib/config.js ADDED
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+
3
+ const DEFAULT_API_BASE = "https://api.verifik.co";
4
+
5
+ /**
6
+ * @returns {import("./types").McpConfig}
7
+ */
8
+ const loadConfig = () => {
9
+ const apiBase = String(process.env.VERIFIK_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, "");
10
+ const token = String(process.env.VERIFIK_API_TOKEN || "").trim();
11
+
12
+ if (!token) {
13
+ throw new Error("VERIFIK_API_TOKEN is required (client JWT / API bearer token)");
14
+ }
15
+
16
+ const smartCheckOnly = parseBooleanEnv(process.env.VERIFIK_MCP_SMARTCHECK_ONLY, true);
17
+ const countryFilter = parseListEnv(process.env.VERIFIK_MCP_COUNTRY);
18
+ const baseCategoryFilter = parseListEnv(process.env.VERIFIK_MCP_BASE_CATEGORY);
19
+ const codeAllowlist = parseListEnv(process.env.VERIFIK_MCP_CODES);
20
+ const catalogRefreshMs = parsePositiveInt(process.env.VERIFIK_MCP_CATALOG_REFRESH_MS, 0);
21
+ const requestTimeoutMs = parsePositiveInt(process.env.VERIFIK_MCP_REQUEST_TIMEOUT_MS, 90_000);
22
+
23
+ return {
24
+ apiBase,
25
+ token,
26
+ smartCheckOnly,
27
+ countryFilter,
28
+ baseCategoryFilter,
29
+ codeAllowlist,
30
+ catalogRefreshMs,
31
+ requestTimeoutMs,
32
+ };
33
+ };
34
+
35
+ /**
36
+ * @param {string|undefined} value
37
+ * @param {boolean} defaultValue
38
+ */
39
+ const parseBooleanEnv = (value, defaultValue) => {
40
+ if (value == null || value === "") return defaultValue;
41
+ const normalized = String(value).trim().toLowerCase();
42
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
43
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
44
+ return defaultValue;
45
+ };
46
+
47
+ /**
48
+ * @param {string|undefined} value
49
+ * @returns {string[]}
50
+ */
51
+ const parseListEnv = (value) => {
52
+ if (!value || !String(value).trim()) return [];
53
+ return String(value)
54
+ .split(",")
55
+ .map((entry) => entry.trim())
56
+ .filter(Boolean);
57
+ };
58
+
59
+ /**
60
+ * @param {string|undefined} value
61
+ * @param {number} defaultValue
62
+ */
63
+ const parsePositiveInt = (value, defaultValue) => {
64
+ if (value == null || value === "") return defaultValue;
65
+ const parsed = Number.parseInt(String(value), 10);
66
+ if (!Number.isFinite(parsed) || parsed < 0) return defaultValue;
67
+ return parsed;
68
+ };
69
+
70
+ module.exports = {
71
+ DEFAULT_API_BASE,
72
+ loadConfig,
73
+ parseBooleanEnv,
74
+ parseListEnv,
75
+ };
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+
3
+ const ROUTE_PATTERN = /\b(?:GET|POST|PUT|PATCH|DELETE)\s+\/v?\d*\/[^\s.]+\b/gi;
4
+ const ALIAS_PATTERN = /\bAlias:\s*[^\n.]+\.?/gi;
5
+ const INTERNAL_JARGON = /\bNo name sources\.?\b/gi;
6
+
7
+ /**
8
+ * @param {string} text
9
+ */
10
+ const stripCountryPrefix = (text) => {
11
+ return String(text || "")
12
+ .replace(/^[A-Za-zÀ-ÿ\s]+?\s*[-—]\s*/u, "")
13
+ .trim();
14
+ };
15
+
16
+ /**
17
+ * @param {string} text
18
+ */
19
+ const stripParentheticalCountry = (text) => {
20
+ return String(text || "").replace(/\s*\([A-Za-zÀ-ÿ\s]+\)\s*/gu, " ").trim();
21
+ };
22
+
23
+ /**
24
+ * @param {string} text
25
+ */
26
+ const cleanDescriptionText = (text) => {
27
+ return String(text || "")
28
+ .replace(ROUTE_PATTERN, "")
29
+ .replace(ALIAS_PATTERN, "")
30
+ .replace(INTERNAL_JARGON, "")
31
+ .replace(/\s+/g, " ")
32
+ .trim();
33
+ };
34
+
35
+ /**
36
+ * @param {number | null | undefined} price
37
+ */
38
+ const formatPrice = (price) => {
39
+ const value = Number(price);
40
+ if (!Number.isFinite(value)) return null;
41
+ return value.toFixed(value % 1 === 0 ? 0 : 2);
42
+ };
43
+
44
+ /**
45
+ * @param {import("./types").AppFeature} feature
46
+ */
47
+ const buildToolDescription = (feature) => {
48
+ const rawDescription = cleanDescriptionText(feature.description || "");
49
+ const rawName = cleanDescriptionText(feature.name || feature.code || "Verifik feature");
50
+ const withoutCountryInName = stripParentheticalCountry(stripCountryPrefix(rawName));
51
+ const descriptionBody = rawDescription || withoutCountryInName;
52
+ const price = formatPrice(feature.smartCheckPrice ?? feature.price);
53
+
54
+ const parts = [descriptionBody];
55
+
56
+ if (price != null) {
57
+ parts.push(`Costs ${price} credit${price === "1" ? "" : "s"} per successful call.`);
58
+ }
59
+
60
+ return parts.join(" ");
61
+ };
62
+
63
+ module.exports = {
64
+ cleanDescriptionText,
65
+ buildToolDescription,
66
+ formatPrice,
67
+ };
@@ -0,0 +1,210 @@
1
+ "use strict";
2
+
3
+ const SERVICE_UNAVAILABLE_MESSAGE =
4
+ "The data source is temporarily unavailable; try again later.";
5
+
6
+ const FEATURE_NOT_ENABLED_MESSAGE =
7
+ "This feature is not enabled on your Verifik account. Contact support to request access.";
8
+
9
+ const FEATURE_UNAVAILABLE_MESSAGE =
10
+ "This feature is currently unavailable; contact support.";
11
+
12
+ const NOT_FOUND_MESSAGE = "No record was found for the provided data.";
13
+
14
+ /**
15
+ * @param {unknown} body
16
+ */
17
+ const isHtmlBody = (body) => {
18
+ if (typeof body !== "string") return false;
19
+ const trimmed = body.trim().toLowerCase();
20
+ return trimmed.startsWith("<!doctype") || trimmed.startsWith("<html");
21
+ };
22
+
23
+ /**
24
+ * @param {unknown} body
25
+ */
26
+ const isVerifikJsonBody = (body) => {
27
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
28
+ return typeof body.code === "string" || typeof body.message === "string";
29
+ };
30
+
31
+ /**
32
+ * Verifik API error envelope (code + message, often with id).
33
+ * @param {unknown} body
34
+ */
35
+ const isVerifikBusinessErrorBody = (body) => {
36
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
37
+ return typeof body.code === "string" && typeof body.message === "string";
38
+ };
39
+
40
+ /**
41
+ * @param {unknown} code
42
+ * @param {unknown} message
43
+ */
44
+ const isEndpointOutOfService = (code, message) => {
45
+ const haystack = `${String(code || "")} ${String(message || "")}`.toLowerCase();
46
+ return haystack.includes("endpoint_out_of_service") || haystack.includes("endpoint out of service");
47
+ };
48
+
49
+ /**
50
+ * @param {unknown} body
51
+ */
52
+ const extractRequestId = (body) => {
53
+ if (!body || typeof body !== "object") return undefined;
54
+ if (typeof body.id === "string" && body.id) return body.id;
55
+ return undefined;
56
+ };
57
+
58
+ /**
59
+ * @param {unknown} body
60
+ */
61
+ const extractCreditsCharged = (body) => {
62
+ if (!body || typeof body !== "object") return null;
63
+
64
+ const creditsCharged = body.creditsCharged;
65
+
66
+ if (typeof creditsCharged === "number" && Number.isFinite(creditsCharged)) {
67
+ return creditsCharged;
68
+ }
69
+
70
+ return null;
71
+ };
72
+
73
+ /**
74
+ * @param {unknown} message
75
+ */
76
+ const isUpstreamGatewayMessage = (message) => {
77
+ if (typeof message !== "string") return false;
78
+ return /request failed with status code 50[234]/i.test(message) || /gateway timeout/i.test(message);
79
+ };
80
+
81
+ /**
82
+ * @param {number} status
83
+ * @param {unknown} body
84
+ */
85
+ const classifyUpstreamError = (status, body) => {
86
+ const isJsonObject = isVerifikJsonBody(body);
87
+ const errorBody = isJsonObject ? body : {};
88
+ const apiCode = typeof errorBody.code === "string" ? errorBody.code : "";
89
+ const apiMessage = typeof errorBody.message === "string" ? errorBody.message : "";
90
+ const plainText = typeof body === "string" ? body.trim() : "";
91
+
92
+ if (status === 403 && apiMessage.toLowerCase().includes("no access to this feature")) {
93
+ return {
94
+ code: "FeatureNotEnabled",
95
+ message: FEATURE_NOT_ENABLED_MESSAGE,
96
+ retryable: false,
97
+ };
98
+ }
99
+
100
+ if (
101
+ isEndpointOutOfService(apiCode, apiMessage) ||
102
+ isEndpointOutOfService("", plainText)
103
+ ) {
104
+ return {
105
+ code: "ServiceUnavailable",
106
+ message: SERVICE_UNAVAILABLE_MESSAGE,
107
+ retryable: true,
108
+ };
109
+ }
110
+
111
+ if (
112
+ status === 502 ||
113
+ status === 503 ||
114
+ status === 504 ||
115
+ status >= 500 ||
116
+ isUpstreamGatewayMessage(apiMessage) ||
117
+ isUpstreamGatewayMessage(plainText) ||
118
+ isHtmlBody(body)
119
+ ) {
120
+ return {
121
+ code: "ServiceUnavailable",
122
+ message: SERVICE_UNAVAILABLE_MESSAGE,
123
+ retryable: true,
124
+ };
125
+ }
126
+
127
+ if (status === 404) {
128
+ if (isVerifikBusinessErrorBody(body)) {
129
+ return {
130
+ code: "NotFound",
131
+ message: NOT_FOUND_MESSAGE,
132
+ retryable: false,
133
+ };
134
+ }
135
+
136
+ return {
137
+ code: "FeatureUnavailable",
138
+ message: FEATURE_UNAVAILABLE_MESSAGE,
139
+ retryable: false,
140
+ };
141
+ }
142
+
143
+ if (apiCode || apiMessage) {
144
+ return {
145
+ code: apiCode || "RequestFailed",
146
+ message: apiMessage || "Request failed",
147
+ retryable: false,
148
+ };
149
+ }
150
+
151
+ if (plainText) {
152
+ return {
153
+ code: "RequestFailed",
154
+ message: plainText,
155
+ retryable: false,
156
+ };
157
+ }
158
+
159
+ return {
160
+ code: "RequestFailed",
161
+ message: "Request failed",
162
+ retryable: false,
163
+ };
164
+ };
165
+
166
+ /**
167
+ * @param {{ status: number, statusText?: string, body: unknown, durationMs?: number }} result
168
+ */
169
+ const buildProxyErrorPayload = (result) => {
170
+ const requestId = extractRequestId(result.body);
171
+ const classified = classifyUpstreamError(result.status, result.body);
172
+
173
+ const payload = {
174
+ ok: false,
175
+ httpStatus: result.status,
176
+ statusText: result.statusText,
177
+ durationMs: result.durationMs,
178
+ error: classified,
179
+ };
180
+
181
+ if (requestId) payload.requestId = requestId;
182
+
183
+ return payload;
184
+ };
185
+
186
+ /**
187
+ * @param {unknown} body
188
+ */
189
+ const sanitizeResponseData = (body) => {
190
+ if (isHtmlBody(body)) return null;
191
+ if (!body || typeof body !== "object") return body;
192
+ if ("data" in body) return body.data;
193
+ return body;
194
+ };
195
+
196
+ module.exports = {
197
+ SERVICE_UNAVAILABLE_MESSAGE,
198
+ FEATURE_NOT_ENABLED_MESSAGE,
199
+ FEATURE_UNAVAILABLE_MESSAGE,
200
+ NOT_FOUND_MESSAGE,
201
+ isHtmlBody,
202
+ isVerifikJsonBody,
203
+ isVerifikBusinessErrorBody,
204
+ isEndpointOutOfService,
205
+ extractRequestId,
206
+ extractCreditsCharged,
207
+ classifyUpstreamError,
208
+ buildProxyErrorPayload,
209
+ sanitizeResponseData,
210
+ };
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+
3
+ const path = require("node:path");
4
+
5
+ /** @type {import("./field-enrichment.types")} */
6
+ const enrichmentData = require(path.join(__dirname, "..", "data", "field-enrichment.json"));
7
+
8
+ /**
9
+ * @param {string} featureCode
10
+ */
11
+ const getFeatureEnrichment = (featureCode) => {
12
+ return enrichmentData.features[featureCode] || {};
13
+ };
14
+
15
+ /**
16
+ * @param {string} fieldName
17
+ */
18
+ const getDefaultFieldEnrichment = (fieldName) => {
19
+ return enrichmentData.defaultFields[fieldName] || {};
20
+ };
21
+
22
+ /**
23
+ * @param {string} featureCode
24
+ * @param {string} fieldName
25
+ */
26
+ const resolveFieldEnrichment = (featureCode, fieldName) => {
27
+ const featureFields = getFeatureEnrichment(featureCode).fields || {};
28
+ const featureField = featureFields[fieldName] || {};
29
+ const defaultField = getDefaultFieldEnrichment(fieldName);
30
+
31
+ return {
32
+ ...defaultField,
33
+ ...featureField,
34
+ };
35
+ };
36
+
37
+ /**
38
+ * @param {string} featureCode
39
+ */
40
+ const getToolNameOverride = (featureCode) => {
41
+ return enrichmentData.toolNameOverrides[featureCode] || null;
42
+ };
43
+
44
+ /**
45
+ * @param {string} featureCode
46
+ */
47
+ const getRequestOverride = (featureCode) => {
48
+ return enrichmentData.requestOverrides[featureCode] || null;
49
+ };
50
+
51
+ /**
52
+ * @param {string} featureCode
53
+ */
54
+ const getParameterAliases = (featureCode) => {
55
+ return enrichmentData.parameterAliases[featureCode] || {};
56
+ };
57
+
58
+ /**
59
+ * @param {string} fieldName
60
+ */
61
+ const isHiddenField = (fieldName) => {
62
+ return enrichmentData.hiddenFields.includes(fieldName);
63
+ };
64
+
65
+ /**
66
+ * @param {string} featureCode
67
+ */
68
+ const getValidationRule = (featureCode) => {
69
+ const feature = getFeatureEnrichment(featureCode);
70
+ return feature.validation || null;
71
+ };
72
+
73
+ /**
74
+ * @param {string} fieldMeta
75
+ */
76
+ const buildFieldDescription = (fieldMeta) => {
77
+ if (!fieldMeta?.description) return undefined;
78
+
79
+ const parts = [fieldMeta.description];
80
+
81
+ if (fieldMeta.format) {
82
+ parts.push(`Format: ${fieldMeta.format}.`);
83
+ }
84
+
85
+ if (fieldMeta.example) {
86
+ parts.push(`Example: ${fieldMeta.example}.`);
87
+ }
88
+
89
+ return parts.join(" ");
90
+ };
91
+
92
+ module.exports = {
93
+ enrichmentData,
94
+ getFeatureEnrichment,
95
+ getDefaultFieldEnrichment,
96
+ resolveFieldEnrichment,
97
+ getToolNameOverride,
98
+ getRequestOverride,
99
+ getParameterAliases,
100
+ isHiddenField,
101
+ getValidationRule,
102
+ buildFieldDescription,
103
+ };