@pipeworx/mcp-udbud-dk 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-udbud-dk
2
+
3
+ Udbud.dk MCP — Danish government public procurement notices (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
+ "udbud-dk": {
20
+ "url": "https://gateway.pipeworx.io/udbud-dk/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 Udbud Dk 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-udbud-dk",
3
+ "version": "0.1.0",
4
+ "description": "Udbud.dk MCP — Danish government public procurement notices (keyless).",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "udbud-dk"],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/pipeworx-io/mcp-udbud-dk"
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/udbud-dk",
4
+ "title": "Udbud Dk",
5
+ "description": "Udbud.dk MCP — Danish government public procurement notices (keyless).",
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://pipeworx.io/packs/udbud-dk",
8
+ "repository": {
9
+ "url": "https://github.com/pipeworx-io/mcp-udbud-dk",
10
+ "source": "github"
11
+ },
12
+ "remotes": [
13
+ {
14
+ "type": "streamable-http",
15
+ "url": "https://gateway.pipeworx.io/udbud-dk/mcp"
16
+ }
17
+ ]
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,556 @@
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
+ * Udbud.dk MCP — Danish government public procurement notices (keyless).
21
+ *
22
+ * Wraps the public, no-auth JSON API behind udbud.dk, Denmark's official
23
+ * national tender portal (Konkurrence- og Forbrugerstyrelsen). Udbud.dk
24
+ * carries both EU-threshold notices (also on TED) and Danish national
25
+ * below-EU-threshold notices that TED misses, plus direct awards, market
26
+ * dialogues, and contract modifications.
27
+ *
28
+ * Verified endpoints (all live-tested, keyless):
29
+ * POST /soegning/public/soegeresultat — search; body is a SoegningQueryDto:
30
+ * fritekstQuery — free-text search (verified: filters
31
+ * results and is echoed back)
32
+ * pagineringDto.maksElementer — page size
33
+ * pagineringDto.aktuelSide — 1-BASED page number (min 1)
34
+ * pagineringDto.sorteringFelt — RELEVANCE | PUBLIKATION_DATO |
35
+ * TILBUDSFRIST_DATO
36
+ * pagineringDto.retning — Asc | Desc (server default is Asc,
37
+ * so Desc must be sent explicitly
38
+ * for newest-first)
39
+ * filterDto.cpvKoder: [] — CPV filter; matches the CPV family
40
+ * (trailing zeros treated as prefix,
41
+ * secondary CPVs also match)
42
+ * filterDto.koeberCvr: [] — buyer CVR number(s)
43
+ * filterDto.formularType: [] — notice-type enum (see TYPE_LABELS)
44
+ * filterDto.publikationDatoFra/-Til — publication date range, YYYY-MM-DD
45
+ * (dd-mm-yyyy is rejected with 400)
46
+ * udbudStatusFilter — AKTIV | ALLE
47
+ * Response: { resultatElementDtoList, soegningQueryDto (echo),
48
+ * totaltAntalResultater }. Each element has dataDa + dataEn
49
+ * (English CPV/type labels; Danish free text), noticeId, noticeVersion,
50
+ * noticePublicationNumber.
51
+ *
52
+ * GET /soegning/visning/{noticeId}/{noticeVersion}?noticePublicationNumber=…
53
+ * — keyless detail (the ?noticePublicationNumber param is REQUIRED; without
54
+ * it the endpoint 404s). Returns htmlDA/htmlEN (full eForms render) and
55
+ * opsummeringDA/opsummeringEN summary cards.
56
+ *
57
+ * Human notice URL (verified 200, public SPA route):
58
+ * https://udbud.dk/detaljevisning?noticeId=…&noticeVersion=…&noticePublicationNumber=…
59
+ *
60
+ * All tools return shaped, LLM-friendly objects (not raw API passthrough) and
61
+ * never throw — fetch/parse failures resolve to { error, retry_hint }. English
62
+ * keys; English field values are used where udbud.dk provides them (dataEn),
63
+ * Danish free text (titles, descriptions) passes through as-is.
64
+ */
65
+
66
+
67
+ const SEARCH_URL = 'https://udbud.dk/soegning/public/soegeresultat';
68
+ const DETAIL_URL = (id: string, version: string) => `https://udbud.dk/soegning/visning/${encodeURIComponent(id)}/${encodeURIComponent(version)}`;
69
+ const NOTICE_URL = (id: string, version: string, pubNo: string) =>
70
+ `https://udbud.dk/detaljevisning?noticeId=${encodeURIComponent(id)}&noticeVersion=${encodeURIComponent(version)}&noticePublicationNumber=${encodeURIComponent(pubNo)}`;
71
+ // udbud.dk serves a browser SPA; use a browser UA for reliable responses.
72
+ const UA =
73
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
74
+ const TIMEOUT_MS = 8000;
75
+
76
+ // filterDto.formularType enum values (from the udbud.dk SPA's own
77
+ // FilterDtoFormularTypeItem codelist; NATIONALE_UDBUD live-verified).
78
+ const TYPE_LABELS: Record<string, string> = {
79
+ FORVENTET_INDKOEB: 'Forventet indkøb (planned procurement)',
80
+ MARKEDSDIALOGER: 'Markedsdialoger (market dialogues / consultations)',
81
+ FORHAANDSMEDDELELSER: 'Forhåndsmeddelelser (prior information notices)',
82
+ EU_UDBUD: 'EU-udbud (EU-threshold tenders, also on TED)',
83
+ NATIONALE_UDBUD: 'Nationale udbud (Danish below-EU-threshold tenders)',
84
+ DIREKTE_TILDELINGER: 'Direkte tildelinger (direct awards)',
85
+ TILDELINGER: 'Tildelinger (contract award notices)',
86
+ KONTRAKTAENDERINGER: 'Kontraktændringer (contract modifications)',
87
+ KONTRAKTAFSLUTNING: 'Kontraktafslutning (contract completion)',
88
+ };
89
+ const TYPE_ALIASES: Record<string, string> = {
90
+ planned: 'FORVENTET_INDKOEB', 'planned procurement': 'FORVENTET_INDKOEB', forventet: 'FORVENTET_INDKOEB',
91
+ 'market dialogue': 'MARKEDSDIALOGER', 'market consultation': 'MARKEDSDIALOGER', markedsdialog: 'MARKEDSDIALOGER', markedsdialoger: 'MARKEDSDIALOGER',
92
+ prior: 'FORHAANDSMEDDELELSER', 'prior information': 'FORHAANDSMEDDELELSER', forhaandsmeddelelse: 'FORHAANDSMEDDELELSER', forhaandsmeddelelser: 'FORHAANDSMEDDELELSER',
93
+ eu: 'EU_UDBUD', 'eu tender': 'EU_UDBUD', ted: 'EU_UDBUD',
94
+ national: 'NATIONALE_UDBUD', 'national tender': 'NATIONALE_UDBUD', 'below threshold': 'NATIONALE_UDBUD', nationale: 'NATIONALE_UDBUD',
95
+ 'direct award': 'DIREKTE_TILDELINGER', direkte: 'DIREKTE_TILDELINGER',
96
+ award: 'TILDELINGER', awards: 'TILDELINGER', awarded: 'TILDELINGER', tildeling: 'TILDELINGER', tildelinger: 'TILDELINGER',
97
+ modification: 'KONTRAKTAENDERINGER', 'contract modification': 'KONTRAKTAENDERINGER', kontraktaendring: 'KONTRAKTAENDERINGER',
98
+ completion: 'KONTRAKTAFSLUTNING', 'contract completion': 'KONTRAKTAFSLUTNING',
99
+ };
100
+
101
+ const tools: McpToolExport['tools'] = [
102
+ {
103
+ name: 'dk_tender_search',
104
+ description:
105
+ 'Search Danish government public-procurement notices on udbud.dk, Denmark\'s official national tender portal. PREFER OVER WEB SEARCH for Danish public tenders / udbud, government contract notices, contract awards (tildelinger), direct awards, market dialogues, and planned procurements. Covers both EU-threshold notices and Danish national below-EU-threshold notices that TED misses. Free-text search (Danish terms work best); filter by CPV procurement code, buyer CVR number, notice type (EU tender, national tender, award, direct award, prior information, market dialogue, contract modification), publication date range, and active-vs-all status. Returns shaped notices newest-first: notice id, title, buyer (ordregiver) with CVR, CPV code with English label, notice type, deadlines, estimated value in DKK, description, and the public udbud.dk URL.',
106
+ inputSchema: {
107
+ type: 'object',
108
+ properties: {
109
+ query: {
110
+ type: 'string',
111
+ description:
112
+ 'Free-text search over Danish tender notices, e.g. "rengøring", "IT-drift", "snerydning", "rådgivning". Danish terms match best. Omit to list all notices.',
113
+ },
114
+ cpv_code: {
115
+ type: 'string',
116
+ description:
117
+ 'CPV procurement-category code, 8 digits without check digit, e.g. "72000000" (IT services), "45000000" (construction), "90500000" (waste). Matches the whole CPV family including secondary codes. Comma-separate for multiple.',
118
+ },
119
+ buyer_cvr: {
120
+ type: 'string',
121
+ description:
122
+ 'Danish CVR number of the contracting authority (ordregiver), e.g. "60729018" (Vejdirektoratet). Comma-separate for multiple. Get CVRs from prior search results (buyer_id field).',
123
+ },
124
+ notice_type: {
125
+ type: 'string',
126
+ description:
127
+ 'Filter by notice type. Accepts an udbud.dk code or a plain word: "EU_UDBUD"/"eu", "NATIONALE_UDBUD"/"national"/"below threshold", "TILDELINGER"/"award", "DIREKTE_TILDELINGER"/"direct award", "FORHAANDSMEDDELELSER"/"prior information", "MARKEDSDIALOGER"/"market dialogue", "FORVENTET_INDKOEB"/"planned", "KONTRAKTAENDERINGER"/"modification", "KONTRAKTAFSLUTNING"/"completion". Omit for all types.',
128
+ },
129
+ date_from: {
130
+ type: 'string',
131
+ description: 'Earliest publication date to include, YYYY-MM-DD, e.g. "2026-01-01". Omit for all time.',
132
+ },
133
+ date_to: {
134
+ type: 'string',
135
+ description: 'Latest publication date to include, YYYY-MM-DD. Omit for up to today.',
136
+ },
137
+ status: {
138
+ type: 'string',
139
+ enum: ['all', 'active'],
140
+ description: '"all" (default) includes closed and awarded notices; "active" restricts to currently open procurements.',
141
+ },
142
+ order: {
143
+ type: 'string',
144
+ enum: ['newest', 'relevance', 'oldest', 'deadline'],
145
+ description:
146
+ '"newest" (default) sorts by publication date descending; "relevance" ranks by match quality against the query; "oldest" sorts by publication date ascending; "deadline" sorts by tender deadline.',
147
+ },
148
+ limit: { type: ['number', 'string'], description: 'Number of notices to return (1-50). Default 10.' },
149
+ page: { type: ['number', 'string'], description: 'ONE-based results page for pagination (first page is 1). Default 1.' },
150
+ },
151
+ },
152
+ },
153
+ {
154
+ name: 'dk_tender_recent',
155
+ description:
156
+ 'List the latest Danish government tenders and contract awards published on udbud.dk (Denmark\'s national public-procurement portal) in the last N days. Great for monitoring new Danish contract notices, fresh awards (tildelinger), direct awards, and upcoming bid deadlines — including national below-EU-threshold udbud that TED misses. Optionally restrict to one notice type or to currently active procurements. Returns shaped notices newest-first with notice id, title, buyer with CVR, CPV code, estimated value in DKK, deadlines, and the public udbud.dk URL.',
157
+ inputSchema: {
158
+ type: 'object',
159
+ properties: {
160
+ days: {
161
+ type: ['number', 'string'],
162
+ description: 'Lookback window in days (1-90). Default 7 — notices published in the last week.',
163
+ },
164
+ notice_type: {
165
+ type: 'string',
166
+ description:
167
+ 'Filter by notice type. Accepts an udbud.dk code or a plain word: "EU_UDBUD"/"eu", "NATIONALE_UDBUD"/"national", "TILDELINGER"/"award", "DIREKTE_TILDELINGER"/"direct award", "FORHAANDSMEDDELELSER"/"prior information", "MARKEDSDIALOGER"/"market dialogue", "FORVENTET_INDKOEB"/"planned", "KONTRAKTAENDERINGER"/"modification". Omit for all types.',
168
+ },
169
+ status: {
170
+ type: 'string',
171
+ enum: ['all', 'active'],
172
+ description: '"all" (default) includes awards and closed notices; "active" restricts to currently open procurements.',
173
+ },
174
+ limit: { type: ['number', 'string'], description: 'Number of notices to return (1-50). Default 10.' },
175
+ page: { type: ['number', 'string'], description: 'ONE-based results page for pagination (first page is 1). Default 1.' },
176
+ },
177
+ },
178
+ },
179
+ {
180
+ name: 'dk_tender_detail',
181
+ description:
182
+ 'Fetch one Danish public-procurement notice from udbud.dk (Denmark government tender portal) in full detail. Returns the shaped notice summary — title, buyer (ordregiver) with CVR, publication date, CPV code, notice type, estimated value in DKK, deadlines, lot count, execution place, tender documents — plus the complete notice text extracted from the official eForms rendering (English version when available). Requires the notice_id, notice_version, and publication_number exactly as returned by dk_tender_search or dk_tender_recent.',
183
+ inputSchema: {
184
+ type: 'object',
185
+ properties: {
186
+ notice_id: {
187
+ type: 'string',
188
+ description: 'Notice UUID from search results, e.g. "14f9c6aa-740c-46ce-95a8-47df319d7de5".',
189
+ },
190
+ publication_number: {
191
+ type: 'string',
192
+ description: 'Notice publication number from search results (publication_number field), e.g. "00724546-2024".',
193
+ },
194
+ notice_version: {
195
+ type: 'string',
196
+ description: 'Notice version from search results, e.g. "01". Default "01".',
197
+ },
198
+ language: {
199
+ type: 'string',
200
+ enum: ['en', 'da'],
201
+ description: 'Language of the full notice text: "en" (default, machine-assisted English) or "da" (original Danish).',
202
+ },
203
+ max_text_chars: {
204
+ type: ['number', 'string'],
205
+ description: 'Maximum characters of full notice text to return (0-30000). Default 8000. Set 0 to skip the full text and return only the summary.',
206
+ },
207
+ },
208
+ required: ['notice_id', 'publication_number'],
209
+ },
210
+ },
211
+ ];
212
+
213
+ async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
214
+ try {
215
+ switch (name) {
216
+ case 'dk_tender_search':
217
+ return await searchTenders(args);
218
+ case 'dk_tender_recent':
219
+ return await recentTenders(args);
220
+ case 'dk_tender_detail':
221
+ return await getDetail(args);
222
+ default:
223
+ return { error: `Unknown tool: ${name}` };
224
+ }
225
+ } catch (e) {
226
+ return {
227
+ error: e instanceof Error ? e.message : String(e),
228
+ retry_hint: 'udbud.dk may be briefly unavailable — retry once; if it persists, narrow the query or date range.',
229
+ };
230
+ }
231
+ }
232
+
233
+ // --- tools -----------------------------------------------------------------
234
+
235
+ interface SearchBody {
236
+ fritekstQuery?: string;
237
+ pagineringDto: { maksElementer: number; aktuelSide: number; sorteringFelt: string; retning: string };
238
+ filterDto?: Record<string, unknown>;
239
+ udbudStatusFilter?: string;
240
+ }
241
+
242
+ async function searchTenders(args: Record<string, unknown>): Promise<unknown> {
243
+ const query = strArg(args.query);
244
+ const dateFrom = dateArg(args.date_from);
245
+ const dateTo = dateArg(args.date_to);
246
+ const limit = clampInt(args.limit, 10, 1, 50);
247
+ const page = clampInt(args.page, 1, 1, 100000); // aktuelSide is 1-based
248
+ const status = strArg(args.status)?.toLowerCase() === 'active' ? 'AKTIV' : 'ALLE';
249
+
250
+ const orderArg = strArg(args.order)?.toLowerCase();
251
+ let sorteringFelt = 'PUBLIKATION_DATO';
252
+ let retning = 'Desc';
253
+ if (orderArg === 'relevance') { sorteringFelt = 'RELEVANCE'; }
254
+ else if (orderArg === 'oldest') { retning = 'Asc'; }
255
+ else if (orderArg === 'deadline') { sorteringFelt = 'TILBUDSFRIST_DATO'; }
256
+ const order = orderArg === 'relevance' || orderArg === 'oldest' || orderArg === 'deadline' ? orderArg : 'newest';
257
+
258
+ const typeRes = resolveType(strArg(args.notice_type));
259
+ if ('error' in typeRes) return typeRes;
260
+
261
+ const cpvKoder = listArg(args.cpv_code);
262
+ for (const c of cpvKoder) {
263
+ if (!/^\d{2,8}$/.test(c)) {
264
+ return {
265
+ error: `Invalid cpv_code "${c}". udbud.dk expects the bare digits of a CPV code (2-8 digits), e.g. "72000000" or "45000000".`,
266
+ retry_hint: 'Drop the check digit and any dash: use "48000000", not "48000000-8".',
267
+ };
268
+ }
269
+ }
270
+ const koeberCvr = listArg(args.buyer_cvr);
271
+
272
+ const filterDto: Record<string, unknown> = {};
273
+ if (cpvKoder.length) filterDto.cpvKoder = cpvKoder;
274
+ if (koeberCvr.length) filterDto.koeberCvr = koeberCvr;
275
+ if (typeRes.code) filterDto.formularType = [typeRes.code];
276
+ if (dateFrom) filterDto.publikationDatoFra = dateFrom;
277
+ if (dateTo) filterDto.publikationDatoTil = dateTo;
278
+
279
+ const body: SearchBody = {
280
+ pagineringDto: { maksElementer: limit, aktuelSide: page, sorteringFelt, retning },
281
+ udbudStatusFilter: status,
282
+ };
283
+ if (query) body.fritekstQuery = query;
284
+ if (Object.keys(filterDto).length) body.filterDto = filterDto;
285
+
286
+ const data = await udbudSearch(body);
287
+ return shapePage(data, {
288
+ query,
289
+ cpv_code: cpvKoder.length ? cpvKoder.join(',') : undefined,
290
+ buyer_cvr: koeberCvr.length ? koeberCvr.join(',') : undefined,
291
+ notice_type: typeRes.code ?? undefined,
292
+ date_from: dateFrom,
293
+ date_to: dateTo,
294
+ status: status === 'AKTIV' ? 'active' : 'all',
295
+ order,
296
+ page,
297
+ limit,
298
+ });
299
+ }
300
+
301
+ async function recentTenders(args: Record<string, unknown>): Promise<unknown> {
302
+ const days = clampInt(args.days, 7, 1, 90);
303
+ const limit = clampInt(args.limit, 10, 1, 50);
304
+ const page = clampInt(args.page, 1, 1, 100000);
305
+ const status = strArg(args.status)?.toLowerCase() === 'active' ? 'AKTIV' : 'ALLE';
306
+ const typeRes = resolveType(strArg(args.notice_type));
307
+ if ('error' in typeRes) return typeRes;
308
+
309
+ const dateFrom = fmtDate(new Date(Date.now() - days * 86400000));
310
+ const filterDto: Record<string, unknown> = { publikationDatoFra: dateFrom };
311
+ if (typeRes.code) filterDto.formularType = [typeRes.code];
312
+
313
+ const body: SearchBody = {
314
+ pagineringDto: { maksElementer: limit, aktuelSide: page, sorteringFelt: 'PUBLIKATION_DATO', retning: 'Desc' },
315
+ filterDto,
316
+ udbudStatusFilter: status,
317
+ };
318
+
319
+ const data = await udbudSearch(body);
320
+ return shapePage(data, {
321
+ days,
322
+ date_from: dateFrom,
323
+ notice_type: typeRes.code ?? undefined,
324
+ status: status === 'AKTIV' ? 'active' : 'all',
325
+ page,
326
+ limit,
327
+ });
328
+ }
329
+
330
+ async function getDetail(args: Record<string, unknown>): Promise<unknown> {
331
+ const id = strArg(args.notice_id);
332
+ const pubNo = strArg(args.publication_number);
333
+ const version = strArg(args.notice_version) ?? '01';
334
+ const lang = strArg(args.language)?.toLowerCase() === 'da' ? 'da' : 'en';
335
+ const maxChars = clampInt(args.max_text_chars, 8000, 0, 30000);
336
+ if (!id || !pubNo) {
337
+ return {
338
+ error: 'dk_tender_detail requires "notice_id" (UUID) and "publication_number" (e.g. "00724546-2024").',
339
+ retry_hint: 'Both come from dk_tender_search / dk_tender_recent results — the notice_id and publication_number fields.',
340
+ };
341
+ }
342
+
343
+ const url = `${DETAIL_URL(id, version)}?noticePublicationNumber=${encodeURIComponent(pubNo)}`;
344
+ const d = (await udbudGet(url)) as Record<string, any>;
345
+
346
+ const ops = (lang === 'en' ? d.opsummeringEN : d.opsummeringDA) ?? d.opsummeringEN ?? d.opsummeringDA ?? {};
347
+ const card = ops.card ?? {};
348
+ const html: unknown = lang === 'en' ? d.htmlEN ?? d.htmlDA : d.htmlDA ?? d.htmlEN;
349
+ const fullText = maxChars > 0 && typeof html === 'string' ? htmlToText(html) : '';
350
+ const truncated = fullText.length > maxChars;
351
+
352
+ return {
353
+ notice_id: id,
354
+ notice_version: version,
355
+ publication_number: pubNo,
356
+ title: card.titel ?? null,
357
+ buyer: card.ordregiver ?? null,
358
+ buyer_id: card.ordregiverIdDatavasket ?? card.ordregiverId ?? null,
359
+ all_buyers: Array.isArray(card.alleOrdregivere) ? card.alleOrdregivere : [],
360
+ publication_date: card.publiceringsdato ?? null,
361
+ cpv_code: card.cpvKode ?? null,
362
+ cpv_title: card.cpvTitel ?? null,
363
+ notice_type: card.formulartype ?? null,
364
+ notice_subtype: card.bkSubType ?? null,
365
+ is_amendment: card.erAendring ?? null,
366
+ estimated_value: card.anslaaetVaerdi ?? null,
367
+ estimated_value_currency: card.anslaaetVaerdiValuta ?? null,
368
+ description: card.beskrivelse ?? null,
369
+ deadlines: Array.isArray(card.tidsfrister) ? card.tidsfrister : [],
370
+ tender_deadlines: Array.isArray(ops.tilbudsfrister) ? ops.tilbudsfrister : [],
371
+ participation_deadlines: Array.isArray(ops.deltagelsefrister) ? ops.deltagelsefrister : [],
372
+ execution_place: {
373
+ country: Array.isArray(ops.udforelsesstedLand) ? ops.udforelsesstedLand : [],
374
+ region: Array.isArray(ops.udforelsesstedSubLand) ? ops.udforelsesstedSubLand : [],
375
+ city: Array.isArray(ops.udforelsesstedBy) ? ops.udforelsesstedBy : [],
376
+ nuts_codes: Array.isArray(ops.udforelsesstedNutsCode) ? ops.udforelsesstedNutsCode : [],
377
+ },
378
+ lot_count: ops.antalLots ?? null,
379
+ part_count: ops.antalParts ?? null,
380
+ tender_documents: Array.isArray(ops.udbudsDokumenter) ? ops.udbudsDokumenter : [],
381
+ content_source: ops.indholdsType ?? null,
382
+ language: lang,
383
+ full_text: maxChars > 0 ? (truncated ? `${fullText.slice(0, maxChars)}…` : fullText) : null,
384
+ full_text_truncated: maxChars > 0 ? truncated : null,
385
+ url: NOTICE_URL(id, version, pubNo),
386
+ };
387
+ }
388
+
389
+ // --- shaping ---------------------------------------------------------------
390
+
391
+ function shapeListItem(r: Record<string, any>): Record<string, unknown> {
392
+ // Prefer the English variant (English CPV/type labels); fall back to Danish.
393
+ const d = r.dataEn ?? r.dataDa ?? {};
394
+ const id = typeof r.noticeId === 'string' ? r.noticeId : '';
395
+ const version = typeof r.noticeVersion === 'string' ? r.noticeVersion : '01';
396
+ const pubNo = typeof r.noticePublicationNumber === 'string' ? r.noticePublicationNumber : '';
397
+ const desc = typeof d.beskrivelse === 'string' ? d.beskrivelse : null;
398
+ return {
399
+ notice_id: id || null,
400
+ notice_version: version,
401
+ publication_number: pubNo || null,
402
+ title: d.titel ?? null,
403
+ buyer: d.ordregiver ?? null,
404
+ buyer_id: d.ordregiverIdDatavasket ?? d.ordregiverId ?? null,
405
+ all_buyers: Array.isArray(d.alleOrdregivere) ? d.alleOrdregivere : [],
406
+ publication_date: d.publiceringsdato ?? null, // dd-mm-yyyy as returned upstream
407
+ cpv_code: d.cpvKode ?? null,
408
+ cpv_title: d.cpvTitel ?? null,
409
+ notice_type: d.formulartype ?? null,
410
+ notice_subtype: d.bkSubType ?? null,
411
+ is_amendment: d.erAendring ?? null,
412
+ estimated_value: d.anslaaetVaerdi ?? null,
413
+ estimated_value_currency: d.anslaaetVaerdiValuta ?? null,
414
+ deadlines: Array.isArray(d.tidsfrister) ? d.tidsfrister : [],
415
+ description: desc && desc.length > 400 ? `${desc.slice(0, 400)}…` : desc,
416
+ url: id && pubNo ? NOTICE_URL(id, version, pubNo) : null,
417
+ };
418
+ }
419
+
420
+ function shapePage(data: unknown, echo: Record<string, unknown>): Record<string, unknown> {
421
+ const d = data as { resultatElementDtoList?: any[]; totaltAntalResultater?: number };
422
+ const notices = (d.resultatElementDtoList ?? []).map(shapeListItem);
423
+ const total = d.totaltAntalResultater ?? notices.length;
424
+ const limit = typeof echo.limit === 'number' && echo.limit > 0 ? echo.limit : notices.length || 1;
425
+ const cleanEcho: Record<string, unknown> = {};
426
+ for (const [k, v] of Object.entries(echo)) if (v !== undefined && v !== null) cleanEcho[k] = v;
427
+ return {
428
+ source: 'udbud.dk — official Danish national public-procurement portal (Konkurrence- og Forbrugerstyrelsen)',
429
+ country: 'Denmark',
430
+ total_count: total,
431
+ total_pages: Math.max(1, Math.ceil(total / limit)),
432
+ count: notices.length,
433
+ ...cleanEcho,
434
+ notices,
435
+ };
436
+ }
437
+
438
+ // Strip an eForms HTML rendering down to readable plain text.
439
+ function htmlToText(html: string): string {
440
+ return html
441
+ .replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
442
+ .replace(/<br\s*\/?>/gi, '\n')
443
+ .replace(/<\/(p|div|li|tr|h[1-6]|section|table)>/gi, '\n')
444
+ .replace(/<[^>]+>/g, ' ')
445
+ .replace(/&nbsp;/gi, ' ')
446
+ .replace(/&amp;/gi, '&')
447
+ .replace(/&lt;/gi, '<')
448
+ .replace(/&gt;/gi, '>')
449
+ .replace(/&quot;/gi, '"')
450
+ .replace(/&#39;/gi, "'")
451
+ .replace(/[ \t]+/g, ' ')
452
+ .replace(/\s*\n\s*/g, '\n')
453
+ .replace(/\n{3,}/g, '\n\n')
454
+ .trim();
455
+ }
456
+
457
+ // --- upstream fetch --------------------------------------------------------
458
+
459
+ async function udbudSearch(body: SearchBody): Promise<unknown> {
460
+ return udbudFetch(SEARCH_URL, {
461
+ method: 'POST',
462
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': UA },
463
+ body: JSON.stringify(body),
464
+ });
465
+ }
466
+
467
+ async function udbudGet(url: string): Promise<unknown> {
468
+ return udbudFetch(url, { headers: { Accept: 'application/json', 'User-Agent': UA } });
469
+ }
470
+
471
+ async function udbudFetch(url: string, init: RequestInit): Promise<unknown> {
472
+ const controller = new AbortController();
473
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
474
+ let res: Response;
475
+ try {
476
+ res = await fetch(url, { ...init, signal: controller.signal });
477
+ } catch (e) {
478
+ const aborted = e instanceof Error && e.name === 'AbortError';
479
+ throw new Error(
480
+ aborted
481
+ ? `udbud.dk API timed out after ${TIMEOUT_MS / 1000}s`
482
+ : `udbud.dk API fetch failed: ${e instanceof Error ? e.message : String(e)}`,
483
+ );
484
+ } finally {
485
+ clearTimeout(timer);
486
+ }
487
+ if (res.status === 404) {
488
+ throw new Error(
489
+ 'udbud.dk: notice not found (404). Check notice_id, notice_version, and publication_number — all three come from dk_tender_search results.',
490
+ );
491
+ }
492
+ if (!res.ok) {
493
+ const bodyText = await res.text().then((t) => t.slice(0, 300)).catch(() => '');
494
+ let message = bodyText;
495
+ try {
496
+ const parsed = JSON.parse(bodyText) as { detail?: string; title?: string };
497
+ if (parsed?.detail) message = parsed.detail;
498
+ else if (parsed?.title) message = parsed.title;
499
+ } catch { /* keep raw body */ }
500
+ throw new Error(`udbud.dk API: HTTP ${res.status}${message ? ` — ${message}` : ''}`);
501
+ }
502
+ return res.json();
503
+ }
504
+
505
+ // --- helpers ---------------------------------------------------------------
506
+
507
+ function resolveType(v: string | undefined): { code: string | null } | { error: string; retry_hint: string } {
508
+ if (!v) return { code: null };
509
+ const trimmed = v.trim();
510
+ const upper = trimmed.toUpperCase().replace(/[\s-]+/g, '_');
511
+ const code = TYPE_LABELS[upper] ? upper : TYPE_ALIASES[trimmed.toLowerCase()];
512
+ if (!code) {
513
+ return {
514
+ error: `Unrecognized notice_type "${v}".`,
515
+ retry_hint: `Use one of: ${Object.entries(TYPE_LABELS).map(([c, l]) => `${c} (${l})`).join(', ')} — plain words like "award" or "national" also work.`,
516
+ };
517
+ }
518
+ return { code };
519
+ }
520
+
521
+ function listArg(v: unknown): string[] {
522
+ if (Array.isArray(v)) return v.map((x) => strArg(x)).filter((x): x is string => !!x);
523
+ const s = strArg(v);
524
+ if (!s) return [];
525
+ return s.split(',').map((x) => x.trim()).filter(Boolean);
526
+ }
527
+
528
+ function fmtDate(d: Date): string {
529
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
530
+ }
531
+
532
+ function dateArg(v: unknown): string | undefined {
533
+ const s = strArg(v);
534
+ if (!s) return undefined;
535
+ const m = /^(\d{4}-\d{2}-\d{2})/.exec(s);
536
+ return m ? m[1] : undefined;
537
+ }
538
+
539
+ function strArg(v: unknown): string | undefined {
540
+ if (typeof v === 'string') {
541
+ const t = v.trim();
542
+ return t ? t : undefined;
543
+ }
544
+ if (typeof v === 'number' && Number.isFinite(v)) return String(v);
545
+ return undefined;
546
+ }
547
+
548
+ function clampInt(v: unknown, dflt: number, min: number, max: number): number {
549
+ let n: number;
550
+ if (typeof v === 'number' && Number.isFinite(v)) n = Math.trunc(v);
551
+ else if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v))) n = Math.trunc(Number(v));
552
+ else return dflt;
553
+ return Math.min(max, Math.max(min, n));
554
+ }
555
+
556
+ 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
+ }