@ansvar/ch-environmental-compliance-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 (113) hide show
  1. package/.github/workflows/check-freshness.yml +49 -0
  2. package/.github/workflows/ci.yml +21 -0
  3. package/.github/workflows/codeql.yml +25 -0
  4. package/.github/workflows/ghcr-build.yml +45 -0
  5. package/.github/workflows/gitleaks.yml +18 -0
  6. package/.github/workflows/ingest.yml +59 -0
  7. package/.github/workflows/publish.yml +24 -0
  8. package/CHANGELOG.md +15 -0
  9. package/CODEOWNERS +1 -0
  10. package/COVERAGE.md +50 -0
  11. package/DISCLAIMER.md +48 -0
  12. package/Dockerfile +26 -0
  13. package/LICENSE +17 -0
  14. package/PRIVACY.md +23 -0
  15. package/README.md +116 -0
  16. package/SECURITY.md +25 -0
  17. package/TOOLS.md +142 -0
  18. package/data/coverage.json +24 -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 +197 -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 +274 -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 +22 -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 +220 -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-environmental-compliance.d.ts +30 -0
  46. package/dist/tools/check-environmental-compliance.d.ts.map +1 -0
  47. package/dist/tools/check-environmental-compliance.js +103 -0
  48. package/dist/tools/check-environmental-compliance.js.map +1 -0
  49. package/dist/tools/check-freshness.d.ts +15 -0
  50. package/dist/tools/check-freshness.d.ts.map +1 -0
  51. package/dist/tools/check-freshness.js +26 -0
  52. package/dist/tools/check-freshness.js.map +1 -0
  53. package/dist/tools/get-ammonia-rules.d.ts +24 -0
  54. package/dist/tools/get-ammonia-rules.d.ts.map +1 -0
  55. package/dist/tools/get-ammonia-rules.js +31 -0
  56. package/dist/tools/get-ammonia-rules.js.map +1 -0
  57. package/dist/tools/get-bff-requirements.d.ts +26 -0
  58. package/dist/tools/get-bff-requirements.d.ts.map +1 -0
  59. package/dist/tools/get-bff-requirements.js +36 -0
  60. package/dist/tools/get-bff-requirements.js.map +1 -0
  61. package/dist/tools/get-buffer-zone-rules.d.ts +23 -0
  62. package/dist/tools/get-buffer-zone-rules.d.ts.map +1 -0
  63. package/dist/tools/get-buffer-zone-rules.js +30 -0
  64. package/dist/tools/get-buffer-zone-rules.js.map +1 -0
  65. package/dist/tools/get-eip-requirements.d.ts +31 -0
  66. package/dist/tools/get-eip-requirements.d.ts.map +1 -0
  67. package/dist/tools/get-eip-requirements.js +40 -0
  68. package/dist/tools/get-eip-requirements.js.map +1 -0
  69. package/dist/tools/get-nutrient-loss-limits.d.ts +24 -0
  70. package/dist/tools/get-nutrient-loss-limits.d.ts.map +1 -0
  71. package/dist/tools/get-nutrient-loss-limits.js +31 -0
  72. package/dist/tools/get-nutrient-loss-limits.js.map +1 -0
  73. package/dist/tools/get-water-protection-zones.d.ts +32 -0
  74. package/dist/tools/get-water-protection-zones.d.ts.map +1 -0
  75. package/dist/tools/get-water-protection-zones.js +36 -0
  76. package/dist/tools/get-water-protection-zones.js.map +1 -0
  77. package/dist/tools/list-sources.d.ts +18 -0
  78. package/dist/tools/list-sources.d.ts.map +1 -0
  79. package/dist/tools/list-sources.js +61 -0
  80. package/dist/tools/list-sources.js.map +1 -0
  81. package/dist/tools/search-environmental-rules.d.ts +25 -0
  82. package/dist/tools/search-environmental-rules.d.ts.map +1 -0
  83. package/dist/tools/search-environmental-rules.js +26 -0
  84. package/dist/tools/search-environmental-rules.js.map +1 -0
  85. package/docker-compose.yml +12 -0
  86. package/eslint.config.js +26 -0
  87. package/package.json +54 -0
  88. package/scripts/ingest.ts +911 -0
  89. package/server.json +16 -0
  90. package/src/db.ts +238 -0
  91. package/src/http-server.ts +307 -0
  92. package/src/jurisdiction.ts +30 -0
  93. package/src/metadata.ts +32 -0
  94. package/src/server.ts +244 -0
  95. package/src/tools/about.ts +28 -0
  96. package/src/tools/check-environmental-compliance.ts +143 -0
  97. package/src/tools/check-freshness.ts +42 -0
  98. package/src/tools/get-ammonia-rules.ts +44 -0
  99. package/src/tools/get-bff-requirements.ts +52 -0
  100. package/src/tools/get-buffer-zone-rules.ts +43 -0
  101. package/src/tools/get-eip-requirements.ts +57 -0
  102. package/src/tools/get-nutrient-loss-limits.ts +44 -0
  103. package/src/tools/get-water-protection-zones.ts +50 -0
  104. package/src/tools/list-sources.ts +75 -0
  105. package/src/tools/search-environmental-rules.ts +35 -0
  106. package/tests/db.test.ts +80 -0
  107. package/tests/helpers/seed-db.ts +173 -0
  108. package/tests/jurisdiction.test.ts +35 -0
  109. package/tests/tools/about.test.ts +26 -0
  110. package/tests/tools/check-freshness.test.ts +50 -0
  111. package/tests/tools/list-sources.test.ts +61 -0
  112. package/tests/tools/search-environmental-rules.test.ts +47 -0
  113. package/tsconfig.json +19 -0
