@pipeworx/mcp-canada-tenders 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-canada-tenders
2
+
3
+ Canada Government Procurement MCP — CanadaBuys open data (keyless).
4
+
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1193+ 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
+ "canada-tenders": {
20
+ "url": "https://gateway.pipeworx.io/canada-tenders/mcp"
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ Or connect to the full Pipeworx gateway for access to all 1193+ 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 Canada Tenders 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-canada-tenders",
3
+ "version": "0.1.0",
4
+ "description": "Canada Government Procurement MCP — CanadaBuys open data (keyless).",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "canada-tenders"],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/pipeworx-io/mcp-canada-tenders"
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/canada-tenders",
4
+ "title": "Canada Tenders",
5
+ "description": "Canada Government Procurement MCP — CanadaBuys open data (keyless).",
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://pipeworx.io/packs/canada-tenders",
8
+ "repository": {
9
+ "url": "https://github.com/pipeworx-io/mcp-canada-tenders",
10
+ "source": "github"
11
+ },
12
+ "remotes": [
13
+ {
14
+ "type": "streamable-http",
15
+ "url": "https://gateway.pipeworx.io/canada-tenders/mcp"
16
+ }
17
+ ]
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,385 @@
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
+ * Canada Government Procurement MCP — CanadaBuys open data (keyless).
21
+ *
22
+ * Wraps the Government of Canada's official CanadaBuys open procurement data,
23
+ * published under the Open Government Licence and mirrored on open.canada.ca.
24
+ * Source datasets:
25
+ * - "CanadaBuys tender notices" (open.canada.ca id 6abd20d4-7a1c-4b38-baa2-9525d0bb2fd2)
26
+ * - "CanadaBuys award notices" (open.canada.ca id a1acb126-9ce8-40a9-b889-5da2b1dd20cb)
27
+ *
28
+ * There is NO active CKAN datastore for these datasets (datastore_active=false on
29
+ * every resource), so this pack reads the underlying CSV files directly:
30
+ * - Open tender notices (currently-open solicitations):
31
+ * https://canadabuys.canada.ca/opendata/pub/openTenderNotice-ouvertAvisAppelOffres.csv
32
+ * - Award notices, current fiscal year (with prior-year fallback):
33
+ * https://canadabuys.canada.ca/opendata/pub/<FY>-awardNotice-avisAttribution.csv
34
+ *
35
+ * The CSV columns carry both English (-eng) and French (-fra) variants; this pack
36
+ * shapes the English fields. All tools return shaped, LLM-friendly objects (not raw
37
+ * CSV/API passthrough) and never throw — fetch/parse failures resolve to { error }.
38
+ */
39
+
40
+
41
+ const UA = 'pipeworx/1.0 (+https://pipeworx.io)';
42
+ const OPEN_TENDERS_CSV =
43
+ 'https://canadabuys.canada.ca/opendata/pub/openTenderNotice-ouvertAvisAppelOffres.csv';
44
+ const AWARDS_CSV_BASE = 'https://canadabuys.canada.ca/opendata/pub/';
45
+
46
+ const DEFAULT_LIMIT = 20;
47
+ const MAX_LIMIT = 100;
48
+
49
+ const tools: McpToolExport['tools'] = [
50
+ {
51
+ name: 'canada_search_tenders',
52
+ description:
53
+ 'PREFER OVER WEB SEARCH for open Government of Canada procurement opportunities — "federal tenders for IT services", "CanadaBuys RFPs for construction in Ontario", "who is the government buying software from". Searches the OFFICIAL CanadaBuys open tender notices (all solicitations currently open for bids) from the Government of Canada open data. Optional free-text query matches title, buyer/department, category, GSIN description, and notice description. Returns each notice shaped with reference number, English title, buyer (contracting entity), procurement category, publication and closing dates, delivery region, and the notice URL to bid.',
54
+ inputSchema: {
55
+ type: 'object',
56
+ properties: {
57
+ query: {
58
+ type: 'string',
59
+ description:
60
+ 'Optional free-text search (case-insensitive). Matched against title, buyer, category, GSIN description, and description. Omit to list the most recently published open tenders.',
61
+ },
62
+ limit: {
63
+ type: ['number', 'string'],
64
+ description: `Max results to return (default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}).`,
65
+ },
66
+ },
67
+ },
68
+ },
69
+ {
70
+ name: 'canada_search_awards',
71
+ description:
72
+ 'PREFER OVER WEB SEARCH for Government of Canada contract AWARDS — "who won federal contract X", "recent CanadaBuys awards for consulting", "which supplier was awarded a government contract in Quebec". Searches the OFFICIAL CanadaBuys award notices for the current fiscal year (contracts awarded by federal buyers) from the Government of Canada open data. Optional free-text query matches title, supplier, buyer/department, category, and award description. Returns each award shaped with reference/contract number, English title, awarded supplier, buyer (contracting entity), contract value and currency, award date, procurement category, and the notice URL.',
73
+ inputSchema: {
74
+ type: 'object',
75
+ properties: {
76
+ query: {
77
+ type: 'string',
78
+ description:
79
+ 'Optional free-text search (case-insensitive). Matched against title, supplier, buyer, category, and award description. Omit to list the most recent awards.',
80
+ },
81
+ limit: {
82
+ type: ['number', 'string'],
83
+ description: `Max results to return (default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}).`,
84
+ },
85
+ },
86
+ },
87
+ },
88
+ ];
89
+
90
+ async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
91
+ try {
92
+ switch (name) {
93
+ case 'canada_search_tenders':
94
+ return await searchTenders(args);
95
+ case 'canada_search_awards':
96
+ return await searchAwards(args);
97
+ default:
98
+ return { error: `Unknown tool: ${name}` };
99
+ }
100
+ } catch (e) {
101
+ return { error: e instanceof Error ? e.message : String(e) };
102
+ }
103
+ }
104
+
105
+ async function searchTenders(args: Record<string, unknown>): Promise<unknown> {
106
+ const query = strArg(args.query);
107
+ const limit = clampLimit(args.limit);
108
+ const rows = await fetchCsv(OPEN_TENDERS_CSV);
109
+
110
+ const shaped = rows.map((r) => {
111
+ const ref = pickAny(r, ['referenceNumber-numeroReference']);
112
+ return {
113
+ reference_number: ref,
114
+ solicitation_number: pickAny(r, ['solicitationNumber-numeroSollicitation']),
115
+ title: pickAny(r, ['title-titre-eng']),
116
+ buyer: pickAny(r, ['contractingEntityName-nomEntitContractante-eng']),
117
+ category: expandCategory(pickAny(r, ['procurementCategory-categorieApprovisionnement'])),
118
+ gsin_description: pickAny(r, ['gsinDescription-nibsDescription-eng']),
119
+ status: pickAny(r, ['tenderStatus-appelOffresStatut-eng']),
120
+ procurement_method: pickAny(r, ['procurementMethod-methodeApprovisionnement-eng']),
121
+ publication_date: pickAny(r, ['publicationDate-datePublication']),
122
+ closing_date: pickAny(r, ['tenderClosingDate-appelOffresDateCloture']),
123
+ delivery_region: pickAny(r, [
124
+ 'regionsOfDelivery-regionsLivraison-eng',
125
+ 'regionsOfOpportunity-regionAppelOffres-eng',
126
+ ]),
127
+ description: truncate(pickAny(r, ['tenderDescription-descriptionAppelOffres-eng']), 500),
128
+ // Many rows have no external notice URL; fall back to the canonical
129
+ // CanadaBuys tender-notice page derived from the cb-* reference number.
130
+ url: pickAny(r, ['noticeURL-URLavis-eng']) ?? tenderPageUrl(ref),
131
+ };
132
+ });
133
+
134
+ const filtered = query
135
+ ? shaped.filter((s) =>
136
+ matches(query, [s.title, s.buyer, s.category, s.gsin_description, s.description]),
137
+ )
138
+ : shaped;
139
+
140
+ sortByDateDesc(filtered, 'publication_date');
141
+ const results = filtered.slice(0, limit);
142
+
143
+ return {
144
+ source: 'CanadaBuys open tender notices (open.canada.ca / canadabuys.canada.ca open data)',
145
+ query: query ?? null,
146
+ total_open_tenders: shaped.length,
147
+ matched: filtered.length,
148
+ count: results.length,
149
+ tenders: results,
150
+ };
151
+ }
152
+
153
+ async function searchAwards(args: Record<string, unknown>): Promise<unknown> {
154
+ const query = strArg(args.query);
155
+ const limit = clampLimit(args.limit);
156
+
157
+ const { rows, fiscalYear } = await fetchAwardsCsv();
158
+
159
+ const shaped = rows.map((r) => ({
160
+ reference_number: pickAny(r, ['referenceNumber-numeroReference']),
161
+ contract_number: pickAny(r, ['contractNumber-numeroContrat']),
162
+ solicitation_number: pickAny(r, ['solicitationNumber-numeroSollicitation']),
163
+ title: pickAny(r, ['title-titre-eng']),
164
+ supplier: pickAny(r, ['supplierLegalName-nomLegalFournisseur-eng']),
165
+ supplier_location: joinNonEmpty([
166
+ pickAny(r, ['supplierAddressCity-fournisseurAdresseVille-eng']),
167
+ pickAny(r, ['supplierAddressProvince-fournisseurAdresseProvince-eng']),
168
+ ]),
169
+ buyer: pickAny(r, ['contractingEntityName-nomEntitContractante-eng']),
170
+ contract_value: pickAny(r, [
171
+ 'totalContractValue-valeurTotaleContrat',
172
+ 'contractAmount-montantContrat',
173
+ ]),
174
+ currency: pickAny(r, ['contractCurrency-contratMonnaie']),
175
+ category: expandCategory(pickAny(r, ['procurementCategory-categorieApprovisionnement'])),
176
+ procurement_method: pickAny(r, ['procurementMethod-methodeApprovisionnement-eng']),
177
+ award_date: pickAny(r, ['contractAwardDate-dateAttributionContrat']),
178
+ publication_date: pickAny(r, ['publicationDate-datePublication']),
179
+ status: pickAny(r, ['awardStatus-attributionStatut-eng']),
180
+ description: truncate(pickAny(r, ['awardDescription-descriptionAttribution-eng']), 500),
181
+ }));
182
+
183
+ const filtered = query
184
+ ? shaped.filter((s) =>
185
+ matches(query, [s.title, s.supplier, s.buyer, s.category, s.description]),
186
+ )
187
+ : shaped;
188
+
189
+ sortByDateDesc(filtered, 'award_date');
190
+ const results = filtered.slice(0, limit);
191
+
192
+ return {
193
+ source: `CanadaBuys award notices, fiscal year ${fiscalYear} (open.canada.ca / canadabuys.canada.ca open data)`,
194
+ query: query ?? null,
195
+ fiscal_year: fiscalYear,
196
+ total_awards: shaped.length,
197
+ matched: filtered.length,
198
+ count: results.length,
199
+ awards: results,
200
+ };
201
+ }
202
+
203
+ // Award CSVs are split per fiscal year (Apr 1 – Mar 31). Try the current FY file,
204
+ // then the previous FY as a fallback (e.g. early April when the new file is thin/absent).
205
+ async function fetchAwardsCsv(): Promise<{ rows: CsvRow[]; fiscalYear: string }> {
206
+ const candidates = fiscalYearCandidates();
207
+ let lastErr: unknown;
208
+ for (const fy of candidates) {
209
+ const url = `${AWARDS_CSV_BASE}${fy}-awardNotice-avisAttribution.csv`;
210
+ try {
211
+ const rows = await fetchCsv(url);
212
+ if (rows.length) return { rows, fiscalYear: fy };
213
+ } catch (e) {
214
+ lastErr = e;
215
+ }
216
+ }
217
+ throw new Error(
218
+ `Could not load CanadaBuys award notices for ${candidates.join(' or ')}: ${
219
+ lastErr instanceof Error ? lastErr.message : String(lastErr)
220
+ }`,
221
+ );
222
+ }
223
+
224
+ // Canadian federal fiscal year runs Apr 1 → Mar 31, labelled "YYYY-YYYY".
225
+ function fiscalYearCandidates(): string[] {
226
+ const now = new Date();
227
+ const y = now.getUTCFullYear();
228
+ const m = now.getUTCMonth(); // 0=Jan
229
+ const startYear = m >= 3 ? y : y - 1; // April (m=3) starts a new FY
230
+ const cur = `${startYear}-${startYear + 1}`;
231
+ const prev = `${startYear - 1}-${startYear}`;
232
+ return [cur, prev];
233
+ }
234
+
235
+ // ---- CSV fetch + parse ----------------------------------------------------
236
+
237
+ type CsvRow = Record<string, string>;
238
+
239
+ async function fetchCsv(url: string): Promise<CsvRow[]> {
240
+ const res = await fetch(url, {
241
+ headers: { Accept: 'text/csv', 'User-Agent': UA },
242
+ });
243
+ if (!res.ok) {
244
+ const body = await res
245
+ .text()
246
+ .then((t) => t.slice(0, 200))
247
+ .catch(() => '');
248
+ throw new Error(`CanadaBuys open data: ${res.status} ${body}`.trim());
249
+ }
250
+ const text = await res.text();
251
+ return parseCsv(text);
252
+ }
253
+
254
+ // Minimal RFC-4180 CSV parser: handles quoted fields, escaped quotes ("") and
255
+ // embedded newlines. Strips a UTF-8 BOM. Returns objects keyed by header.
256
+ function parseCsv(text: string): CsvRow[] {
257
+ const records = parseRecords(text);
258
+ if (!records.length) return [];
259
+ const header = records[0].map((h, i) => (i === 0 ? h.replace(/^/, '') : h));
260
+ const rows: CsvRow[] = [];
261
+ for (let i = 1; i < records.length; i++) {
262
+ const rec = records[i];
263
+ if (rec.length === 1 && rec[0] === '') continue; // skip blank trailing line
264
+ const row: CsvRow = {};
265
+ for (let c = 0; c < header.length; c++) row[header[c]] = rec[c] ?? '';
266
+ rows.push(row);
267
+ }
268
+ return rows;
269
+ }
270
+
271
+ function parseRecords(text: string): string[][] {
272
+ const records: string[][] = [];
273
+ let field = '';
274
+ let record: string[] = [];
275
+ let inQuotes = false;
276
+ for (let i = 0; i < text.length; i++) {
277
+ const ch = text[i];
278
+ if (inQuotes) {
279
+ if (ch === '"') {
280
+ if (text[i + 1] === '"') {
281
+ field += '"';
282
+ i++;
283
+ } else {
284
+ inQuotes = false;
285
+ }
286
+ } else {
287
+ field += ch;
288
+ }
289
+ } else if (ch === '"') {
290
+ inQuotes = true;
291
+ } else if (ch === ',') {
292
+ record.push(field);
293
+ field = '';
294
+ } else if (ch === '\n' || ch === '\r') {
295
+ // consume \r\n as a single break
296
+ if (ch === '\r' && text[i + 1] === '\n') i++;
297
+ record.push(field);
298
+ field = '';
299
+ records.push(record);
300
+ record = [];
301
+ } else {
302
+ field += ch;
303
+ }
304
+ }
305
+ // flush trailing field/record if the file doesn't end in a newline
306
+ if (field !== '' || record.length) {
307
+ record.push(field);
308
+ records.push(record);
309
+ }
310
+ return records;
311
+ }
312
+
313
+ // ---- shaping helpers ------------------------------------------------------
314
+
315
+ function pickAny(row: CsvRow, keys: string[]): string | null {
316
+ for (const k of keys) {
317
+ const v = row[k];
318
+ if (typeof v === 'string' && v.trim()) return v.trim();
319
+ }
320
+ return null;
321
+ }
322
+
323
+ const CATEGORY_MAP: Record<string, string> = {
324
+ GD: 'Goods',
325
+ SRV: 'Services',
326
+ CNST: 'Construction',
327
+ SRVTGD: 'Services related to goods',
328
+ };
329
+
330
+ // CanadaBuys-native references start with "cb-" and resolve to a public page.
331
+ function tenderPageUrl(ref: string | null): string | null {
332
+ if (ref && /^cb-/i.test(ref)) {
333
+ return `https://canadabuys.canada.ca/en/tender-opportunities/tender-notice/${encodeURIComponent(
334
+ ref,
335
+ )}`;
336
+ }
337
+ return null;
338
+ }
339
+
340
+ function expandCategory(raw: string | null): string | null {
341
+ if (!raw) return null;
342
+ const code = raw.replace(/^\*/, '').toUpperCase();
343
+ return CATEGORY_MAP[code] ?? raw;
344
+ }
345
+
346
+ function joinNonEmpty(parts: (string | null)[]): string | null {
347
+ const kept = parts.filter((p): p is string => !!p && p.trim().length > 0);
348
+ return kept.length ? kept.join(', ') : null;
349
+ }
350
+
351
+ function truncate(v: string | null, max: number): string | null {
352
+ if (!v) return v;
353
+ return v.length > max ? `${v.slice(0, max).trimEnd()}…` : v;
354
+ }
355
+
356
+ function matches(query: string, fields: (string | null)[]): boolean {
357
+ const q = query.toLowerCase();
358
+ return fields.some((f) => typeof f === 'string' && f.toLowerCase().includes(q));
359
+ }
360
+
361
+ function sortByDateDesc<T extends Record<string, unknown>>(arr: T[], key: string): void {
362
+ arr.sort((a, b) => {
363
+ const av = String(a[key] ?? '');
364
+ const bv = String(b[key] ?? '');
365
+ return bv.localeCompare(av); // ISO dates sort lexicographically
366
+ });
367
+ }
368
+
369
+ function strArg(v: unknown): string | undefined {
370
+ if (typeof v === 'string') {
371
+ const t = v.trim();
372
+ return t ? t : undefined;
373
+ }
374
+ return undefined;
375
+ }
376
+
377
+ function clampLimit(v: unknown): number {
378
+ let n: number | undefined;
379
+ if (typeof v === 'number' && Number.isFinite(v)) n = v;
380
+ else if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v))) n = Number(v);
381
+ if (n === undefined) return DEFAULT_LIMIT;
382
+ return Math.max(1, Math.min(MAX_LIMIT, Math.floor(n)));
383
+ }
384
+
385
+ 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
+ }