@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.
- package/.github/workflows/check-freshness.yml +53 -0
- package/.github/workflows/ci.yml +21 -0
- package/.github/workflows/codeql.yml +30 -0
- package/.github/workflows/ghcr-build.yml +45 -0
- package/.github/workflows/gitleaks.yml +24 -0
- package/.github/workflows/ingest.yml +67 -0
- package/.github/workflows/publish.yml +24 -0
- package/CHANGELOG.md +23 -0
- package/CODEOWNERS +1 -0
- package/COVERAGE.md +54 -0
- package/DISCLAIMER.md +51 -0
- package/Dockerfile +26 -0
- package/LICENSE +17 -0
- package/PRIVACY.md +25 -0
- package/README.md +129 -0
- package/SECURITY.md +25 -0
- package/TOOLS.md +140 -0
- package/data/coverage.json +22 -0
- package/data/database.db +0 -0
- package/data/sources.yml +36 -0
- package/dist/db.d.ts +25 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +200 -0
- package/dist/db.js.map +1 -0
- package/dist/http-server.d.ts +2 -0
- package/dist/http-server.d.ts.map +1 -0
- package/dist/http-server.js +250 -0
- package/dist/http-server.js.map +1 -0
- package/dist/jurisdiction.d.ts +18 -0
- package/dist/jurisdiction.d.ts.map +1 -0
- package/dist/jurisdiction.js +16 -0
- package/dist/jurisdiction.js.map +1 -0
- package/dist/metadata.d.ts +10 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +20 -0
- package/dist/metadata.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +196 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/about.d.ts +15 -0
- package/dist/tools/about.d.ts.map +1 -0
- package/dist/tools/about.js +27 -0
- package/dist/tools/about.js.map +1 -0
- package/dist/tools/check-freshness.d.ts +15 -0
- package/dist/tools/check-freshness.d.ts.map +1 -0
- package/dist/tools/check-freshness.js +26 -0
- package/dist/tools/check-freshness.js.map +1 -0
- package/dist/tools/get-accident-reporting.d.ts +49 -0
- package/dist/tools/get-accident-reporting.d.ts.map +1 -0
- package/dist/tools/get-accident-reporting.js +31 -0
- package/dist/tools/get-accident-reporting.js.map +1 -0
- package/dist/tools/get-chemical-safety.d.ts +47 -0
- package/dist/tools/get-chemical-safety.d.ts.map +1 -0
- package/dist/tools/get-chemical-safety.js +31 -0
- package/dist/tools/get-chemical-safety.js.map +1 -0
- package/dist/tools/get-machinery-safety.d.ts +46 -0
- package/dist/tools/get-machinery-safety.d.ts.map +1 -0
- package/dist/tools/get-machinery-safety.js +31 -0
- package/dist/tools/get-machinery-safety.js.map +1 -0
- package/dist/tools/get-risk-assessment-requirements.d.ts +47 -0
- package/dist/tools/get-risk-assessment-requirements.d.ts.map +1 -0
- package/dist/tools/get-risk-assessment-requirements.js +31 -0
- package/dist/tools/get-risk-assessment-requirements.js.map +1 -0
- package/dist/tools/get-young-worker-rules.d.ts +47 -0
- package/dist/tools/get-young-worker-rules.d.ts.map +1 -0
- package/dist/tools/get-young-worker-rules.js +31 -0
- package/dist/tools/get-young-worker-rules.js.map +1 -0
- package/dist/tools/list-sources.d.ts +18 -0
- package/dist/tools/list-sources.d.ts.map +1 -0
- package/dist/tools/list-sources.js +51 -0
- package/dist/tools/list-sources.js.map +1 -0
- package/dist/tools/search-bul-guidance.d.ts +23 -0
- package/dist/tools/search-bul-guidance.d.ts.map +1 -0
- package/dist/tools/search-bul-guidance.js +35 -0
- package/dist/tools/search-bul-guidance.js.map +1 -0
- package/dist/tools/search-safety-rules.d.ts +25 -0
- package/dist/tools/search-safety-rules.d.ts.map +1 -0
- package/dist/tools/search-safety-rules.js +26 -0
- package/dist/tools/search-safety-rules.js.map +1 -0
- package/docker-compose.yml +12 -0
- package/eslint.config.js +27 -0
- package/package.json +54 -0
- package/scripts/ingest.ts +879 -0
- package/server.json +31 -0
- package/src/db.ts +241 -0
- package/src/http-server.ts +282 -0
- package/src/jurisdiction.ts +30 -0
- package/src/metadata.ts +30 -0
- package/src/server.ts +219 -0
- package/src/tools/about.ts +28 -0
- package/src/tools/check-freshness.ts +42 -0
- package/src/tools/get-accident-reporting.ts +52 -0
- package/src/tools/get-chemical-safety.ts +52 -0
- package/src/tools/get-machinery-safety.ts +52 -0
- package/src/tools/get-risk-assessment-requirements.ts +52 -0
- package/src/tools/get-young-worker-rules.ts +52 -0
- package/src/tools/list-sources.ts +65 -0
- package/src/tools/search-bul-guidance.ts +51 -0
- package/src/tools/search-safety-rules.ts +35 -0
- package/tests/db.test.ts +121 -0
- package/tests/helpers/seed-db.ts +134 -0
- package/tests/jurisdiction.test.ts +54 -0
- package/tests/tools/about.test.ts +44 -0
- package/tests/tools/check-freshness.test.ts +68 -0
- package/tests/tools/list-sources.test.ts +75 -0
- package/tests/tools/search-safety-rules.test.ts +75 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { buildMeta } from '../metadata.js';
|
|
2
|
+
import { validateJurisdiction } from '../jurisdiction.js';
|
|
3
|
+
import { ftsSearch, type Database } from '../db.js';
|
|
4
|
+
|
|
5
|
+
interface SearchArgs {
|
|
6
|
+
query: string;
|
|
7
|
+
topic?: string;
|
|
8
|
+
jurisdiction?: string;
|
|
9
|
+
limit?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function handleSearchSafetyRules(db: Database, args: SearchArgs) {
|
|
13
|
+
const jv = validateJurisdiction(args.jurisdiction);
|
|
14
|
+
if (!jv.valid) return jv.error;
|
|
15
|
+
|
|
16
|
+
const limit = Math.min(args.limit ?? 20, 50);
|
|
17
|
+
let results = ftsSearch(db, args.query, limit);
|
|
18
|
+
|
|
19
|
+
if (args.topic) {
|
|
20
|
+
results = results.filter(r => r.area.toLowerCase() === args.topic!.toLowerCase());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
query: args.query,
|
|
25
|
+
jurisdiction: jv.jurisdiction,
|
|
26
|
+
results_count: results.length,
|
|
27
|
+
results: results.map(r => ({
|
|
28
|
+
title: r.title,
|
|
29
|
+
body: r.body,
|
|
30
|
+
area: r.area,
|
|
31
|
+
relevance_rank: r.rank,
|
|
32
|
+
})),
|
|
33
|
+
_meta: buildMeta(),
|
|
34
|
+
};
|
|
35
|
+
}
|
package/tests/db.test.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { createTestDatabase } from './helpers/seed-db.js';
|
|
3
|
+
import { ftsSearch, tieredFtsSearch, type Database } from '../src/db.js';
|
|
4
|
+
|
|
5
|
+
let db: Database;
|
|
6
|
+
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
db = createTestDatabase();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterAll(() => {
|
|
12
|
+
db.close();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('database schema', () => {
|
|
16
|
+
it('creates all expected tables', () => {
|
|
17
|
+
const tables = db.all<{ name: string }>(
|
|
18
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'search_index%' AND name != 'sqlite_stat1' ORDER BY name"
|
|
19
|
+
);
|
|
20
|
+
const names = tables.map(t => t.name);
|
|
21
|
+
expect(names).toContain('safety_rules');
|
|
22
|
+
expect(names).toContain('risk_assessments');
|
|
23
|
+
expect(names).toContain('machinery_safety');
|
|
24
|
+
expect(names).toContain('chemical_safety');
|
|
25
|
+
expect(names).toContain('young_worker_rules');
|
|
26
|
+
expect(names).toContain('accident_reporting');
|
|
27
|
+
expect(names).toContain('bul_guidance');
|
|
28
|
+
expect(names).toContain('db_metadata');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('creates FTS5 virtual table', () => {
|
|
32
|
+
const fts = db.get<{ name: string }>(
|
|
33
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='search_index'"
|
|
34
|
+
);
|
|
35
|
+
expect(fts).toBeDefined();
|
|
36
|
+
expect(fts!.name).toBe('search_index');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('stores schema version in db_metadata', () => {
|
|
40
|
+
const version = db.get<{ value: string }>(
|
|
41
|
+
"SELECT value FROM db_metadata WHERE key = 'schema_version'"
|
|
42
|
+
);
|
|
43
|
+
expect(version).toBeDefined();
|
|
44
|
+
expect(version!.value).toBe('1.0');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('stores mcp_name in db_metadata', () => {
|
|
48
|
+
const name = db.get<{ value: string }>(
|
|
49
|
+
"SELECT value FROM db_metadata WHERE key = 'mcp_name'"
|
|
50
|
+
);
|
|
51
|
+
expect(name).toBeDefined();
|
|
52
|
+
expect(name!.value).toBe('Switzerland Farm Safety MCP');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('enforces area CHECK constraint on safety_rules', () => {
|
|
56
|
+
expect(() => {
|
|
57
|
+
db.run(
|
|
58
|
+
"INSERT INTO safety_rules (topic, rule, area) VALUES ('test', 'test', 'invalid_area')"
|
|
59
|
+
);
|
|
60
|
+
}).toThrow();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('ftsSearch', () => {
|
|
65
|
+
it('returns results for a matching query', () => {
|
|
66
|
+
const results = ftsSearch(db, 'Traktor');
|
|
67
|
+
expect(results.length).toBeGreaterThan(0);
|
|
68
|
+
expect(results[0]).toHaveProperty('title');
|
|
69
|
+
expect(results[0]).toHaveProperty('body');
|
|
70
|
+
expect(results[0]).toHaveProperty('area');
|
|
71
|
+
expect(results[0]).toHaveProperty('rank');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns empty array for non-matching query', () => {
|
|
75
|
+
const results = ftsSearch(db, 'xyznonexistent');
|
|
76
|
+
expect(results).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('respects limit parameter', () => {
|
|
80
|
+
const results = ftsSearch(db, 'Sicherheit', 2);
|
|
81
|
+
expect(results.length).toBeLessThanOrEqual(2);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('tieredFtsSearch', () => {
|
|
86
|
+
it('returns tier information with results', () => {
|
|
87
|
+
const { tier, results } = tieredFtsSearch(db, 'search_index', ['title', 'body', 'area', 'jurisdiction'], 'ROPS');
|
|
88
|
+
expect(tier).toBeTruthy();
|
|
89
|
+
expect(tier).not.toBe('empty');
|
|
90
|
+
expect(results.length).toBeGreaterThan(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns empty tier for empty query', () => {
|
|
94
|
+
const { tier, results } = tieredFtsSearch(db, 'search_index', ['title', 'body', 'area', 'jurisdiction'], '');
|
|
95
|
+
expect(tier).toBe('empty');
|
|
96
|
+
expect(results).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('falls through tiers for partial matches', () => {
|
|
100
|
+
const { tier, results } = tieredFtsSearch(db, 'search_index', ['title', 'body', 'area', 'jurisdiction'], 'Zapfwelle Schutz');
|
|
101
|
+
expect(results.length).toBeGreaterThan(0);
|
|
102
|
+
// Should match via AND, prefix, or another tier
|
|
103
|
+
expect(['phrase', 'and', 'prefix', 'stemmed', 'or', 'like']).toContain(tier);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('database wrapper', () => {
|
|
108
|
+
it('get returns undefined for missing row', () => {
|
|
109
|
+
const result = db.get<{ value: string }>(
|
|
110
|
+
"SELECT value FROM db_metadata WHERE key = 'nonexistent'"
|
|
111
|
+
);
|
|
112
|
+
expect(result).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('all returns empty array for no matches', () => {
|
|
116
|
+
const results = db.all<{ id: number }>(
|
|
117
|
+
"SELECT * FROM safety_rules WHERE topic = 'nonexistent'"
|
|
118
|
+
);
|
|
119
|
+
expect(results).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createDatabase, type Database } from '../../src/db.js';
|
|
2
|
+
import { tmpdir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a temporary in-memory-like test database with seed data.
|
|
8
|
+
* Uses a temp file because better-sqlite3 FTS5 needs a real file.
|
|
9
|
+
*/
|
|
10
|
+
export function createTestDatabase(): Database {
|
|
11
|
+
const dbPath = join(tmpdir(), `ch-farm-safety-test-${randomUUID()}.db`);
|
|
12
|
+
const db = createDatabase(dbPath);
|
|
13
|
+
|
|
14
|
+
// --- safety_rules ---
|
|
15
|
+
const insertSafetyRule = `
|
|
16
|
+
INSERT INTO safety_rules (topic, rule, area, description, source, language, jurisdiction)
|
|
17
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
18
|
+
`;
|
|
19
|
+
const safetyRules = [
|
|
20
|
+
['Traktor ROPS', 'Ueberrollschutz (ROPS) ist obligatorisch auf allen Traktoren', 'maschinen', 'Alle Traktoren muessen mit einem geprueften ROPS ausgeruestet sein. Sicherheitsgurt tragen.', 'BUL Lebenswichtige Regeln', 'DE', 'CH'],
|
|
21
|
+
['Zapfwellenschutz', 'Zapfwelle muss vollstaendig verkleidet sein', 'maschinen', 'Rotierende Zapfwellen muessen mit geeignetem Schutz abgedeckt sein. Niemals offene Zapfwellen beruehren.', 'BUL Lebenswichtige Regeln', 'DE', 'CH'],
|
|
22
|
+
['Silogas Gefahr', 'Silo niemals alleine betreten', 'silogas', 'Silogase (CO2, NO2) koennen toedlich sein. Immer zu zweit arbeiten und Gasmelder verwenden.', 'Suva Merkblatt', 'DE', 'CH'],
|
|
23
|
+
['Tierhaltung Fluchtweg', 'Fluchtmoeglichkeit bei Grossvieh sicherstellen', 'tiere', 'Bei Arbeiten mit Rindern und Pferden muss immer ein Fluchtweg vorhanden sein.', 'BUL Lebenswichtige Regeln', 'DE', 'CH'],
|
|
24
|
+
['Absturzsicherung', 'Absturzsicherung ab 2 Meter Hoehe', 'hoehe', 'Ab einer Absturzhoehe von 2 Metern sind Sicherungsmassnahmen wie Gelaender oder Gurte obligatorisch.', 'EKAS Richtlinie', 'DE', 'CH'],
|
|
25
|
+
['Pflanzenschutzmittel PSA', 'Schutzausruestung bei PSM-Anwendung tragen', 'chemie', 'Bei der Anwendung von Pflanzenschutzmitteln ist die vorgeschriebene persoenliche Schutzausruestung zu tragen.', 'Suva / BUL', 'DE', 'CH'],
|
|
26
|
+
['Holzerei Sicherheitsabstand', 'Sicherheitsabstand bei Baumfaellarbeiten einhalten', 'wald', 'Mindestabstand von 2 Baumlaengen bei Faellarbeiten. Rueckzugsweg planen.', 'BUL Lebenswichtige Regeln', 'DE', 'CH'],
|
|
27
|
+
['Allgemeine Sicherheitsregeln', 'Betriebsanleitung vor Inbetriebnahme lesen', 'allgemein', 'Jede Maschine und jedes Geraet muss gemaess Betriebsanleitung bedient werden.', 'EKAS', 'DE', 'CH'],
|
|
28
|
+
];
|
|
29
|
+
for (const rule of safetyRules) {
|
|
30
|
+
db.run(insertSafetyRule, rule);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- Populate FTS search_index from safety_rules ---
|
|
34
|
+
const rules = db.all<{ topic: string; description: string; area: string; jurisdiction: string }>(
|
|
35
|
+
'SELECT topic, COALESCE(description, rule) as description, area, jurisdiction FROM safety_rules'
|
|
36
|
+
);
|
|
37
|
+
for (const r of rules) {
|
|
38
|
+
db.run(
|
|
39
|
+
'INSERT INTO search_index (title, body, area, jurisdiction) VALUES (?, ?, ?, ?)',
|
|
40
|
+
[r.topic, r.description, r.area, r.jurisdiction]
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- risk_assessments ---
|
|
45
|
+
const insertRiskAssessment = `
|
|
46
|
+
INSERT INTO risk_assessments (activity_type, requirement, content, update_trigger, legal_basis, language, jurisdiction)
|
|
47
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
48
|
+
`;
|
|
49
|
+
const riskAssessments = [
|
|
50
|
+
['Alleinarbeit', 'Risikobeurteilung obligatorisch', 'Alleinarbeit mit erhoehtem Risiko erfordert schriftliche Risikobeurteilung und Notfallkonzept.', 'Bei Aenderung der Arbeitsorganisation', 'VUV Art. 8, EKAS 6508', 'DE', 'CH'],
|
|
51
|
+
['Siloarbeiten', 'Gasmelder und Begleitperson', 'Vor Betreten des Silos: Belueftung, Gasmessung, zweite Person als Sicherungsposten.', 'Jaehrlich oder nach Zwischenfall', 'VUV Art. 3, BUL Merkblatt Silogas', 'DE', 'CH'],
|
|
52
|
+
['Tierhaltung', 'Umgang mit Grossvieh beurteilen', 'Risikobeurteilung fuer Tierhaltung umfasst Fluchtweg, Fixiereinrichtungen, Erste-Hilfe-Material.', 'Bei neuem Tierbestand oder Stallumbau', 'VUV Art. 3-10', 'DE', 'CH'],
|
|
53
|
+
];
|
|
54
|
+
for (const ra of riskAssessments) {
|
|
55
|
+
db.run(insertRiskAssessment, ra);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- machinery_safety ---
|
|
59
|
+
const insertMachinery = `
|
|
60
|
+
INSERT INTO machinery_safety (machine_type, requirement, certification, notes, language, jurisdiction)
|
|
61
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
62
|
+
`;
|
|
63
|
+
const machinerySafety = [
|
|
64
|
+
['Traktor', 'ROPS obligatorisch, Sicherheitsgurt tragen, MFK alle 4 Jahre', 'MFK / Typenpruefung', 'Gilt fuer alle Traktoren ab Baujahr 1970. Ausnahme: historische Traktoren mit Sonderbewilligung.', 'DE', 'CH'],
|
|
65
|
+
['Motorsaege', 'Schnittschutzhose, Helm mit Gehoerschutz und Visier, Schnittschutzstiefel', 'CE-Kennzeichnung', 'Nur geschulte Personen. BUL-Kurs empfohlen.', 'DE', 'CH'],
|
|
66
|
+
['Kreiselmaeher', 'Schutzvorrichtung gegen Steinschlag, Abstand zu Personen', 'CE-Kennzeichnung', 'Mindestabstand 50 Meter zu Personen waehrend des Betriebs.', 'DE', 'CH'],
|
|
67
|
+
];
|
|
68
|
+
for (const ms of machinerySafety) {
|
|
69
|
+
db.run(insertMachinery, ms);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- chemical_safety ---
|
|
73
|
+
const insertChemical = `
|
|
74
|
+
INSERT INTO chemical_safety (substance_type, exposure_limit, ppe_required, storage_rule, language, jurisdiction)
|
|
75
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
76
|
+
`;
|
|
77
|
+
const chemicalSafety = [
|
|
78
|
+
['Pflanzenschutzmittel', 'Produktspezifisch (siehe Sicherheitsdatenblatt)', 'Schutzhandschuhe, Schutzbrille, Schutzanzug, Atemschutz je nach Produkt', 'Verschlossener Giftschrank, getrennt von Lebens- und Futtermitteln, Auffangwanne', 'DE', 'CH'],
|
|
79
|
+
['Silogas', 'CO2: MAK 5000 ppm / NO2: MAK 0.5 ppm', 'Atemschutzgeraet (Isoliergeraet), Gaswarngeraet', 'Silo nach Befuellung 3 Wochen nicht betreten. Belueftung sicherstellen.', 'DE', 'CH'],
|
|
80
|
+
['Ammoniak', 'MAK 20 ppm (14 mg/m3)', 'Atemschutz (Gasfilter K), Schutzbrille, Handschuhe', 'Belueftete Raeume, Notdusche in der Naehe', 'DE', 'CH'],
|
|
81
|
+
];
|
|
82
|
+
for (const cs of chemicalSafety) {
|
|
83
|
+
db.run(insertChemical, cs);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- young_worker_rules ---
|
|
87
|
+
const insertYoungWorker = `
|
|
88
|
+
INSERT INTO young_worker_rules (age_group, restriction, exception, legal_basis, language, jurisdiction)
|
|
89
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
90
|
+
`;
|
|
91
|
+
const youngWorkerRules = [
|
|
92
|
+
['unter-13', 'Keine Arbeit erlaubt', 'Leichte Hilfstaetigkeiten auf dem eigenen Familienbetrieb ab 13 Jahren moeglich', 'ArG Art. 30, ArGV 5 Art. 8', 'DE', 'CH'],
|
|
93
|
+
['13-15', 'Leichte Arbeiten, keine gefaehrlichen Taetigkeiten, max. 3h/Tag waehrend Schulzeit', 'Familienbetrieb: erleichterte Regeln, aber kein Umgang mit gefaehrlichen Maschinen', 'ArGV 5 Art. 9-11', 'DE', 'CH'],
|
|
94
|
+
['15-18', 'Keine Alleinarbeit mit Grossvieh, keine Motorsaege ohne Ausbildung, keine Siloarbeiten', 'Ab 16 mit Berufsbildung: erweiterte Taetigkeiten unter Aufsicht moeglich', 'ArGV 5 Art. 4, Anhang 2', 'DE', 'CH'],
|
|
95
|
+
];
|
|
96
|
+
for (const yw of youngWorkerRules) {
|
|
97
|
+
db.run(insertYoungWorker, yw);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- accident_reporting ---
|
|
101
|
+
const insertAccidentReporting = `
|
|
102
|
+
INSERT INTO accident_reporting (severity, obligation, deadline, form, insurer, language, jurisdiction)
|
|
103
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
104
|
+
`;
|
|
105
|
+
const accidentReporting = [
|
|
106
|
+
['toedlich', 'Sofortige Meldung an Polizei und Suva/Privatversicherer', 'Sofort', 'Telefonische Meldung, dann Suva-Formular', 'Suva (Angestellte) / Privatversicherer (Selbstaendige)', 'DE', 'CH'],
|
|
107
|
+
['schwer', 'Meldung an Versicherer innert 3 Tagen', '3 Arbeitstage', 'Unfallmeldung UVG (Suva-Formular oder Privatversicherer)', 'Suva (Angestellte) / Privatversicherer (Selbstaendige)', 'DE', 'CH'],
|
|
108
|
+
['leicht', 'Meldung wenn Arbeitsunfaehigkeit >3 Tage', 'Innert 3 Arbeitstagen nach Kenntnis', 'Unfallmeldung UVG', 'Suva (Angestellte) / Privatversicherer (Selbstaendige)', 'DE', 'CH'],
|
|
109
|
+
];
|
|
110
|
+
for (const ar of accidentReporting) {
|
|
111
|
+
db.run(insertAccidentReporting, ar);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- bul_guidance ---
|
|
115
|
+
const insertBulGuidance = `
|
|
116
|
+
INSERT INTO bul_guidance (topic, title, description, url, language, jurisdiction)
|
|
117
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
118
|
+
`;
|
|
119
|
+
const bulGuidance = [
|
|
120
|
+
['Maschinen', 'Lebenswichtige Regeln Maschinen', 'Die 8 lebenswichtigen Regeln fuer den sicheren Umgang mit landwirtschaftlichen Maschinen.', 'https://www.bul.ch/de/themen/maschinen', 'DE', 'CH'],
|
|
121
|
+
['Tiere', 'Sicherer Umgang mit Grossvieh', 'Merkblatt zum sicheren Umgang mit Rindern, Pferden und anderen Grosstieren.', 'https://www.bul.ch/de/themen/tierhaltung', 'DE', 'CH'],
|
|
122
|
+
['Wald', 'Sicherheit bei Holzerei', 'Regeln fuer sichere Waldarbeit: Faelltechnik, Schutzausruestung, Rettungskette.', 'https://www.bul.ch/de/themen/wald', 'DE', 'CH'],
|
|
123
|
+
['Safe@Work', 'Safe@Work Kampagne Landwirtschaft', 'Praeventionskampagne fuer junge Landwirte und Lernende in der Landwirtschaft.', 'https://www.bul.ch/de/themen/safeatwork', 'DE', 'CH'],
|
|
124
|
+
];
|
|
125
|
+
for (const bg of bulGuidance) {
|
|
126
|
+
db.run(insertBulGuidance, bg);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- db_metadata ---
|
|
130
|
+
db.run("INSERT OR REPLACE INTO db_metadata (key, value) VALUES ('last_ingest', '2026-04-05')");
|
|
131
|
+
db.run("INSERT OR REPLACE INTO db_metadata (key, value) VALUES ('build_date', '2026-04-05')");
|
|
132
|
+
|
|
133
|
+
return db;
|
|
134
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validateJurisdiction, SUPPORTED_JURISDICTIONS } from '../src/jurisdiction.js';
|
|
3
|
+
|
|
4
|
+
describe('validateJurisdiction', () => {
|
|
5
|
+
it('accepts CH', () => {
|
|
6
|
+
const result = validateJurisdiction('CH');
|
|
7
|
+
expect(result.valid).toBe(true);
|
|
8
|
+
if (result.valid) {
|
|
9
|
+
expect(result.jurisdiction).toBe('CH');
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('accepts lowercase ch', () => {
|
|
14
|
+
const result = validateJurisdiction('ch');
|
|
15
|
+
expect(result.valid).toBe(true);
|
|
16
|
+
if (result.valid) {
|
|
17
|
+
expect(result.jurisdiction).toBe('CH');
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('defaults to CH when undefined', () => {
|
|
22
|
+
const result = validateJurisdiction(undefined);
|
|
23
|
+
expect(result.valid).toBe(true);
|
|
24
|
+
if (result.valid) {
|
|
25
|
+
expect(result.jurisdiction).toBe('CH');
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('rejects unsupported jurisdiction', () => {
|
|
30
|
+
const result = validateJurisdiction('DE');
|
|
31
|
+
expect(result.valid).toBe(false);
|
|
32
|
+
if (!result.valid) {
|
|
33
|
+
expect(result.error.error).toBe('jurisdiction_not_supported');
|
|
34
|
+
expect(result.error.supported).toEqual(SUPPORTED_JURISDICTIONS);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rejects empty string by defaulting to CH', () => {
|
|
39
|
+
// Empty string normalised to '' which is not in supported list, but toUpperCase of '' is ''
|
|
40
|
+
const result = validateJurisdiction('');
|
|
41
|
+
// '' is not 'CH', so this should fail
|
|
42
|
+
expect(result.valid).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('SUPPORTED_JURISDICTIONS', () => {
|
|
47
|
+
it('contains CH', () => {
|
|
48
|
+
expect(SUPPORTED_JURISDICTIONS).toContain('CH');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('is a readonly array', () => {
|
|
52
|
+
expect(Array.isArray(SUPPORTED_JURISDICTIONS)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { handleAbout } from '../../src/tools/about.js';
|
|
3
|
+
|
|
4
|
+
describe('about tool', () => {
|
|
5
|
+
const result = handleAbout();
|
|
6
|
+
|
|
7
|
+
it('returns server name', () => {
|
|
8
|
+
expect(result.name).toBe('Switzerland Farm Safety MCP');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('returns version', () => {
|
|
12
|
+
expect(result.version).toBe('0.1.0');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('includes CH in jurisdictions', () => {
|
|
16
|
+
expect(result.jurisdiction).toContain('CH');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns data sources', () => {
|
|
20
|
+
expect(result.data_sources).toBeInstanceOf(Array);
|
|
21
|
+
expect(result.data_sources.length).toBeGreaterThan(0);
|
|
22
|
+
const joined = result.data_sources.join(' ');
|
|
23
|
+
expect(joined).toContain('BUL');
|
|
24
|
+
expect(joined).toContain('Suva');
|
|
25
|
+
expect(joined).toContain('EKAS');
|
|
26
|
+
expect(joined).toContain('Agriss');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns tool count of 10', () => {
|
|
30
|
+
expect(result.tools_count).toBe(10);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('includes links', () => {
|
|
34
|
+
expect(result.links).toHaveProperty('homepage');
|
|
35
|
+
expect(result.links).toHaveProperty('repository');
|
|
36
|
+
expect(result.links).toHaveProperty('mcp_network');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('includes _meta with disclaimer', () => {
|
|
40
|
+
expect(result._meta).toHaveProperty('disclaimer');
|
|
41
|
+
expect(result._meta.disclaimer.length).toBeGreaterThan(50);
|
|
42
|
+
expect(result._meta).toHaveProperty('server', 'ch-farm-safety-mcp');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { createTestDatabase } from '../helpers/seed-db.js';
|
|
3
|
+
import { handleCheckFreshness } from '../../src/tools/check-freshness.js';
|
|
4
|
+
import type { Database } from '../../src/db.js';
|
|
5
|
+
|
|
6
|
+
let db: Database;
|
|
7
|
+
|
|
8
|
+
beforeAll(() => {
|
|
9
|
+
db = createTestDatabase();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterAll(() => {
|
|
13
|
+
db.close();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('check_data_freshness tool', () => {
|
|
17
|
+
it('returns freshness status', () => {
|
|
18
|
+
const result = handleCheckFreshness(db);
|
|
19
|
+
expect(['fresh', 'stale', 'unknown']).toContain(result.status);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns last_ingest date from seed data', () => {
|
|
23
|
+
const result = handleCheckFreshness(db);
|
|
24
|
+
expect(result.last_ingest).toBe('2026-04-05');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns build_date from seed data', () => {
|
|
28
|
+
const result = handleCheckFreshness(db);
|
|
29
|
+
expect(result.build_date).toBe('2026-04-05');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns schema_version', () => {
|
|
33
|
+
const result = handleCheckFreshness(db);
|
|
34
|
+
expect(result.schema_version).toBe('1.0');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('calculates days_since_ingest as a number', () => {
|
|
38
|
+
const result = handleCheckFreshness(db);
|
|
39
|
+
expect(typeof result.days_since_ingest).toBe('number');
|
|
40
|
+
expect(result.days_since_ingest).toBeGreaterThanOrEqual(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns staleness_threshold_days of 90', () => {
|
|
44
|
+
const result = handleCheckFreshness(db);
|
|
45
|
+
expect(result.staleness_threshold_days).toBe(90);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns refresh_command', () => {
|
|
49
|
+
const result = handleCheckFreshness(db);
|
|
50
|
+
expect(result.refresh_command).toContain('ingest.yml');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('includes _meta', () => {
|
|
54
|
+
const result = handleCheckFreshness(db);
|
|
55
|
+
expect(result._meta).toBeDefined();
|
|
56
|
+
expect(result._meta).toHaveProperty('server', 'ch-farm-safety-mcp');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('reports unknown when last_ingest is missing', () => {
|
|
60
|
+
// Create a DB without last_ingest
|
|
61
|
+
const tmpDb = createTestDatabase();
|
|
62
|
+
tmpDb.run("DELETE FROM db_metadata WHERE key = 'last_ingest'");
|
|
63
|
+
const result = handleCheckFreshness(tmpDb);
|
|
64
|
+
expect(result.status).toBe('unknown');
|
|
65
|
+
expect(result.days_since_ingest).toBeNull();
|
|
66
|
+
tmpDb.close();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { createTestDatabase } from '../helpers/seed-db.js';
|
|
3
|
+
import { handleListSources } from '../../src/tools/list-sources.js';
|
|
4
|
+
import type { Database } from '../../src/db.js';
|
|
5
|
+
|
|
6
|
+
let db: Database;
|
|
7
|
+
|
|
8
|
+
beforeAll(() => {
|
|
9
|
+
db = createTestDatabase();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterAll(() => {
|
|
13
|
+
db.close();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('list_sources tool', () => {
|
|
17
|
+
it('returns an array of sources', () => {
|
|
18
|
+
const result = handleListSources(db);
|
|
19
|
+
expect(result.sources).toBeInstanceOf(Array);
|
|
20
|
+
expect(result.sources.length).toBeGreaterThan(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('includes BUL/SPAA source', () => {
|
|
24
|
+
const result = handleListSources(db);
|
|
25
|
+
const bul = result.sources.find(s => s.name.includes('BUL'));
|
|
26
|
+
expect(bul).toBeDefined();
|
|
27
|
+
expect(bul!.authority).toContain('BUL');
|
|
28
|
+
expect(bul!.official_url).toContain('bul.ch');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('includes Suva source', () => {
|
|
32
|
+
const result = handleListSources(db);
|
|
33
|
+
const suva = result.sources.find(s => s.name.includes('Suva'));
|
|
34
|
+
expect(suva).toBeDefined();
|
|
35
|
+
expect(suva!.official_url).toContain('suva.ch');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('includes EKAS source', () => {
|
|
39
|
+
const result = handleListSources(db);
|
|
40
|
+
const ekas = result.sources.find(s => s.name.includes('EKAS'));
|
|
41
|
+
expect(ekas).toBeDefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('includes Agriss source', () => {
|
|
45
|
+
const result = handleListSources(db);
|
|
46
|
+
const agriss = result.sources.find(s => s.name.includes('Agriss'));
|
|
47
|
+
expect(agriss).toBeDefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('each source has required fields', () => {
|
|
51
|
+
const result = handleListSources(db);
|
|
52
|
+
for (const source of result.sources) {
|
|
53
|
+
expect(source).toHaveProperty('name');
|
|
54
|
+
expect(source).toHaveProperty('authority');
|
|
55
|
+
expect(source).toHaveProperty('official_url');
|
|
56
|
+
expect(source).toHaveProperty('retrieval_method');
|
|
57
|
+
expect(source).toHaveProperty('license');
|
|
58
|
+
expect(source).toHaveProperty('coverage');
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('populates last_retrieved from db_metadata', () => {
|
|
63
|
+
const result = handleListSources(db);
|
|
64
|
+
// Seed data has last_ingest = 2026-04-05
|
|
65
|
+
for (const source of result.sources) {
|
|
66
|
+
expect(source.last_retrieved).toBe('2026-04-05');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('includes _meta', () => {
|
|
71
|
+
const result = handleListSources(db);
|
|
72
|
+
expect(result._meta).toBeDefined();
|
|
73
|
+
expect(result._meta).toHaveProperty('disclaimer');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { createTestDatabase } from '../helpers/seed-db.js';
|
|
3
|
+
import { handleSearchSafetyRules } from '../../src/tools/search-safety-rules.js';
|
|
4
|
+
import type { Database } from '../../src/db.js';
|
|
5
|
+
|
|
6
|
+
let db: Database;
|
|
7
|
+
|
|
8
|
+
beforeAll(() => {
|
|
9
|
+
db = createTestDatabase();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterAll(() => {
|
|
13
|
+
db.close();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('search_safety_rules tool', () => {
|
|
17
|
+
it('returns results for a valid query', () => {
|
|
18
|
+
const result = handleSearchSafetyRules(db, { query: 'Traktor' });
|
|
19
|
+
expect(result).toHaveProperty('results_count');
|
|
20
|
+
expect((result as any).results_count).toBeGreaterThan(0);
|
|
21
|
+
expect((result as any).results).toBeInstanceOf(Array);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns jurisdiction in response', () => {
|
|
25
|
+
const result = handleSearchSafetyRules(db, { query: 'Traktor' });
|
|
26
|
+
expect((result as any).jurisdiction).toBe('CH');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns results with expected fields', () => {
|
|
30
|
+
const result = handleSearchSafetyRules(db, { query: 'ROPS' });
|
|
31
|
+
if ((result as any).results_count > 0) {
|
|
32
|
+
const first = (result as any).results[0];
|
|
33
|
+
expect(first).toHaveProperty('title');
|
|
34
|
+
expect(first).toHaveProperty('body');
|
|
35
|
+
expect(first).toHaveProperty('area');
|
|
36
|
+
expect(first).toHaveProperty('relevance_rank');
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('filters by topic when provided', () => {
|
|
41
|
+
const result = handleSearchSafetyRules(db, { query: 'Sicherheit', topic: 'maschinen' });
|
|
42
|
+
if ((result as any).results_count > 0) {
|
|
43
|
+
for (const r of (result as any).results) {
|
|
44
|
+
expect(r.area.toLowerCase()).toBe('maschinen');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('respects limit parameter', () => {
|
|
50
|
+
const result = handleSearchSafetyRules(db, { query: 'Sicherheit', limit: 1 });
|
|
51
|
+
expect((result as any).results.length).toBeLessThanOrEqual(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('caps limit at 50', () => {
|
|
55
|
+
const result = handleSearchSafetyRules(db, { query: 'Sicherheit', limit: 100 });
|
|
56
|
+
expect((result as any).results.length).toBeLessThanOrEqual(50);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('rejects unsupported jurisdiction', () => {
|
|
60
|
+
const result = handleSearchSafetyRules(db, { query: 'test', jurisdiction: 'DE' });
|
|
61
|
+
expect(result).toHaveProperty('error', 'jurisdiction_not_supported');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('includes _meta in successful response', () => {
|
|
65
|
+
const result = handleSearchSafetyRules(db, { query: 'Traktor' });
|
|
66
|
+
expect((result as any)._meta).toBeDefined();
|
|
67
|
+
expect((result as any)._meta).toHaveProperty('disclaimer');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns empty results for non-matching query', () => {
|
|
71
|
+
const result = handleSearchSafetyRules(db, { query: 'xyznonexistent' });
|
|
72
|
+
expect((result as any).results_count).toBe(0);
|
|
73
|
+
expect((result as any).results).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true,
|
|
15
|
+
"sourceMap": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*"],
|
|
18
|
+
"exclude": ["node_modules", "dist", "tests", "scripts"]
|
|
19
|
+
}
|