@ansvar/ch-farm-safety-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/.github/workflows/check-freshness.yml +53 -0
  2. package/.github/workflows/ci.yml +21 -0
  3. package/.github/workflows/codeql.yml +30 -0
  4. package/.github/workflows/ghcr-build.yml +45 -0
  5. package/.github/workflows/gitleaks.yml +24 -0
  6. package/.github/workflows/ingest.yml +67 -0
  7. package/.github/workflows/publish.yml +24 -0
  8. package/CHANGELOG.md +23 -0
  9. package/CODEOWNERS +1 -0
  10. package/COVERAGE.md +54 -0
  11. package/DISCLAIMER.md +51 -0
  12. package/Dockerfile +26 -0
  13. package/LICENSE +17 -0
  14. package/PRIVACY.md +25 -0
  15. package/README.md +129 -0
  16. package/SECURITY.md +25 -0
  17. package/TOOLS.md +140 -0
  18. package/data/coverage.json +22 -0
  19. package/data/database.db +0 -0
  20. package/data/sources.yml +36 -0
  21. package/dist/db.d.ts +25 -0
  22. package/dist/db.d.ts.map +1 -0
  23. package/dist/db.js +200 -0
  24. package/dist/db.js.map +1 -0
  25. package/dist/http-server.d.ts +2 -0
  26. package/dist/http-server.d.ts.map +1 -0
  27. package/dist/http-server.js +250 -0
  28. package/dist/http-server.js.map +1 -0
  29. package/dist/jurisdiction.d.ts +18 -0
  30. package/dist/jurisdiction.d.ts.map +1 -0
  31. package/dist/jurisdiction.js +16 -0
  32. package/dist/jurisdiction.js.map +1 -0
  33. package/dist/metadata.d.ts +10 -0
  34. package/dist/metadata.d.ts.map +1 -0
  35. package/dist/metadata.js +20 -0
  36. package/dist/metadata.js.map +1 -0
  37. package/dist/server.d.ts +3 -0
  38. package/dist/server.d.ts.map +1 -0
  39. package/dist/server.js +196 -0
  40. package/dist/server.js.map +1 -0
  41. package/dist/tools/about.d.ts +15 -0
  42. package/dist/tools/about.d.ts.map +1 -0
  43. package/dist/tools/about.js +27 -0
  44. package/dist/tools/about.js.map +1 -0
  45. package/dist/tools/check-freshness.d.ts +15 -0
  46. package/dist/tools/check-freshness.d.ts.map +1 -0
  47. package/dist/tools/check-freshness.js +26 -0
  48. package/dist/tools/check-freshness.js.map +1 -0
  49. package/dist/tools/get-accident-reporting.d.ts +49 -0
  50. package/dist/tools/get-accident-reporting.d.ts.map +1 -0
  51. package/dist/tools/get-accident-reporting.js +31 -0
  52. package/dist/tools/get-accident-reporting.js.map +1 -0
  53. package/dist/tools/get-chemical-safety.d.ts +47 -0
  54. package/dist/tools/get-chemical-safety.d.ts.map +1 -0
  55. package/dist/tools/get-chemical-safety.js +31 -0
  56. package/dist/tools/get-chemical-safety.js.map +1 -0
  57. package/dist/tools/get-machinery-safety.d.ts +46 -0
  58. package/dist/tools/get-machinery-safety.d.ts.map +1 -0
  59. package/dist/tools/get-machinery-safety.js +31 -0
  60. package/dist/tools/get-machinery-safety.js.map +1 -0
  61. package/dist/tools/get-risk-assessment-requirements.d.ts +47 -0
  62. package/dist/tools/get-risk-assessment-requirements.d.ts.map +1 -0
  63. package/dist/tools/get-risk-assessment-requirements.js +31 -0
  64. package/dist/tools/get-risk-assessment-requirements.js.map +1 -0
  65. package/dist/tools/get-young-worker-rules.d.ts +47 -0
  66. package/dist/tools/get-young-worker-rules.d.ts.map +1 -0
  67. package/dist/tools/get-young-worker-rules.js +31 -0
  68. package/dist/tools/get-young-worker-rules.js.map +1 -0
  69. package/dist/tools/list-sources.d.ts +18 -0
  70. package/dist/tools/list-sources.d.ts.map +1 -0
  71. package/dist/tools/list-sources.js +51 -0
  72. package/dist/tools/list-sources.js.map +1 -0
  73. package/dist/tools/search-bul-guidance.d.ts +23 -0
  74. package/dist/tools/search-bul-guidance.d.ts.map +1 -0
  75. package/dist/tools/search-bul-guidance.js +35 -0
  76. package/dist/tools/search-bul-guidance.js.map +1 -0
  77. package/dist/tools/search-safety-rules.d.ts +25 -0
  78. package/dist/tools/search-safety-rules.d.ts.map +1 -0
  79. package/dist/tools/search-safety-rules.js +26 -0
  80. package/dist/tools/search-safety-rules.js.map +1 -0
  81. package/docker-compose.yml +12 -0
  82. package/eslint.config.js +27 -0
  83. package/package.json +54 -0
  84. package/scripts/ingest.ts +879 -0
  85. package/server.json +31 -0
  86. package/src/db.ts +241 -0
  87. package/src/http-server.ts +282 -0
  88. package/src/jurisdiction.ts +30 -0
  89. package/src/metadata.ts +30 -0
  90. package/src/server.ts +219 -0
  91. package/src/tools/about.ts +28 -0
  92. package/src/tools/check-freshness.ts +42 -0
  93. package/src/tools/get-accident-reporting.ts +52 -0
  94. package/src/tools/get-chemical-safety.ts +52 -0
  95. package/src/tools/get-machinery-safety.ts +52 -0
  96. package/src/tools/get-risk-assessment-requirements.ts +52 -0
  97. package/src/tools/get-young-worker-rules.ts +52 -0
  98. package/src/tools/list-sources.ts +65 -0
  99. package/src/tools/search-bul-guidance.ts +51 -0
  100. package/src/tools/search-safety-rules.ts +35 -0
  101. package/tests/db.test.ts +121 -0
  102. package/tests/helpers/seed-db.ts +134 -0
  103. package/tests/jurisdiction.test.ts +54 -0
  104. package/tests/tools/about.test.ts +44 -0
  105. package/tests/tools/check-freshness.test.ts +68 -0
  106. package/tests/tools/list-sources.test.ts +75 -0
  107. package/tests/tools/search-safety-rules.test.ts +75 -0
  108. package/tsconfig.json +19 -0
  109. package/vitest.config.ts +9 -0