@@ -0,0 +1,44 @@
1
+ import { buildMeta } from '../metadata.js';
2
+ import { validateJurisdiction } from '../jurisdiction.js';
3
+ import type { Database } from '../db.js';
4
+
5
+ interface NutrientLossArgs {
6
+ nutrient?: string;
7
+ jurisdiction?: string;
8
+ }
9
+
10
+ export function handleGetNutrientLossLimits(db: Database, args: NutrientLossArgs) {
11
+ const jv = validateJurisdiction(args.jurisdiction);
12
+ if (!jv.valid) return jv.error;
13
+
14
+ let sql = 'SELECT * FROM nutrient_loss_limits WHERE jurisdiction = ?';
15
+ const params: unknown[] = [jv.jurisdiction];
16
+
17
+ if (args.nutrient) {
18
+ sql += ' AND LOWER(nutrient) = LOWER(?)';
19
+ params.push(args.nutrient);
20
+ }
21
+
22
+ sql += ' ORDER BY nutrient, year';
23
+
24
+ const limits = db.all<{
25
+ nutrient: string; year: number; limit_pct: number;
26
+ target: string; legal_basis: string; notes: string;
27
+ }>(sql, params);
28
+
29
+ return {
30
+ jurisdiction: jv.jurisdiction,
31
+ limits_count: limits.length,
32
+ limits: limits.map(l => ({
33
+ nutrient: l.nutrient,
34
+ year: l.year,
35
+ reduction_target_pct: l.limit_pct,
36
+ target: l.target,
37
+ legal_basis: l.legal_basis,
38
+ notes: l.notes,
39
+ })),
40
+ _meta: buildMeta({
41
+ source_url: 'https://www.blw.admin.ch/blw/de/home/nachhaltige-produktion/umwelt/naehrstoffe.html',
42
+ }),
43
+ };
44
+ }
@@ -0,0 +1,50 @@
1
+ import { buildMeta } from '../metadata.js';
2
+ import { validateJurisdiction } from '../jurisdiction.js';
3
+ import type { Database } from '../db.js';
4
+
5
+ interface WaterProtectionArgs {
6
+ zone_type?: string;
7
+ jurisdiction?: string;
8
+ }
9
+
10
+ export function handleGetWaterProtectionZones(db: Database, args: WaterProtectionArgs) {
11
+ const jv = validateJurisdiction(args.jurisdiction);
12
+ if (!jv.valid) return jv.error;
13
+
14
+ let sql = 'SELECT * FROM water_protection_zones WHERE jurisdiction = ?';
15
+ const params: unknown[] = [jv.jurisdiction];
16
+
17
+ if (args.zone_type) {
18
+ sql += ' AND LOWER(zone_type) = LOWER(?)';
19
+ params.push(args.zone_type);
20
+ }
21
+
22
+ sql += ' ORDER BY zone_type';
23
+
24
+ const zones = db.all<{
25
+ zone_type: string; name: string; restrictions: string;
26
+ description: string; legal_basis: string;
27
+ }>(sql, params);
28
+
29
+ if (zones.length === 0 && args.zone_type) {
30
+ return {
31
+ error: 'not_found',
32
+ message: `No water protection zone of type '${args.zone_type}' found. Valid types: S1, S2, S3, Sm, Zu.`,
33
+ };
34
+ }
35
+
36
+ return {
37
+ jurisdiction: jv.jurisdiction,
38
+ zones_count: zones.length,
39
+ zones: zones.map(z => ({
40
+ zone_type: z.zone_type,
41
+ name: z.name,
42
+ restrictions: z.restrictions,
43
+ description: z.description,
44
+ legal_basis: z.legal_basis,
45
+ })),
46
+ _meta: buildMeta({
47
+ source_url: 'https://www.bafu.admin.ch/bafu/de/home/themen/wasser/fachinformationen/gewaesserschutz.html',
48
+ }),
49
+ };
50
+ }
@@ -0,0 +1,75 @@
1
+ import { buildMeta } from '../metadata.js';
2
+ import type { Database } from '../db.js';
3
+
4
+ interface Source {
5
+ name: string;
6
+ authority: string;
7
+ official_url: string;
8
+ retrieval_method: string;
9
+ update_frequency: string;
10
+ license: string;
11
+ coverage: string;
12
+ last_retrieved?: string;
13
+ }
14
+
15
+ export function handleListSources(db: Database): { sources: Source[]; _meta: ReturnType<typeof buildMeta> } {
16
+ const lastIngest = db.get<{ value: string }>('SELECT value FROM db_metadata WHERE key = ?', ['last_ingest']);
17
+
18
+ const sources: Source[] = [
19
+ {
20
+ name: 'Gewaesserschutzgesetz (GSchG) / Gewaesserschutzverordnung (GSchV)',
21
+ authority: 'BAFU (Bundesamt fuer Umwelt)',
22
+ official_url: 'https://www.bafu.admin.ch/bafu/de/home/themen/wasser/fachinformationen/gewaesserschutz.html',
23
+ retrieval_method: 'PDF_EXTRACT',
24
+ update_frequency: 'periodic (amendments as enacted)',
25
+ license: 'Swiss Federal Administration — free reuse',
26
+ coverage: 'Grundwasserschutzzonen S1-S3/Sm/Zu, Pufferstreifen, Gewaesserraum',
27
+ last_retrieved: lastIngest?.value,
28
+ },
29
+ {
30
+ name: 'Luftreinhalte-Verordnung (LRV) / Ammoniak-Emissionsfaktoren',
31
+ authority: 'BAFU / Agroscope',
32
+ official_url: 'https://www.bafu.admin.ch/bafu/de/home/themen/luft/fachinformationen/luftschadstoffquellen/emissionen-der-landwirtschaft.html',
33
+ retrieval_method: 'PDF_EXTRACT',
34
+ update_frequency: 'periodic (Agrammon model updates)',
35
+ license: 'Swiss Federal Administration — free reuse',
36
+ coverage: 'Ammoniakemissionen nach Ausbringtechnik, Lagerung, Tierkategorie',
37
+ last_retrieved: lastIngest?.value,
38
+ },
39
+ {
40
+ name: 'Direktzahlungsverordnung (DZV) — BFF-Typen und OELN',
41
+ authority: 'BLW (Bundesamt fuer Landwirtschaft)',
42
+ official_url: 'https://www.blw.admin.ch/blw/de/home/instrumente/direktzahlungen/biodiversitaetsbeitraege.html',
43
+ retrieval_method: 'PDF_EXTRACT',
44
+ update_frequency: 'annual (with DZV revisions)',
45
+ license: 'Swiss Federal Administration — free reuse',
46
+ coverage: 'BFF-Typen QI/QII, Beitraege, Mindestanteil, botanische Kriterien',
47
+ last_retrieved: lastIngest?.value,
48
+ },
49
+ {
50
+ name: 'Pa.Iv. 19.475 — Absenkpfad Naehrstoffverluste',
51
+ authority: 'Parlament / BLW',
52
+ official_url: 'https://www.blw.admin.ch/blw/de/home/nachhaltige-produktion/umwelt/naehrstoffe.html',
53
+ retrieval_method: 'PDF_EXTRACT',
54
+ update_frequency: 'annual targets through 2030',
55
+ license: 'Swiss Federal Administration — free reuse',
56
+ coverage: 'N- und P-Verlust-Reduktionsziele, jaehrliche Absenkpfade',
57
+ last_retrieved: lastIngest?.value,
58
+ },
59
+ {
60
+ name: 'VBBo — Verordnung ueber Belastungen des Bodens',
61
+ authority: 'BAFU',
62
+ official_url: 'https://www.bafu.admin.ch/bafu/de/home/themen/boden/fachinformationen/bodenschutz.html',
63
+ retrieval_method: 'PDF_EXTRACT',
64
+ update_frequency: 'periodic',
65
+ license: 'Swiss Federal Administration — free reuse',
66
+ coverage: 'Schwermetall-Richtwerte (Cd, Cu, Zn, Pb), Massnahmen bei Ueberschreitung',
67
+ last_retrieved: lastIngest?.value,
68
+ },
69
+ ];
70
+
71
+ return {
72
+ sources,
73
+ _meta: buildMeta(),
74
+ };
75
+ }
@@ -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 handleSearchEnvironmentalRules(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.topic.toLowerCase().includes(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
+ topic: r.topic,
31
+ relevance_rank: r.rank,
32
+ })),
33
+ _meta: buildMeta(),
34
+ };
35
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'vitest';
2
+ import { createDatabase, type Database } from '../src/db.js';
3
+ import { existsSync, unlinkSync } from 'fs';
4
+
5
+ const TEST_DB = 'tests/test-database.db';
6
+
7
+ describe('database layer', () => {
8
+ let db: Database;
9
+
10
+ beforeAll(() => {
11
+ db = createDatabase(TEST_DB);
12
+ });
13
+
14
+ afterAll(() => {
15
+ db.close();
16
+ if (existsSync(TEST_DB)) unlinkSync(TEST_DB);
17
+ });
18
+
19
+ test('creates database with db_metadata table', () => {
20
+ const row = db.get<{ value: string }>(
21
+ 'SELECT value FROM db_metadata WHERE key = ?',
22
+ ['schema_version']
23
+ );
24
+ expect(row?.value).toBe('1.0');
25
+ });
26
+
27
+ test('creates water_protection_zones table', () => {
28
+ const result = db.all<{ name: string }>(
29
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='water_protection_zones'"
30
+ );
31
+ expect(result).toHaveLength(1);
32
+ });
33
+
34
+ test('creates buffer_zones table', () => {
35
+ const result = db.all<{ name: string }>(
36
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='buffer_zones'"
37
+ );
38
+ expect(result).toHaveLength(1);
39
+ });
40
+
41
+ test('creates ammonia_rules table', () => {
42
+ const result = db.all<{ name: string }>(
43
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='ammonia_rules'"
44
+ );
45
+ expect(result).toHaveLength(1);
46
+ });
47
+
48
+ test('creates bff_types table', () => {
49
+ const result = db.all<{ name: string }>(
50
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='bff_types'"
51
+ );
52
+ expect(result).toHaveLength(1);
53
+ });
54
+
55
+ test('creates nutrient_loss_limits table', () => {
56
+ const result = db.all<{ name: string }>(
57
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='nutrient_loss_limits'"
58
+ );
59
+ expect(result).toHaveLength(1);
60
+ });
61
+
62
+ test('creates environmental_rules table', () => {
63
+ const result = db.all<{ name: string }>(
64
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='environmental_rules'"
65
+ );
66
+ expect(result).toHaveLength(1);
67
+ });
68
+
69
+ test('FTS5 search_index exists', () => {
70
+ const result = db.all<{ name: string }>(
71
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='search_index'"
72
+ );
73
+ expect(result).toHaveLength(1);
74
+ });
75
+
76
+ test('journal mode is DELETE', () => {
77
+ const row = db.get<{ journal_mode: string }>('PRAGMA journal_mode');
78
+ expect(row?.journal_mode).toBe('delete');
79
+ });
80
+ });
@@ -0,0 +1,173 @@
1
+ import { createDatabase, type Database } from '../../src/db.js';
2
+
3
+ export function createSeededDatabase(dbPath: string): Database {
4
+ const db = createDatabase(dbPath);
5
+
6
+ // Water protection zones (S1, S2, S3)
7
+ db.run(
8
+ `INSERT INTO water_protection_zones (zone_type, name, restrictions, description, legal_basis, jurisdiction)
9
+ VALUES (?, ?, ?, ?, ?, ?)`,
10
+ ['S1', 'Fassungsbereich', 'Kein Zugang, keine Bauten, keine Duengung, keine PSM', 'Engste Schutzzone direkt um die Trinkwasserfassung', 'GSchG Art. 20, GSchV Art. 29', 'CH']
11
+ );
12
+ db.run(
13
+ `INSERT INTO water_protection_zones (zone_type, name, restrictions, description, legal_basis, jurisdiction)
14
+ VALUES (?, ?, ?, ?, ?, ?)`,
15
+ ['S2', 'Engere Schutzzone', 'Keine Bauten, kein Aushub, keine Guellegrube, eingeschraenkte Duengung', 'Schutzzone zum Schutz vor bakterieller Verunreinigung', 'GSchG Art. 20, GSchV Art. 29', 'CH']
16
+ );
17
+ db.run(
18
+ `INSERT INTO water_protection_zones (zone_type, name, restrictions, description, legal_basis, jurisdiction)
19
+ VALUES (?, ?, ?, ?, ?, ?)`,
20
+ ['S3', 'Weitere Schutzzone', 'Einschraenkungen fuer Grabungen, Lagerung von Gefahrstoffen', 'Schutzzone zum Schutz vor schwer abbaubaren Stoffen', 'GSchG Art. 20, GSchV Art. 29', 'CH']
21
+ );
22
+
23
+ // Buffer zones
24
+ db.run(
25
+ `INSERT INTO buffer_zones (type, distance_m, requirement, source_law, notes, jurisdiction)
26
+ VALUES (?, ?, ?, ?, ?, ?)`,
27
+ ['Gewaesser — Pflanzenschutzmittel', 6, 'Kein Einsatz von PSM innerhalb 6m entlang Oberflaechengewaessern', 'ChemRRV Anhang 2.5', 'Standardabstand, produktspezifisch erhoehbar (SPe-Auflagen)', 'CH']
28
+ );
29
+ db.run(
30
+ `INSERT INTO buffer_zones (type, distance_m, requirement, source_law, notes, jurisdiction)
31
+ VALUES (?, ?, ?, ?, ?, ?)`,
32
+ ['Gewaesser — Duenger', 3, 'Kein Einsatz von Duenger innerhalb 3m entlang Oberflaechengewaessern', 'OELN/DZV Art. 18', 'Gilt fuer alle Direktzahlungsempfaenger', 'CH']
33
+ );
34
+ db.run(
35
+ `INSERT INTO buffer_zones (type, distance_m, requirement, source_law, notes, jurisdiction)
36
+ VALUES (?, ?, ?, ?, ?, ?)`,
37
+ ['Hecke/Feldgehoelz', 3, 'Kein Einsatz von PSM und Duenger innerhalb 3m ab Stockmitte', 'OELN/DZV Art. 18', null, 'CH']
38
+ );
39
+
40
+ // Ammonia rules
41
+ db.run(
42
+ `INSERT INTO ammonia_rules (technique, emission_factor, requirement, legal_basis, effective_date, notes, jurisdiction)
43
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
44
+ ['Schleppschlauch', 30, 'Pflicht ab 2024 fuer Betriebe >3 ha in Talzone', 'LRV Anhang 2 Ziff. 5', '2024-01-01', 'Reduktion gegenueber Prallteller ca. 30-50%', 'CH']
45
+ );
46
+ db.run(
47
+ `INSERT INTO ammonia_rules (technique, emission_factor, requirement, legal_basis, effective_date, notes, jurisdiction)
48
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
49
+ ['Prallteller', 100, 'Referenztechnik — hoechster Emissionsfaktor', 'LRV Anhang 2 Ziff. 5', null, 'Basisvergleich fuer Agrammon-Modell', 'CH']
50
+ );
51
+ db.run(
52
+ `INSERT INTO ammonia_rules (technique, emission_factor, requirement, legal_basis, effective_date, notes, jurisdiction)
53
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
54
+ ['Injektion', 10, 'Niedrigste Emissionen, geeignet fuer Ackerbau', 'LRV Anhang 2 Ziff. 5', null, 'Nicht auf allen Boeden einsetzbar', 'CH']
55
+ );
56
+
57
+ // BFF types
58
+ db.run(
59
+ `INSERT INTO bff_types (id, name, quality_level, payment_chf_ha, min_area_pct, botanical_criteria, notes, jurisdiction)
60
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
61
+ ['extensiv-wiese-qi', 'Extensiv genutzte Wiese', 'QI', 1500, 7, 'Keine Duengung, 1 Schnitt ab 15. Juni', 'Haeufigster BFF-Typ', 'CH']
62
+ );
63
+ db.run(
64
+ `INSERT INTO bff_types (id, name, quality_level, payment_chf_ha, min_area_pct, botanical_criteria, notes, jurisdiction)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
66
+ ['extensiv-wiese-qii', 'Extensiv genutzte Wiese', 'QII', 2800, 7, 'Mind. 6 Indikatorpflanzen pro Areal, Strukturvielfalt', 'QII erfordert botanische Kartierung', 'CH']
67
+ );
68
+ db.run(
69
+ `INSERT INTO bff_types (id, name, quality_level, payment_chf_ha, min_area_pct, botanical_criteria, notes, jurisdiction)
70
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
71
+ ['buntbrache-qi', 'Buntbrache', 'QI', 3800, 0, 'Ansaat von mind. 30 einheimischen Wildkraeutern', 'Mindestens 1 Jahr stehenlassen', 'CH']
72
+ );
73
+ db.run(
74
+ `INSERT INTO bff_types (id, name, quality_level, payment_chf_ha, min_area_pct, botanical_criteria, notes, jurisdiction)
75
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
76
+ ['hecke-qi', 'Hecke mit Krautsaum', 'QI', 2700, 0, 'Einheimische Strauch- und Baumarten, 3m Krautsaum', 'Pflege alle 5-8 Jahre', 'CH']
77
+ );
78
+
79
+ // Nutrient loss limits
80
+ db.run(
81
+ `INSERT INTO nutrient_loss_limits (nutrient, year, limit_pct, target, legal_basis, notes, jurisdiction)
82
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
83
+ ['N', 2025, 10, 'Reduktion N-Verluste um 10% gegenueber 2014-2016', 'LwG Art. 6a (Pa.Iv. 19.475)', 'Zwischenziel', 'CH']
84
+ );
85
+ db.run(
86
+ `INSERT INTO nutrient_loss_limits (nutrient, year, limit_pct, target, legal_basis, notes, jurisdiction)
87
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
88
+ ['N', 2030, 20, 'Reduktion N-Verluste um 20% gegenueber 2014-2016', 'LwG Art. 6a (Pa.Iv. 19.475)', 'Endziel 2030', 'CH']
89
+ );
90
+ db.run(
91
+ `INSERT INTO nutrient_loss_limits (nutrient, year, limit_pct, target, legal_basis, notes, jurisdiction)
92
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
93
+ ['P', 2025, 10, 'Reduktion P-Verluste um 10% gegenueber 2014-2016', 'LwG Art. 6a (Pa.Iv. 19.475)', 'Zwischenziel', 'CH']
94
+ );
95
+ db.run(
96
+ `INSERT INTO nutrient_loss_limits (nutrient, year, limit_pct, target, legal_basis, notes, jurisdiction)
97
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
98
+ ['P', 2030, 20, 'Reduktion P-Verluste um 20% gegenueber 2014-2016', 'LwG Art. 6a (Pa.Iv. 19.475)', 'Endziel 2030', 'CH']
99
+ );
100
+
101
+ // Environmental rules — UVP
102
+ db.run(
103
+ `INSERT INTO environmental_rules (topic, rule, authority, legal_basis, threshold, notes, jurisdiction)
104
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
105
+ ['UVP', 'UVP-Pflicht fuer Stallbauten ab Schwellenwert', 'BAFU / kantonale Umweltaemter', 'UVPV Anhang Nr. 80.4', 'Ab 150 GVE (kantonal abweichend)', 'Schwellenwerte variieren je nach Kanton und Tierart', 'CH']
106
+ );
107
+ db.run(
108
+ `INSERT INTO environmental_rules (topic, rule, authority, legal_basis, threshold, notes, jurisdiction)
109
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
110
+ ['UVP', 'UVP-Pflicht fuer Biogasanlagen', 'BAFU / kantonale Umweltaemter', 'UVPV Anhang Nr. 80.5', 'Ab 5000 t Substrat pro Jahr', null, 'CH']
111
+ );
112
+
113
+ // Environmental rules — VBBo
114
+ db.run(
115
+ `INSERT INTO environmental_rules (topic, rule, authority, legal_basis, threshold, notes, jurisdiction)
116
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
117
+ ['VBBo', 'Richtwert Cadmium im Boden', 'BAFU', 'VBBo Anhang 1', '0.8 mg/kg (Richtwert)', 'Bei Ueberschreitung: Ursachenabklaerung und Massnahmenplan', 'CH']
118
+ );
119
+ db.run(
120
+ `INSERT INTO environmental_rules (topic, rule, authority, legal_basis, threshold, notes, jurisdiction)
121
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
122
+ ['VBBo', 'Richtwert Kupfer im Boden', 'BAFU', 'VBBo Anhang 1', '40 mg/kg (Richtwert)', 'Haeufig ueberschritten in Rebbergen und Hopfengaerten', 'CH']
123
+ );
124
+
125
+ // db_metadata for freshness
126
+ db.run(
127
+ `INSERT OR REPLACE INTO db_metadata (key, value) VALUES (?, ?)`,
128
+ ['last_ingest', '2026-04-01']
129
+ );
130
+ db.run(
131
+ `INSERT OR REPLACE INTO db_metadata (key, value) VALUES (?, ?)`,
132
+ ['build_date', '2026-04-01']
133
+ );
134
+
135
+ // FTS5 search index entries
136
+ db.run(
137
+ `INSERT INTO search_index (title, body, topic, jurisdiction)
138
+ VALUES (?, ?, ?, ?)`,
139
+ ['Grundwasserschutzzone S1', 'Fassungsbereich. Kein Zugang, keine Bauten, keine Duengung, keine PSM.', 'Gewaesserschutz', 'CH']
140
+ );
141
+ db.run(
142
+ `INSERT INTO search_index (title, body, topic, jurisdiction)
143
+ VALUES (?, ?, ?, ?)`,
144
+ ['Schleppschlauch-Pflicht', 'Pflicht ab 2024 fuer Betriebe >3 ha in Talzone. Reduktion Ammoniakemissionen.', 'Ammoniak', 'CH']
145
+ );
146
+ db.run(
147
+ `INSERT INTO search_index (title, body, topic, jurisdiction)
148
+ VALUES (?, ?, ?, ?)`,
149
+ ['Extensiv genutzte Wiese QII', 'Mind. 6 Indikatorpflanzen pro Areal. Beitrag CHF 2800/ha. BFF-Typ.', 'BFF', 'CH']
150
+ );
151
+ db.run(
152
+ `INSERT INTO search_index (title, body, topic, jurisdiction)
153
+ VALUES (?, ?, ?, ?)`,
154
+ ['Pufferstreifen Gewaesser PSM', 'Kein Einsatz von Pflanzenschutzmitteln innerhalb 6m entlang Oberflaechengewaessern.', 'Pufferstreifen', 'CH']
155
+ );
156
+ db.run(
157
+ `INSERT INTO search_index (title, body, topic, jurisdiction)
158
+ VALUES (?, ?, ?, ?)`,
159
+ ['UVP Stallbau Schwellenwert', 'UVP-Pflicht fuer Stallbauten ab 150 GVE. Kantonal abweichend.', 'UVP', 'CH']
160
+ );
161
+ db.run(
162
+ `INSERT INTO search_index (title, body, topic, jurisdiction)
163
+ VALUES (?, ?, ?, ?)`,
164
+ ['Absenkpfad Stickstoff 2030', 'Reduktion N-Verluste um 20% gegenueber 2014-2016. Pa.Iv. 19.475.', 'Naehrstoffe', 'CH']
165
+ );
166
+ db.run(
167
+ `INSERT INTO search_index (title, body, topic, jurisdiction)
168
+ VALUES (?, ?, ?, ?)`,
169
+ ['VBBo Richtwert Cadmium', 'Richtwert Cadmium im Boden 0.8 mg/kg. Bei Ueberschreitung Massnahmenplan.', 'VBBo', 'CH']
170
+ );
171
+
172
+ return db;
173
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import { validateJurisdiction, SUPPORTED_JURISDICTIONS } from '../src/jurisdiction.js';
3
+
4
+ describe('jurisdiction validation', () => {
5
+ test('accepts CH', () => {
6
+ const result = validateJurisdiction('CH');
7
+ expect(result).toEqual({ valid: true, jurisdiction: 'CH' });
8
+ });
9
+
10
+ test('defaults to CH when undefined', () => {
11
+ const result = validateJurisdiction(undefined);
12
+ expect(result).toEqual({ valid: true, jurisdiction: 'CH' });
13
+ });
14
+
15
+ test('rejects unsupported jurisdiction', () => {
16
+ const result = validateJurisdiction('DE');
17
+ expect(result).toEqual({
18
+ valid: false,
19
+ error: {
20
+ error: 'jurisdiction_not_supported',
21
+ supported: ['CH'],
22
+ message: 'This server currently covers Switzerland. More jurisdictions are planned.',
23
+ },
24
+ });
25
+ });
26
+
27
+ test('normalises lowercase input', () => {
28
+ const result = validateJurisdiction('ch');
29
+ expect(result).toEqual({ valid: true, jurisdiction: 'CH' });
30
+ });
31
+
32
+ test('SUPPORTED_JURISDICTIONS contains CH', () => {
33
+ expect(SUPPORTED_JURISDICTIONS).toContain('CH');
34
+ });
35
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import { handleAbout } from '../../src/tools/about.js';
3
+
4
+ describe('about tool', () => {
5
+ test('returns server metadata', () => {
6
+ const result = handleAbout();
7
+ expect(result.name).toBe('Switzerland Environmental Compliance MCP');
8
+ expect(result.description).toContain('GSchG');
9
+ expect(result.jurisdiction).toEqual(['CH']);
10
+ expect(result.tools_count).toBe(11);
11
+ expect(result.links).toHaveProperty('homepage');
12
+ expect(result.links).toHaveProperty('repository');
13
+ expect(result._meta).toHaveProperty('disclaimer');
14
+ });
15
+
16
+ test('includes version', () => {
17
+ const result = handleAbout();
18
+ expect(result.version).toMatch(/^\d+\.\d+\.\d+$/);
19
+ });
20
+
21
+ test('lists data sources', () => {
22
+ const result = handleAbout();
23
+ expect(result.data_sources).toBeDefined();
24
+ expect(result.data_sources.length).toBeGreaterThan(0);
25
+ });
26
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'vitest';
2
+ import { handleCheckFreshness } from '../../src/tools/check-freshness.js';
3
+ import { createSeededDatabase } from '../helpers/seed-db.js';
4
+ import type { Database } from '../../src/db.js';
5
+ import { existsSync, unlinkSync } from 'fs';
6
+
7
+ const TEST_DB = 'tests/test-freshness.db';
8
+
9
+ describe('check_data_freshness tool', () => {
10
+ let db: Database;
11
+
12
+ beforeAll(() => {
13
+ db = createSeededDatabase(TEST_DB);
14
+ });
15
+
16
+ afterAll(() => {
17
+ db.close();
18
+ if (existsSync(TEST_DB)) unlinkSync(TEST_DB);
19
+ });
20
+
21
+ test('returns freshness status', () => {
22
+ const result = handleCheckFreshness(db);
23
+ expect(['fresh', 'stale', 'unknown']).toContain(result.status);
24
+ });
25
+
26
+ test('returns last_ingest date', () => {
27
+ const result = handleCheckFreshness(db);
28
+ expect(result.last_ingest).toBe('2026-04-01');
29
+ });
30
+
31
+ test('returns schema_version', () => {
32
+ const result = handleCheckFreshness(db);
33
+ expect(result.schema_version).toBe('1.0');
34
+ });
35
+
36
+ test('includes staleness threshold', () => {
37
+ const result = handleCheckFreshness(db);
38
+ expect(result.staleness_threshold_days).toBe(90);
39
+ });
40
+
41
+ test('includes refresh command', () => {
42
+ const result = handleCheckFreshness(db);
43
+ expect(result.refresh_command).toContain('ingest.yml');
44
+ });
45
+
46
+ test('includes _meta with disclaimer', () => {
47
+ const result = handleCheckFreshness(db);
48
+ expect(result._meta).toHaveProperty('disclaimer');
49
+ });
50
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'vitest';
2
+ import { handleListSources } from '../../src/tools/list-sources.js';
3
+ import { createSeededDatabase } from '../helpers/seed-db.js';
4
+ import type { Database } from '../../src/db.js';
5
+ import { existsSync, unlinkSync } from 'fs';
6
+
7
+ const TEST_DB = 'tests/test-list-sources.db';
8
+
9
+ describe('list_sources tool', () => {
10
+ let db: Database;
11
+
12
+ beforeAll(() => {
13
+ db = createSeededDatabase(TEST_DB);
14
+ });
15
+
16
+ afterAll(() => {
17
+ db.close();
18
+ if (existsSync(TEST_DB)) unlinkSync(TEST_DB);
19
+ });
20
+
21
+ test('returns sources array', () => {
22
+ const result = handleListSources(db);
23
+ expect(result.sources).toBeDefined();
24
+ expect(result.sources.length).toBeGreaterThan(0);
25
+ });
26
+
27
+ test('each source has required fields', () => {
28
+ const result = handleListSources(db);
29
+ for (const source of result.sources) {
30
+ expect(source).toHaveProperty('name');
31
+ expect(source).toHaveProperty('authority');
32
+ expect(source).toHaveProperty('official_url');
33
+ expect(source).toHaveProperty('license');
34
+ }
35
+ });
36
+
37
+ test('includes GSchG source', () => {
38
+ const result = handleListSources(db);
39
+ const gschg = result.sources.find(s => s.name.includes('GSchG'));
40
+ expect(gschg).toBeDefined();
41
+ expect(gschg!.authority).toContain('BAFU');
42
+ });
43
+
44
+ test('includes LRV source', () => {
45
+ const result = handleListSources(db);
46
+ const lrv = result.sources.find(s => s.name.includes('LRV'));
47
+ expect(lrv).toBeDefined();
48
+ });
49
+
50
+ test('includes DZV source', () => {
51
+ const result = handleListSources(db);
52
+ const dzv = result.sources.find(s => s.name.includes('DZV'));
53
+ expect(dzv).toBeDefined();
54
+ expect(dzv!.authority).toContain('BLW');
55
+ });
56
+
57
+ test('includes _meta with disclaimer', () => {
58
+ const result = handleListSources(db);
59
+ expect(result._meta).toHaveProperty('disclaimer');
60
+ });
61
+ });