@cyanheads/ris-austria-mcp-server 0.4.0 → 0.4.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 (36) hide show
  1. package/AGENTS.md +86 -88
  2. package/CLAUDE.md +86 -88
  3. package/Dockerfile +11 -6
  4. package/README.md +55 -7
  5. package/changelog/0.4.x/0.4.1.md +15 -0
  6. package/changelog/0.4.x/0.4.2.md +26 -0
  7. package/changelog/template.md +55 -16
  8. package/dist/index.js +6 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/mcp-server/resources/definitions/ris-document.resource.d.ts +4 -3
  11. package/dist/mcp-server/resources/definitions/ris-document.resource.d.ts.map +1 -1
  12. package/dist/mcp-server/resources/definitions/ris-document.resource.js +12 -6
  13. package/dist/mcp-server/resources/definitions/ris-document.resource.js.map +1 -1
  14. package/dist/mcp-server/tools/definitions/_shared.d.ts.map +1 -1
  15. package/dist/mcp-server/tools/definitions/_shared.js +0 -2
  16. package/dist/mcp-server/tools/definitions/_shared.js.map +1 -1
  17. package/dist/mcp-server/tools/definitions/ris-get-document.tool.d.ts +95 -34
  18. package/dist/mcp-server/tools/definitions/ris-get-document.tool.d.ts.map +1 -1
  19. package/dist/mcp-server/tools/definitions/ris-get-document.tool.js +198 -46
  20. package/dist/mcp-server/tools/definitions/ris-get-document.tool.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/ris-lookup-citation.tool.d.ts +3 -3
  22. package/dist/mcp-server/tools/definitions/ris-lookup-citation.tool.d.ts.map +1 -1
  23. package/dist/mcp-server/tools/definitions/ris-lookup-citation.tool.js +5 -6
  24. package/dist/mcp-server/tools/definitions/ris-lookup-citation.tool.js.map +1 -1
  25. package/dist/mcp-server/tools/definitions/ris-track-changes.tool.js +2 -2
  26. package/dist/mcp-server/tools/definitions/ris-track-changes.tool.js.map +1 -1
  27. package/dist/services/ris/normalizer.d.ts +1 -1
  28. package/dist/services/ris/normalizer.d.ts.map +1 -1
  29. package/dist/services/ris/normalizer.js +3 -3
  30. package/dist/services/ris/normalizer.js.map +1 -1
  31. package/dist/services/ris/ris-service.d.ts +1 -1
  32. package/dist/services/ris/ris-service.d.ts.map +1 -1
  33. package/dist/services/ris/ris-service.js +4 -10
  34. package/dist/services/ris/ris-service.js.map +1 -1
  35. package/package.json +7 -7
  36. package/server.json +3 -3
package/AGENTS.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** ris-austria-mcp-server
4
- **Version:** 0.4.0
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.11.0`
4
+ **Version:** 0.4.2
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.12.3`
6
6
  **Engines:** Bun ≥1.3.0, Node ≥24.0.0
7
- **MCP SDK:** `@modelcontextprotocol/sdk` ^1.29.0
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.0.0
8
8
  **Zod:** ^4.4.3
9
9
 
10
10
  > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
@@ -35,7 +35,7 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
35
35
  - **Logic throws, framework catches.** Tool/resource handlers are pure — throw on failure, no `try/catch`. Plain `Error` is fine; the framework catches, classifies, and formats. Use error factories (`notFound()`, `validationError()`, etc.) when the error code matters.
36
36
  - **Use `ctx.log`** for request-scoped logging. No `console` calls.
37
37
  - **Use `ctx.state`** for tenant-scoped storage. Never access persistence directly.
38
- - **Check `ctx.elicit`** for presence before calling.
38
+ - **Need input the caller didn't supply?** `return ctx.requestInput(...)` and read `ctx.inputs` when the handler is re-entered. Never `await` for user input mid-handler.
39
39
  - **Secrets in env vars only** — never hardcoded.
40
40
  - **Close the loop on issues.** When implementing work tracked by a GitHub issue, comment on the issue with what landed and close it. Do both — a comment without a close leaves stale issues open; a close without a comment leaves no record of what shipped. The comment is for future readers — state the concrete changes, not the conversation that produced them.
41
41
 
@@ -43,77 +43,72 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
43
43
 
44
44
  ## Patterns
45
45
 
46
+ These are representative excerpts; the named source files remain canonical for complete schemas,
47
+ contracts, and rendering logic.
48
+
46
49
  ### Tool