package/server.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "io.github.Ansvar-Systems/ch-farm-safety",
3
+ "display_name": "Switzerland Farm Safety MCP",
4
+ "description": "Swiss farm workplace safety via MCP -- BUL/SPAA rules, Suva requirements, EKAS Branchenloesung, Agriss statistics, machinery safety, chemical exposure",
5
+ "version": "0.1.0",
6
+ "repository": "https://github.com/ansvar-systems/ch-farm-safety-mcp",
7
+ "homepage": "https://ansvar.eu/open-agriculture",
8
+ "license": "Apache-2.0",
9
+ "author": {
10
+ "name": "Ansvar Systems",
11
+ "url": "https://ansvar.eu"
12
+ },
13
+ "jurisdiction": ["CH"],
14
+ "tools": 10,
15
+ "transport": {
16
+ "stdio": {
17
+ "command": "npx",
18
+ "args": ["-y", "@ansvar/ch-farm-safety-mcp"]
19
+ },
20
+ "streamable_http": {
21
+ "url": "https://mcp.ansvar.eu/ch-farm-safety/mcp"
22
+ }
23
+ },
24
+ "data_sources": [
25
+ "BUL/SPAA",
26
+ "Suva",
27
+ "EKAS",
28
+ "Agriss",
29
+ "SECO"
30
+ ]
31
+ }
package/src/db.ts ADDED
@@ -0,0 +1,241 @@
1
+ import BetterSqlite3 from 'better-sqlite3';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ export interface Database {
6
+ get<T>(sql: string, params?: unknown[]): T | undefined;
7
+ all<T>(sql: string, params?: unknown[]): T[];
8
+ run(sql: string, params?: unknown[]): void;
9
+ close(): void;
10
+ readonly instance: BetterSqlite3.Database;
11
+ }
12
+
13
+ export function createDatabase(dbPath?: string): Database {
14
+ const resolvedPath =
15
+ dbPath ??
16
+ join(dirname(fileURLToPath(import.meta.url)), '..', 'data', 'database.db');
17
+ const db = new BetterSqlite3(resolvedPath);
18
+
19
+ db.pragma('journal_mode = DELETE');
20
+ db.pragma('foreign_keys = ON');
21
+
22
+ initSchema(db);
23
+
24
+ return {
25
+ get<T>(sql: string, params: unknown[] = []): T | undefined {
26
+ return db.prepare(sql).get(...params) as T | undefined;
27
+ },
28
+ all<T>(sql: string, params: unknown[] = []): T[] {
29
+ return db.prepare(sql).all(...params) as T[];
30
+ },
31
+ run(sql: string, params: unknown[] = []): void {
32
+ db.prepare(sql).run(...params);
33
+ },
34
+ close(): void {
35
+ db.close();
36
+ },
37
+ get instance() {
38
+ return db;
39
+ },
40
+ };
41
+ }
42
+
43
+ function initSchema(db: BetterSqlite3.Database): void {
44
+ db.exec(`
45
+ CREATE TABLE IF NOT EXISTS safety_rules (
46
+ id INTEGER PRIMARY KEY,
47
+ topic TEXT NOT NULL,
48
+ rule TEXT NOT NULL,
49
+ area TEXT NOT NULL CHECK(area IN ('maschinen','tiere','wald','hoehe','chemie','silogas','allgemein')),
50
+ description TEXT,
51
+ source TEXT,
52
+ language TEXT NOT NULL DEFAULT 'DE',
53
+ jurisdiction TEXT NOT NULL DEFAULT 'CH'
54
+ );
55
+
56
+ CREATE TABLE IF NOT EXISTS risk_assessments (
57
+ id INTEGER PRIMARY KEY,
58
+ activity_type TEXT NOT NULL,
59
+ requirement TEXT NOT NULL,
60
+ content TEXT,
61
+ update_trigger TEXT,
62
+ legal_basis TEXT,
63
+ language TEXT NOT NULL DEFAULT 'DE',
64
+ jurisdiction TEXT NOT NULL DEFAULT 'CH'
65
+ );
66
+
67
+ CREATE TABLE IF NOT EXISTS machinery_safety (
68
+ id INTEGER PRIMARY KEY,
69
+ machine_type TEXT NOT NULL,
70
+ requirement TEXT NOT NULL,
71
+ certification TEXT,
72
+ notes TEXT,
73
+ language TEXT NOT NULL DEFAULT 'DE',
74
+ jurisdiction TEXT NOT NULL DEFAULT 'CH'
75
+ );
76
+
77
+ CREATE TABLE IF NOT EXISTS chemical_safety (
78
+ id INTEGER PRIMARY KEY,
79
+ substance_type TEXT NOT NULL,
80
+ exposure_limit TEXT,
81
+ ppe_required TEXT,
82
+ storage_rule TEXT,
83
+ language TEXT NOT NULL DEFAULT 'DE',
84
+ jurisdiction TEXT NOT NULL DEFAULT 'CH'
85
+ );
86
+
87
+ CREATE TABLE IF NOT EXISTS young_worker_rules (
88
+ id INTEGER PRIMARY KEY,
89
+ age_group TEXT NOT NULL,
90
+ restriction TEXT NOT NULL,
91
+ exception TEXT,
92
+ legal_basis TEXT,
93
+ language TEXT NOT NULL DEFAULT 'DE',
94
+ jurisdiction TEXT NOT NULL DEFAULT 'CH'
95
+ );
96
+
97
+ CREATE TABLE IF NOT EXISTS accident_reporting (
98
+ id INTEGER PRIMARY KEY,
99
+ severity TEXT NOT NULL,
100
+ obligation TEXT NOT NULL,
101
+ deadline TEXT,
102
+ form TEXT,
103
+ insurer TEXT,
104
+ language TEXT NOT NULL DEFAULT 'DE',
105
+ jurisdiction TEXT NOT NULL DEFAULT 'CH'
106
+ );
107
+
108
+ CREATE TABLE IF NOT EXISTS bul_guidance (
109
+ id INTEGER PRIMARY KEY,
110
+ topic TEXT NOT NULL,
111
+ title TEXT NOT NULL,
112
+ description TEXT,
113
+ url TEXT,
114
+ language TEXT NOT NULL DEFAULT 'DE',
115
+ jurisdiction TEXT NOT NULL DEFAULT 'CH'
116
+ );
117
+
118
+ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
119
+ title, body, area, jurisdiction
120
+ );
121
+
122
+ CREATE TABLE IF NOT EXISTS db_metadata (
123
+ key TEXT PRIMARY KEY,
124
+ value TEXT
125
+ );
126
+
127
+ INSERT OR IGNORE INTO db_metadata (key, value) VALUES ('schema_version', '1.0');
128
+ INSERT OR IGNORE INTO db_metadata (key, value) VALUES ('mcp_name', 'Switzerland Farm Safety MCP');
129
+ INSERT OR IGNORE INTO db_metadata (key, value) VALUES ('jurisdiction', 'CH');
130
+ `);
131
+ }
132
+
133
+ const FTS_COLUMNS = ['title', 'body', 'area', 'jurisdiction'];
134
+
135
+ export function ftsSearch(
136
+ db: Database,
137
+ query: string,
138
+ limit: number = 20
139
+ ): { title: string; body: string; area: string; jurisdiction: string; rank: number }[] {
140
+ const { results } = tieredFtsSearch(db, 'search_index', FTS_COLUMNS, query, limit);
141
+ return results as { title: string; body: string; area: string; jurisdiction: string; rank: number }[];
142
+ }
143
+
144
+ /**
145
+ * Tiered FTS5 search with automatic fallback.
146
+ * Tiers: exact phrase -> AND -> prefix -> stemmed prefix -> OR -> LIKE
147
+ */
148
+ export function tieredFtsSearch(
149
+ db: Database,
150
+ table: string,
151
+ columns: string[],
152
+ query: string,
153
+ limit: number = 20
154
+ ): { tier: string; results: Record<string, unknown>[] } {
155
+ const sanitized = sanitizeFtsInput(query);
156
+ if (!sanitized.trim()) return { tier: 'empty', results: [] };
157
+
158
+ const columnList = columns.join(', ');
159
+ const select = `SELECT ${columnList}, rank FROM ${table}`;
160
+ const order = `ORDER BY rank LIMIT ?`;
161
+
162
+ // Tier 1: Exact phrase
163
+ const phrase = `"${sanitized}"`;
164
+ let results = tryFts(db, select, table, order, phrase, limit);
165
+ if (results.length > 0) return { tier: 'phrase', results };
166
+
167
+ // Tier 2: AND
168
+ const words = sanitized.split(/\s+/).filter(w => w.length > 1);
169
+ if (words.length > 1) {
170
+ const andQuery = words.join(' AND ');
171
+ results = tryFts(db, select, table, order, andQuery, limit);
172
+ if (results.length > 0) return { tier: 'and', results };
173
+ }
174
+
175
+ // Tier 3: Prefix
176
+ const prefixQuery = words.map(w => `${w}*`).join(' AND ');
177
+ results = tryFts(db, select, table, order, prefixQuery, limit);
178
+ if (results.length > 0) return { tier: 'prefix', results };
179
+
180
+ // Tier 4: Stemmed prefix
181
+ const stemmed = words.map(w => stemWord(w) + '*');
182
+ const stemmedQuery = stemmed.join(' AND ');
183
+ if (stemmedQuery !== prefixQuery) {
184
+ results = tryFts(db, select, table, order, stemmedQuery, limit);
185
+ if (results.length > 0) return { tier: 'stemmed', results };
186
+ }
187
+
188
+ // Tier 5: OR
189
+ if (words.length > 1) {
190
+ const orQuery = words.join(' OR ');
191
+ results = tryFts(db, select, table, order, orQuery, limit);
192
+ if (results.length > 0) return { tier: 'or', results };
193
+ }
194
+
195
+ // Tier 6: LIKE fallback
196
+ const baseCols = ['topic', 'rule'];
197
+ const likeConditions = words.map(() =>
198
+ `(${baseCols.map(c => `${c} LIKE ?`).join(' OR ')})`
199
+ ).join(' AND ');
200
+ const likeParams = words.flatMap(w =>
201
+ baseCols.map(() => `%${w}%`)
202
+ );
203
+ try {
204
+ const likeResults = db.all<Record<string, unknown>>(
205
+ `SELECT topic as title, COALESCE(description, rule) as body, area, jurisdiction FROM safety_rules WHERE ${likeConditions} LIMIT ?`,
206
+ [...likeParams, limit]
207
+ );
208
+ if (likeResults.length > 0) return { tier: 'like', results: likeResults };
209
+ } catch {
210
+ // LIKE fallback failed
211
+ }
212
+
213
+ return { tier: 'none', results: [] };
214
+ }
215
+
216
+ function tryFts(
217
+ db: Database, select: string, table: string,
218
+ order: string, matchExpr: string, limit: number
219
+ ): Record<string, unknown>[] {
220
+ try {
221
+ return db.all(
222
+ `${select} WHERE ${table} MATCH ? ${order}`,
223
+ [matchExpr, limit]
224
+ );
225
+ } catch {
226
+ return [];
227
+ }
228
+ }
229
+
230
+ function sanitizeFtsInput(query: string): string {
231
+ return query
232
+ .replace(/["""'',,,,]/g, '"')
233
+ .replace(/[^a-zA-Z0-9\s*"_\u00C0-\u024F-]/g, ' ')
234
+ .replace(/\s+/g, ' ')
235
+ .trim();
236
+ }
237
+
238
+ function stemWord(word: string): string {
239
+ return word
240
+ .replace(/(ung|heit|keit|lich|isch|ieren|tion|ment|ness|able|ible|ous|ive|ing|ers|ed|es|er|en|ly|s)$/i, '');
241
+ }
@@ -0,0 +1,282 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from 'http';
2
+ import { randomUUID } from 'crypto';
3
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import {
6
+ ListToolsRequestSchema,
7
+ CallToolRequestSchema,
8
+ } from '@modelcontextprotocol/sdk/types.js';
9
+ import { z } from 'zod';
10
+ import { createDatabase, type Database } from './db.js';
11
+ import { handleAbout } from './tools/about.js';
12
+ import { handleListSources } from './tools/list-sources.js';
13
+ import { handleCheckFreshness } from './tools/check-freshness.js';
14
+ import { handleSearchSafetyRules } from './tools/search-safety-rules.js';
15
+ import { handleGetRiskAssessmentRequirements } from './tools/get-risk-assessment-requirements.js';
16
+ import { handleGetMachinerySafety } from './tools/get-machinery-safety.js';
17
+ import { handleGetChemicalSafety } from './tools/get-chemical-safety.js';
18
+ import { handleGetYoungWorkerRules } from './tools/get-young-worker-rules.js';
19
+ import { handleGetAccidentReporting } from './tools/get-accident-reporting.js';
20
+ import { handleSearchBulGuidance } from './tools/search-bul-guidance.js';
21
+
22
+ const SERVER_NAME = 'ch-farm-safety-mcp';
23
+ const SERVER_VERSION = '0.1.0';
24
+ const PORT = parseInt(process.env.PORT ?? '3000', 10);
25
+
26
+ const SearchArgsSchema = z.object({
27
+ query: z.string(),
28
+ topic: z.string().optional(),
29
+ jurisdiction: z.string().optional(),
30
+ limit: z.number().optional(),
31
+ });
32
+
33
+ const RiskAssessmentArgsSchema = z.object({
34
+ activity_type: z.string().optional(),
35
+ jurisdiction: z.string().optional(),
36
+ });
37
+
38
+ const MachinerySafetyArgsSchema = z.object({
39
+ machine_type: z.string().optional(),
40
+ jurisdiction: z.string().optional(),
41
+ });
42
+
43
+ const ChemicalSafetyArgsSchema = z.object({
44
+ substance_type: z.string().optional(),
45
+ jurisdiction: z.string().optional(),
46
+ });
47
+
48
+ const YoungWorkerArgsSchema = z.object({
49
+ age_group: z.string().optional(),
50
+ jurisdiction: z.string().optional(),
51
+ });
52
+
53
+ const AccidentReportingArgsSchema = z.object({
54
+ severity: z.string().optional(),
55
+ jurisdiction: z.string().optional(),
56
+ });
57
+
58
+ const BulGuidanceArgsSchema = z.object({
59
+ query: z.string(),
60
+ jurisdiction: z.string().optional(),
61
+ });
62
+
63
+ const TOOLS = [
64
+ {
65
+ name: 'about',
66
+ description: 'Get server metadata: name, version, coverage, data sources, and links.',
67
+ inputSchema: { type: 'object' as const, properties: {} },
68
+ },
69
+ {
70
+ name: 'list_sources',
71
+ description: 'List all data sources with authority, URL, license, and freshness info.',
72
+ inputSchema: { type: 'object' as const, properties: {} },
73
+ },
74
+ {
75
+ name: 'check_data_freshness',
76
+ description: 'Check when data was last ingested, staleness status, and how to trigger a refresh.',
77
+ inputSchema: { type: 'object' as const, properties: {} },
78
+ },
79
+ {
80
+ name: 'search_safety_rules',
81
+ description: 'Search Swiss farm workplace safety rules (BUL lebenswichtige Regeln, Suva, EKAS). Use for broad queries about farm safety in Switzerland.',
82
+ inputSchema: {
83
+ type: 'object' as const,
84
+ properties: {
85
+ query: { type: 'string', description: 'Free-text search query (German or English)' },
86
+ topic: { type: 'string', description: 'Filter by safety area: maschinen, tiere, wald, hoehe, chemie, silogas, allgemein' },
87
+ jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
88
+ limit: { type: 'number', description: 'Max results (default: 20, max: 50)' },
89
+ },
90
+ required: ['query'],
91
+ },
92
+ },
93
+ {
94
+ name: 'get_risk_assessment_requirements',
95
+ description: 'Get risk assessment (Risikobeurteilung) obligations for farm activities. Based on EKAS Branchenloesung Landwirtschaft and VUV.',
96
+ inputSchema: {
97
+ type: 'object' as const,
98
+ properties: {
99
+ activity_type: { type: 'string', description: 'Activity type (e.g. Alleinarbeit, Siloarbeiten, Holzerei, Tierhaltung)' },
100
+ jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
101
+ },
102
+ },
103
+ },
104
+ {
105
+ name: 'get_machinery_safety',
106
+ description: 'Get safety requirements for agricultural machinery. Covers ROPS, Gurtpflicht, MFK, speed limits, PTO guards.',
107
+ inputSchema: {
108
+ type: 'object' as const,
109
+ properties: {
110
+ machine_type: { type: 'string', description: 'Machine type (e.g. Traktor, Maehdrescher, Motorsaege, Kreiselmaeher)' },
111
+ jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
112
+ },
113
+ },
114
+ },
115
+ {
116
+ name: 'get_chemical_safety',
117
+ description: 'Get chemical safety data: exposure limits (MAK), required PPE, and storage rules. Covers PSM (Pflanzenschutzmittel), Silogas, Duenger.',
118
+ inputSchema: {
119
+ type: 'object' as const,
120
+ properties: {
121
+ substance_type: { type: 'string', description: 'Substance type (e.g. Pflanzenschutzmittel, Silogas, Ammoniak, Dieselkraftstoff)' },
122
+ jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
123
+ },
124
+ },
125
+ },
126
+ {
127
+ name: 'get_young_worker_rules',
128
+ description: 'Get restrictions for young workers (Jugendarbeitsschutz) on Swiss farms. Covers age thresholds, allowed/forbidden tasks, exceptions for family farms.',
129
+ inputSchema: {
130
+ type: 'object' as const,
131
+ properties: {
132
+ age_group: { type: 'string', description: 'Age group (e.g. unter-13, 13-15, 15-18)' },
133
+ jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
134
+ },
135
+ },
136
+ },
137
+ {
138
+ name: 'get_accident_reporting',
139
+ description: 'Get accident reporting obligations: deadlines, forms, and responsible insurer (Suva for employees, private for self-employed).',
140
+ inputSchema: {
141
+ type: 'object' as const,
142
+ properties: {
143
+ severity: { type: 'string', description: 'Accident severity (e.g. toedlich, schwer, leicht, Berufskrankheit)' },
144
+ jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
145
+ },
146
+ },
147
+ },
148
+ {
149
+ name: 'search_bul_guidance',
150
+ description: 'Search BUL/SPAA safety guidance documents, fact sheets, and campaigns. Returns topic, title, description, and URL.',
151
+ inputSchema: {
152
+ type: 'object' as const,
153
+ properties: {
154
+ query: { type: 'string', description: 'Free-text search query (German or English)' },
155
+ jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
156
+ },
157
+ required: ['query'],
158
+ },
159
+ },
160
+ ];
161
+
162
+ function textResult(data: unknown) {
163
+ return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
164
+ }
165
+
166
+ function errorResult(message: string) {
167
+ return { content: [{ type: 'text' as const, text: JSON.stringify({ error: message }) }], isError: true };
168
+ }
169
+
170
+ function registerTools(server: Server, db: Database): void {
171
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
172
+
173
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
174
+ const { name, arguments: args = {} } = request.params;
175
+
176
+ try {
177
+ switch (name) {
178
+ case 'about':
179
+ return textResult(handleAbout());
180
+ case 'list_sources':
181
+ return textResult(handleListSources(db));
182
+ case 'check_data_freshness':
183
+ return textResult(handleCheckFreshness(db));
184
+ case 'search_safety_rules':
185
+ return textResult(handleSearchSafetyRules(db, SearchArgsSchema.parse(args)));
186
+ case 'get_risk_assessment_requirements':
187
+ return textResult(handleGetRiskAssessmentRequirements(db, RiskAssessmentArgsSchema.parse(args)));
188
+ case 'get_machinery_safety':
189
+ return textResult(handleGetMachinerySafety(db, MachinerySafetyArgsSchema.parse(args)));
190
+ case 'get_chemical_safety':
191
+ return textResult(handleGetChemicalSafety(db, ChemicalSafetyArgsSchema.parse(args)));
192
+ case 'get_young_worker_rules':
193
+ return textResult(handleGetYoungWorkerRules(db, YoungWorkerArgsSchema.parse(args)));
194
+ case 'get_accident_reporting':
195
+ return textResult(handleGetAccidentReporting(db, AccidentReportingArgsSchema.parse(args)));
196
+ case 'search_bul_guidance':
197
+ return textResult(handleSearchBulGuidance(db, BulGuidanceArgsSchema.parse(args)));
198
+ default:
199
+ return errorResult(`Unknown tool: ${name}`);
200
+ }
201
+ } catch (err) {
202
+ return errorResult(err instanceof Error ? err.message : String(err));
203
+ }
204
+ });
205
+ }
206
+
207
+ const db = createDatabase();
208
+ const sessions = new Map<string, { transport: StreamableHTTPServerTransport; server: Server }>();
209
+
210
+ function createMcpServer(): Server {
211
+ const mcpServer = new Server(
212
+ { name: SERVER_NAME, version: SERVER_VERSION },
213
+ { capabilities: { tools: {} } }
214
+ );
215
+ registerTools(mcpServer, db);
216
+ return mcpServer;
217
+ }
218
+
219
+ async function handleMCPRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
220
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
221
+
222
+ if (sessionId && sessions.has(sessionId)) {
223
+ const session = sessions.get(sessionId)!;
224
+ await session.transport.handleRequest(req, res);
225
+ return;
226
+ }
227
+
228
+ if (req.method === 'GET' || req.method === 'DELETE') {
229
+ res.writeHead(400, { 'Content-Type': 'application/json' });
230
+ res.end(JSON.stringify({ error: 'Invalid or missing session ID' }));
231
+ return;
232
+ }
233
+
234
+ const mcpServer = createMcpServer();
235
+ const transport = new StreamableHTTPServerTransport({
236
+ sessionIdGenerator: () => randomUUID(),
237
+ });
238
+
239
+ await mcpServer.connect(transport);
240
+
241
+ transport.onclose = () => {
242
+ if (transport.sessionId) {
243
+ sessions.delete(transport.sessionId);
244
+ }
245
+ mcpServer.close().catch(() => {});
246
+ };
247
+
248
+ await transport.handleRequest(req, res);
249
+
250
+ if (transport.sessionId) {
251
+ sessions.set(transport.sessionId, { transport, server: mcpServer });
252
+ }
253
+ }
254
+
255
+ const httpServer = createServer(async (req, res) => {
256
+ const url = new URL(req.url || '/', `http://localhost:${PORT}`);
257
+
258
+ if (url.pathname === '/health' && req.method === 'GET') {
259
+ res.writeHead(200, { 'Content-Type': 'application/json' });
260
+ res.end(JSON.stringify({ status: 'healthy', server: SERVER_NAME, version: SERVER_VERSION }));
261
+ return;
262
+ }
263
+
264
+ if (url.pathname === '/mcp' || url.pathname === '/') {
265
+ try {
266
+ await handleMCPRequest(req, res);
267
+ } catch (err) {
268
+ if (!res.headersSent) {
269
+ res.writeHead(500, { 'Content-Type': 'application/json' });
270
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Internal server error' }));
271
+ }
272
+ }
273
+ return;
274
+ }
275
+
276
+ res.writeHead(404, { 'Content-Type': 'application/json' });
277
+ res.end(JSON.stringify({ error: 'Not found' }));
278
+ });
279
+
280
+ httpServer.listen(PORT, () => {
281
+ console.log(`${SERVER_NAME} v${SERVER_VERSION} listening on port ${PORT}`);
282
+ });
@@ -0,0 +1,30 @@
1
+ export const SUPPORTED_JURISDICTIONS = ['CH'] as const;
2
+ export type Jurisdiction = (typeof SUPPORTED_JURISDICTIONS)[number];
3
+
4
+ type ValidationSuccess = { valid: true; jurisdiction: Jurisdiction };
5
+ type ValidationFailure = {
6
+ valid: false;
7
+ error: {
8
+ error: string;
9
+ supported: readonly string[];
10
+ message: string;
11
+ };
12
+ };
13
+ type ValidationResult = ValidationSuccess | ValidationFailure;
14
+
15
+ export function validateJurisdiction(input: string | undefined): ValidationResult {
16
+ const normalised = (input ?? 'CH').toUpperCase();
17
+
18
+ if (SUPPORTED_JURISDICTIONS.includes(normalised as Jurisdiction)) {
19
+ return { valid: true, jurisdiction: normalised as Jurisdiction };
20
+ }
21
+
22
+ return {
23
+ valid: false,
24
+ error: {
25
+ error: 'jurisdiction_not_supported',
26
+ supported: SUPPORTED_JURISDICTIONS,
27
+ message: 'This server currently covers Switzerland. More jurisdictions are planned.',
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,30 @@
1
+ export interface Meta {
2
+ disclaimer: string;
3
+ data_age: string;
4
+ source_url: string;
5
+ copyright: string;
6
+ server: string;
7
+ version: string;
8
+ }
9
+
10
+ const DISCLAIMER =
11
+ 'Diese Daten dienen ausschliesslich der Information und ersetzen keine Sicherheitsberatung. ' +
12
+ 'Vor Umsetzung von Sicherheitsmassnahmen ist die BUL/SPAA (Beratungsstelle fuer Unfallverhuetung ' +
13
+ 'in der Landwirtschaft), Suva oder eine qualifizierte Sicherheitsfachperson beizuziehen. ' +
14
+ 'Die Daten basieren auf den Sicherheitsregeln der BUL, den Richtlinien der EKAS, der Agriss-Unfallstatistik ' +
15
+ 'und den Vorgaben der Suva. Betriebsspezifische Risikobeurteilungen sind eigenstaendig durchzufuehren. / ' +
16
+ 'This data is provided for informational purposes only and does not constitute professional safety advice. ' +
17
+ 'Always consult BUL/SPAA, Suva, or a qualified safety professional before implementing safety measures. ' +
18
+ 'Data sourced from BUL safety rules, EKAS guidelines, Agriss accident statistics, and Suva requirements.';
19
+
20
+ export function buildMeta(overrides?: Partial<Meta>): Meta {
21
+ return {
22
+ disclaimer: DISCLAIMER,
23
+ data_age: overrides?.data_age ?? 'unknown',
24
+ source_url: overrides?.source_url ?? 'https://www.bul.ch',
25
+ copyright: 'Data: BUL/SPAA, Suva, EKAS, Agriss — used under public-sector information principles. Server: Apache-2.0 Ansvar Systems.',
26
+ server: 'ch-farm-safety-mcp',
27
+ version: '0.1.0',
28
+ ...overrides,
29
+ };
30
+ }