@jarib/pxweb-mcp 1.0.0 → 1.0.1
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/build/index.js +349 -0
- package/package.json +4 -3
package/build/index.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import yargs from "yargs";
|
|
7
|
+
import { hideBin } from "yargs/helpers";
|
|
8
|
+
const DEFAULT_API_BASE = "https://data.ssb.no/api/pxwebapi/v2";
|
|
9
|
+
const DEFAULT_LANGUAGE = "no";
|
|
10
|
+
const USER_AGENT = "pxweb-mcp/1.0";
|
|
11
|
+
const DEFAULT_PORT = 3000;
|
|
12
|
+
const argv = yargs(hideBin(process.argv))
|
|
13
|
+
.option("url", {
|
|
14
|
+
type: "string",
|
|
15
|
+
description: "PxWeb API base URL",
|
|
16
|
+
default: DEFAULT_API_BASE,
|
|
17
|
+
})
|
|
18
|
+
.option("port", {
|
|
19
|
+
type: "number",
|
|
20
|
+
description: "Port to listen on",
|
|
21
|
+
default: DEFAULT_PORT,
|
|
22
|
+
})
|
|
23
|
+
.help()
|
|
24
|
+
.parseSync();
|
|
25
|
+
const languageSchema = z
|
|
26
|
+
.enum(["en", "no"])
|
|
27
|
+
.default(DEFAULT_LANGUAGE)
|
|
28
|
+
.describe("Language - 'no' for Norwegian (default), 'en' for English.");
|
|
29
|
+
async function makeRequest(url) {
|
|
30
|
+
const response = await fetch(url, {
|
|
31
|
+
headers: {
|
|
32
|
+
"User-Agent": USER_AGENT,
|
|
33
|
+
Accept: "application/json",
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
|
38
|
+
}
|
|
39
|
+
return response;
|
|
40
|
+
}
|
|
41
|
+
function createMcpServer(apiBase) {
|
|
42
|
+
const server = new McpServer({
|
|
43
|
+
name: "pxweb-mcp",
|
|
44
|
+
version: "1.0.0",
|
|
45
|
+
});
|
|
46
|
+
// Tool: search_tables
|
|
47
|
+
server.registerTool("search_tables", {
|
|
48
|
+
description: `Search for tables in Statistics Norway (SSB) database.
|
|
49
|
+
|
|
50
|
+
Supports wildcards (*) at end of words, boolean operators (AND, OR), and special filters:
|
|
51
|
+
- title:word - search only in titles
|
|
52
|
+
- updated:20250908* - tables updated on date
|
|
53
|
+
- "word1 word2"~5 - proximity search (words within 5 words of each other)
|
|
54
|
+
|
|
55
|
+
Geographic indicators in titles: (F) = county, (K) = municipality, (B) = city district.`,
|
|
56
|
+
inputSchema: {
|
|
57
|
+
query: z
|
|
58
|
+
.string()
|
|
59
|
+
.describe("Search query. Examples: 'befolkning*', 'title:children AND title:(K)'"),
|
|
60
|
+
language: languageSchema,
|
|
61
|
+
include_discontinued: z
|
|
62
|
+
.boolean()
|
|
63
|
+
.default(false)
|
|
64
|
+
.describe("Include discontinued table series."),
|
|
65
|
+
},
|
|
66
|
+
}, async ({ query, language, include_discontinued }) => {
|
|
67
|
+
const params = new URLSearchParams({
|
|
68
|
+
lang: language,
|
|
69
|
+
query: query.trim(),
|
|
70
|
+
includeDiscontinued: String(include_discontinued),
|
|
71
|
+
});
|
|
72
|
+
const url = `${apiBase}/tables?${params}`;
|
|
73
|
+
try {
|
|
74
|
+
const response = await makeRequest(url);
|
|
75
|
+
const data = await response.json();
|
|
76
|
+
const tables = data.tables || [];
|
|
77
|
+
if (tables.length === 0) {
|
|
78
|
+
return {
|
|
79
|
+
content: [{ type: "text", text: "No tables found for your query." }],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const results = tables.map((t) => `${t.id}: ${t.label}`);
|
|
83
|
+
return {
|
|
84
|
+
content: [{ type: "text", text: results.join("\n") }],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
return {
|
|
89
|
+
content: [
|
|
90
|
+
{
|
|
91
|
+
type: "text",
|
|
92
|
+
text: `Error searching tables: ${error instanceof Error ? error.message : String(error)}`,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
// Tool: get_table_info
|
|
99
|
+
server.registerTool("get_table_info", {
|
|
100
|
+
description: `Get basic information about a table (title, time range, variables).
|
|
101
|
+
|
|
102
|
+
Use this for a quick overview before fetching full metadata.`,
|
|
103
|
+
inputSchema: {
|
|
104
|
+
table_id: z.string().describe("The table ID (e.g. '07459', '11342')."),
|
|
105
|
+
language: languageSchema,
|
|
106
|
+
},
|
|
107
|
+
}, async ({ table_id, language }) => {
|
|
108
|
+
const url = `${apiBase}/tables/${table_id.trim()}?lang=${language}`;
|
|
109
|
+
try {
|
|
110
|
+
const response = await makeRequest(url);
|
|
111
|
+
const text = await response.text();
|
|
112
|
+
return {
|
|
113
|
+
content: [{ type: "text", text }],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
return {
|
|
118
|
+
content: [
|
|
119
|
+
{
|
|
120
|
+
type: "text",
|
|
121
|
+
text: `Error fetching table info: ${error instanceof Error ? error.message : String(error)}`,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
// Tool: fetch_metadata
|
|
128
|
+
server.registerTool("fetch_metadata", {
|
|
129
|
+
description: `Fetch detailed metadata for a table to understand its structure.
|
|
130
|
+
|
|
131
|
+
Returns variable IDs, value codes, elimination info, and available code lists.
|
|
132
|
+
Use this to construct queries.`,
|
|
133
|
+
inputSchema: {
|
|
134
|
+
table_id: z.string().describe("The table ID (e.g. '07459', '11342')."),
|
|
135
|
+
language: languageSchema,
|
|
136
|
+
},
|
|
137
|
+
}, async ({ table_id, language }) => {
|
|
138
|
+
const url = `${apiBase}/tables/${table_id.trim()}/metadata?lang=${language}`;
|
|
139
|
+
try {
|
|
140
|
+
const response = await makeRequest(url);
|
|
141
|
+
const text = await response.text();
|
|
142
|
+
return {
|
|
143
|
+
content: [{ type: "text", text }],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
return {
|
|
148
|
+
content: [
|
|
149
|
+
{
|
|
150
|
+
type: "text",
|
|
151
|
+
text: `Error fetching metadata: ${error instanceof Error ? error.message : String(error)}`,
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
// Tool: query_table
|
|
158
|
+
server.registerTool("query_table", {
|
|
159
|
+
description: `Query data from a table using the v2 API syntax.
|
|
160
|
+
|
|
161
|
+
Value selection syntax:
|
|
162
|
+
- Specific values: valueCodes[Region]=0301,0402
|
|
163
|
+
- All values: valueCodes[Region]=*
|
|
164
|
+
- Wildcard: valueCodes[Konsumgrp]=?? (two-digit codes)
|
|
165
|
+
- Latest N: valueCodes[Tid]=top(5)
|
|
166
|
+
- From value: valueCodes[Tid]=from(2020M01)
|
|
167
|
+
- Range: valueCodes[Region]=[range(01,05)]
|
|
168
|
+
|
|
169
|
+
Output formats: json-stat2, csv, xlsx, html, px, json-px
|
|
170
|
+
|
|
171
|
+
For csv/xlsx/html, use stub and heading to control layout.`,
|
|
172
|
+
inputSchema: {
|
|
173
|
+
table_id: z.string().describe("The table ID to query."),
|
|
174
|
+
value_codes: z
|
|
175
|
+
.record(z.string(), z.string())
|
|
176
|
+
.describe("Object mapping variable IDs to value selections. Example: { Region: '0301', Tid: 'top(5)', ContentsCode: '*' }"),
|
|
177
|
+
language: languageSchema,
|
|
178
|
+
output_format: z
|
|
179
|
+
.enum(["json-stat2", "csv", "xlsx", "html", "px", "json-px"])
|
|
180
|
+
.default("json-stat2")
|
|
181
|
+
.describe("Output format."),
|
|
182
|
+
code_list: z
|
|
183
|
+
.record(z.string(), z.string())
|
|
184
|
+
.optional()
|
|
185
|
+
.describe("Optional code lists to use. Example: { Region: 'agg_Fylker2024' }"),
|
|
186
|
+
output_values: z
|
|
187
|
+
.record(z.string(), z.enum(["aggregated", "single"]))
|
|
188
|
+
.optional()
|
|
189
|
+
.describe("For groupings: 'aggregated' for sums, 'single' for individual values."),
|
|
190
|
+
},
|
|
191
|
+
}, async ({ table_id, value_codes, language, output_format, code_list, output_values }) => {
|
|
192
|
+
const params = new URLSearchParams({ lang: language, outputFormat: output_format });
|
|
193
|
+
for (const [key, value] of Object.entries(value_codes)) {
|
|
194
|
+
params.append(`valueCodes[${key}]`, value);
|
|
195
|
+
}
|
|
196
|
+
if (code_list) {
|
|
197
|
+
for (const [key, value] of Object.entries(code_list)) {
|
|
198
|
+
params.append(`codelist[${key}]`, value);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (output_values) {
|
|
202
|
+
for (const [key, value] of Object.entries(output_values)) {
|
|
203
|
+
params.append(`outputValues[${key}]`, value);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const url = `${apiBase}/tables/${table_id.trim()}/data?${params}`;
|
|
207
|
+
try {
|
|
208
|
+
const response = await fetch(url, {
|
|
209
|
+
headers: {
|
|
210
|
+
"User-Agent": USER_AGENT,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
|
215
|
+
}
|
|
216
|
+
const text = await response.text();
|
|
217
|
+
return {
|
|
218
|
+
content: [{ type: "text", text }],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
return {
|
|
223
|
+
content: [
|
|
224
|
+
{
|
|
225
|
+
type: "text",
|
|
226
|
+
text: `Error querying table: ${error instanceof Error ? error.message : String(error)}`,
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
// Tool: get_code_list
|
|
233
|
+
server.registerTool("get_code_list", {
|
|
234
|
+
description: `Fetch a code list (valueset or grouping).
|
|
235
|
+
|
|
236
|
+
Valuesets (vs_*): Lists of valid values for a variable.
|
|
237
|
+
Groupings (agg_*): Aggregation mappings (e.g., municipality mergers).
|
|
238
|
+
|
|
239
|
+
Find available code lists in table metadata under 'codeLists'.`,
|
|
240
|
+
inputSchema: {
|
|
241
|
+
code_list_id: z
|
|
242
|
+
.string()
|
|
243
|
+
.describe("Code list ID (e.g. 'vs_Fylker', 'agg_KommSummer')."),
|
|
244
|
+
language: languageSchema,
|
|
245
|
+
},
|
|
246
|
+
}, async ({ code_list_id, language }) => {
|
|
247
|
+
const url = `${apiBase}/codeLists/${code_list_id.trim()}?lang=${language}`;
|
|
248
|
+
try {
|
|
249
|
+
const response = await makeRequest(url);
|
|
250
|
+
const text = await response.text();
|
|
251
|
+
return {
|
|
252
|
+
content: [{ type: "text", text }],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
return {
|
|
257
|
+
content: [
|
|
258
|
+
{
|
|
259
|
+
type: "text",
|
|
260
|
+
text: `Error fetching code list: ${error instanceof Error ? error.message : String(error)}`,
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
// Tool: list_recent_tables
|
|
267
|
+
server.registerTool("list_recent_tables", {
|
|
268
|
+
description: `List tables updated in the past N days.
|
|
269
|
+
|
|
270
|
+
Use this to find newly published statistics.`,
|
|
271
|
+
inputSchema: {
|
|
272
|
+
days: z.number().int().min(1).max(365).describe("Number of days to look back."),
|
|
273
|
+
language: languageSchema,
|
|
274
|
+
},
|
|
275
|
+
}, async ({ days, language }) => {
|
|
276
|
+
const params = new URLSearchParams({
|
|
277
|
+
lang: language,
|
|
278
|
+
pastdays: String(days),
|
|
279
|
+
});
|
|
280
|
+
const url = `${apiBase}/tables?${params}`;
|
|
281
|
+
try {
|
|
282
|
+
const response = await makeRequest(url);
|
|
283
|
+
const data = await response.json();
|
|
284
|
+
const tables = data.tables || [];
|
|
285
|
+
if (tables.length === 0) {
|
|
286
|
+
return {
|
|
287
|
+
content: [{ type: "text", text: `No tables updated in the past ${days} days.` }],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const results = tables.map((t) => `${t.id}: ${t.label} (updated: ${t.updated})`);
|
|
291
|
+
return {
|
|
292
|
+
content: [{ type: "text", text: results.join("\n") }],
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
return {
|
|
297
|
+
content: [
|
|
298
|
+
{
|
|
299
|
+
type: "text",
|
|
300
|
+
text: `Error listing recent tables: ${error instanceof Error ? error.message : String(error)}`,
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
return server;
|
|
307
|
+
}
|
|
308
|
+
async function main() {
|
|
309
|
+
const { url, port } = argv;
|
|
310
|
+
const mcpServer = createMcpServer(url);
|
|
311
|
+
const transport = new StreamableHTTPServerTransport({
|
|
312
|
+
sessionIdGenerator: () => crypto.randomUUID(),
|
|
313
|
+
});
|
|
314
|
+
const httpServer = createServer(async (req, res) => {
|
|
315
|
+
const pathname = req.url?.split("?")[0] || "/";
|
|
316
|
+
// Health check endpoint
|
|
317
|
+
if (pathname === "/health") {
|
|
318
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
319
|
+
res.end(JSON.stringify({ status: "ok" }));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
// MCP endpoint
|
|
323
|
+
if (pathname === "/mcp") {
|
|
324
|
+
try {
|
|
325
|
+
await transport.handleRequest(req, res);
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
console.error("MCP error:", error);
|
|
329
|
+
if (!res.headersSent) {
|
|
330
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
331
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
// Not found
|
|
337
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
338
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
339
|
+
});
|
|
340
|
+
await mcpServer.connect(transport);
|
|
341
|
+
httpServer.listen(port, () => {
|
|
342
|
+
console.error(`PxWeb MCP Server running on http://localhost:${port}/mcp`);
|
|
343
|
+
console.error(`Using API: ${url}`);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
main().catch((error) => {
|
|
347
|
+
console.error("Fatal error:", error);
|
|
348
|
+
process.exit(1);
|
|
349
|
+
});
|
package/package.json
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarib/pxweb-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "MCP server for accessing statistical data via the PxWeb API v2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "build/index.js",
|
|
7
7
|
"bin": {
|
|
8
|
-
"pxweb-mcp": "
|
|
8
|
+
"pxweb-mcp": "build/index.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "tsc",
|
|
12
|
-
"start": "node build/index.js"
|
|
12
|
+
"start": "node build/index.js",
|
|
13
|
+
"prepublishOnly": "npm run build"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
15
16
|
"build"
|