47
50
 
48
51
  ```ts
49
52
  import { tool, z } from '@cyanheads/mcp-ts-core';
50
53
 
51
- export const searchItems = tool('search_items', {
52
- description: 'Search inventory items by query.',
53
- annotations: { readOnlyHint: true },
54
+ export const risListReference = tool('ris_list_reference', {
55
+ title: 'RIS Reference Lists',
56
+ description: 'Return one static RIS vocabulary table without an upstream call.',
57
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
54
58
  input: z.object({
55
- query: z.string().describe('Search terms'),
56
- limit: z.number().default(10).describe('Max results'),
57
- }),
58
- output: z.object({
59
- items: z.array(z.object({
60
- id: z.string().describe('Item ID'),
61
- name: z.string().describe('Item name'),
62
- })).describe('Matching items'),
59
+ topic: z.enum(TOPICS).describe('Reference table to return.'),
63
60
  }),
64
- auth: ['inventory:read'],
65
-
66
- async handler(input, ctx) {
67
- const items = await findItems(input.query, input.limit);
68
- ctx.log.info('Search completed', { query: input.query, count: items.length });
69
- return { items };
61
+ output: z.object({ /* topic, summary, entries, notes — see the full definition */ }),
62
+ handler(input) {
63
+ return { topic: input.topic, ...TOPIC_BUILDERS[input.topic]() };
70
64
  },
71
-
72
- // format() populates content[] — the markdown twin of structuredContent.
73
- // Different clients read different surfaces (Claude Code → structuredContent,
74
- // Claude Desktop → content[]); both must carry the same data.
75
- // Enforced at lint time: every field in `output` must appear in the rendered text.
76
- format: (result) => [{
77
- type: 'text',
78
- text: result.items.map(i => `**${i.id}**: ${i.name}`).join('\n'),
79
- }],
65
+ format: (result) => { /* render every entry, detail pair, and note as markdown */ },
80
66
  });
81
67
  ```
82
68
 
69
+ The full output schema, `TOPIC_BUILDERS`, and markdown renderer stay local to
70
+ `ris-list-reference.tool.ts`; every other `ris_*` tool links to these vocabularies from its
71
+ descriptions and recovery hints.
72
+
83
73
  ### Resource
84
74
 
85
75
  ```ts
86
76
  import { resource, z } from '@cyanheads/mcp-ts-core';
87
- import { notFound } from '@cyanheads/mcp-ts-core/errors';
77
+ import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
88
78
 
89
- export const itemData = resource('inventory://{itemId}', {
90
- description: 'Fetch an inventory item by ID.',
91
- params: z.object({ itemId: z.string().describe('Item identifier') }),
92
- auth: ['inventory:read'],
79
+ export const risDocumentResource = resource('ris://document/{application}/{documentNumber}', {
80
+ name: 'ris_document',
81
+ title: 'RIS Document (Markdown)',
82
+ description: 'Markdown text of one RIS document by application and technical ID.',
83
+ mimeType: 'text/markdown',
84
+ cacheHint: { ttlMs: 86_400_000, cacheScope: 'public' },
85
+ params: z.object({
86
+ application: z.enum(APPLICATION_CODES).describe('RIS application code.'),
87
+ documentNumber: z.string().describe('Technical RIS document number.'),
88
+ }),
89
+ errors: [
90
+ {
91
+ reason: 'document_not_found',
92
+ code: JsonRpcErrorCode.NotFound,
93
+ when: 'The application/document-number pair does not resolve.',
94
+ recovery: 'Copy both values from a fresh RIS search result.',
95
+ },
96
+ ],
93
97
  async handler(params, ctx) {
94
- const item = await ctx.state.get(`item:${params.itemId}`);
95
- if (!item) throw notFound(`Item ${params.itemId} not found`, { itemId: params.itemId });
96
- return item;
98
+ const rendition = await renderDocument(
99
+ params.application,
100
+ params.documentNumber,
101
+ 'markdown',
102
+ ctx,
103
+ );
104
+ // Map errors to the declared contract and return full text or a section outline.
105
+ return rendition.text ?? rendition.unavailableNotice ?? '';
97
106
  },
98
107
  });
99
108
  ```
100
109
 
