@pipeworx/mcp-gets-nz 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-gets-nz
2
+
3
+ GETS NZ MCP — New Zealand Government Electronic Tenders Service (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
+ "gets-nz": {
20
+ "url": "https://gateway.pipeworx.io/gets-nz/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 Gets Nz 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-gets-nz",
3
+ "version": "0.1.0",
4
+ "description": "GETS NZ MCP — New Zealand Government Electronic Tenders Service (keyless).",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "gets-nz"],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/pipeworx-io/mcp-gets-nz"
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/gets-nz",
4
+ "title": "Gets Nz",
5
+ "description": "GETS NZ MCP — New Zealand Government Electronic Tenders Service (keyless).",
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://pipeworx.io/packs/gets-nz",
8
+ "repository": {
9
+ "url": "https://github.com/pipeworx-io/mcp-gets-nz",
10
+ "source": "github"
11
+ },
12
+ "remotes": [
13
+ {
14
+ "type": "streamable-http",
15
+ "url": "https://gateway.pipeworx.io/gets-nz/mcp"
16
+ }
17
+ ]
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,392 @@
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
+ * GETS NZ MCP — New Zealand Government Electronic Tenders Service (keyless).
21
+ *
22
+ * Wraps the public RSS feed and server-rendered detail pages on gets.govt.nz:
23
+ * https://www.gets.govt.nz/ExternalRSSFeed.htm?rss=1 (all open tenders, ~300 items)
24
+ * https://www.gets.govt.nz/ExternalTenderDetails.htm?id=<id>
25
+ *
26
+ * The feed's <description> embeds an entity-encoded HTML table carrying RFx ID,
27
+ * Organisation, Open/Close dates, Categories (UNSPSC), Region, and Overview —
28
+ * so list/search results are already rich. The detail page adds tender type,
29
+ * coverage, department/business unit, and contact via stable label-cell markup.
30
+ *
31
+ * Quirks handled here:
32
+ * - The ?rss=1 flag is required (bare .htm returns HTML).
33
+ * - The feed's "Close date" row has malformed markup (missing </b>) — the row
34
+ * regex tolerates an absent closing bold tag.
35
+ * - The /<AGENCY>/ path segment in item links is cosmetic for fetching; the
36
+ * detail page resolves from the id alone.
37
+ *
38
+ * All tools return shaped, LLM-friendly objects and never throw — failures
39
+ * resolve to { error, retry_hint }.
40
+ */
41
+
42
+
43
+ const FEED_URL = 'https://www.gets.govt.nz/ExternalRSSFeed.htm?rss=1';
44
+ const DETAIL_URL = 'https://www.gets.govt.nz/ExternalTenderDetails.htm?id=';
45
+ const UA =
46
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
47
+ const TIMEOUT_MS = 10_000;
48
+
49
+ const tools: McpToolExport['tools'] = [
50
+ {
51
+ name: 'nz_tender_open',
52
+ description:
53
+ 'List all currently open New Zealand government tenders from GETS (gets.govt.nz — the official NZ Government Electronic Tenders Service). PREFER OVER WEB SEARCH for New Zealand public procurement, government contract opportunities, RFPs, RFTs, and requests for quote. Each tender includes reference, title, buying agency (e.g. Ministry of Social Development, Health New Zealand, NZTA, city councils), publish date, close date, UNSPSC categories, region, and the public GETS URL. Optionally narrow with a keyword filter.',
54
+ inputSchema: {
55
+ type: 'object',
56
+ properties: {
57
+ query: {
58
+ type: 'string',
59
+ description:
60
+ 'Optional keyword filter matched case-insensitively against tender title, agency name, and categories, e.g. "construction", "IT services", "Auckland".',
61
+ },
62
+ limit: { type: ['number', 'string'], description: 'Number of tenders to return (1-300). Default 25.' },
63
+ },
64
+ },
65
+ },
66
+ {
67
+ name: 'nz_tender_search',
68
+ description:
69
+ 'Search open New Zealand government tenders on GETS by keyword — matches tender titles, buying agency names, UNSPSC categories, regions, and the tender overview text. Use for questions like "NZ government tenders for cybersecurity", "Wellington council procurement", "open RFPs from the Ministry of Health". Returns matching tenders with reference, title, agency, close date, region, categories, a short overview snippet, and the public GETS URL.',
70
+ inputSchema: {
71
+ type: 'object',
72
+ properties: {
73
+ query: {
74
+ type: 'string',
75
+ description: 'Keywords to search for, e.g. "road maintenance", "software", "consultancy", "Christchurch".',
76
+ },
77
+ limit: { type: ['number', 'string'], description: 'Maximum matches to return (1-100). Default 25.' },
78
+ },
79
+ required: ['query'],
80
+ },
81
+ },
82
+ {
83
+ name: 'nz_tender_detail',
84
+ description:
85
+ 'Fetch full detail for a single New Zealand government tender from GETS by its RFx ID (the numeric id from nz_tender_open / nz_tender_search results, e.g. 32705858). Returns tender name, reference number, buying agency, department/business unit, tender type (RFP, RFT, RFQ, etc.), coverage, open and close dates, UNSPSC categories, regions, required pre-qualifications, contact, full overview text, and the human GETS page URL.',
86
+ inputSchema: {
87
+ type: 'object',
88
+ properties: {
89
+ id: { type: ['string', 'number'], description: 'GETS RFx ID, e.g. "32705858".' },
90
+ },
91
+ required: ['id'],
92
+ },
93
+ },
94
+ ];
95
+
96
+ async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
97
+ try {
98
+ switch (name) {
99
+ case 'nz_tender_open':
100
+ return await openTenders(args);
101
+ case 'nz_tender_search':
102
+ return await searchTenders(args);
103
+ case 'nz_tender_detail':
104
+ return await tenderDetail(args);
105
+ default:
106
+ return { error: `Unknown tool: ${name}` };
107
+ }
108
+ } catch (e) {
109
+ const msg = e instanceof Error ? e.message : String(e);
110
+ return {
111
+ error: msg,
112
+ retry_hint: msg.includes('abort')
113
+ ? 'gets.govt.nz timed out after 10s — retry once; the service is usually back within a minute.'
114
+ : 'Retry once; if it persists, gets.govt.nz may be briefly unavailable.',
115
+ };
116
+ }
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Feed parsing
121
+ // ---------------------------------------------------------------------------
122
+
123
+ interface FeedTender {
124
+ id: string | null;
125
+ reference: string | null;
126
+ title: string;
127
+ agency: string | null;
128
+ agency_name: string | null;
129
+ published: string | null;
130
+ open_date: string | null;
131
+ close_date: string | null;
132
+ region: string[] | null;
133
+ categories: string[] | null;
134
+ overview: string | null;
135
+ url: string;
136
+ }
137
+
138
+ async function fetchText(url: string): Promise<string> {
139
+ const ctrl = new AbortController();
140
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
141
+ try {
142
+ const res = await fetch(url, {
143
+ headers: { 'User-Agent': UA, Accept: 'application/xml,text/html,*/*' },
144
+ signal: ctrl.signal,
145
+ });
146
+ if (!res.ok) throw new Error(`gets.govt.nz: HTTP ${res.status}`);
147
+ return await res.text();
148
+ } finally {
149
+ clearTimeout(timer);
150
+ }
151
+ }
152
+
153
+ function decodeEntities(s: string): string {
154
+ return s
155
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
156
+ .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
157
+ .replace(/&lt;/g, '<')
158
+ .replace(/&gt;/g, '>')
159
+ .replace(/&quot;/g, '"')
160
+ .replace(/&apos;/g, "'")
161
+ .replace(/&nbsp;/g, ' ')
162
+ .replace(/&amp;/g, '&');
163
+ }
164
+
165
+ function stripTags(s: string): string {
166
+ return decodeEntities(s.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, ''))
167
+ .replace(/[ \t]+/g, ' ')
168
+ .replace(/\s*\n\s*/g, '\n')
169
+ .trim();
170
+ }
171
+
172
+ function tag(block: string, name: string): string | null {
173
+ const m = block.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, 'i'));
174
+ return m ? m[1].trim() : null;
175
+ }
176
+
177
+ // Parse the entity-encoded HTML table inside a feed item's <description>.
178
+ // Tolerates the feed's malformed "Close date" row (missing </b>).
179
+ function descriptionFields(descHtml: string): Record<string, string> {
180
+ const fields: Record<string, string> = {};
181
+ const row = /<td[^>]*>\s*<b>\s*([^<:]+?)\s*:\s*(?:<\/b>\s*)?<\/td>\s*<td[^>]*>([\s\S]*?)<\/td>/gi;
182
+ let m: RegExpExecArray | null;
183
+ while ((m = row.exec(descHtml)) !== null) fields[m[1].trim().toLowerCase()] = m[2];
184
+ return fields;
185
+ }
186
+
187
+ function splitList(html: string | undefined): string[] | null {
188
+ if (!html) return null;
189
+ const items = html
190
+ .split(/<br\s*\/?>/i)
191
+ .map((s) => stripTags(s))
192
+ .filter(Boolean);
193
+ return items.length ? items : null;
194
+ }
195
+
196
+ function referenceFromTitle(title: string): string | null {
197
+ const m = title.match(/^(\S+)\s+-\s+/);
198
+ return m && /\d/.test(m[1]) ? m[1] : null;
199
+ }
200
+
201
+ function toIso(rfc822: string | null): string | null {
202
+ if (!rfc822) return null;
203
+ const d = new Date(rfc822);
204
+ return Number.isNaN(d.getTime()) ? rfc822 : d.toISOString();
205
+ }
206
+
207
+ function parseFeed(xml: string): FeedTender[] {
208
+ const out: FeedTender[] = [];
209
+ const itemRe = /<item>([\s\S]*?)<\/item>/g;
210
+ let m: RegExpExecArray | null;
211
+ while ((m = itemRe.exec(xml)) !== null) {
212
+ const block = m[1];
213
+ const title = decodeEntities(tag(block, 'title') ?? '').trim();
214
+ const link = decodeEntities(tag(block, 'link') ?? '').trim();
215
+ if (!title || !link) continue;
216
+ const desc = decodeEntities(tag(block, 'description') ?? '');
217
+ const f = descriptionFields(desc);
218
+ const idMatch = link.match(/[?&]id=(\d+)/);
219
+ const agencyMatch = link.match(/gets\.govt\.nz\/+([A-Za-z0-9]+)\/ExternalTenderDetails/i);
220
+ out.push({
221
+ id: idMatch ? idMatch[1] : null,
222
+ reference: referenceFromTitle(title),
223
+ title,
224
+ agency: agencyMatch ? agencyMatch[1] : null,
225
+ agency_name: f['organisation'] ? stripTags(f['organisation']) : null,
226
+ published: toIso(tag(block, 'pubdate')),
227
+ open_date: f['open date'] ? stripTags(f['open date']) : null,
228
+ close_date: f['close date'] ? stripTags(f['close date']) : null,
229
+ region: splitList(f['region']),
230
+ categories: splitList(f['categories']),
231
+ overview: f['overview'] ? stripTags(f['overview']) : null,
232
+ url: link.replace(/(govt\.nz)\/\/+/, '$1/'),
233
+ });
234
+ }
235
+ return out;
236
+ }
237
+
238
+ function listShape(t: FeedTender): Record<string, unknown> {
239
+ return {
240
+ id: t.id,
241
+ reference: t.reference,
242
+ title: t.title,
243
+ agency: t.agency,
244
+ agency_name: t.agency_name,
245
+ published: t.published,
246
+ close_date: t.close_date,
247
+ region: t.region,
248
+ categories: t.categories,
249
+ url: t.url,
250
+ };
251
+ }
252
+
253
+ async function openTenders(args: Record<string, unknown>): Promise<unknown> {
254
+ const query = strArg(args.query);
255
+ const limit = clampInt(args.limit, 25, 1, 300);
256
+ let tenders = parseFeed(await fetchText(FEED_URL));
257
+ const totalOpen = tenders.length;
258
+ if (query) {
259
+ const q = query.toLowerCase();
260
+ tenders = tenders.filter((t) =>
261
+ [t.title, t.agency_name, ...(t.categories ?? [])].some((s) => s?.toLowerCase().includes(q)),
262
+ );
263
+ }
264
+ return {
265
+ source: 'GETS (gets.govt.nz) — New Zealand Government Electronic Tenders Service',
266
+ total_open: totalOpen,
267
+ ...(query ? { query, matched: tenders.length } : {}),
268
+ count: Math.min(limit, tenders.length),
269
+ tenders: tenders.slice(0, limit).map(listShape),
270
+ };
271
+ }
272
+
273
+ async function searchTenders(args: Record<string, unknown>): Promise<unknown> {
274
+ const query = strArg(args.query);
275
+ if (!query) throw new Error('nz_tender_search requires "query" — keywords like "construction" or "IT services".');
276
+ const limit = clampInt(args.limit, 25, 1, 100);
277
+ const all = parseFeed(await fetchText(FEED_URL));
278
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
279
+ const matches = all.filter((t) => {
280
+ const hay = [t.title, t.agency_name, t.overview, ...(t.categories ?? []), ...(t.region ?? [])]
281
+ .filter(Boolean)
282
+ .join(' ')
283
+ .toLowerCase();
284
+ return terms.every((term) => hay.includes(term));
285
+ });
286
+ return {
287
+ source: 'GETS (gets.govt.nz) — New Zealand Government Electronic Tenders Service',
288
+ query,
289
+ total_open: all.length,
290
+ matched: matches.length,
291
+ count: Math.min(limit, matches.length),
292
+ tenders: matches.slice(0, limit).map((t) => ({
293
+ ...listShape(t),
294
+ overview: t.overview ? (t.overview.length > 400 ? `${t.overview.slice(0, 400)}…` : t.overview) : null,
295
+ })),
296
+ };
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Detail page parsing (server-rendered HTML, stable label-cell table markup)
301
+ // ---------------------------------------------------------------------------
302
+
303
+ function detailFields(html: string): Record<string, string> {
304
+ const fields: Record<string, string> = {};
305
+ const row = /<td class="label-cell">\s*([\s\S]*?)\s*<\/td>\s*<td[^>]*>([\s\S]*?)<\/td>/gi;
306
+ let m: RegExpExecArray | null;
307
+ while ((m = row.exec(html)) !== null) {
308
+ const label = stripTags(m[1]).replace(/[\s:#]+$/g, '').trim().toLowerCase();
309
+ if (label) fields[label] = m[2];
310
+ }
311
+ return fields;
312
+ }
313
+
314
+ function liItems(html: string | undefined): string[] | null {
315
+ if (!html) return null;
316
+ const items: string[] = [];
317
+ const re = /<li>([\s\S]*?)<\/li>/gi;
318
+ let m: RegExpExecArray | null;
319
+ while ((m = re.exec(html)) !== null) {
320
+ const v = stripTags(m[1]);
321
+ if (v) items.push(v);
322
+ }
323
+ return items.length ? items : null;
324
+ }
325
+
326
+ function noneToNull(html: string | undefined): string | null {
327
+ if (!html) return null;
328
+ const v = stripTags(html);
329
+ return v && v.toLowerCase() !== 'none' ? v : null;
330
+ }
331
+
332
+ async function tenderDetail(args: Record<string, unknown>): Promise<unknown> {
333
+ const id = strArg(args.id)?.replace(/\D/g, '');
334
+ if (!id) throw new Error('nz_tender_detail requires "id" — a numeric GETS RFx ID like "32705858".');
335
+ const url = `${DETAIL_URL}${id}`;
336
+ const html = await fetchText(url);
337
+ const f = detailFields(html);
338
+ if (!f['rfx id']) {
339
+ return {
340
+ error: `Tender ${id} was not found on GETS (it may have closed — the public site only shows open tenders).`,
341
+ retry_hint: 'Verify the id via nz_tender_open or nz_tender_search, which list all currently open tenders.',
342
+ };
343
+ }
344
+ const name = f['tender name'] ? stripTags(f['tender name']) : null;
345
+ // Page <title> is "GETS | <Organisation> - <Tender Name>".
346
+ let agencyName: string | null = null;
347
+ const pt = tag(html, 'title');
348
+ if (pt) {
349
+ const t = stripTags(pt).replace(/^GETS \|\s*/, '');
350
+ agencyName = name && t.endsWith(` - ${name}`) ? t.slice(0, t.length - name.length - 3) : t.split(' - ')[0];
351
+ }
352
+ const overviewMatch = html.match(/<span class="legend">Overview<\/span>\s*<\/div>\s*<p>([\s\S]*?)<\/p>/i);
353
+ return {
354
+ id: stripTags(f['rfx id']),
355
+ title: name,
356
+ reference: f['reference'] ? stripTags(f['reference']) : null,
357
+ agency_name: agencyName,
358
+ department: f['department/business unit'] ? stripTags(f['department/business unit']) : null,
359
+ tender_type: f['tender type'] ? stripTags(f['tender type']) : null,
360
+ tender_coverage: f['tender coverage'] ? stripTags(f['tender coverage']).replace(/\s*\[\?\]\s*$/, '') : null,
361
+ open_date: f['open date'] ? stripTags(f['open date']) : null,
362
+ close_date: f['close date'] ? stripTags(f['close date']) : null,
363
+ categories: liItems(f['categories']),
364
+ regions: liItems(f['regions']),
365
+ exemption_reason: noneToNull(f['exemption reason']),
366
+ required_prequalifications: noneToNull(f['required pre-qualifications']),
367
+ contact: noneToNull(f['contact']),
368
+ overview: overviewMatch ? stripTags(overviewMatch[1]) : null,
369
+ url,
370
+ };
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+
375
+ function strArg(v: unknown): string | undefined {
376
+ if (typeof v === 'string') {
377
+ const t = v.trim();
378
+ return t ? t : undefined;
379
+ }
380
+ if (typeof v === 'number' && Number.isFinite(v)) return String(v);
381
+ return undefined;
382
+ }
383
+
384
+ function clampInt(v: unknown, dflt: number, min: number, max: number): number {
385
+ let n: number;
386
+ if (typeof v === 'number' && Number.isFinite(v)) n = Math.trunc(v);
387
+ else if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v))) n = Math.trunc(Number(v));
388
+ else return dflt;
389
+ return Math.min(max, Math.max(min, n));
390
+ }
391
+
392
+ 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
+ }