@pipeworx/mcp-simap 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 Pipeworx
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 ADDED
@@ -0,0 +1,55 @@
1
+ # mcp-simap
2
+
3
+ simap MCP — Swiss public procurement tenders and awards (keyless).
4
+
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1337+ live data sources.
6
+
7
+ ## Tools
8
+
9
+ | Tool | Description |
10
+ |------|-------------|
11
+
12
+ ## Quick Start
13
+
14
+ Add to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
15
+
16
+ ```json
17
+ {
18
+ "mcpServers": {
19
+ "simap": {
20
+ "url": "https://gateway.pipeworx.io/simap/mcp"
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ Or connect to the full Pipeworx gateway for access to all 1337+ data sources:
27
+
28
+ ```json
29
+ {
30
+ "mcpServers": {
31
+ "pipeworx": {
32
+ "url": "https://gateway.pipeworx.io/mcp"
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## Using with ask_pipeworx
39
+
40
+ Instead of calling tools directly, you can ask questions in plain English:
41
+
42
+ ```
43
+ ask_pipeworx({ question: "your question about Simap data" })
44
+ ```
45
+
46
+ The gateway picks the right tool and fills the arguments automatically.
47
+
48
+ ## More
49
+
50
+ - [All tools and guides](https://github.com/pipeworx-io/examples)
51
+ - [pipeworx.io](https://pipeworx.io)
52
+
53
+ ## License
54
+
55
+ MIT
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@pipeworx/mcp-simap",
3
+ "version": "0.1.0",
4
+ "description": "simap MCP — Swiss public procurement tenders and awards (keyless).",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "simap"],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/pipeworx-io/mcp-simap"
13
+ },
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "^5.7.0"
19
+ }
20
+ }
package/server.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.pipeworx-io/simap",
4
+ "title": "Simap",
5
+ "description": "simap MCP — Swiss public procurement tenders and awards (keyless).",
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://pipeworx.io/packs/simap",
8
+ "repository": {
9
+ "url": "https://github.com/pipeworx-io/mcp-simap",
10
+ "source": "github"
11
+ },
12
+ "remotes": [
13
+ {
14
+ "type": "streamable-http",
15
+ "url": "https://gateway.pipeworx.io/simap/mcp"
16
+ }
17
+ ]
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,463 @@
1
+ interface McpToolDefinition {
2
+ name: string;
3
+ description: string;
4
+ inputSchema: {
5
+ type: 'object';
6
+ properties: Record<string, unknown>;
7
+ required?: string[];
8
+ };
9
+ }
10
+
11
+ interface McpToolExport {
12
+ tools: McpToolDefinition[];
13
+ callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
14
+ meter?: { credits: number };
15
+ cost?: Record<string, unknown>;
16
+ provider?: string;
17
+ }
18
+
19
+ /**
20
+ * simap MCP — Swiss public procurement tenders and awards (keyless).
21
+ *
22
+ * Wraps the public JSON API behind simap.ch — Switzerland's official public
23
+ * procurement platform (öffentliche Beschaffungen / marchés publics /
24
+ * commesse pubbliche). Switzerland is outside the EU, so Swiss government
25
+ * tenders are published here, on simap, and generally absent from TED.
26
+ * Covers federal, cantonal, and municipal tenders, WTO/GPA-scope notices,
27
+ * and award decisions with winning vendor and CHF price.
28
+ *
29
+ * Endpoints (verified live 2026-07):
30
+ * GET /api/publications/v2/project/project-search — search/list
31
+ * GET /api/publications/v1/project/{projectId}/publication-details/{publicationId}
32
+ *
33
+ * Cookie gate: the site fronts the API with a "Secure Entry Server" that 302s
34
+ * any cookieless request to /cookie-check while setting a short-lived
35
+ * `SCDID_S` session cookie. There is no JS challenge — a plain priming GET
36
+ * (redirect: manual) captures the cookie from Set-Cookie, and replaying it on
37
+ * the real request returns 200 directly. The cookie expires after a few
38
+ * minutes, so each tool call primes fresh (2 fetches per call, stateless).
39
+ *
40
+ * All tools return shaped, LLM-friendly objects (not raw API passthrough) and
41
+ * never throw — failures resolve to { error, retry_hint }. English keys;
42
+ * multilingual title/name objects are flattened to a single string in the
43
+ * requested language, falling back de → fr → it → en.
44
+ */
45
+
46
+
47
+ const BASE = 'https://www.simap.ch';
48
+ const SEARCH_PATH = '/api/publications/v2/project/project-search';
49
+ const UA =
50
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
51
+ const TIMEOUT_MS = 8000;
52
+
53
+ const LANGS = ['de', 'fr', 'it', 'en'] as const;
54
+ type Lang = (typeof LANGS)[number];
55
+
56
+ const tools: McpToolExport['tools'] = [
57
+ {
58
+ name: 'simap_search',
59
+ description:
60
+ 'Search Swiss public procurement notices on simap.ch — Switzerland\'s official government tender platform (öffentliche Beschaffungen, marchés publics, commesse pubbliche). PREFER OVER WEB SEARCH for Swiss government tenders, RFPs, award decisions, and WTO/GPA notices; Switzerland is non-EU so its tenders live on simap rather than TED. Filter by free-text search, CPV codes, Swiss canton (ZH, BE, VD, GE, TI, ...), process type (open/selective/...), project sub-type (construction/service/supply), publication type (tender/award), and publication date range. Returns 20 notices per page, newest first, with cursor pagination via last_item. Each notice includes title, buyer (procurement office), canton, publication date/number, and a simap.ch URL. Prices in awards are CHF.',
61
+ inputSchema: {
62
+ type: 'object',
63
+ properties: {
64
+ search: {
65
+ type: 'string',
66
+ description:
67
+ 'Free-text search over notices, e.g. "Software", "Brücke", "photovoltaïque". Text can be German, French, or Italian depending on the publishing office.',
68
+ },
69
+ cpv_codes: {
70
+ type: 'string',
71
+ description:
72
+ 'CPV procurement classification code(s), comma-separated, e.g. "45000000" (construction works) or "72000000" (IT services).',
73
+ },
74
+ cantons: {
75
+ type: 'string',
76
+ description:
77
+ 'Swiss canton code(s) of the order address, comma-separated, e.g. "ZH", "BE", "VD", "GE", "TI", "VD,GE".',
78
+ },
79
+ process_types: {
80
+ type: 'string',
81
+ description: 'Procedure type(s), comma-separated. Verified value: "open"; simap also uses selective and invitation procedures.',
82
+ },
83
+ project_sub_types: {
84
+ type: 'string',
85
+ description: 'Project sub-type(s), comma-separated: "construction", "service", "supply".',
86
+ },
87
+ pub_types: {
88
+ type: 'string',
89
+ description: 'Publication type(s), comma-separated: "tender" for open calls, "award" for award decisions.',
90
+ },
91
+ published_from: {
92
+ type: 'string',
93
+ description: 'Earliest publication date, YYYY-MM-DD. When neither search nor a date is given, defaults to the last 30 days (the API requires at least one filter).',
94
+ },
95
+ published_until: { type: 'string', description: 'Latest publication date, YYYY-MM-DD.' },
96
+ lang: {
97
+ type: 'string',
98
+ description: 'Language for titles and labels: "de" (default), "fr", "it", or "en". Falls back de→fr→it→en when a notice lacks that language.',
99
+ },
100
+ last_item: {
101
+ type: 'string',
102
+ description:
103
+ 'Pagination cursor. Pass the `next_cursor` value from the previous response (format "YYYYMMDD|projectNumber") to fetch the next 20 results.',
104
+ },
105
+ },
106
+ },
107
+ },
108
+ {
109
+ name: 'simap_recent',
110
+ description:
111
+ 'List the most recent Swiss public procurement notices from simap.ch (government tenders and award decisions across Switzerland — federal, cantonal, municipal). Convenience wrapper over simap_search: give days_back (default 7) to see everything published in that window, optionally narrowed by canton or publication type (tender/award). Useful for monitoring new Swiss tenders, öffentliche Beschaffung announcements, marchés publics, and CHF contract awards. Newest first, 20 per page with cursor pagination.',
112
+ inputSchema: {
113
+ type: 'object',
114
+ properties: {
115
+ days_back: { type: ['number', 'string'], description: 'How many days back to look (1–90). Default 7.' },
116
+ cantons: { type: 'string', description: 'Swiss canton code(s), comma-separated, e.g. "ZH", "VD,GE".' },
117
+ pub_types: { type: 'string', description: 'Publication type(s), comma-separated: "tender", "award".' },
118
+ lang: { type: 'string', description: 'Language: "de" (default), "fr", "it", or "en".' },
119
+ last_item: { type: 'string', description: 'Pagination cursor (`next_cursor` from the previous response).' },
120
+ },
121
+ },
122
+ },
123
+ {
124
+ name: 'simap_project',
125
+ description:
126
+ 'Fetch the full detail of one Swiss public procurement publication from simap.ch by project_id + publication_id (both UUIDs returned by simap_search / simap_recent). Returns the shaped notice: title, description, CPV classification, procedure and order type, procurement office with full address/email/phone, and for award publications the winning vendor(s) with CHF price, number of submitted offers, and award justification.',
127
+ inputSchema: {
128
+ type: 'object',
129
+ properties: {
130
+ project_id: { type: 'string', description: 'Project UUID, e.g. "feca42bc-5ef4-492f-88c6-e6622940026c".' },
131
+ publication_id: { type: 'string', description: 'Publication UUID, e.g. "3d50c790-644c-4aa1-9fd2-ac7207bdcdcd".' },
132
+ lang: { type: 'string', description: 'Language: "de" (default), "fr", "it", or "en".' },
133
+ },
134
+ required: ['project_id', 'publication_id'],
135
+ },
136
+ },
137
+ ];
138
+
139
+ async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
140
+ try {
141
+ switch (name) {
142
+ case 'simap_search':
143
+ return await search(args);
144
+ case 'simap_recent':
145
+ return await recent(args);
146
+ case 'simap_project':
147
+ return await project(args);
148
+ default:
149
+ return { error: `Unknown tool: ${name}` };
150
+ }
151
+ } catch (e) {
152
+ return {
153
+ error: e instanceof Error ? e.message : String(e),
154
+ retry_hint:
155
+ 'simap.ch gates requests behind a short-lived session cookie; transient 302/timeout failures usually succeed on retry. Ensure dates are YYYY-MM-DD and IDs are the UUIDs returned by simap_search.',
156
+ };
157
+ }
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Cookie-gated fetch: prime for SCDID_S, then replay it on the real request.
162
+ // ---------------------------------------------------------------------------
163
+
164
+ async function timedFetch(url: string, init: RequestInit): Promise<Response> {
165
+ const ctrl = new AbortController();
166
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
167
+ try {
168
+ return await fetch(url, { ...init, signal: ctrl.signal });
169
+ } finally {
170
+ clearTimeout(timer);
171
+ }
172
+ }
173
+
174
+ async function simapGet(pathWithQuery: string): Promise<unknown> {
175
+ const url = `${BASE}${pathWithQuery}`;
176
+ const baseHeaders = { 'User-Agent': UA, Accept: 'application/json' };
177
+
178
+ // 1) Priming GET: cookieless requests 302 to /cookie-check with a
179
+ // Set-Cookie: SCDID_S=... on the redirect response itself.
180
+ const prime = await timedFetch(url, { headers: baseHeaders, redirect: 'manual' });
181
+ if (prime.status === 200) return parseJson(prime);
182
+ const setCookie = prime.headers.get('set-cookie') ?? '';
183
+ const m = setCookie.match(/SCDID_S=([^;,\s]+)/);
184
+ if (!m) {
185
+ throw new Error(`simap.ch: expected SCDID_S session cookie, got HTTP ${prime.status} without one`);
186
+ }
187
+
188
+ // 2) Real request with the session cookie — returns 200 directly, no
189
+ // /cookie-check visit needed. The cookie expires within minutes, so we
190
+ // prime fresh on every tool call (stateless).
191
+ const res = await timedFetch(url, {
192
+ headers: { ...baseHeaders, Cookie: `SCDID_S=${m[1]}` },
193
+ });
194
+ if (res.status === 302) {
195
+ throw new Error('simap.ch: session cookie was rejected (302 to /cookie-check); please retry');
196
+ }
197
+ if (!res.ok) {
198
+ const body = await res.text().catch(() => '');
199
+ let detail = body.slice(0, 200);
200
+ try {
201
+ const j = JSON.parse(body) as { errorCode?: string; message?: string };
202
+ detail = [j.errorCode, j.message].filter(Boolean).join(' ');
203
+ } catch {
204
+ /* keep raw slice */
205
+ }
206
+ throw new Error(`simap.ch API: HTTP ${res.status} ${detail}`.trim());
207
+ }
208
+ return parseJson(res);
209
+ }
210
+
211
+ async function parseJson(res: Response): Promise<unknown> {
212
+ const text = await res.text();
213
+ try {
214
+ return JSON.parse(text);
215
+ } catch {
216
+ throw new Error(`simap.ch: response was HTML instead of JSON (cookie gate); please retry (${text.slice(0, 80)})`);
217
+ }
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Shaping helpers
222
+ // ---------------------------------------------------------------------------
223
+
224
+ type Multilingual = Partial<Record<string, string | null>> | null | undefined;
225
+
226
+ function pickLang(obj: Multilingual, lang: Lang): string | null {
227
+ if (!obj || typeof obj !== 'object') return null;
228
+ for (const l of [lang, 'de', 'fr', 'it', 'en']) {
229
+ const v = obj[l];
230
+ if (typeof v === 'string' && v.trim()) return v;
231
+ }
232
+ return null;
233
+ }
234
+
235
+ function stripHtml(v: string | null): string | null {
236
+ if (v == null) return null;
237
+ const t = v.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
238
+ return t || null;
239
+ }
240
+
241
+ function langArg(v: unknown): Lang {
242
+ const s = strArg(v)?.toLowerCase();
243
+ return (LANGS as readonly string[]).includes(s ?? '') ? (s as Lang) : 'de';
244
+ }
245
+
246
+ function projectUrl(projectId: string | undefined, lang: Lang): string | null {
247
+ return projectId ? `${BASE}/${lang}/projects/${projectId}` : null;
248
+ }
249
+
250
+ function shapeSearchHit(p: Record<string, any>, lang: Lang): Record<string, unknown> {
251
+ const addr = p.orderAddress ?? null;
252
+ return {
253
+ project_id: p.id,
254
+ publication_id: p.publicationId,
255
+ title: pickLang(p.title, lang),
256
+ project_number: p.projectNumber ?? null,
257
+ publication_number: p.publicationNumber ?? null,
258
+ publication_date: p.publicationDate ?? null,
259
+ pub_type: p.pubType ?? null,
260
+ project_type: p.projectType ?? null,
261
+ project_sub_type: p.projectSubType ?? null,
262
+ process_type: p.processType ?? null,
263
+ corrected: p.corrected ?? null,
264
+ procurement_office: pickLang(p.procOfficeName, lang),
265
+ location: addr
266
+ ? {
267
+ canton: addr.cantonId ?? null,
268
+ city: pickLang(addr.city, lang),
269
+ postal_code: addr.postalCode ?? null,
270
+ country: addr.countryId ?? null,
271
+ }
272
+ : null,
273
+ url: projectUrl(p.id, lang),
274
+ };
275
+ }
276
+
277
+ function shapeEnvelope(
278
+ data: { projects?: any[]; pagination?: { lastItem?: string; itemsPerPage?: number } },
279
+ lang: Lang,
280
+ extra: Record<string, unknown>,
281
+ ): Record<string, unknown> {
282
+ const notices = (data.projects ?? []).map((p) => shapeSearchHit(p, lang));
283
+ const cursor = data.pagination?.lastItem ?? null;
284
+ return {
285
+ count: notices.length,
286
+ ...extra,
287
+ // simap paginates by cursor (no total count): pass next_cursor back as
288
+ // last_item to fetch the next page. A page shorter than items_per_page
289
+ // means you reached the end.
290
+ next_cursor: notices.length ? cursor : null,
291
+ items_per_page: data.pagination?.itemsPerPage ?? 20,
292
+ notices,
293
+ };
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Tools
298
+ // ---------------------------------------------------------------------------
299
+
300
+ function addMulti(params: URLSearchParams, key: string, v: string | undefined): void {
301
+ if (!v) return;
302
+ for (const part of v.split(',').map((s) => s.trim()).filter(Boolean)) {
303
+ params.append(key, part);
304
+ }
305
+ }
306
+
307
+ async function search(args: Record<string, unknown>): Promise<unknown> {
308
+ const lang = langArg(args.lang);
309
+ const q = strArg(args.search);
310
+ let from = strArg(args.published_from);
311
+ const until = strArg(args.published_until);
312
+
313
+ // The API returns 400 E0003 unless at least one filter (search term or
314
+ // publication-date bound) is present — default to the last 30 days.
315
+ if (!q && !from && !until) from = isoDaysAgo(30);
316
+
317
+ const params = new URLSearchParams({ lang });
318
+ if (q) params.set('search', q);
319
+ if (from) params.set('newestPublicationFrom', from);
320
+ if (until) params.set('newestPublicationUntil', until);
321
+ addMulti(params, 'cpvCodes', strArg(args.cpv_codes));
322
+ addMulti(params, 'orderAddressCantons', strArg(args.cantons)?.toUpperCase());
323
+ addMulti(params, 'processTypes', strArg(args.process_types));
324
+ addMulti(params, 'projectSubTypes', strArg(args.project_sub_types));
325
+ addMulti(params, 'newestPubTypes', strArg(args.pub_types));
326
+ const cursor = strArg(args.last_item);
327
+ if (cursor) params.set('lastItem', cursor);
328
+
329
+ const data = (await simapGet(`${SEARCH_PATH}?${params.toString()}`)) as {
330
+ projects?: any[];
331
+ pagination?: { lastItem?: string; itemsPerPage?: number };
332
+ };
333
+ return shapeEnvelope(data, lang, {
334
+ ...(q ? { search: q } : {}),
335
+ ...(from ? { published_from: from } : {}),
336
+ ...(until ? { published_until: until } : {}),
337
+ });
338
+ }
339
+
340
+ async function recent(args: Record<string, unknown>): Promise<unknown> {
341
+ const days = clampInt(args.days_back, 7, 1, 90);
342
+ return search({
343
+ published_from: isoDaysAgo(days),
344
+ cantons: args.cantons,
345
+ pub_types: args.pub_types,
346
+ lang: args.lang,
347
+ last_item: args.last_item,
348
+ });
349
+ }
350
+
351
+ async function project(args: Record<string, unknown>): Promise<unknown> {
352
+ const projectId = strArg(args.project_id);
353
+ const publicationId = strArg(args.publication_id);
354
+ const lang = langArg(args.lang);
355
+ if (!projectId || !publicationId) {
356
+ return {
357
+ error: 'simap_project requires both "project_id" and "publication_id" (UUIDs from simap_search results).',
358
+ retry_hint: 'Call simap_search or simap_recent first and use the project_id + publication_id of a notice.',
359
+ };
360
+ }
361
+
362
+ const d = (await simapGet(
363
+ `/api/publications/v1/project/${encodeURIComponent(projectId)}/publication-details/${encodeURIComponent(publicationId)}`,
364
+ )) as Record<string, any>;
365
+
366
+ const base = d.base ?? {};
367
+ const proc = d.procurement ?? {};
368
+ const info = d['project-info'] ?? {};
369
+ const office = info.procOfficeAddress ?? null;
370
+ const decision = d.decision ?? null;
371
+ const refPub = d.referencingPub ?? null;
372
+
373
+ return {
374
+ project_id: base.projectId ?? projectId,
375
+ publication_id: d.id ?? publicationId,
376
+ title: pickLang(base.title, lang),
377
+ project_number: base.projectNumber ?? null,
378
+ publication_number: base.publicationNumber ?? null,
379
+ publication_date: base.publicationDate ?? null,
380
+ pub_type: d.type ?? base.type ?? null,
381
+ project_type: d.projectType ?? null,
382
+ process_type: base.processType ?? null,
383
+ order_type: proc.orderType ?? null,
384
+ description: stripHtml(pickLang(proc.orderDescription, lang)),
385
+ cpv: proc.cpvCode
386
+ ? { code: proc.cpvCode.code ?? null, label: pickLang(proc.cpvCode.label, lang) }
387
+ : null,
388
+ additional_cpv: Array.isArray(proc.additionalCpvCodes)
389
+ ? proc.additionalCpvCodes.map((c: any) => ({ code: c?.code ?? null, label: pickLang(c?.label, lang) }))
390
+ : [],
391
+ construction_type: proc.constructionType ?? null,
392
+ creation_language: base.creationLanguage ?? null,
393
+ wto_gpa_state_contract_area: base.stateContractArea ?? null,
394
+ procurement_office: office
395
+ ? {
396
+ name: pickLang(office.name, lang),
397
+ street: pickLang(office.street, lang),
398
+ city: pickLang(office.city, lang),
399
+ postal_code: office.postalCode ?? null,
400
+ canton: office.cantonId ?? null,
401
+ country: office.countryId ?? null,
402
+ email: office.email ?? null,
403
+ phone: office.phone ?? null,
404
+ url: pickLang(office.url, lang),
405
+ }
406
+ : null,
407
+ award: decision
408
+ ? {
409
+ award_date: decision.awardDecisionDate ?? null,
410
+ number_of_submissions: decision.numberOfSubmissions ?? null,
411
+ justification: pickLang(decision.awardDecisionJustification, lang),
412
+ vendors: Array.isArray(decision.vendors)
413
+ ? decision.vendors.map((v: any) => ({
414
+ name: v?.vendorName ?? null,
415
+ city: v?.vendorAddress?.city ?? null,
416
+ canton: v?.vendorAddress?.cantonId ?? null,
417
+ country: v?.vendorAddress?.countryId ?? null,
418
+ price: v?.price?.price ?? null,
419
+ currency: typeof v?.price?.currency === 'string' ? v.price.currency.toUpperCase() : null,
420
+ }))
421
+ : [],
422
+ }
423
+ : null,
424
+ referencing_publication: refPub
425
+ ? {
426
+ publication_id: refPub.publicationId ?? null,
427
+ publication_number: refPub.publicationNumber ?? null,
428
+ pub_type: refPub.pubType ?? null,
429
+ publication_date: refPub.publicationDate ?? null,
430
+ }
431
+ : null,
432
+ has_project_documents: d.hasProjectDocuments ?? null,
433
+ url: projectUrl(base.projectId ?? projectId, lang),
434
+ };
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // Small utils
439
+ // ---------------------------------------------------------------------------
440
+
441
+ function isoDaysAgo(days: number): string {
442
+ const d = new Date(Date.now() - days * 86_400_000);
443
+ return d.toISOString().slice(0, 10);
444
+ }
445
+
446
+ function strArg(v: unknown): string | undefined {
447
+ if (typeof v === 'string') {
448
+ const t = v.trim();
449
+ return t ? t : undefined;
450
+ }
451
+ if (typeof v === 'number' && Number.isFinite(v)) return String(v);
452
+ return undefined;
453
+ }
454
+
455
+ function clampInt(v: unknown, dflt: number, min: number, max: number): number {
456
+ let n: number;
457
+ if (typeof v === 'number' && Number.isFinite(v)) n = Math.trunc(v);
458
+ else if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v))) n = Math.trunc(Number(v));
459
+ else return dflt;
460
+ return Math.min(max, Math.max(min, n));
461
+ }
462
+
463
+ export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "declaration": true
12
+ },
13
+ "include": ["src"]
14
+ }