101
- ### Prompt
102
-
103
- ```ts
104
- import { prompt, z } from '@cyanheads/mcp-ts-core';
105
-
106
- export const reviewCode = prompt('review_code', {
107
- description: 'Review code for issues and best practices.',
108
- args: z.object({
109
- code: z.string().describe('Code to review'),
110
- language: z.string().optional().describe('Programming language'),
111
- }),
112
- generate: (args) => [
113
- { role: 'user', content: { type: 'text', text: `Review this ${args.language ?? ''} code:\n${args.code}` } },
114
- ],
115
- });
116
- ```
110
+ The real handler maps `renderDocument` failures onto the resource's three-entry error contract and
111
+ degrades oversized documents to the same section outline as `ris_get_document`.
117
112
 
118
113
  ### Server config
119
114
 
@@ -123,42 +118,48 @@ import { z } from '@cyanheads/mcp-ts-core';
123
118
  import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
124
119
 
125
120
  const ServerConfigSchema = z.object({
126
- apiKey: z.string().describe('External API key'),
127
- maxResults: z.coerce.number().default(100),
128
- verboseLogging: z.stringbool().default(false).describe('Enable verbose logging'),
121
+ apiBaseUrl: z.url().default('https://data.bka.gv.at/ris/api/v2.6'),
122
+ contentBaseUrl: z.url().default('https://www.ris.bka.gv.at'),
123
+ contact: z.string().optional(),
129
124
  });
130
125
 
131
126
  let _config: z.infer<typeof ServerConfigSchema> | undefined;
132
127
  export function getServerConfig() {
133
128
  _config ??= parseEnvConfig(ServerConfigSchema, {
134
- apiKey: 'MY_API_KEY',
135
- maxResults: 'MY_MAX_RESULTS',
136
- verboseLogging: 'MY_VERBOSE_LOGGING',
129
+ apiBaseUrl: 'RIS_API_BASE_URL',
130
+ contentBaseUrl: 'RIS_CONTENT_BASE_URL',
131
+ contact: 'RIS_CONTACT',
137
132
  });
138
133
  return _config;
139
134
  }
140
135
  ```
141
136
 
142
- `parseEnvConfig` maps Zod schema paths → env var names so errors name the variable (`MY_API_KEY`) not the path (`apiKey`). Throws `ConfigurationError`, which the framework prints as a clean startup banner.
143
-
144
- For env booleans use `z.stringbool()`, never `z.coerce.boolean()` — `Boolean("false")` is `true`, so a coerced flag can't be disabled through the environment. `z.stringbool()` parses `true/false/1/0/yes/no/on/off` and rejects anything else, so `=false` actually disables.
137
+ All RIS variables are optional because the OGD API is keyless. `parseEnvConfig` maps schema paths
138
+ to env-var names so a configuration failure names the variable the operator can fix.
145
139
 
146
140
  ### Server identity and instructions
147
141
 
148
- `createApp()` accepts optional identity fields forwarded to the SDK's `initialize` response and the server manifest (`/.well-known/mcp.json`):
142
+ The entry point pins the machine identity and immutable public catalog cache hints:
149
143
 
150
144
  ```ts
151
145
  await createApp({
152
- name: 'my-mcp-server',
153
- title: 'My Server', // human-readable display name
154
- websiteUrl: 'https://github.com/owner/repo', // canonical homepage URL
155
- description: 'One-line description.', // wins over MCP_SERVER_DESCRIPTION
156
- icons: [{ src: 'https://example.com/icon.png', sizes: ['48x48'], mimeType: 'image/png' }],
157
- instructions: 'Use shortcut alpha for the most common case.', // session-level context
146
+ name: 'ris-austria-mcp-server',
147
+ title: 'ris-austria-mcp-server',
148
+ cacheHints: {
149
+ 'tools/list': { ttlMs: 3_600_000, cacheScope: 'public' },
150
+ 'resources/list': { ttlMs: 3_600_000, cacheScope: 'public' },
151
+ 'resources/templates/list': { ttlMs: 3_600_000, cacheScope: 'public' },
152
+ 'server/discover': { ttlMs: 3_600_000, cacheScope: 'public' },
153
+ },
154
+ tools: [/* nine ris_* definitions */],
155
+ resources: [risDocumentResource],
156
+ setup(core) {
157
+ initRisService(core.config);
158
+ },
158
159
  });
