@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.
@@ -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 ADDED
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * @param {import("./types").AppFeature} feature
5
+ * @param {import("./types").McpConfig} config
6
+ * @param {Set<string>|null} grantedFeatureCodes
7
+ */
8
+ const isEligibleFeature = (feature, config, grantedFeatureCodes = null) => {
9
+ if (!feature?.code || !feature?.url) return false;
10
+ if (config.smartCheckOnly && !feature.smartCheckEnabled) return false;
11
+ if (config.codeAllowlist.length && !config.codeAllowlist.includes(feature.code)) return false;
12
+ if (config.countryFilter.length && !config.countryFilter.includes(feature.country)) return false;
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
+
19
+ return true;
20
+ };
21
+
22
+ /**
23
+ * @param {import("./types").AppFeature[]} features
24
+ * @param {import("./types").McpConfig} config
25
+ * @param {Set<string>|null} [grantedFeatureCodes]
26
+ */
27
+ const filterFeatures = (features, config, grantedFeatureCodes = null) => {
28
+ if (!Array.isArray(features)) return [];
29
+ return features.filter((feature) => isEligibleFeature(feature, config, grantedFeatureCodes));
30
+ };
31
+
32
+ /**
33
+ * @param {import("./types").AppFeature} feature
34
+ */
35
+ const toFeatureSummary = (feature) => ({
36
+ code: feature.code,
37
+ name: feature.name,
38
+ description: feature.description,
39
+ country: feature.country,
40
+ baseCategory: feature.baseCategory,
41
+ url: feature.url,
42
+ method: feature.method || "GET",
43
+ smartCheckEnabled: Boolean(feature.smartCheckEnabled),
44
+ price: feature.price,
45
+ smartCheckPrice: feature.smartCheckPrice,
46
+ dependencyCount: Array.isArray(feature.dependencies) ? feature.dependencies.length : 0,
47
+ });
48
+
49
+ module.exports = {
50
+ isEligibleFeature,
51
+ filterFeatures,
52
+ toFeatureSummary,
53
+ };
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+
3
+ const { toFeatureSummary } = require("./filters");
4
+ const { buildInputSchema } = require("./tool-schema");
5
+
6
+ const LIST_CATALOG_TOOL = "verifik_list_catalog";
7
+ const GET_FEATURE_TOOL = "verifik_get_feature";
8
+
9
+ const META_TOOL_DEFINITIONS = [
10
+ {
11
+ name: LIST_CATALOG_TOOL,
12
+ description:
13
+ "List SmartCheck / Database Screening features available to this client without charging credits.",
14
+ inputSchema: {
15
+ type: "object",
16
+ properties: {
17
+ country: {
18
+ type: "string",
19
+ description: "Optional comma-separated country filter (e.g. Colombia,world).",
20
+ },
21
+ baseCategory: {
22
+ type: "string",
23
+ description: "Optional comma-separated baseCategory filter.",
24
+ },
25
+ code: {
26
+ type: "string",
27
+ description: "Optional comma-separated feature code allowlist.",
28
+ },
29
+ smartCheckOnly: {
30
+ type: "boolean",
31
+ description: "When true, only return smartCheckEnabled features. Defaults to server config.",
32
+ },
33
+ },
34
+ additionalProperties: false,
35
+ },
36
+ },
37
+ {
38
+ name: GET_FEATURE_TOOL,
39
+ description: "Get full metadata and dependency schema for one AppFeature by code (no credit charge).",
40
+ inputSchema: {
41
+ type: "object",
42
+ properties: {
43
+ code: {
44
+ type: "string",
45
+ description: "AppFeature code (e.g. colombia_api_identity_lookup).",
46
+ },
47
+ },
48
+ required: ["code"],
49
+ additionalProperties: false,
50
+ },
51
+ },
52
+ ];
53
+
54
+ const META_TOOL_NAMES = new Set(META_TOOL_DEFINITIONS.map((tool) => tool.name));
55
+
56
+ /**
57
+ * @param {import("./catalog").CatalogCache} catalogCache
58
+ * @param {Record<string, unknown>} args
59
+ */
60
+ const handleListCatalog = async (catalogCache, args = {}) => {
61
+ const state = await catalogCache.getState();
62
+ const countryFilter = parseOptionalList(args.country);
63
+ const baseCategoryFilter = parseOptionalList(args.baseCategory);
64
+ const codeFilter = parseOptionalList(args.code);
65
+ const smartCheckOnly =
66
+ args.smartCheckOnly === undefined ? undefined : Boolean(args.smartCheckOnly);
67
+
68
+ const features = state.features.filter((feature) => {
69
+ if (smartCheckOnly === true && !feature.smartCheckEnabled) return false;
70
+ if (countryFilter.length && !countryFilter.includes(feature.country)) return false;
71
+ if (baseCategoryFilter.length && !baseCategoryFilter.includes(feature.baseCategory)) return false;
72
+ if (codeFilter.length && !codeFilter.includes(feature.code)) return false;
73
+ return true;
74
+ });
75
+
76
+ return {
77
+ loadedAt: state.loadedAt,
78
+ total: features.length,
79
+ features: features.map(toFeatureSummary),
80
+ };
81
+ };
82
+
83
+ /**
84
+ * @param {import("./catalog").CatalogCache} catalogCache
85
+ * @param {Record<string, unknown>} args
86
+ */
87
+ const handleGetFeature = async (catalogCache, args = {}) => {
88
+ const code = String(args.code || "").trim();
89
+
90
+ if (!code) {
91
+ throw new Error("code is required");
92
+ }
93
+
94
+ const feature = catalogCache.findFeatureByCode(code);
95
+
96
+ if (!feature) {
97
+ throw new Error(`feature_not_found:${code}`);
98
+ }
99
+
100
+ const toolName = catalogCache.state?.registry?.featureCodeToToolName?.get(code) || null;
101
+
102
+ return {
103
+ ...toFeatureSummary(feature),
104
+ toolName,
105
+ dependencies: feature.dependencies || [],
106
+ inputSchema: buildInputSchema(feature),
107
+ };
108
+ };
109
+
110
+ /**
111
+ * @param {unknown} value
112
+ */
113
+ const parseOptionalList = (value) => {
114
+ if (value == null || value === "") return [];
115
+ return String(value)
116
+ .split(",")
117
+ .map((entry) => entry.trim())
118
+ .filter(Boolean);
119
+ };
120
+
121
+ module.exports = {
122
+ LIST_CATALOG_TOOL,
123
+ GET_FEATURE_TOOL,
124
+ META_TOOL_DEFINITIONS,
125
+ META_TOOL_NAMES,
126
+ handleListCatalog,
127
+ handleGetFeature,
128
+ };
package/lib/proxy.js ADDED
@@ -0,0 +1,224 @@
1
+ "use strict";
2
+
3
+ const {
4
+ buildFeatureRequestUrl,
5
+ normalizeHttpMethod,
6
+ sanitizeRequestParams,
7
+ splitRequestPayload,
8
+ } = require("./url-builder");
9
+ const { getRequestOverride, getParameterAliases } = require("./field-enrichment");
10
+ const { normalizeRequestParams } = require("./field-normalize");
11
+ const {
12
+ extractCreditsCharged,
13
+ buildProxyErrorPayload,
14
+ sanitizeResponseData,
15
+ classifyUpstreamError,
16
+ SERVICE_UNAVAILABLE_MESSAGE,
17
+ } = require("./error-response");
18
+
19
+ /**
20
+ * @param {import("./types").AppFeature} feature
21
+ */
22
+ const resolveFeatureRequest = (feature) => {
23
+ const override = getRequestOverride(feature.code);
24
+
25
+ return {
26
+ method: override?.method || feature.method,
27
+ url: override?.url || feature.url,
28
+ };
29
+ };
30
+
31
+ /**
32
+ * @param {string} featureCode
33
+ * @param {Record<string, unknown>} params
34
+ */
35
+ const applyParameterAliases = (featureCode, params) => {
36
+ const aliases = getParameterAliases(featureCode);
37
+ const mapped = { ...params };
38
+
39
+ for (const [from, to] of Object.entries(aliases)) {
40
+ if (mapped[from] !== undefined) {
41
+ mapped[to] = mapped[from];
42
+ delete mapped[from];
43
+ }
44
+ }
45
+
46
+ return mapped;
47
+ };
48
+
49
+ /**
50
+ * @param {import("./types").AppFeature} feature
51
+ * @param {Record<string, unknown>} args
52
+ * @param {import("./types").McpConfig} config
53
+ */
54
+ const invokeFeature = async (feature, args, config) => {
55
+ const requestMeta = resolveFeatureRequest(feature);
56
+ const method = normalizeHttpMethod(requestMeta.method);
57
+ const url = buildFeatureRequestUrl(config.apiBase, requestMeta.url);
58
+ const params = applyParameterAliases(
59
+ feature.code,
60
+ normalizeRequestParams(sanitizeRequestParams(args))
61
+ );
62
+ const { query, body } = splitRequestPayload(method, params);
63
+
64
+ const requestInit = {
65
+ method,
66
+ headers: {
67
+ Authorization: `Bearer ${config.token}`,
68
+ Accept: "application/json",
69
+ },
70
+ signal: AbortSignal.timeout(config.requestTimeoutMs),
71
+ };
72
+
73
+ if (body !== undefined) {
74
+ requestInit.headers["Content-Type"] = "application/json";
75
+ requestInit.body = JSON.stringify({ ...body, includeCost: true });
76
+ }
77
+
78
+ const requestUrl = query ? appendQueryParams(url, { ...query, includeCost: "true" }) : url;
79
+ const startedAt = Date.now();
80
+
81
+ try {
82
+ const response = await fetch(requestUrl, requestInit);
83
+ const responseBody = await readResponseBody(response);
84
+ const durationMs = Date.now() - startedAt;
85
+
86
+ return {
87
+ ok: response.ok,
88
+ status: response.status,
89
+ statusText: response.statusText,
90
+ body: responseBody,
91
+ durationMs,
92
+ request: {
93
+ method,
94
+ url: requestUrl,
95
+ },
96
+ };
97
+ } catch (error) {
98
+ const durationMs = Date.now() - startedAt;
99
+
100
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") {
101
+ return {
102
+ ok: false,
103
+ status: 504,
104
+ statusText: "Gateway Timeout",
105
+ body: null,
106
+ durationMs,
107
+ timedOut: true,
108
+ request: {
109
+ method,
110
+ url: requestUrl,
111
+ },
112
+ };
113
+ }
114
+
115
+ throw error;
116
+ }
117
+ };
118
+
119
+ /**
120
+ * @param {string} url
121
+ * @param {Record<string, unknown>} query
122
+ */
123
+ const appendQueryParams = (url, query) => {
124
+ const target = new URL(url);
125
+
126
+ for (const [key, value] of Object.entries(query)) {
127
+ if (Array.isArray(value)) {
128
+ for (const entry of value) target.searchParams.append(key, String(entry));
129
+ continue;
130
+ }
131
+
132
+ target.searchParams.set(key, String(value));
133
+ }
134
+
135
+ return target.toString();
136
+ };
137
+
138
+ /**
139
+ * @param {Response} response
140
+ */
141
+ const readResponseBody = async (response) => {
142
+ const contentType = response.headers.get("content-type") || "";
143
+
144
+ if (contentType.includes("application/json")) {
145
+ return response.json();
146
+ }
147
+
148
+ return response.text();
149
+ };
150
+
151
+ /**
152
+ * @param {{ status: number, statusText?: string, body: unknown, durationMs?: number, request?: { method: string, url: string }, timedOut?: boolean }} result
153
+ */
154
+ const formatProxyResult = (result) => {
155
+ if (result.timedOut) {
156
+ const payload = {
157
+ ok: false,
158
+ httpStatus: result.status,
159
+ statusText: result.statusText,
160
+ durationMs: result.durationMs,
161
+ error: {
162
+ code: "ServiceUnavailable",
163
+ message: SERVICE_UNAVAILABLE_MESSAGE,
164
+ retryable: true,
165
+ },
166
+ };
167
+
168
+ return {
169
+ text: JSON.stringify(payload, null, 2),
170
+ structuredContent: payload,
171
+ };
172
+ }
173
+
174
+ const creditsCharged = extractCreditsCharged(result.body);
175
+ const data = result.ok ? sanitizeResponseData(result.body) : undefined;
176
+
177
+ const payload = {
178
+ ok: result.ok,
179
+ httpStatus: result.status,
180
+ statusText: result.statusText,
181
+ durationMs: result.durationMs,
182
+ ...(creditsCharged != null ? { creditsCharged } : {}),
183
+ ...(result.ok ? { data } : {}),
184
+ };
185
+
186
+ if (!result.ok) {
187
+ Object.assign(payload, buildProxyErrorPayload(result));
188
+ delete payload.data;
189
+ }
190
+
191
+ return {
192
+ text: JSON.stringify(payload, null, 2),
193
+ structuredContent: payload,
194
+ };
195
+ };
196
+
197
+ /**
198
+ * @param {string} message
199
+ */
200
+ const formatValidationError = (message) => {
201
+ const payload = {
202
+ ok: false,
203
+ error: {
204
+ code: "ValidationError",
205
+ message,
206
+ },
207
+ };
208
+
209
+ return {
210
+ text: JSON.stringify(payload, null, 2),
211
+ structuredContent: payload,
212
+ };
213
+ };
214
+
215
+ module.exports = {
216
+ invokeFeature,
217
+ formatProxyResult,
218
+ formatValidationError,
219
+ appendQueryParams,
220
+ applyParameterAliases,
221
+ resolveFeatureRequest,
222
+ extractCreditsCharged,
223
+ classifyUpstreamError,
224
+ };
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+
3
+ const { createHash } = require("node:crypto");
4
+ const { getToolNameOverride } = require("./field-enrichment");
5
+
6
+ const TOOL_NAME_REGEX = /^[a-z][a-z0-9_]{0,127}$/;
7
+
8
+ /**
9
+ * Normalize a catalog feature code into a stable MCP tool name.
10
+ * @param {string} code
11
+ */
12
+ const normalizeToolName = (code) => {
13
+ const override = getToolNameOverride(code);
14
+ if (override) return override;
15
+
16
+ const raw = String(code || "")
17
+ .trim()
18
+ .toLowerCase()
19
+ .replace(/-/g, "_")
20
+ .replace(/[^a-z0-9_]+/g, "_")
21
+ .replace(/_+/g, "_")
22
+ .replace(/^_+|_+$/g, "");
23
+
24
+ if (!raw) return "verifik_feature";
25
+
26
+ if (raw.length <= 128 && TOOL_NAME_REGEX.test(raw)) return raw;
27
+
28
+ const hash = createHash("sha1").update(String(code)).digest("hex").slice(0, 8);
29
+ const prefix = raw.slice(0, 119).replace(/_+$/g, "");
30
+ return `${prefix}_${hash}`;
31
+ };
32
+
33
+ /**
34
+ * @param {import("./types").AppFeature[]} features
35
+ */
36
+ const buildToolNameMapping = (features) => {
37
+ const mapping = new Map();
38
+
39
+ for (const feature of features) {
40
+ if (!feature?.code) continue;
41
+ mapping.set(feature.code, normalizeToolName(feature.code));
42
+ }
43
+
44
+ return mapping;
45
+ };
46
+
47
+ module.exports = {
48
+ normalizeToolName,
49
+ buildToolNameMapping,
50
+ TOOL_NAME_REGEX,
51
+ };