@jarib/pxweb-mcp 1.0.0 → 1.0.2

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.
Files changed (2) hide show
  1. package/build/index.js +349 -0
  2. package/package.json +5 -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,17 @@
1
1
  {
2
2
  "name": "@jarib/pxweb-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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": "./build/index.js"
8
+ "@jarib/pxweb-mcp": "build/index.js",
9
+ "pxweb-mcp": "build/index.js"
9
10
  },
10
11
  "scripts": {
11
12
  "build": "tsc",
12
- "start": "node build/index.js"
13
+ "start": "node build/index.js",
14
+ "prepublishOnly": "npm run build"
13
15
  },
14
16
  "files": [
15
17
  "build"