159
160
  ```
160
161
 
161
- `instructions` is optional server-level orientation, sent on every `initialize` as session-level context. Use it for deployment guidance (connection aliases, regional notes, scope hints) instead of repeating the same context across tool descriptions. Client adoption is uneven, but there's no downside when set.
162
+ `package.json` is the canonical description source; do not duplicate it into `createApp()`.
162
163
 
163
164
  ---
164
165
 
@@ -168,15 +169,10 @@ Handlers receive a unified `ctx` object. Key properties:
168
169
 
169
170
  | Property | Description |
170
171
  |:---------|:------------|
171
- | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. |
172
- | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any serializable value. |
173
- | `ctx.elicit` | Ask user for structured input — form call `(message, schema)` or `.url(message, url)` for an external link. **Check for presence first:** `if (ctx.elicit) { ... }` |
172
+ | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. Dual-sink: Pino **and** `notifications/message` to the client, so treat it as client-visible. |
174
173
  | `ctx.enrich` | Success-path agent context (empty-result notices, query echo, pagination totals) — `ctx.enrich(...)` or `.notice()` / `.total()` / `.echo()` / `.truncated()`. Reaches `structuredContent` and `content[]`; lands only when the definition declares an `enrichment` block (no-op otherwise). |
175
- | `ctx.content` | Non-text content blocks — `.image(data, mimeType)`, `.audio(data, mimeType)`, or `ctx.content(block)` for a raw block. Prepended to `content[]` after `format()`; never enters `structuredContent`. |
176
174
  | `ctx.signal` | `AbortSignal` for cancellation. |
177
- | `ctx.progress` | Task progress (present when `task: true`) — `.setTotal(n)`, `.increment()`, `.update(message)`. |
178
- | `ctx.requestId` | Unique request ID. |
179
- | `ctx.tenantId` | Tenant ID from JWT; `'default'` for stdio or HTTP with auth off. |
175
+ | `ctx.requestId` / `traceId` / `spanId` / `tenantId` | Correlation fields inherited by each RIS service request context. |
180
176
 
181
177
  ---
182
178
 
@@ -228,20 +224,22 @@ See framework CLAUDE.md and the `api-errors` skill for the full auto-classificat
228
224
 
229
225
  ```text
230
226
  src/
231
- index.ts # createApp() entry point
227
+ index.ts # createApp() entry point
232
228
  config/
233
- server-config.ts # Server-specific env vars (Zod schema)
229
+ server-config.ts # Optional RIS endpoint/contact config
234
230
  services/
235
- [domain]/
236
- [domain]-service.ts # Domain service (init/accessor pattern)
237
- types.ts # Domain types
231
+ ris/
232
+ ris-service.ts # Search/content HTTP gateway
233
+ request-builder.ts # Per-application request mapping
234
+ normalizer.ts # Raw RIS envelope normalization
235
+ types.ts # Raw/domain types
236
+ reference/ # Static RIS vocabularies
238
237
  mcp-server/
239
238
  tools/definitions/
240
- [tool-name].tool.ts # Tool definitions
239
+ ris-*.tool.ts # Nine tool definitions
240
+ _shared.ts # Shared date/error-contract helpers
241
241
  resources/definitions/
242
- [resource-name].resource.ts # Resource definitions
243
- prompts/definitions/
244
- [prompt-name].prompt.ts # Prompt definitions
242
+ ris-document.resource.ts # Markdown document resource
245
243
  ```
246
244
 
247
245
  ---
@@ -290,7 +288,7 @@ Available skills:
290
288
  | `api-auth` | Auth modes, scopes, JWT/OAuth |
291
289
  | `api-canvas` | DataCanvas: register tabular data, run SQL, export, plus the `spillover()` helper for big result sets — Tier 3 opt-in |
292
290
  | `api-config` | AppConfig, parseConfig, env vars |
293
- | `api-context` | Context interface, logger, state, progress |
291
+ | `api-context` | Context interface, RequestContext, logger, state, multi-round-trip input |
294
292
  | `api-errors` | McpError, JsonRpcErrorCode, error patterns |
295
293
  | `api-linter` | Definition linter rule catalog — invoked by `bun run lint:mcp` and `devcheck` |
296
294
  | `api-mirror` | MirrorService: persistent self-refreshing local mirror (embedded SQLite + FTS5) of a bulk upstream dataset — Tier 3 opt-in |
package/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** ris-austria-mcp-server
4
- **Version:** 0.4.0
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.11.0`
4
+ **Version:** 0.4.2
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.12.3`
6
6
  **Engines:** Bun ≥1.3.0, Node ≥24.0.0
7
- **MCP SDK:** `@modelcontextprotocol/sdk` ^1.29.0
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.0.0
8
8
  **Zod:** ^4.4.3
9
9
 
10
10
  > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
@@ -35,7 +35,7 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
35
35
  - **Logic throws, framework catches.** Tool/resource handlers are pure — throw on failure, no `try/catch`. Plain `Error` is fine; the framework catches, classifies, and formats. Use error factories (`notFound()`, `validationError()`, etc.) when the error code matters.
36
36
  - **Use `ctx.log`** for request-scoped logging. No `console` calls.
37
37
  - **Use `ctx.state`** for tenant-scoped storage. Never access persistence directly.
38
- - **Check `ctx.elicit`** for presence before calling.
38
+ - **Need input the caller didn't supply?** `return ctx.requestInput(...)` and read `ctx.inputs` when the handler is re-entered. Never `await` for user input mid-handler.
39
39
  - **Secrets in env vars only** — never hardcoded.
40
40
  - **Close the loop on issues.** When implementing work tracked by a GitHub issue, comment on the issue with what landed and close it. Do both — a comment without a close leaves stale issues open; a close without a comment leaves no record of what shipped. The comment is for future readers — state the concrete changes, not the conversation that produced them.
41
41
 
@@ -43,77 +43,72 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
43
43
 
44
44
  ## Patterns
45
45
 
46
+ These are representative excerpts; the named source files remain canonical for complete schemas,
47
+ contracts, and rendering logic.
48
+
46
49
  ### Tool
47
50
 
48
51
  ```ts
