@teryt24/mcp 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,70 @@
1
- # Temporary Holding Version
1
+ # @teryt24/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) stdio server
4
+ for the [teryt24.pl API](https://teryt24.pl/docs) — twelve tools that let
5
+ an agent search Polish localities, streets and postal codes, validate and
6
+ geocode addresses, and read the TERYT change feed.
7
+
8
+ - **Runs in the client's process.** stdio transport; the API key comes
9
+ from an environment variable and never leaves the machine.
10
+ - **Results pass through verbatim.** A tool returns the API's own JSON —
11
+ no re-shaping — so the API's documentation applies unchanged, including
12
+ the `{error, message, code, details}` error envelope.
13
+ - **Built on `@teryt24/api`.** The tools call the official client, so the
14
+ SDK and the server move together.
15
+
16
+ ## Install & register
17
+
18
+ ```bash
19
+ npm install -g @teryt24/mcp # or just use npx, below
20
+ ```
21
+
22
+ Point your MCP host at it (Claude-style config; the same shape works for
23
+ other hosts):
24
+
25
+ ```json
26
+ {
27
+ "mcpServers": {
28
+ "teryt24": {
29
+ "command": "npx",
30
+ "args": ["-y", "@teryt24/mcp"],
31
+ "env": { "TERYT24_API_KEY": "t24_..." }
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ Environment:
38
+
39
+ | Variable | Meaning |
40
+ |---|---|
41
+ | `TERYT24_API_KEY` | Optional. The key sent as `X-API-Key`; keyless calls share a 60 req/min/IP bucket. |
42
+ | `TERYT24_API_BASE` | Optional. The API base URL (default `https://teryt24.pl`) — for self-hosted instances. |
43
+
44
+ ## The tools
45
+
46
+ | Tool | Answers |
47
+ |---|---|
48
+ | `search_localities` | Which places match a name (or lie near a coordinate) — GUS SIMC, ≈54k entries. |
49
+ | `search_streets` | Streets by name — within one locality, or country-wide. |
50
+ | `search_postal_codes` | Postal codes by prefix, by locality, or nearest to a coordinate. |
51
+ | `validate_address` | A written address → structured fields, per-field match kinds, a verdict and the issues. |
52
+ | `geocode` | An address → building coordinates at house-number precision (GUGiK PRG). |
53
+ | `reverse_geocode` | A coordinate → the nearest address. |
54
+ | `get_locality` | One locality's full record (hierarchy, postal codes, street count, figures). |
55
+ | `get_street` | One street's full record (every locality it is registered in, PRG aggregates). |
56
+ | `get_postal_code` | One postal code's full record (localities served, PRG aggregates). |
57
+ | `list_regions` | The TERC hierarchy: 16 voivodeships, or one voivodeship's counties/municipalities. |
58
+ | `get_region_stats` | A unit's statistics: the demographic decomposition (voivodeship) or the 2021 census. |
59
+ | `list_changes` | What the official registers changed, and when (the TERYT change feed). |
60
+
61
+ Input arguments use the same names as the API's query parameters —
62
+ [`/docs`](https://teryt24.pl/docs) documents every one of them, including
63
+ the search semantics (diacritics-insensitive matching, disambiguation by
64
+ TERC codes, the `offset` ≤ 10 000 paging bound).
65
+
66
+ ## Attribution
67
+
68
+ When results are displayed to users, attribute the data — `Data: GUS
69
+ TERYT, PRG address points (GUGiK), GeoNames postal codes — via
70
+ teryt24.pl` — per the [usage policy](https://teryt24.pl/usage-policy).
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The stdio MCP server entry point (`@teryt24/mcp`).
4
+ *
5
+ * Runs IN THE CLIENT'S PROCESS (an editor, an agent host, a CI job): it
6
+ * reads the API key from the environment and talks to teryt24.pl over
7
+ * HTTPS through the `@teryt24/api` client. The key never leaves the
8
+ * customer's machine, and the API service gains no new attack surface —
9
+ * the server is just another HTTPS client of /v1.
10
+ *
11
+ * Environment:
12
+ * TERYT24_API_KEY the API key sent as X-API-Key (optional; keyless
13
+ * calls share the 60 req/min/IP bucket)
14
+ * TERYT24_API_BASE an API base URL override (default
15
+ * https://teryt24.pl — for self-hosted instances)
16
+ *
17
+ * Register with an MCP host, e.g. a Claude-style config:
18
+ *
19
+ * { "mcpServers": { "teryt24": {
20
+ * "command": "npx", "args": ["-y", "@teryt24/mcp"],
21
+ * "env": { "TERYT24_API_KEY": "t24_..." } } } }
22
+ */
23
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
24
+ import { Teryt24Api } from "@teryt24/api";
25
+ /**
26
+ * Builds the server around a client. Exported for the test suite (the
27
+ * InMemory transport wires a test client to it without spawning a
28
+ * process) — the stdio entry point below is the only thing `bin` runs.
29
+ */
30
+ export declare function buildMcpServer(client: Teryt24Api): McpServer;
package/dist/index.js ADDED
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The stdio MCP server entry point (`@teryt24/mcp`).
4
+ *
5
+ * Runs IN THE CLIENT'S PROCESS (an editor, an agent host, a CI job): it
6
+ * reads the API key from the environment and talks to teryt24.pl over
7
+ * HTTPS through the `@teryt24/api` client. The key never leaves the
8
+ * customer's machine, and the API service gains no new attack surface —
9
+ * the server is just another HTTPS client of /v1.
10
+ *
11
+ * Environment:
12
+ * TERYT24_API_KEY the API key sent as X-API-Key (optional; keyless
13
+ * calls share the 60 req/min/IP bucket)
14
+ * TERYT24_API_BASE an API base URL override (default
15
+ * https://teryt24.pl — for self-hosted instances)
16
+ *
17
+ * Register with an MCP host, e.g. a Claude-style config:
18
+ *
19
+ * { "mcpServers": { "teryt24": {
20
+ * "command": "npx", "args": ["-y", "@teryt24/mcp"],
21
+ * "env": { "TERYT24_API_KEY": "t24_..." } } } }
22
+ */
23
+ import { pathToFileURL } from "node:url";
24
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
25
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
26
+ import { Teryt24Api, SDK_VERSION } from "@teryt24/api";
27
+ import { toolDefinitions } from "./tools.js";
28
+ /** The tool result: the API's JSON, verbatim, as the one text content. */
29
+ function jsonResult(body) {
30
+ return {
31
+ content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
32
+ };
33
+ }
34
+ /**
35
+ * Builds the server around a client. Exported for the test suite (the
36
+ * InMemory transport wires a test client to it without spawning a
37
+ * process) — the stdio entry point below is the only thing `bin` runs.
38
+ */
39
+ export function buildMcpServer(client) {
40
+ const server = new McpServer({ name: "teryt24", version: SDK_VERSION }, {
41
+ instructions: "Polish registry & geo data (GUS TERYT, GUGiK PRG, GeoNames postal): " +
42
+ "localities, streets, postal codes, address validation and geocoding. " +
43
+ "Start from search_localities / search_streets / search_postal_codes; " +
44
+ "validate_address answers a written address with a verdict and the " +
45
+ "issues to fix. Tool results are the API's own JSON, unchanged — the " +
46
+ "`code`/`message`/`details` fields of an error are the published " +
47
+ "error contract, and null means 'unknown', never zero.",
48
+ });
49
+ for (const tool of toolDefinitions()) {
50
+ server.registerTool(tool.name, {
51
+ title: tool.title,
52
+ description: tool.description,
53
+ inputSchema: tool.inputSchema,
54
+ }, async (args) => {
55
+ try {
56
+ const body = await tool.call(client, args ?? {});
57
+ return jsonResult(body);
58
+ }
59
+ catch (error) {
60
+ // An API error is DATA here: the verbatim error envelope
61
+ // ({error, message, code, details}) tells the agent which
62
+ // branch to take — retry a 503, fix the arguments on a 400.
63
+ // Inventing a prose wrapper would only blur it.
64
+ const isApiError = error instanceof Error && error.name === "Teryt24ApiError";
65
+ if (isApiError) {
66
+ const apiError = error;
67
+ return {
68
+ isError: true,
69
+ content: [
70
+ {
71
+ type: "text",
72
+ text: JSON.stringify(apiError.body ?? { message: error.message }, null, 2),
73
+ },
74
+ ],
75
+ };
76
+ }
77
+ throw error;
78
+ }
79
+ });
80
+ }
81
+ return server;
82
+ }
83
+ async function main() {
84
+ const apiKey = process.env.TERYT24_API_KEY;
85
+ const baseUrl = process.env.TERYT24_API_BASE;
86
+ const client = new Teryt24Api({
87
+ ...(apiKey ? { apiKey } : {}),
88
+ ...(baseUrl ? { baseUrl } : {}),
89
+ });
90
+ const server = buildMcpServer(client);
91
+ await server.connect(new StdioServerTransport());
92
+ // stdio serves one session for the life of the process; nothing to
93
+ // await after the transport is wired.
94
+ }
95
+ // Only the executable entry point serves stdio; the import for tests
96
+ // must not open a transport.
97
+ const invokedDirectly = process.argv[1] !== undefined &&
98
+ import.meta.url === pathToFileURL(process.argv[1]).href;
99
+ if (invokedDirectly) {
100
+ await main();
101
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The MCP server for the teryt24.pl API (`@teryt24/mcp`) — twelve tools,
3
+ * one per surface an integration question touches, all riding the
4
+ * `@teryt24/api` client so the shapes are shared by construction.
5
+ *
6
+ * Two rules the plan holds this server to:
7
+ *
8
+ * - **Result JSON passes through VERBATIM.** A tool returns the API's own
9
+ * response body, stringified — no re-shaping, no field renaming, no
10
+ * invented summary fields. An agent reading a tool result reads exactly
11
+ * what `GET /v1/...` answers, so the API's documentation (and the
12
+ * `code`/`details` error contract) applies to tool output unchanged.
13
+ * - **It runs in the client's process.** stdio transport, the key from an
14
+ * environment variable — the key never leaves the customer's machine,
15
+ * and the API service gains no attack surface.
16
+ *
17
+ * The input schemas mirror the HTTP query parameters by NAME (a tool
18
+ * argument is the same word the API documents), so an agent can move
19
+ * between the /docs reference and the tools without a translation layer.
20
+ * The one ergonomic fold: `list_regions` takes an optional `code`, so the
21
+ * regions family's three list operations (the 16 voivodeships, one
22
+ * voivodeship's counties, one county's municipalities) ride one tool —
23
+ * the description spells the mapping out, and the responses are as
24
+ * verbatim as every other tool's.
25
+ */
26
+ import { z } from "zod";
27
+ import type { Teryt24Api } from "@teryt24/api";
28
+ export interface McpToolDefinition {
29
+ name: string;
30
+ /** One line for a picker; the description carries the guidance. */
31
+ title: string;
32
+ description: string;
33
+ inputSchema: Record<string, z.ZodType>;
34
+ call: (client: Teryt24Api, args: Record<string, unknown>) => Promise<unknown>;
35
+ }
36
+ /** The twelve tools. Order is the /docs reference family order. */
37
+ export declare function toolDefinitions(): McpToolDefinition[];
package/dist/tools.js ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * The MCP server for the teryt24.pl API (`@teryt24/mcp`) — twelve tools,
3
+ * one per surface an integration question touches, all riding the
4
+ * `@teryt24/api` client so the shapes are shared by construction.
5
+ *
6
+ * Two rules the plan holds this server to:
7
+ *
8
+ * - **Result JSON passes through VERBATIM.** A tool returns the API's own
9
+ * response body, stringified — no re-shaping, no field renaming, no
10
+ * invented summary fields. An agent reading a tool result reads exactly
11
+ * what `GET /v1/...` answers, so the API's documentation (and the
12
+ * `code`/`details` error contract) applies to tool output unchanged.
13
+ * - **It runs in the client's process.** stdio transport, the key from an
14
+ * environment variable — the key never leaves the customer's machine,
15
+ * and the API service gains no attack surface.
16
+ *
17
+ * The input schemas mirror the HTTP query parameters by NAME (a tool
18
+ * argument is the same word the API documents), so an agent can move
19
+ * between the /docs reference and the tools without a translation layer.
20
+ * The one ergonomic fold: `list_regions` takes an optional `code`, so the
21
+ * regions family's three list operations (the 16 voivodeships, one
22
+ * voivodeship's counties, one county's municipalities) ride one tool —
23
+ * the description spells the mapping out, and the responses are as
24
+ * verbatim as every other tool's.
25
+ */
26
+ import { z } from "zod";
27
+ /** Coerce-free integer: the API's limit/offset bounds, restated. */
28
+ const limitField = (max, def) => z.number().int().min(1).max(max).optional().describe(`Maximum results (1–${max}, default ${def}).`);
29
+ const offsetField = z
30
+ .number()
31
+ .int()
32
+ .min(0)
33
+ .max(10_000)
34
+ .optional()
35
+ .describe("Skip the first N results (0-based, max 10000) to page past the limit.");
36
+ const tercDetail = "TERC code, the length naming the level: 2 digits for a voivodeship ('14'), 4 for a county ('1401'), 6 for a municipality ('140101').";
37
+ /** The twelve tools. Order is the /docs reference family order. */
38
+ export function toolDefinitions() {
39
+ return [
40
+ {
41
+ name: "search_localities",
42
+ title: "Search Polish localities",
43
+ description: "Search Poland's localities — cities, towns, villages (the GUS SIMC register, ≈54k entries). " +
44
+ "Answers the questions 'which voivodeship/county is X in', 'how many places are named X', 'what is near these coordinates'. " +
45
+ "Same-named localities are all returned; disambiguate by province/county (TERC codes, e.g. province '14' = Mazowieckie) or by population. " +
46
+ "Geographic mode (lat+lon): nearest-first with distanceKm. Rows carry the SYM id used by get_locality, geocode and validate_address.",
47
+ inputSchema: {
48
+ q: z.string().optional().describe("Name query; diacritics- and punctuation-insensitive ('wroclaw' finds 'Wrocław'). Optional in geographic mode or with a province/county/municipality/population filter."),
49
+ province: z.string().optional().describe("Optional 2-digit TERC voivodeship code filter (e.g. '14')."),
50
+ county: z.string().optional().describe("Optional 4-digit TERC code filter (WOJ+POW, e.g. '1465')."),
51
+ municipality: z.string().optional().describe("Optional 6-digit TERC code filter (WOJ+POW+GMI, e.g. '146503')."),
52
+ kind: z.enum(["urban"]).optional().describe("'urban' selects the urban units (miasto + Warszawa's dzielnica + delegatury) — the reliable 'cities' filter."),
53
+ rm: z.string().optional().describe("Optional rodzaj filter: a 2-digit RM code ('01' wieś, '96' miasto) or a comma-separated list ('96,95,98')."),
54
+ minPopulation: z.number().int().optional().describe("Optional inhabitants lower bound (unknown population is excluded)."),
55
+ maxPopulation: z.number().int().optional().describe("Optional inhabitants upper bound (unknown population is excluded)."),
56
+ lat: z.number().min(-90).max(90).optional().describe("WGS84 latitude; with lon triggers geographic (nearest-first) mode."),
57
+ lon: z.number().min(-180).max(180).optional().describe("WGS84 longitude; must be given together with lat."),
58
+ radiusKm: z.number().min(0).max(100).optional().describe("Geographic-mode radius in kilometres (default 10, max 100)."),
59
+ aliases: z.boolean().optional().describe("Opt-in exonym fallback: 'Danzig' finds Gdańsk when the name search came up empty."),
60
+ mode: z.enum(["autocomplete", "resolve", "hybrid"]).optional().describe("autocomplete = prefix-first, resolve = exact only, hybrid = combined. Ignored in geographic mode."),
61
+ limit: limitField(50, 10),
62
+ offset: offsetField,
63
+ },
64
+ call: (client, args) => client.searchLocalities(validated(normalizeArgs(args))),
65
+ },
66
+ {
67
+ name: "search_streets",
68
+ title: "Search Polish streets",
69
+ description: "Search streets (the GUS ULIC register, ≈310k). Within one locality (city = a SYM from search_localities): the street list of that locality. " +
70
+ "Without city: country-wide — 'which localities have a street with this name?' — rows then carry the locality and inflated province/county so repeated names disambiguate. " +
71
+ "Diacritics-insensitive; matches the name without its 'ul.'/'al.' prefix.",
72
+ inputSchema: {
73
+ city: z.string().optional().describe("Locality SYM (7 digits, from search_localities). Omit (with q) for a country-wide search."),
74
+ q: z.string().optional().describe("Street-name prefix ('rynek' finds 'ul. Rynek'). Required for a country-wide search; optional within a city."),
75
+ sort: z.enum(["name", "prominence"]).optional().describe("'prominence' ranks by real-world volume (PRG address points first) instead of the name key."),
76
+ limit: limitField(50, 10),
77
+ offset: offsetField,
78
+ },
79
+ call: (client, args) => client.searchStreets(validated(normalizeArgs(args))),
80
+ },
81
+ {
82
+ name: "search_postal_codes",
83
+ title: "Search Polish postal codes",
84
+ description: "Search Polish postal codes (PNA). Three modes, exactly one of: `q` (a code prefix: '59-7', '59700'), `locality` (a SYM: the codes officially serving one locality), or `lat`+`lon` (nearest codes by PRG centroid, with distanceKm). " +
85
+ "Rows carry the TERC-inflated hierarchy (province/county/municipality) and — when the row resolves to one locality — its SYM, type and population.",
86
+ inputSchema: {
87
+ q: z.string().optional().describe("Postal-code prefix in any form: '00-950', '00950', '00-9', '009'."),
88
+ locality: z.string().optional().describe("Locality SYM (7 digits): the codes serving it, one row per code."),
89
+ lat: z.number().min(-90).max(90).optional().describe("WGS84 latitude; with lon triggers nearest mode."),
90
+ lon: z.number().min(-180).max(180).optional().describe("WGS84 longitude; must be given together with lat."),
91
+ limit: limitField(50, 10),
92
+ offset: offsetField,
93
+ },
94
+ call: (client, args) => client.searchPostalCodes(validated(normalizeArgs(args))),
95
+ },
96
+ {
97
+ name: "validate_address",
98
+ title: "Validate a Polish address",
99
+ description: "Validate a Polish address against the official registers and get a structured answer: the corrected address fields (locality, street, house number, unit, postal code, coordinates) with registry identifiers (SYM, SYM_UL, TERC), " +
100
+ "a per-field match kind (exact / normalized / partial / ambiguous / not-found), a VERDICT (valid / corrected / ambiguous / incomplete / invalid) and the issues explaining every discrepancy. " +
101
+ "Accept three input forms: `address` (one free-text string), or `sym`/`locality` + `street`/`streetName` + `number`. The verdicts come from the same pipeline geocode uses, so the two never disagree. " +
102
+ "A fuzzy-tier hit is never reported as confirmed, and a value the register did not confirm is EMPTY — never a plausible guess.",
103
+ inputSchema: {
104
+ address: z.string().optional().describe("Free-text Polish address ('Wrocław, ul. Eugeniusza Horbaczewskiego 35'), parsed into locality/street/number. Mutually exclusive with the fielded form."),
105
+ sym: z.string().optional().describe("Locality SYM (7 digits, from search_localities) for the fielded form. Mutually exclusive with locality and address."),
106
+ locality: z.string().optional().describe("Locality NAME (diacritics-insensitive) for the structured-by-name form. Several matches → verdict 'ambiguous' with a candidate shortlist."),
107
+ postalCode: z.string().optional().describe("The caller's claim about the postal code — VERIFIED against the matched building point's own code; a disagreement is reported as the postal_code_mismatch issue."),
108
+ number: z.string().optional().describe("House number ('21A'). Optional here: a missing number makes the address INCOMPLETE, not invalid."),
109
+ street: z.string().optional().describe("Street SYM_UL (5 digits, from search_streets). Mutually exclusive with streetName."),
110
+ streetName: z.string().optional().describe("Street-name prefix ('rynek' finds 'ul. Rynek'). Several matches → verdict 'ambiguous' with candidates."),
111
+ limit: limitField(50, 10),
112
+ },
113
+ call: (client, args) => client.validate(validated(normalizeArgs(args))),
114
+ },
115
+ {
116
+ name: "geocode",
117
+ title: "Geocode a Polish address",
118
+ description: "Resolve a Polish address to building coordinates (WGS84) at house-number precision, from the GUGiK PRG address points (~8.6M rows). " +
119
+ "Same three input forms as validate_address (free-text `address`, `sym`+street, or `locality` name); several matches of a street/locality name answer 'ambiguous' with candidates rather than picking one. " +
120
+ "Use validate_address when you need the verdict and the issues; use geocode when you need the point.",
121
+ inputSchema: {
122
+ address: z.string().optional().describe("Free-text Polish address; parsed into locality/street/number internally."),
123
+ sym: z.string().optional().describe("Locality SYM (7 digits). Required unless address or locality is given."),
124
+ locality: z.string().optional().describe("Locality NAME instead of a SYM — resolved through the locality index; several matches → result 'locality-ambiguous' with a shortlist."),
125
+ postalCode: z.string().optional().describe("Optional narrowing hint for the locality mode: only localities the code officially serving stay candidates."),
126
+ number: z.string().optional().describe("House number ('21A'). Required unless address is given."),
127
+ street: z.string().optional().describe("Street SYM_UL (5 digits). Omit for rural 'locality + number' addresses."),
128
+ streetName: z.string().optional().describe("Street-name prefix. Several matches → result 'street-ambiguous' with candidates."),
129
+ limit: limitField(50, 10),
130
+ },
131
+ call: (client, args) => client.geocode(validated(normalizeArgs(args))),
132
+ },
133
+ {
134
+ name: "reverse_geocode",
135
+ title: "Reverse-geocode a coordinate",
136
+ description: "Given a WGS84 coordinate, return the nearest Polish address(es) — the PRG address points within 128 km, distance-sorted with distanceKm, each with street, locality, house number and postal code. " +
137
+ "Where no address is near enough, the nearest localities from the registry are offered (result 'locality') instead of a wrong pin.",
138
+ inputSchema: {
139
+ lat: z.number().min(-90).max(90).describe("WGS84 latitude."),
140
+ lon: z.number().min(-180).max(180).describe("WGS84 longitude."),
141
+ limit: limitField(50, 5),
142
+ },
143
+ call: (client, args) => client.reverseGeocode(validated(normalizeArgs(args))),
144
+ },
145
+ {
146
+ name: "get_locality",
147
+ title: "Get one locality",
148
+ description: "Full record of one Polish locality by SYM: identity and administrative hierarchy (province → county → municipality, TERC codes and names), the postal codes serving it, its registered street count, " +
149
+ "and — where published — its population figures and 2021 census blocks. Use search_localities first when you have a name, not an id.",
150
+ inputSchema: {
151
+ sym: z.string().describe("Locality SYM (7 digits, from search_localities)."),
152
+ },
153
+ call: (client, args) => client.locality(requireString(args, "sym")),
154
+ },
155
+ {
156
+ name: "get_street",
157
+ title: "Get one street",
158
+ description: "Full record of one street by SYM_UL: the display name and type (cecha), every locality where that street id is registered (with hierarchy and coordinates), and the PRG aggregates (address points, postal codes, centroid, house numbers) per occurrence.",
159
+ inputSchema: {
160
+ symUl: z.string().describe("Street SYM_UL (5 digits, from search_streets rows' id)."),
161
+ },
162
+ call: (client, args) => client.street(requireString(args, "symUl")),
163
+ },
164
+ {
165
+ name: "get_postal_code",
166
+ title: "Get one postal code",
167
+ description: "Full record of one Polish postal code: every locality the code serves (TERC-inflated), plus the official PRG aggregates — building count, distinct streets, weighted centroid, and a street-name sample. " +
168
+ "Accepts the display ('59-700') or digits ('59700') form.",
169
+ inputSchema: {
170
+ code: z.string().describe("Postal code, '59-700' or '59700'."),
171
+ },
172
+ call: (client, args) => client.postalCode(requireString(args, "code")),
173
+ },
174
+ {
175
+ name: "list_regions",
176
+ title: "List regions (TERC hierarchy)",
177
+ description: "The administrative hierarchy from the TERC register. With NO arguments: the 16 voivodeships. With `code` (a 2-digit voivodeship TERC, e.g. '14'): that voivodeship's counties (miasta na prawach powiatu included; pass kind='municipalities' for its municipalities, optionally narrowed with `county`). " +
178
+ "Rows carry the verbatim TERC names and codes the other tools' province/county/municipality filters take.",
179
+ inputSchema: {
180
+ code: z.string().optional().describe("Optional 2-digit voivodeship TERC (e.g. '14'): lists that voivodeship's counties instead of all voivodeships."),
181
+ kind: z.enum(["counties", "municipalities"]).optional().describe("With code: 'counties' (default) or 'municipalities' (gminy)."),
182
+ county: z.string().optional().describe("With code and kind='municipalities': optional 2-digit county code to narrow to one county."),
183
+ },
184
+ call: async (client, args) => {
185
+ const code = typeof args.code === "string" ? args.code : undefined;
186
+ if (code === undefined) {
187
+ return client.regions();
188
+ }
189
+ if (args.kind === "municipalities") {
190
+ const county = typeof args.county === "string" ? args.county : undefined;
191
+ return client.regionMunicipalities(code, county ? { county } : {});
192
+ }
193
+ return client.regionCounties(code);
194
+ },
195
+ },
196
+ {
197
+ name: "get_region_stats",
198
+ title: "Get region statistics",
199
+ description: "Statistics for one administrative unit by TERC code. `kind` selects the measurement: 'demography' (default) = the voivodeship's demographic decomposition — natural balance (births − deaths), net internal and net foreign migration per year, reconciled against the population series, with the residual returned as it stands; 'census' = the NSP 2021 census blocks (economic activity, dwellings, buildings, households) for a voivodeship (2-digit code, summed over its counties), county (4) or municipality (6). " +
200
+ "A figure the register does not publish is null — never zero.",
201
+ inputSchema: {
202
+ code: z.string().describe(tercDetail),
203
+ kind: z.enum(["demography", "census"]).optional().describe("'demography' (voivodeship codes only) or 'census' (all three levels). Default: demography."),
204
+ },
205
+ call: (client, args) => {
206
+ const code = requireString(args, "code");
207
+ return args.kind === "census" ? client.regionCensus(code) : client.regionDemography(code);
208
+ },
209
+ },
210
+ {
211
+ name: "list_changes",
212
+ title: "List TERYT registry changes",
213
+ description: "The change feed of Poland's official registers: what GUS published in its TERYT update files — additions, modifications, moves, renames, orderings ('reordered' — a FORMATTING change, safe to filter out) and deletions, per registry (terc/simc/ulic), with the catalogue states (StanPrzed/StanPo) each side belongs to. " +
214
+ "A `deleted` row means the object left the catalogue — NOT that an address became invalid. Page with limit/offset; narrow with since/until (YYYY-MM-DD catalogue states) and dataset/type.",
215
+ inputSchema: {
216
+ since: z.string().optional().describe("Start catalogue state (YYYY-MM-DD): changes whose 'after' state is on or after this date."),
217
+ until: z.string().optional().describe("End catalogue state (YYYY-MM-DD), inclusive."),
218
+ dataset: z.enum(["terc", "simc", "ulic"]).optional().describe("The registry: terc (administrative units) | simc (localities) | ulic (streets). All three when omitted."),
219
+ type: z.string().optional().describe("Change type(s), comma-separated: added, modified, moved, renamed, reordered, deleted."),
220
+ limit: z.number().int().min(1).max(200).optional().describe("Maximum changes per page (1–200, default 50)."),
221
+ offset: offsetField,
222
+ },
223
+ call: (client, args) => client.changes(validated(normalizeArgs(args))),
224
+ },
225
+ ];
226
+ }
227
+ /** The arg names MCP hands over → the client's query objects, dropping empties. */
228
+ function normalizeArgs(args) {
229
+ const out = {};
230
+ for (const [key, value] of Object.entries(args)) {
231
+ if (value === undefined || value === null || value === "")
232
+ continue;
233
+ out[key] = value;
234
+ }
235
+ return out;
236
+ }
237
+ /**
238
+ * The bridge between the MCP layer and the typed client: `registerTool`
239
+ * validates `args` against the tool's zod schema BEFORE the handler runs,
240
+ * so by the time `call` executes the shape is guaranteed — this cast
241
+ * records that guarantee, it does not create it.
242
+ */
243
+ function validated(args) {
244
+ return args;
245
+ }
246
+ function requireString(args, key) {
247
+ const value = args[key];
248
+ if (typeof value !== "string" || value.length === 0) {
249
+ throw new TypeError(`@teryt24/mcp: "${key}" is required`);
250
+ }
251
+ return value;
252
+ }
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@teryt24/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "MCP (Model Context Protocol) stdio server for the teryt24.pl API — 12 tools over the official @teryt24/api client: localities, streets, postal codes, address validation, geocoding, regions and the TERYT change feed.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "bin": {
9
+ "teryt24-mcp": "./dist/index.js"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "keywords": [
22
+ "mcp",
23
+ "model-context-protocol",
24
+ "poland",
25
+ "teryt",
26
+ "geocoding",
27
+ "address",
28
+ "llm",
29
+ "agent"
30
+ ],
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/dev-softify/teryt24-pl.git"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.32.2",
41
+ "@teryt24/api": "^0.1.1",
42
+ "zod": "^4.3.5"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ }
47
+ }