@verifik/mcp 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Open-Verifik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,231 @@
1
- # Temporary Holding Version
1
+ # @verifik/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Thin [Model Context Protocol](https://modelcontextprotocol.io) adapter for Verifik SmartCheck / Database Screening endpoints.
4
+
5
+ Each MCP tool maps 1:1 to an `AppFeature` from `GET /v2/app-features/my-list`. Tool calls proxy to the existing Verifik REST API with your API token, so auth, ClientFeature gating, and credit deduction stay unchanged.
6
+
7
+ ## Architecture
8
+
9
+ ```mermaid
10
+ flowchart LR
11
+ A[MCP Client\nCursor / Claude Desktop] -->|stdio JSON-RPC| B["@verifik/mcp"]
12
+ B -->|GET /v2/app-features/my-list| C[Verifik API]
13
+ B -->|Bearer token proxy| C
14
+ C --> D[Existing middleware chain\nvalidateClient + billing]
15
+ ```
16
+
17
+ Data flow:
18
+
19
+ 1. On boot, the server loads the client catalog from `GET /v2/app-features/my-list`.
20
+ 2. Eligible features become MCP tools (name derived from `code`, schema from `dependencies[]`).
21
+ 3. `tools/call` proxies to `${VERIFIK_API_BASE}/${feature.url}` using the feature `method`.
22
+ 4. Credits are charged by the normal API path — this server does not implement billing.
23
+
24
+ ## Requirements
25
+
26
+ - Node.js 18+
27
+ - A Verifik **API token** from the Smart-Agent **API Tokens** screen (`/settings/api-key`)
28
+
29
+ API tokens are client JWTs minted by `POST /v2/auth/renew-and-revoke` while you are logged in. They include `clientId` and `JWTPhrase`, so they work for the catalog call and for feature endpoints the MCP proxies.
30
+
31
+ ## Install (recommended)
32
+
33
+ ```bash
34
+ npx -y @verifik/mcp
35
+ ```
36
+
37
+ No global install required. MCP clients invoke the package via `npx` (see configuration below).
38
+
39
+ ### Local development fallback
40
+
41
+ ```bash
42
+ cd verifik-mcp
43
+ npm install
44
+ node server.js
45
+ ```
46
+
47
+ ## Environment variables
48
+
49
+ | Variable | Required | Default | Description |
50
+ | --- | --- | --- | --- |
51
+ | `VERIFIK_API_TOKEN` | **Yes** | — | API token / client JWT from Smart-Agent |
52
+ | `VERIFIK_API_BASE` | No | `https://api.verifik.co` | API base URL (use `https://staging-api.verifik.co` for staging) |
53
+ | `VERIFIK_MCP_SMARTCHECK_ONLY` | No | `true` | Only expose `smartCheckEnabled` features |
54
+ | `VERIFIK_MCP_COUNTRY` | No | — | Comma-separated country filter (e.g. `Colombia,world`) |
55
+ | `VERIFIK_MCP_BASE_CATEGORY` | No | — | Comma-separated `baseCategory` filter |
56
+ | `VERIFIK_MCP_CODES` | No | — | Comma-separated feature code allowlist |
57
+ | `VERIFIK_MCP_CATALOG_REFRESH_MS` | No | `0` | Optional in-memory catalog refresh interval |
58
+
59
+ Security: keep the token in environment variables only. The server never logs the full JWT.
60
+
61
+ ## Cursor configuration (`mcp.json`)
62
+
63
+ Add to your Cursor MCP settings (global or project-level):
64
+
65
+ ```json
66
+ {
67
+ "mcpServers": {
68
+ "verifik-smartcheck": {
69
+ "command": "npx",
70
+ "args": ["-y", "@verifik/mcp"],
71
+ "env": {
72
+ "VERIFIK_API_TOKEN": "YOUR_API_TOKEN",
73
+ "VERIFIK_API_BASE": "https://api.verifik.co",
74
+ "VERIFIK_MCP_SMARTCHECK_ONLY": "true",
75
+ "VERIFIK_MCP_COUNTRY": "Colombia,world"
76
+ }
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ ## Claude Desktop configuration
83
+
84
+ `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or the equivalent Claude Desktop config file:
85
+
86
+ ```json
87
+ {
88
+ "mcpServers": {
89
+ "verifik-smartcheck": {
90
+ "command": "npx",
91
+ "args": ["-y", "@verifik/mcp"],
92
+ "env": {
93
+ "VERIFIK_API_TOKEN": "YOUR_API_TOKEN",
94
+ "VERIFIK_API_BASE": "https://api.verifik.co"
95
+ }
96
+ }
97
+ }
98
+ }
99
+ ```
100
+
101
+ ## Generic MCP JSON (other clients)
102
+
103
+ ```json
104
+ {
105
+ "mcpServers": {
106
+ "verifik-smartcheck": {
107
+ "command": "npx",
108
+ "args": ["-y", "@verifik/mcp"],
109
+ "env": {
110
+ "VERIFIK_API_TOKEN": "YOUR_API_TOKEN",
111
+ "VERIFIK_API_BASE": "https://api.verifik.co",
112
+ "VERIFIK_MCP_SMARTCHECK_ONLY": "true"
113
+ }
114
+ }
115
+ }
116
+ }
117
+ ```
118
+
119
+ ### Local repo fallback (no npm publish yet)
120
+
121
+ ```json
122
+ {
123
+ "mcpServers": {
124
+ "verifik-smartcheck": {
125
+ "command": "node",
126
+ "args": ["/absolute/path/to/verifik-mcp/server.js"],
127
+ "env": {
128
+ "VERIFIK_API_TOKEN": "YOUR_API_TOKEN",
129
+ "VERIFIK_API_BASE": "https://api.verifik.co"
130
+ }
131
+ }
132
+ }
133
+ }
134
+ ```
135
+
136
+ Restart Cursor / Claude Desktop after editing MCP config.
137
+
138
+ ## Tools
139
+
140
+ ### Meta tools (no credit charge)
141
+
142
+ - `verifik_list_catalog` — filtered feature summaries from the in-memory catalog
143
+ - `verifik_get_feature` — full metadata + dependency JSON schema for one `code`
144
+
145
+ ### Feature tools
146
+
147
+ One tool per eligible `AppFeature`:
148
+
149
+ - **Name:** sanitized `code` (`A-Za-z0-9._-`, max 128 chars)
150
+ - **Description:** name, country, description, credit note
151
+ - **Input schema:** built from `dependencies[]`
152
+ - `String` → `string`
153
+ - `Number` / `Integer` → `number`
154
+ - `Boolean` → `boolean`
155
+ - `enum`, `min`, `max`, `description`, `required`
156
+ - **Call behavior:** HTTP proxy to the feature `url` using `method` (`GET` query params by default)
157
+
158
+ Features with binary/file dependencies (images, selfies, uploads) are skipped until a safe transport exists.
159
+
160
+ ## Smoke test
161
+
162
+ ### 1. Unit tests (no live token)
163
+
164
+ ```bash
165
+ cd verifik-mcp
166
+ npm test
167
+ ```
168
+
169
+ ### 2. Missing token exits cleanly
170
+
171
+ ```bash
172
+ node server.js
173
+ # stderr: VERIFIK_API_TOKEN is required (client JWT / API bearer token)
174
+ # exit code: 1
175
+ ```
176
+
177
+ ### 3. Live catalog load (requires token)
178
+
179
+ ```bash
180
+ export VERIFIK_API_TOKEN="YOUR_API_TOKEN"
181
+ node -e "
182
+ const { loadConfig } = require('./lib/config');
183
+ const { loadCatalogState } = require('./lib/catalog');
184
+ (async () => {
185
+ const config = loadConfig();
186
+ const state = await loadCatalogState(config);
187
+ console.log('tools:', state.tools.length);
188
+ console.log('sample:', state.tools.slice(0, 3).map(t => t.name));
189
+ })().catch(err => { console.error(err.message); process.exit(1); });
190
+ "
191
+ ```
192
+
193
+ ### 4. MCP stdio handshake
194
+
195
+ Start the server via Cursor MCP panel, or use any MCP client that supports stdio transport.
196
+
197
+ ## Example tool call result
198
+
199
+ Non-2xx responses are returned as MCP text with HTTP status and JSON body so agents can handle `404`, `409`, and `402` the same as REST:
200
+
201
+ ```json
202
+ {
203
+ "httpStatus": 409,
204
+ "statusText": "Conflict",
205
+ "durationMs": 142,
206
+ "request": {
207
+ "method": "GET",
208
+ "url": "https://api.verifik.co/v2/co/cedula?documentType=CC"
209
+ },
210
+ "body": {
211
+ "code": "MissingParameter",
212
+ "message": "documentNumber is required"
213
+ }
214
+ }
215
+ ```
216
+
217
+ ## Publishing
218
+
219
+ Published as [`@verifik/mcp`](https://www.npmjs.com/package/@verifik/mcp) from this directory.
220
+
221
+ Release tags use the `v*` prefix (for example `v0.1.0`). See the repository workflow `.github/workflows/publish.yml`.
222
+
223
+ ## Follow-ups
224
+
225
+ - Streamable HTTP MCP transport for hosted deployments
226
+ - Biometric / multipart endpoints once file upload bridging is defined
227
+
228
+ ## Related
229
+
230
+ - GitHub issue: https://github.com/Open-Verifik/verifik-backend/issues/370
231
+ - Support ticket: #1313
package/lib/catalog.js ADDED
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+
3
+ const { filterFeatures } = require("./filters");
4
+ const { buildToolRegistry, toMcpToolDefinition } = require("./tool-schema");
5
+
6
+ const CATALOG_PATH = "/v2/app-features/my-list";
7
+ const PAGE_LIMIT = 500;
8
+
9
+ /**
10
+ * @param {import("./types").McpConfig} config
11
+ */
12
+ const fetchCatalogPage = async (config, page = 1) => {
13
+ const url = new URL(`${config.apiBase}${CATALOG_PATH}`);
14
+ url.searchParams.set("page", String(page));
15
+ url.searchParams.set("limit", String(PAGE_LIMIT));
16
+
17
+ const response = await fetch(url, {
18
+ method: "GET",
19
+ headers: {
20
+ Authorization: `Bearer ${config.token}`,
21
+ Accept: "application/json",
22
+ },
23
+ });
24
+
25
+ const body = await readResponseBody(response);
26
+
27
+ if (!response.ok) {
28
+ throw new Error(`Catalog request failed (${response.status}): ${formatBodyForError(body)}`);
29
+ }
30
+
31
+ return normalizeCatalogResponse(body);
32
+ };
33
+
34
+ /**
35
+ * @param {unknown} body
36
+ */
37
+ const normalizeCatalogResponse = (body) => {
38
+ const payload = body && typeof body === "object" ? body : {};
39
+ const data = Array.isArray(payload.data)
40
+ ? payload.data
41
+ : Array.isArray(payload.data?.docs)
42
+ ? payload.data.docs
43
+ : [];
44
+
45
+ return {
46
+ features: data,
47
+ page: Number(payload.page) || 1,
48
+ pages: Number(payload.pages) || 1,
49
+ total: Number(payload.total) || data.length,
50
+ };
51
+ };
52
+
53
+ /**
54
+ * @param {import("./types").McpConfig} config
55
+ */
56
+ const fetchAllCatalogFeatures = async (config) => {
57
+ const firstPage = await fetchCatalogPage(config, 1);
58
+ const features = [...firstPage.features];
59
+
60
+ for (let page = firstPage.page + 1; page <= firstPage.pages; page += 1) {
61
+ const nextPage = await fetchCatalogPage(config, page);
62
+ features.push(...nextPage.features);
63
+ }
64
+
65
+ return features;
66
+ };
67
+
68
+ /**
69
+ * @param {import("./types").McpConfig} config
70
+ */
71
+ const loadCatalogState = async (config) => {
72
+ const allFeatures = await fetchAllCatalogFeatures(config);
73
+ const filteredFeatures = filterFeatures(allFeatures, config);
74
+ const registry = buildToolRegistry(filteredFeatures);
75
+
76
+ const tools = [];
77
+
78
+ for (const [toolName, feature] of registry.toolNameToFeature.entries()) {
79
+ tools.push(toMcpToolDefinition(feature, toolName));
80
+ }
81
+
82
+ return {
83
+ features: filteredFeatures,
84
+ allFeatures,
85
+ tools,
86
+ registry,
87
+ loadedAt: new Date().toISOString(),
88
+ };
89
+ };
90
+
91
+ /**
92
+ * @param {Response} response
93
+ */
94
+ const readResponseBody = async (response) => {
95
+ const contentType = response.headers.get("content-type") || "";
96
+
97
+ if (contentType.includes("application/json")) {
98
+ return response.json();
99
+ }
100
+
101
+ return response.text();
102
+ };
103
+
104
+ /**
105
+ * @param {unknown} body
106
+ */
107
+ const formatBodyForError = (body) => {
108
+ if (typeof body === "string") return body.slice(0, 500);
109
+ try {
110
+ return JSON.stringify(body).slice(0, 500);
111
+ } catch {
112
+ return "unknown_error";
113
+ }
114
+ };
115
+
116
+ class CatalogCache {
117
+ /**
118
+ * @param {import("./types").McpConfig} config
119
+ */
120
+ constructor(config) {
121
+ this.config = config;
122
+ this.state = null;
123
+ this.loadingPromise = null;
124
+ }
125
+
126
+ async getState(forceRefresh = false) {
127
+ const shouldRefresh =
128
+ forceRefresh ||
129
+ !this.state ||
130
+ (this.config.catalogRefreshMs > 0 &&
131
+ Date.now() - Date.parse(this.state.loadedAt) >= this.config.catalogRefreshMs);
132
+
133
+ if (!shouldRefresh) return this.state;
134
+ if (this.loadingPromise) return this.loadingPromise;
135
+
136
+ this.loadingPromise = loadCatalogState(this.config)
137
+ .then((state) => {
138
+ this.state = state;
139
+ return state;
140
+ })
141
+ .finally(() => {
142
+ this.loadingPromise = null;
143
+ });
144
+
145
+ return this.loadingPromise;
146
+ }
147
+
148
+ findFeatureByCode(code) {
149
+ if (!this.state) return null;
150
+ return this.state.features.find((feature) => feature.code === code) || null;
151
+ }
152
+
153
+ resolveFeatureByToolName(toolName) {
154
+ if (!this.state) return null;
155
+ return this.state.registry.toolNameToFeature.get(toolName) || null;
156
+ }
157
+ }
158
+
159
+ module.exports = {
160
+ CATALOG_PATH,
161
+ fetchCatalogPage,
162
+ fetchAllCatalogFeatures,
163
+ loadCatalogState,
164
+ CatalogCache,
165
+ normalizeCatalogResponse,
166
+ };
package/lib/config.js ADDED
@@ -0,0 +1,73 @@
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
+
22
+ return {
23
+ apiBase,
24
+ token,
25
+ smartCheckOnly,
26
+ countryFilter,
27
+ baseCategoryFilter,
28
+ codeAllowlist,
29
+ catalogRefreshMs,
30
+ };
31
+ };
32
+
33
+ /**
34
+ * @param {string|undefined} value
35
+ * @param {boolean} defaultValue
36
+ */
37
+ const parseBooleanEnv = (value, defaultValue) => {
38
+ if (value == null || value === "") return defaultValue;
39
+ const normalized = String(value).trim().toLowerCase();
40
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
41
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
42
+ return defaultValue;
43
+ };
44
+
45
+ /**
46
+ * @param {string|undefined} value
47
+ * @returns {string[]}
48
+ */
49
+ const parseListEnv = (value) => {
50
+ if (!value || !String(value).trim()) return [];
51
+ return String(value)
52
+ .split(",")
53
+ .map((entry) => entry.trim())
54
+ .filter(Boolean);
55
+ };
56
+
57
+ /**
58
+ * @param {string|undefined} value
59
+ * @param {number} defaultValue
60
+ */
61
+ const parsePositiveInt = (value, defaultValue) => {
62
+ if (value == null || value === "") return defaultValue;
63
+ const parsed = Number.parseInt(String(value), 10);
64
+ if (!Number.isFinite(parsed) || parsed < 0) return defaultValue;
65
+ return parsed;
66
+ };
67
+
68
+ module.exports = {
69
+ DEFAULT_API_BASE,
70
+ loadConfig,
71
+ parseBooleanEnv,
72
+ parseListEnv,
73
+ };
package/lib/filters.js ADDED
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * @param {import("./types").AppFeature} feature
5
+ * @param {import("./types").McpConfig} config
6
+ */
7
+ const isEligibleFeature = (feature, config) => {
8
+ if (!feature?.code || !feature?.url) return false;
9
+ if (config.smartCheckOnly && !feature.smartCheckEnabled) return false;
10
+ if (config.codeAllowlist.length && !config.codeAllowlist.includes(feature.code)) return false;
11
+ if (config.countryFilter.length && !config.countryFilter.includes(feature.country)) return false;
12
+ if (config.baseCategoryFilter.length && !config.baseCategoryFilter.includes(feature.baseCategory)) return false;
13
+ return true;
14
+ };
15
+
16
+ /**
17
+ * @param {import("./types").AppFeature[]} features
18
+ * @param {import("./types").McpConfig} config
19
+ */
20
+ const filterFeatures = (features, config) => {
21
+ if (!Array.isArray(features)) return [];
22
+ return features.filter((feature) => isEligibleFeature(feature, config));
23
+ };
24
+
25
+ /**
26
+ * @param {import("./types").AppFeature} feature
27
+ */
28
+ const toFeatureSummary = (feature) => ({
29
+ code: feature.code,
30
+ name: feature.name,
31
+ description: feature.description,
32
+ country: feature.country,
33
+ baseCategory: feature.baseCategory,
34
+ url: feature.url,
35
+ method: feature.method || "GET",
36
+ smartCheckEnabled: Boolean(feature.smartCheckEnabled),
37
+ price: feature.price,
38
+ smartCheckPrice: feature.smartCheckPrice,
39
+ dependencyCount: Array.isArray(feature.dependencies) ? feature.dependencies.length : 0,
40
+ });
41
+
42
+ module.exports = {
43
+ isEligibleFeature,
44
+ filterFeatures,
45
+ toFeatureSummary,
46
+ };
@@ -0,0 +1,125 @@
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
+ return {
101
+ ...toFeatureSummary(feature),
102
+ dependencies: feature.dependencies || [],
103
+ inputSchema: buildInputSchema(feature),
104
+ };
105
+ };
106
+
107
+ /**
108
+ * @param {unknown} value
109
+ */
110
+ const parseOptionalList = (value) => {
111
+ if (value == null || value === "") return [];
112
+ return String(value)
113
+ .split(",")
114
+ .map((entry) => entry.trim())
115
+ .filter(Boolean);
116
+ };
117
+
118
+ module.exports = {
119
+ LIST_CATALOG_TOOL,
120
+ GET_FEATURE_TOOL,
121
+ META_TOOL_DEFINITIONS,
122
+ META_TOOL_NAMES,
123
+ handleListCatalog,
124
+ handleGetFeature,
125
+ };
package/lib/proxy.js ADDED
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+
3
+ const {
4
+ buildFeatureRequestUrl,
5
+ normalizeHttpMethod,
6
+ sanitizeRequestParams,
7
+ splitRequestPayload,
8
+ } = require("./url-builder");
9
+
10
+ /**
11
+ * @param {import("./types").AppFeature} feature
12
+ * @param {Record<string, unknown>} args
13
+ * @param {import("./types").McpConfig} config
14
+ */
15
+ const invokeFeature = async (feature, args, config) => {
16
+ const method = normalizeHttpMethod(feature.method);
17
+ const url = buildFeatureRequestUrl(config.apiBase, feature.url);
18
+ const params = sanitizeRequestParams(args);
19
+ const { query, body } = splitRequestPayload(method, params);
20
+
21
+ const requestInit = {
22
+ method,
23
+ headers: {
24
+ Authorization: `Bearer ${config.token}`,
25
+ Accept: "application/json",
26
+ },
27
+ };
28
+
29
+ if (body !== undefined) {
30
+ requestInit.headers["Content-Type"] = "application/json";
31
+ requestInit.body = JSON.stringify(body);
32
+ }
33
+
34
+ const requestUrl = query ? appendQueryParams(url, query) : url;
35
+ const startedAt = Date.now();
36
+ const response = await fetch(requestUrl, requestInit);
37
+ const responseBody = await readResponseBody(response);
38
+ const durationMs = Date.now() - startedAt;
39
+
40
+ return {
41
+ ok: response.ok,
42
+ status: response.status,
43
+ statusText: response.statusText,
44
+ body: responseBody,
45
+ durationMs,
46
+ request: {
47
+ method,
48
+ url: requestUrl,
49
+ },
50
+ };
51
+ };
52
+
53
+ /**
54
+ * @param {string} url
55
+ * @param {Record<string, unknown>} query
56
+ */
57
+ const appendQueryParams = (url, query) => {
58
+ const target = new URL(url);
59
+
60
+ for (const [key, value] of Object.entries(query)) {
61
+ if (Array.isArray(value)) {
62
+ for (const entry of value) target.searchParams.append(key, String(entry));
63
+ continue;
64
+ }
65
+
66
+ target.searchParams.set(key, String(value));
67
+ }
68
+
69
+ return target.toString();
70
+ };
71
+
72
+ /**
73
+ * @param {Response} response
74
+ */
75
+ const readResponseBody = async (response) => {
76
+ const contentType = response.headers.get("content-type") || "";
77
+
78
+ if (contentType.includes("application/json")) {
79
+ return response.json();
80
+ }
81
+
82
+ return response.text();
83
+ };
84
+
85
+ /**
86
+ * @param {{ status: number, statusText?: string, body: unknown, durationMs?: number, request?: { method: string, url: string } }} result
87
+ */
88
+ const formatProxyResult = (result) => {
89
+ const payload = {
90
+ httpStatus: result.status,
91
+ statusText: result.statusText,
92
+ durationMs: result.durationMs,
93
+ request: result.request,
94
+ body: result.body,
95
+ };
96
+
97
+ return JSON.stringify(payload, null, 2);
98
+ };
99
+
100
+ module.exports = {
101
+ invokeFeature,
102
+ formatProxyResult,
103
+ appendQueryParams,
104
+ };
@@ -0,0 +1,164 @@
1
+ "use strict";
2
+
3
+ const { createHash } = require("node:crypto");
4
+
5
+ const TOOL_NAME_REGEX = /^[A-Za-z0-9._-]{1,128}$/;
6
+ const BINARY_TYPE_PATTERN = /^(buffer|file|binary|multipart|image|blob|base64)$/i;
7
+ const BINARY_FIELD_PATTERN = /(image|photo|selfie|file|document|pdf|attachment|biometric)/i;
8
+
9
+ /**
10
+ * @param {string} code
11
+ */
12
+ const sanitizeToolName = (code) => {
13
+ const raw = String(code || "")
14
+ .trim()
15
+ .replace(/[^A-Za-z0-9._-]+/g, "_")
16
+ .replace(/_+/g, "_")
17
+ .replace(/^_+|_+$/g, "");
18
+
19
+ if (!raw) return "verifik_feature";
20
+
21
+ if (raw.length <= 128 && TOOL_NAME_REGEX.test(raw)) return raw;
22
+
23
+ const hash = createHash("sha1").update(String(code)).digest("hex").slice(0, 8);
24
+ const prefix = raw.slice(0, 119).replace(/[._-]+$/g, "");
25
+ return `${prefix}_${hash}`;
26
+ };
27
+
28
+ /**
29
+ * @param {import("./types").AppFeatureDependency} dependency
30
+ */
31
+ const mapDependencyType = (dependency) => {
32
+ const type = String(dependency?.type || "String").trim();
33
+
34
+ if (/^(number|integer|float|decimal)$/i.test(type)) return "number";
35
+ if (/^(boolean|bool)$/i.test(type)) return "boolean";
36
+ return "string";
37
+ };
38
+
39
+ /**
40
+ * @param {import("./types").AppFeatureDependency} dependency
41
+ */
42
+ const isBinaryDependency = (dependency) => {
43
+ const type = String(dependency?.type || "").trim();
44
+ const field = String(dependency?.field || "").trim();
45
+
46
+ if (BINARY_TYPE_PATTERN.test(type)) return true;
47
+ if (BINARY_FIELD_PATTERN.test(field) && !/documentType|documentNumber/i.test(field)) return true;
48
+ return false;
49
+ };
50
+
51
+ /**
52
+ * @param {import("./types").AppFeature} feature
53
+ */
54
+ const hasUnsupportedDependencies = (feature) => {
55
+ const dependencies = Array.isArray(feature?.dependencies) ? feature.dependencies : [];
56
+ return dependencies.some((dependency) => isBinaryDependency(dependency));
57
+ };
58
+
59
+ /**
60
+ * @param {import("./types").AppFeature} feature
61
+ */
62
+ const buildToolDescription = (feature) => {
63
+ const parts = [
64
+ feature.name || feature.code,
65
+ feature.country ? `(${feature.country})` : null,
66
+ feature.description ? `- ${feature.description}` : null,
67
+ "Charges Verifik credits via the REST API.",
68
+ ].filter(Boolean);
69
+
70
+ return parts.join(" ");
71
+ };
72
+
73
+ /**
74
+ * @param {import("./types").AppFeatureDependency} dependency
75
+ */
76
+ const buildDependencyProperty = (dependency) => {
77
+ const property = {
78
+ type: mapDependencyType(dependency),
79
+ };
80
+
81
+ if (dependency.description) property.description = dependency.description;
82
+ if (dependency.default != null) property.default = dependency.default;
83
+ if (Array.isArray(dependency.enum) && dependency.enum.length) property.enum = dependency.enum;
84
+ if (dependency.min != null) property.minimum = dependency.min;
85
+ if (dependency.max != null) property.maximum = dependency.max;
86
+
87
+ return property;
88
+ };
89
+
90
+ /**
91
+ * @param {import("./types").AppFeature} feature
92
+ */
93
+ const buildInputSchema = (feature) => {
94
+ const dependencies = Array.isArray(feature?.dependencies) ? feature.dependencies : [];
95
+ const properties = {};
96
+ const required = [];
97
+
98
+ for (const dependency of dependencies) {
99
+ if (!dependency?.field || isBinaryDependency(dependency)) continue;
100
+
101
+ properties[dependency.field] = buildDependencyProperty(dependency);
102
+
103
+ if (dependency.required) required.push(dependency.field);
104
+ }
105
+
106
+ const schema = {
107
+ type: "object",
108
+ properties,
109
+ additionalProperties: false,
110
+ };
111
+
112
+ if (required.length) schema.required = required;
113
+
114
+ return schema;
115
+ };
116
+
117
+ /**
118
+ * @param {import("./types").AppFeature[]} features
119
+ */
120
+ const buildToolRegistry = (features) => {
121
+ const toolNameToFeature = new Map();
122
+ const featureCodeToToolName = new Map();
123
+ const usedNames = new Set();
124
+
125
+ for (const feature of features) {
126
+ if (!feature?.code || !feature?.url || hasUnsupportedDependencies(feature)) continue;
127
+
128
+ let toolName = sanitizeToolName(feature.code);
129
+
130
+ if (usedNames.has(toolName)) {
131
+ const suffix = createHash("sha1").update(feature.code).digest("hex").slice(0, 6);
132
+ toolName = sanitizeToolName(`${feature.code}_${suffix}`);
133
+ }
134
+
135
+ usedNames.add(toolName);
136
+ toolNameToFeature.set(toolName, feature);
137
+ featureCodeToToolName.set(feature.code, toolName);
138
+ }
139
+
140
+ return {
141
+ toolNameToFeature,
142
+ featureCodeToToolName,
143
+ };
144
+ };
145
+
146
+ /**
147
+ * @param {import("./types").AppFeature} feature
148
+ */
149
+ const toMcpToolDefinition = (feature, toolName) => ({
150
+ name: toolName,
151
+ description: buildToolDescription(feature),
152
+ inputSchema: buildInputSchema(feature),
153
+ });
154
+
155
+ module.exports = {
156
+ sanitizeToolName,
157
+ mapDependencyType,
158
+ isBinaryDependency,
159
+ hasUnsupportedDependencies,
160
+ buildToolDescription,
161
+ buildInputSchema,
162
+ buildToolRegistry,
163
+ toMcpToolDefinition,
164
+ };
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Normalize an AppFeature URL into an absolute API URL.
5
+ * AppFeature.url is stored without a leading slash and already includes the version prefix (e.g. "v2/co/cedula").
6
+ * @param {string} apiBase
7
+ * @param {string} featureUrl
8
+ */
9
+ const buildFeatureRequestUrl = (apiBase, featureUrl) => {
10
+ const base = String(apiBase || "").replace(/\/+$/, "");
11
+ const path = String(featureUrl || "").replace(/^\/+/, "");
12
+ return `${base}/${path}`;
13
+ };
14
+
15
+ /**
16
+ * @param {string} method
17
+ */
18
+ const normalizeHttpMethod = (method) => {
19
+ const normalized = String(method || "GET").trim().toUpperCase();
20
+ return normalized || "GET";
21
+ };
22
+
23
+ /**
24
+ * @param {Record<string, unknown>} args
25
+ */
26
+ const sanitizeRequestParams = (args) => {
27
+ if (!args || typeof args !== "object") return {};
28
+
29
+ const params = {};
30
+
31
+ for (const [key, value] of Object.entries(args)) {
32
+ if (value === undefined || value === null || value === "") continue;
33
+ params[key] = value;
34
+ }
35
+
36
+ return params;
37
+ };
38
+
39
+ /**
40
+ * @param {string} method
41
+ * @param {Record<string, unknown>} params
42
+ */
43
+ const splitRequestPayload = (method, params) => {
44
+ const normalizedMethod = normalizeHttpMethod(method);
45
+
46
+ if (normalizedMethod === "GET" || normalizedMethod === "DELETE") {
47
+ return { query: params, body: undefined };
48
+ }
49
+
50
+ return { query: undefined, body: params };
51
+ };
52
+
53
+ module.exports = {
54
+ buildFeatureRequestUrl,
55
+ normalizeHttpMethod,
56
+ sanitizeRequestParams,
57
+ splitRequestPayload,
58
+ };
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
- "name": "@verifik/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@verifik/mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP stdio server that exposes Verifik SmartCheck AppFeatures as MCP tools via REST proxy",
5
+ "main": "server.js",
6
+ "bin": {
7
+ "verifik-mcp": "./server.js"
8
+ },
9
+ "files": [
10
+ "server.js",
11
+ "lib/",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "start": "node server.js",
16
+ "test": "node --test tests/*.test.js",
17
+ "test:sandbox": "node --test tests/*.test.js"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "keywords": [
23
+ "mcp",
24
+ "verifik",
25
+ "smartcheck",
26
+ "model-context-protocol"
27
+ ],
28
+ "author": "Open-Verifik",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/Open-Verifik/verifik-mcp.git"
33
+ },
34
+ "homepage": "https://github.com/Open-Verifik/verifik-mcp#readme",
35
+ "bugs": {
36
+ "url": "https://github.com/Open-Verifik/verifik-mcp/issues"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "dependencies": {
42
+ "@modelcontextprotocol/sdk": "^1.31.0",
43
+ "zod": "^3.23.0"
44
+ }
45
+ }
package/server.js ADDED
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
5
+ const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
6
+ const {
7
+ CallToolRequestSchema,
8
+ ListToolsRequestSchema,
9
+ } = require("@modelcontextprotocol/sdk/types.js");
10
+
11
+ const { loadConfig } = require("./lib/config");
12
+ const { CatalogCache } = require("./lib/catalog");
13
+ const { invokeFeature, formatProxyResult } = require("./lib/proxy");
14
+ const {
15
+ META_TOOL_DEFINITIONS,
16
+ META_TOOL_NAMES,
17
+ handleListCatalog,
18
+ handleGetFeature,
19
+ } = require("./lib/meta-tools");
20
+
21
+ const { version: packageVersion } = require("./package.json");
22
+
23
+ const SERVER_NAME = "verifik-smartcheck";
24
+ const SERVER_VERSION = packageVersion;
25
+
26
+ const logStderr = (message) => {
27
+ process.stderr.write(`[${SERVER_NAME}] ${message}\n`);
28
+ };
29
+
30
+ const createServer = async () => {
31
+ const config = loadConfig();
32
+ const catalogCache = new CatalogCache(config);
33
+
34
+ logStderr(`Loading catalog from ${config.apiBase} ...`);
35
+ await catalogCache.getState();
36
+ logStderr(`Catalog loaded with ${catalogCache.state.tools.length} proxy tools.`);
37
+
38
+ const server = new Server(
39
+ {
40
+ name: SERVER_NAME,
41
+ version: SERVER_VERSION,
42
+ },
43
+ {
44
+ capabilities: {
45
+ tools: {},
46
+ },
47
+ }
48
+ );
49
+
50
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
51
+ const state = await catalogCache.getState();
52
+ return {
53
+ tools: [...META_TOOL_DEFINITIONS, ...state.tools],
54
+ };
55
+ });
56
+
57
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
58
+ const toolName = request.params.name;
59
+ const args = request.params.arguments || {};
60
+
61
+ try {
62
+ if (toolName === "verifik_list_catalog") {
63
+ const payload = await handleListCatalog(catalogCache, args);
64
+ return textResult(JSON.stringify(payload, null, 2));
65
+ }
66
+
67
+ if (toolName === "verifik_get_feature") {
68
+ const payload = await handleGetFeature(catalogCache, args);
69
+ return textResult(JSON.stringify(payload, null, 2));
70
+ }
71
+
72
+ const feature = catalogCache.resolveFeatureByToolName(toolName);
73
+
74
+ if (!feature) {
75
+ return errorResult(`Unknown tool: ${toolName}`);
76
+ }
77
+
78
+ const proxyResult = await invokeFeature(feature, args, config);
79
+ return textResult(formatProxyResult(proxyResult), !proxyResult.ok);
80
+ } catch (error) {
81
+ return errorResult(error instanceof Error ? error.message : String(error));
82
+ }
83
+ });
84
+
85
+ return server;
86
+ };
87
+
88
+ /**
89
+ * @param {string} text
90
+ * @param {boolean} isError
91
+ */
92
+ const textResult = (text, isError = false) => ({
93
+ content: [{ type: "text", text }],
94
+ isError,
95
+ });
96
+
97
+ /**
98
+ * @param {string} message
99
+ */
100
+ const errorResult = (message) => textResult(message, true);
101
+
102
+ const main = async () => {
103
+ const server = await createServer();
104
+ const transport = new StdioServerTransport();
105
+ await server.connect(transport);
106
+ logStderr("stdio transport connected.");
107
+ };
108
+
109
+ main().catch((error) => {
110
+ logStderr(error instanceof Error ? error.message : String(error));
111
+ process.exit(1);
112
+ });