49
52
  import { tool, z } from '@cyanheads/mcp-ts-core';
50
53
 
51
- export const searchItems = tool('search_items', {
52
- description: 'Search inventory items by query.',
53
- annotations: { readOnlyHint: true },
54
+ export const risListReference = tool('ris_list_reference', {
55
+ title: 'RIS Reference Lists',
56
+ description: 'Return one static RIS vocabulary table without an upstream call.',
57
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
54
58
  input: z.object({
55
- query: z.string().describe('Search terms'),
56
- limit: z.number().default(10).describe('Max results'),
57
- }),
58
- output: z.object({
59
- items: z.array(z.object({
60
- id: z.string().describe('Item ID'),
61
- name: z.string().describe('Item name'),
62
- })).describe('Matching items'),
59
+ topic: z.enum(TOPICS).describe('Reference table to return.'),
63
60
  }),
64
- auth: ['inventory:read'],
65
-
66
- async handler(input, ctx) {
67
- const items = await findItems(input.query, input.limit);
68
- ctx.log.info('Search completed', { query: input.query, count: items.length });
69
- return { items };
61
+ output: z.object({ /* topic, summary, entries, notes — see the full definition */ }),
62
+ handler(input) {
63
+ return { topic: input.topic, ...TOPIC_BUILDERS[input.topic]() };
70
64
  },
71
-
72
- // format() populates content[] — the markdown twin of structuredContent.
73
- // Different clients read different surfaces (Claude Code → structuredContent,
74
- // Claude Desktop → content[]); both must carry the same data.
75
- // Enforced at lint time: every field in `output` must appear in the rendered text.
76
- format: (result) => [{
77
- type: 'text',
78
- text: result.items.map(i => `**${i.id}**: ${i.name}`).join('\n'),
79
- }],
65
+ format: (result) => { /* render every entry, detail pair, and note as markdown */ },
80
66
  });
81
67
  ```
82
68
 
69
+ The full output schema, `TOPIC_BUILDERS`, and markdown renderer stay local to
70
+ `ris-list-reference.tool.ts`; every other `ris_*` tool links to these vocabularies from its
71
+ descriptions and recovery hints.
72
+
83
73
  ### Resource
84
74
 
85
75
  ```ts
86
76
  import { resource, z } from '@cyanheads/mcp-ts-core';
87
- import { notFound } from '@cyanheads/mcp-ts-core/errors';
77
+ import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
88
78
 
89
- export const itemData = resource('inventory://{itemId}', {
90
- description: 'Fetch an inventory item by ID.',
91
- params: z.object({ itemId: z.string().describe('Item identifier') }),
92
- auth: ['inventory:read'],
79
+ export const risDocumentResource = resource('ris://document/{application}/{documentNumber}', {
80
+ name: 'ris_document',
81
+ title: 'RIS Document (Markdown)',
82
+ description: 'Markdown text of one RIS document by application and technical ID.',
83
+ mimeType: 'text/markdown',
84
+ cacheHint: { ttlMs: 86_400_000, cacheScope: 'public' },
85
+ params: z.object({
86
+ application: z.enum(APPLICATION_CODES).describe('RIS application code.'),
87
+ documentNumber: z.string().describe('Technical RIS document number.'),
88
+ }),
89
+ errors: [
90
+ {
91
+ reason: 'document_not_found',
92
+ code: JsonRpcErrorCode.NotFound,
93
+ when: 'The application/document-number pair does not resolve.',
94
+ recovery: 'Copy both values from a fresh RIS search result.',
95
+ },
96
+ ],
93
97
  async handler(params, ctx) {
94
- const item = await ctx.state.get(`item:${params.itemId}`);
95
- if (!item) throw notFound(`Item ${params.itemId} not found`, { itemId: params.itemId });
96
- return item;
98
+ const rendition = await renderDocument(
99
+ params.application,
100
+ params.documentNumber,
101
+ 'markdown',
102
+ ctx,
103
+ );
104
+ // Map errors to the declared contract and return full text or a section outline.
105
+ return rendition.text ?? rendition.unavailableNotice ?? '';
97
106
  },
98
107
  });
99
108
  ```
100
109
 
101
- ### Prompt
102
-
103
- ```ts
104
- import { prompt, z } from '@cyanheads/mcp-ts-core';
105
-
106
- export const reviewCode = prompt('review_code', {
107
- description: 'Review code for issues and best practices.',
108
- args: z.object({
109
- code: z.string().describe('Code to review'),
110
- language: z.string().optional().describe('Programming language'),
111
- }),
112
- generate: (args) => [
113
- { role: 'user', content: { type: 'text', text: `Review this ${args.language ?? ''} code:\n${args.code}` } },
114
- ],
115
- });
116
- ```
110
+ The real handler maps `renderDocument` failures onto the resource's three-entry error contract and
111
+ degrades oversized documents to the same section outline as `ris_get_document`.
117
112
 
118
113
  ### Server config
119
114
 
@@ -123,42 +118,48 @@ import { z } from '@cyanheads/mcp-ts-core';
123
118
  import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
124
119
 
125
120
  const ServerConfigSchema = z.object({
126
- apiKey: z.string().describe('External API key'),
127
- maxResults: z.coerce.number().default(100),
128
- verboseLogging: z.stringbool().default(false).describe('Enable verbose logging'),
121
+ apiBaseUrl: z.url().default('https://data.bka.gv.at/ris/api/v2.6'),
122
+ contentBaseUrl: z.url().default('https://www.ris.bka.gv.at'),
123
+ contact: z.string().optional(),
129
124
  });
130
125
 
131
126
  let _config: z.infer<typeof ServerConfigSchema> | undefined;
132
127
  export function getServerConfig() {
133
128
  _config ??= parseEnvConfig(ServerConfigSchema, {
134
- apiKey: 'MY_API_KEY',
135
- maxResults: 'MY_MAX_RESULTS',
136
- verboseLogging: 'MY_VERBOSE_LOGGING',
129
+ apiBaseUrl: 'RIS_API_BASE_URL',
130
+ contentBaseUrl: 'RIS_CONTENT_BASE_URL',
131
+ contact: 'RIS_CONTACT',
137
132
  });
138
133
  return _config;
139
134
  }
140
135
  ```
141
136
 
142
- `parseEnvConfig` maps Zod schema paths → env var names so errors name the variable (`MY_API_KEY`) not the path (`apiKey`). Throws `ConfigurationError`, which the framework prints as a clean startup banner.
143
-
144
- For env booleans use `z.stringbool()`, never `z.coerce.boolean()` — `Boolean("false")` is `true`, so a coerced flag can't be disabled through the environment. `z.stringbool()` parses `true/false/1/0/yes/no/on/off` and rejects anything else, so `=false` actually disables.
137
+ All RIS variables are optional because the OGD API is keyless. `parseEnvConfig` maps schema paths
138
+ to env-var names so a configuration failure names the variable the operator can fix.
145
139
 
146
140
  ### Server identity and instructions
147
141
 
148
- `createApp()` accepts optional identity fields forwarded to the SDK's `initialize` response and the server manifest (`/.well-known/mcp.json`):
142
+ The entry point pins the machine identity and immutable public catalog cache hints:
149
143
 
150
144
  ```ts
151
145
  await createApp({
152
- name: 'my-mcp-server',
153
- title: 'My Server', // human-readable display name
154
- websiteUrl: 'https://github.com/owner/repo', // canonical homepage URL
155
- description: 'One-line description.', // wins over MCP_SERVER_DESCRIPTION
156
- icons: [{ src: 'https://example.com/icon.png', sizes: ['48x48'], mimeType: 'image/png' }],
157
- instructions: 'Use shortcut alpha for the most common case.', // session-level context
146
+ name: 'ris-austria-mcp-server',
147
+ title: 'ris-austria-mcp-server',
148
+ cacheHints: {
149
+ 'tools/list': { ttlMs: 3_600_000, cacheScope: 'public' },
150
+ 'resources/list': { ttlMs: 3_600_000, cacheScope: 'public' },
151
+ 'resources/templates/list': { ttlMs: 3_600_000, cacheScope: 'public' },
152
+ 'server/discover': { ttlMs: 3_600_000, cacheScope: 'public' },
153
+ },
154
+ tools: [/* nine ris_* definitions */],
155
+ resources: [risDocumentResource],
156
+ setup(core) {
157
+ initRisService(core.config);
158
+ },
158
159
  });
159
160
  ```
160
161
 
161
- `instructions` is optional server-level orientation, sent on every `initialize` as session-level context. Use it for deployment guidance (connection aliases, regional notes, scope hints) instead of repeating the same context across tool descriptions. Client adoption is uneven, but there's no downside when set.
162
+ `package.json` is the canonical description source; do not duplicate it into `createApp()`.
162
163
 
163
164
  ---
164
165
 
@@ -168,15 +169,10 @@ Handlers receive a unified `ctx` object. Key properties:
168
169
 
169
170
  | Property | Description |
170
171
  |:---------|:------------|
171
- | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. |
172
- | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any serializable value. |
173
- | `ctx.elicit` | Ask user for structured input — form call `(message, schema)` or `.url(message, url)` for an external link. **Check for presence first:** `if (ctx.elicit) { ... }` |
172
+ | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. Dual-sink: Pino **and** `notifications/message` to the client, so treat it as client-visible. |
174
173
  | `ctx.enrich` | Success-path agent context (empty-result notices, query echo, pagination totals) — `ctx.enrich(...)` or `.notice()` / `.total()` / `.echo()` / `.truncated()`. Reaches `structuredContent` and `content[]`; lands only when the definition declares an `enrichment` block (no-op otherwise). |
175
- | `ctx.content` | Non-text content blocks — `.image(data, mimeType)`, `.audio(data, mimeType)`, or `ctx.content(block)` for a raw block. Prepended to `content[]` after `format()`; never enters `structuredContent`. |
176
174
  | `ctx.signal` | `AbortSignal` for cancellation. |
177
- | `ctx.progress` | Task progress (present when `task: true`) — `.setTotal(n)`, `.increment()`, `.update(message)`. |
178
- | `ctx.requestId` | Unique request ID. |
179
- | `ctx.tenantId` | Tenant ID from JWT; `'default'` for stdio or HTTP with auth off. |
175
+ | `ctx.requestId` / `traceId` / `spanId` / `tenantId` | Correlation fields inherited by each RIS service request context. |
180
176
 
181
177
  ---
182
178
 
@@ -228,20 +224,22 @@ See framework CLAUDE.md and the `api-errors` skill for the full auto-classificat
228
224
 
229
225
  ```text
230
226
  src/
231
- index.ts # createApp() entry point
227
+ index.ts # createApp() entry point
232
228
  config/
233
- server-config.ts # Server-specific env vars (Zod schema)
229
+ server-config.ts # Optional RIS endpoint/contact config
234
230
  services/
235
- [domain]/
236
- [domain]-service.ts # Domain service (init/accessor pattern)
237
- types.ts # Domain types
231
+ ris/
232
+ ris-service.ts # Search/content HTTP gateway
233
+ request-builder.ts # Per-application request mapping
234
+ normalizer.ts # Raw RIS envelope normalization
235
+ types.ts # Raw/domain types
236
+ reference/ # Static RIS vocabularies
238
237
  mcp-server/
239
238
  tools/definitions/
240
- [tool-name].tool.ts # Tool definitions
239
+ ris-*.tool.ts # Nine tool definitions
240
+ _shared.ts # Shared date/error-contract helpers
241
241
  resources/definitions/
242
- [resource-name].resource.ts # Resource definitions
243
- prompts/definitions/
244
- [prompt-name].prompt.ts # Prompt definitions
242
+ ris-document.resource.ts # Markdown document resource
245
243
  ```
246
244
 
247
245
  ---
@@ -290,7 +288,7 @@ Available skills:
290
288
  | `api-auth` | Auth modes, scopes, JWT/OAuth |
291
289
  | `api-canvas` | DataCanvas: register tabular data, run SQL, export, plus the `spillover()` helper for big result sets — Tier 3 opt-in |
292
290
  | `api-config` | AppConfig, parseConfig, env vars |
293
- | `api-context` | Context interface, logger, state, progress |
291
+ | `api-context` | Context interface, RequestContext, logger, state, multi-round-trip input |
294
292
  | `api-errors` | McpError, JsonRpcErrorCode, error patterns |
295
293
  | `api-linter` | Definition linter rule catalog — invoked by `bun run lint:mcp` and `devcheck` |
296
294
  | `api-mirror` | MirrorService: persistent self-refreshing local mirror (embedded SQLite + FTS5) of a bulk upstream dataset — Tier 3 opt-in |
package/Dockerfile CHANGED
@@ -4,7 +4,7 @@
4
4
  # This stage installs all dependencies (including dev), builds the TypeScript
5
5
  # source code into JavaScript, and prepares the production assets.
6
6
  # ==============================================================================
7
- FROM oven/bun:1.3.14 AS build
7
+ FROM --platform=$BUILDPLATFORM oven/bun:1.4.0 AS build
8
8
 
9
9
  WORKDIR /usr/src/app
10
10
 
@@ -30,7 +30,7 @@ RUN bun run build
30
30
  # application. It uses a slim base image and only includes production
31
31
  # dependencies and build artifacts.
32
32
  # ==============================================================================
33
- FROM oven/bun:1.3.14-slim AS production
33
+ FROM oven/bun:1.4.0-slim AS production
34
34
 
35
35
  WORKDIR /usr/src/app
36
36
 
@@ -41,18 +41,23 @@ ENV NODE_ENV=production
41
41
  # OCI image metadata (https://github.com/opencontainers/image-spec/blob/main/annotations.md)
42
42
  ARG APP_VERSION
43
43
  LABEL org.opencontainers.image.title="ris-austria-mcp-server"
44
- LABEL org.opencontainers.image.description=""
44
+ LABEL org.opencontainers.image.description="Search Austrian consolidated federal & state law, court decisions, and the authentic Bundesgesetzblatt from the official RIS via MCP. Keyless. STDIO or Streamable HTTP."
45
45
  LABEL org.opencontainers.image.licenses="Apache-2.0"
46
46
  LABEL org.opencontainers.image.version="${APP_VERSION}"
47
- LABEL org.opencontainers.image.source=""
47
+ LABEL org.opencontainers.image.source="https://github.com/cyanheads/ris-austria-mcp-server"
48
48
 
49
49
  # Copy dependency manifests
50
50
  COPY package.json bun.lock ./
51
51
 
52
52
  # Install only production dependencies, ignoring any lifecycle scripts (like 'prepare')
53
53
  # that are not needed in the final production image.
54
+ # `--omit=peer` drops the framework's optional peer tiers (test runner, service
55
+ # SDKs, parsers) that Bun would otherwise auto-install. Anything this server
56
+ # actually imports belongs in its own `dependencies`, so nothing needed at
57
+ # runtime is lost. The OTEL step below carries the same flag — without it, that
58
+ # install re-resolves the graph and pulls every optional peer back in.
54
59
  RUN --mount=type=cache,target=/root/.bun/install/cache \
55
- bun install --production --frozen-lockfile --ignore-scripts
60
+ bun install --production --omit=peer --frozen-lockfile --ignore-scripts
56
61
 
57
62
  # Conditionally install OpenTelemetry optional peer dependencies (Tier 3).
58
63
  # These are not bundled by default to keep the base image lean. Enable at build time
@@ -60,7 +65,7 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \
60
65
  ARG OTEL_ENABLED=true
61
66
  RUN --mount=type=cache,target=/root/.bun/install/cache \
62
67
  if [ "$OTEL_ENABLED" = "true" ]; then \
63
- bun add --omit=dev --ignore-scripts @hono/otel \
68
+ bun add --omit=dev --omit=peer --ignore-scripts @hono/otel \
64
69
  @opentelemetry/instrumentation-http \
65
70
  @opentelemetry/exporter-metrics-otlp-http \
66
71
  @opentelemetry/exporter-trace-otlp-http \