@verifik/mcp 0.1.0 → 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.
@@ -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 CHANGED
@@ -18,6 +18,7 @@ const loadConfig = () => {
18
18
  const baseCategoryFilter = parseListEnv(process.env.VERIFIK_MCP_BASE_CATEGORY);
19
19
  const codeAllowlist = parseListEnv(process.env.VERIFIK_MCP_CODES);
20
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);
21
22
 
22
23
  return {
23
24
  apiBase,
@@ -27,6 +28,7 @@ const loadConfig = () => {
27
28
  baseCategoryFilter,
28
29
  codeAllowlist,
29
30
  catalogRefreshMs,
31
+ requestTimeoutMs,
30
32
  };
31
33
  };
32
34
 
@@ -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
+ };
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+
3
+ /** Fields where common punctuation (dots, dashes, slashes, spaces) may appear but length limits apply to alphanumeric content. */
4
+ const PUNCTUATION_TOLERANT_FIELDS = new Set([
5
+ "documentNumber",
6
+ "plate",
7
+ "card",
8
+ "serial",
9
+ "citizenIdentifier",
10
+ "emissionNumber",
11
+ ]);
12
+
13
+ /**
14
+ * Remove common formatting characters used in ID numbers and plates.
15
+ * @param {unknown} value
16
+ */
17
+ const stripFormatting = (value) => {
18
+ return String(value).replace(/[.\-\s/]/g, "");
19
+ };
20
+
21
+ /**
22
+ * @param {string} fieldName
23
+ */
24
+ const isPunctuationTolerantField = (fieldName) => {
25
+ return PUNCTUATION_TOLERANT_FIELDS.has(fieldName);
26
+ };
27
+
28
+ /**
29
+ * Value used for minLength/maxLength validation (punctuation ignored for tolerant fields).
30
+ * @param {string} fieldName
31
+ * @param {unknown} value
32
+ */
33
+ const valueForLengthCheck = (fieldName, value) => {
34
+ const text = String(value);
35
+ if (!isPunctuationTolerantField(fieldName)) return text;
36
+ return stripFormatting(text);
37
+ };
38
+
39
+ /**
40
+ * Normalize request params: trim plate whitespace; leave document numbers as entered.
41
+ * @param {Record<string, unknown>} params
42
+ */
43
+ const normalizeRequestParams = (params) => {
44
+ if (!params || typeof params !== "object") return {};
45
+
46
+ const normalized = { ...params };
47
+
48
+ for (const [field, value] of Object.entries(normalized)) {
49
+ if (value == null || value === "") continue;
50
+
51
+ if (field === "plate" || field === "card") {
52
+ normalized[field] = String(value).replace(/\s+/g, "").trim();
53
+ }
54
+ }
55
+
56
+ return normalized;
57
+ };
58
+
59
+ module.exports = {
60
+ PUNCTUATION_TOLERANT_FIELDS,
61
+ stripFormatting,
62
+ isPunctuationTolerantField,
63
+ valueForLengthCheck,
64
+ normalizeRequestParams,
65
+ };
package/lib/filters.js CHANGED
@@ -3,23 +3,30 @@
3
3
  /**
4
4
  * @param {import("./types").AppFeature} feature
5
5
  * @param {import("./types").McpConfig} config
6
+ * @param {Set<string>|null} grantedFeatureCodes
6
7
  */
7
- const isEligibleFeature = (feature, config) => {
8
+ const isEligibleFeature = (feature, config, grantedFeatureCodes = null) => {
8
9
  if (!feature?.code || !feature?.url) return false;
9
10
  if (config.smartCheckOnly && !feature.smartCheckEnabled) return false;
10
11
  if (config.codeAllowlist.length && !config.codeAllowlist.includes(feature.code)) return false;
11
12
  if (config.countryFilter.length && !config.countryFilter.includes(feature.country)) return false;
12
13
  if (config.baseCategoryFilter.length && !config.baseCategoryFilter.includes(feature.baseCategory)) return false;
14
+
15
+ if (feature.requiresClientFeature && grantedFeatureCodes && !grantedFeatureCodes.has(feature.code)) {
16
+ return false;
17
+ }
18
+
13
19
  return true;
14
20
  };
15
21
 
16
22
  /**
17
23
  * @param {import("./types").AppFeature[]} features
18
24
  * @param {import("./types").McpConfig} config
25
+ * @param {Set<string>|null} [grantedFeatureCodes]
19
26
  */
20
- const filterFeatures = (features, config) => {
27
+ const filterFeatures = (features, config, grantedFeatureCodes = null) => {
21
28
  if (!Array.isArray(features)) return [];
22
- return features.filter((feature) => isEligibleFeature(feature, config));
29
+ return features.filter((feature) => isEligibleFeature(feature, config, grantedFeatureCodes));
23
30
  };
24
31
 
25
32
  /**
package/lib/meta-tools.js CHANGED
@@ -97,8 +97,11 @@ const handleGetFeature = async (catalogCache, args = {}) => {
97
97
  throw new Error(`feature_not_found:${code}`);
98
98
  }
99
99
 
100
+ const toolName = catalogCache.state?.registry?.featureCodeToToolName?.get(code) || null;
101
+
100
102
  return {
101
103
  ...toFeatureSummary(feature),
104
+ toolName,
102
105
  dependencies: feature.dependencies || [],
103
106
  inputSchema: buildInputSchema(feature),
104
107
  };