@ansvar/ch-livestock-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 +52 -0
  2. package/.github/workflows/ci.yml +21 -0
  3. package/.github/workflows/codeql.yml +31 -0
  4. package/.github/workflows/ghcr-build.yml +45 -0
  5. package/.github/workflows/gitleaks.yml +23 -0
  6. package/.github/workflows/ingest.yml +58 -0
  7. package/.github/workflows/publish.yml +24 -0
  8. package/CHANGELOG.md +24 -0
  9. package/CODEOWNERS +1 -0
  10. package/COVERAGE.md +62 -0
  11. package/DISCLAIMER.md +41 -0
  12. package/Dockerfile +26 -0
  13. package/LICENSE +17 -0
  14. package/PRIVACY.md +28 -0
  15. package/README.md +145 -0
  16. package/SECURITY.md +33 -0
  17. package/TOOLS.md +261 -0
  18. package/data/coverage.json +23 -0
  19. package/data/database.db +0 -0
  20. package/data/sources.yml +29 -0
  21. package/dist/db.d.ts +26 -0
  22. package/dist/db.d.ts.map +1 -0
  23. package/dist/db.js +220 -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 +294 -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 +21 -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 +240 -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-breeding-guidance.d.ts +37 -0
  50. package/dist/tools/get-breeding-guidance.d.ts.map +1 -0
  51. package/dist/tools/get-breeding-guidance.js +39 -0
  52. package/dist/tools/get-breeding-guidance.js.map +1 -0
  53. package/dist/tools/get-feed-requirements.d.ts +40 -0
  54. package/dist/tools/get-feed-requirements.d.ts.map +1 -0
  55. package/dist/tools/get-feed-requirements.js +35 -0
  56. package/dist/tools/get-feed-requirements.js.map +1 -0
  57. package/dist/tools/get-housing-requirements.d.ts +39 -0
  58. package/dist/tools/get-housing-requirements.d.ts.map +1 -0
  59. package/dist/tools/get-housing-requirements.js +35 -0
  60. package/dist/tools/get-housing-requirements.js.map +1 -0
  61. package/dist/tools/get-movement-rules.d.ts +36 -0
  62. package/dist/tools/get-movement-rules.d.ts.map +1 -0
  63. package/dist/tools/get-movement-rules.js +31 -0
  64. package/dist/tools/get-movement-rules.js.map +1 -0
  65. package/dist/tools/get-stocking-density.d.ts +37 -0
  66. package/dist/tools/get-stocking-density.d.ts.map +1 -0
  67. package/dist/tools/get-stocking-density.js +35 -0
  68. package/dist/tools/get-stocking-density.js.map +1 -0
  69. package/dist/tools/get-welfare-standards.d.ts +38 -0
  70. package/dist/tools/get-welfare-standards.d.ts.map +1 -0
  71. package/dist/tools/get-welfare-standards.js +32 -0
  72. package/dist/tools/get-welfare-standards.js.map +1 -0
  73. package/dist/tools/list-sources.d.ts +18 -0
  74. package/dist/tools/list-sources.d.ts.map +1 -0
  75. package/dist/tools/list-sources.js +51 -0
  76. package/dist/tools/list-sources.js.map +1 -0
  77. package/dist/tools/search-animal-health.d.ts +28 -0
  78. package/dist/tools/search-animal-health.d.ts.map +1 -0
  79. package/dist/tools/search-animal-health.js +33 -0
  80. package/dist/tools/search-animal-health.js.map +1 -0
  81. package/dist/tools/search-livestock-guidance.d.ts +27 -0
  82. package/dist/tools/search-livestock-guidance.d.ts.map +1 -0
  83. package/dist/tools/search-livestock-guidance.js +25 -0
  84. package/dist/tools/search-livestock-guidance.js.map +1 -0
  85. package/docker-compose.yml +12 -0
  86. package/eslint.config.js +27 -0
  87. package/package.json +54 -0
  88. package/scripts/ingest.ts +553 -0
  89. package/server.json +10 -0
  90. package/src/db.ts +268 -0
  91. package/src/http-server.ts +327 -0
  92. package/src/jurisdiction.ts +30 -0
  93. package/src/metadata.ts +31 -0
  94. package/src/server.ts +264 -0
  95. package/src/tools/about.ts +28 -0
  96. package/src/tools/check-freshness.ts +42 -0
  97. package/src/tools/get-breeding-guidance.ts +53 -0
  98. package/src/tools/get-feed-requirements.ts +53 -0
  99. package/src/tools/get-housing-requirements.ts +52 -0
  100. package/src/tools/get-movement-rules.ts +45 -0
  101. package/src/tools/get-stocking-density.ts +52 -0
  102. package/src/tools/get-welfare-standards.ts +47 -0
  103. package/src/tools/list-sources.ts +65 -0
  104. package/src/tools/search-animal-health.ts +48 -0
  105. package/src/tools/search-livestock-guidance.ts +33 -0
  106. package/tests/db.test.ts +96 -0
  107. package/tests/helpers/seed-db.ts +97 -0
  108. package/tests/jurisdiction.test.ts +41 -0
  109. package/tests/tools/about.test.ts +32 -0
  110. package/tests/tools/check-freshness.test.ts +55 -0
  111. package/tests/tools/list-sources.test.ts +56 -0
  112. package/tests/tools/search-livestock-guidance.test.ts +63 -0
  113. package/tsconfig.json +19 -0
@@ -0,0 +1,65 @@
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: 'Tierschutzverordnung (TSchV, SR 455.1)',
21
+ authority: 'Bundesamt fuer Lebensmittelsicherheit und Veterinaerwesen (BLV)',
22
+ official_url: 'https://www.fedlex.admin.ch/eli/cc/2008/416/de',
23
+ retrieval_method: 'PDF_EXTRACT',
24
+ update_frequency: 'periodic (amended as needed)',
25
+ license: 'Swiss Federal Administration — free reuse',
26
+ coverage: 'Minimum welfare standards per species, space requirements, housing, transport, slaughter',
27
+ last_retrieved: lastIngest?.value,
28
+ },
29
+ {
30
+ name: 'Direktzahlungsverordnung (DZV) — RAUS/BTS-Programme',
31
+ authority: 'Bundesamt fuer Landwirtschaft (BLW)',
32
+ official_url: 'https://www.blw.admin.ch/blw/de/home/instrumente/direktzahlungen/produktionssystembeitraege/tierwohlbeitraege.html',
33
+ retrieval_method: 'PDF_EXTRACT',
34
+ update_frequency: 'annual (with DZV updates)',
35
+ license: 'Swiss Federal Administration — free reuse',
36
+ coverage: 'RAUS outdoor access requirements, BTS housing standards, payment rates per GVE',
37
+ last_retrieved: lastIngest?.value,
38
+ },
39
+ {
40
+ name: 'Tierverkehrsdatenbank (TVD)',
41
+ authority: 'Identitas AG / BLV',
42
+ official_url: 'https://www.identitas.ch/tvd',
43
+ retrieval_method: 'PUBLIC_DOCS',
44
+ update_frequency: 'continuous (registration rules updated periodically)',
45
+ license: 'Public regulatory information',
46
+ coverage: 'Animal registration, ear tags, movement reporting, species coverage',
47
+ last_retrieved: lastIngest?.value,
48
+ },
49
+ {
50
+ name: 'Zuchtorganisationen (Braunvieh Schweiz, swissherdbook, Mutterkuh Schweiz, Suisseporcs)',
51
+ authority: 'Various breed associations',
52
+ official_url: 'https://www.braunvieh.ch',
53
+ retrieval_method: 'PUBLIC_DOCS',
54
+ update_frequency: 'annual',
55
+ license: 'Public breed information',
56
+ coverage: 'Swiss cattle, pig, sheep, goat, and horse breeds — characteristics, purpose, regional distribution',
57
+ last_retrieved: lastIngest?.value,
58
+ },
59
+ ];
60
+
61
+ return {
62
+ sources,
63
+ _meta: buildMeta(),
64
+ };
65
+ }
@@ -0,0 +1,48 @@
1
+ import { buildMeta } from '../metadata.js';
2
+ import { validateJurisdiction } from '../jurisdiction.js';
3
+ import type { Database } from '../db.js';
4
+
5
+ interface HealthSearchArgs {
6
+ query: string;
7
+ species?: string;
8
+ jurisdiction?: string;
9
+ }
10
+
11
+ export function handleSearchAnimalHealth(db: Database, args: HealthSearchArgs) {
12
+ const jv = validateJurisdiction(args.jurisdiction);
13
+ if (!jv.valid) return jv.error;
14
+
15
+ let sql = 'SELECT * FROM animal_health WHERE jurisdiction = ?';
16
+ const params: unknown[] = [jv.jurisdiction];
17
+
18
+ if (args.species) {
19
+ sql += ' AND LOWER(species) = LOWER(?)';
20
+ params.push(args.species);
21
+ }
22
+
23
+ const all = db.all<{
24
+ id: number; species: string; condition: string; symptoms: string;
25
+ prevention: string; regulatory_status: string; details: string;
26
+ }>(sql, params);
27
+
28
+ // Filter by query terms (case-insensitive substring match across all text fields)
29
+ const queryLower = args.query.toLowerCase();
30
+ const terms = queryLower.split(/\s+/).filter(t => t.length > 1);
31
+
32
+ const filtered = all.filter(row => {
33
+ const text = [row.condition, row.symptoms, row.prevention, row.details, row.regulatory_status]
34
+ .filter(Boolean)
35
+ .join(' ')
36
+ .toLowerCase();
37
+ return terms.some(t => text.includes(t));
38
+ });
39
+
40
+ return {
41
+ query: args.query,
42
+ species_filter: args.species ?? null,
43
+ jurisdiction: jv.jurisdiction,
44
+ results_count: filtered.length,
45
+ results: filtered,
46
+ _meta: buildMeta(),
47
+ };
48
+ }
@@ -0,0 +1,33 @@
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
+ species?: string;
8
+ jurisdiction?: string;
9
+ limit?: number;
10
+ }
11
+
12
+ export function handleSearchLivestockGuidance(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
+ const results = ftsSearch(db, args.query, limit, args.species);
18
+
19
+ return {
20
+ query: args.query,
21
+ species_filter: args.species ?? null,
22
+ jurisdiction: jv.jurisdiction,
23
+ results_count: results.length,
24
+ results: results.map(r => ({
25
+ title: r.title,
26
+ body: r.body,
27
+ species: r.species,
28
+ category: r.category,
29
+ relevance_rank: r.rank,
30
+ })),
31
+ _meta: buildMeta(),
32
+ };
33
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, afterEach } 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
+ afterEach(() => {
8
+ db?.close();
9
+ });
10
+
11
+ describe('createDatabase', () => {
12
+ it('creates all tables and metadata', () => {
13
+ db = createTestDatabase();
14
+ const tables = db.all<{ name: string }>(
15
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
16
+ );
17
+ const tableNames = tables.map(t => t.name).sort();
18
+ expect(tableNames).toContain('welfare_standards');
19
+ expect(tableNames).toContain('stocking_densities');
20
+ expect(tableNames).toContain('housing_requirements');
21
+ expect(tableNames).toContain('movement_rules');
22
+ expect(tableNames).toContain('breeds');
23
+ expect(tableNames).toContain('feed_requirements');
24
+ expect(tableNames).toContain('animal_health');
25
+ expect(tableNames).toContain('db_metadata');
26
+ });
27
+
28
+ it('has search_index FTS table', () => {
29
+ db = createTestDatabase();
30
+ const fts = db.all<{ name: string }>(
31
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='search_index'"
32
+ );
33
+ expect(fts.length).toBe(1);
34
+ });
35
+
36
+ it('seeds db_metadata correctly', () => {
37
+ db = createTestDatabase();
38
+ const meta = db.get<{ value: string }>(
39
+ 'SELECT value FROM db_metadata WHERE key = ?',
40
+ ['schema_version']
41
+ );
42
+ expect(meta?.value).toBe('1.0');
43
+ });
44
+ });
45
+
46
+ describe('ftsSearch', () => {
47
+ it('returns results for a matching query', () => {
48
+ db = createTestDatabase();
49
+ const results = ftsSearch(db, 'Milchkuh Laufstall');
50
+ expect(results.length).toBeGreaterThan(0);
51
+ expect(results[0].title).toContain('Milchkuh');
52
+ });
53
+
54
+ it('filters by species', () => {
55
+ db = createTestDatabase();
56
+ const results = ftsSearch(db, 'Auslauf', 20, 'Schweine');
57
+ expect(results.length).toBeGreaterThan(0);
58
+ expect(results[0].species).toBe('Schweine');
59
+ });
60
+
61
+ it('returns empty array for non-matching query', () => {
62
+ db = createTestDatabase();
63
+ const results = ftsSearch(db, 'xyznonexistent');
64
+ expect(results).toEqual([]);
65
+ });
66
+
67
+ it('respects limit parameter', () => {
68
+ db = createTestDatabase();
69
+ const results = ftsSearch(db, 'Milchkuh', 1);
70
+ expect(results.length).toBeLessThanOrEqual(1);
71
+ });
72
+ });
73
+
74
+ describe('tieredFtsSearch', () => {
75
+ it('returns tier information with results', () => {
76
+ db = createTestDatabase();
77
+ const { tier, results } = tieredFtsSearch(
78
+ db, 'search_index',
79
+ ['title', 'body', 'species', 'category', 'jurisdiction'],
80
+ 'Milchkuh', 20
81
+ );
82
+ expect(results.length).toBeGreaterThan(0);
83
+ expect(['phrase', 'and', 'prefix', 'stemmed', 'or', 'like']).toContain(tier);
84
+ });
85
+
86
+ it('returns empty for blank query', () => {
87
+ db = createTestDatabase();
88
+ const { tier, results } = tieredFtsSearch(
89
+ db, 'search_index',
90
+ ['title', 'body', 'species', 'category', 'jurisdiction'],
91
+ '', 20
92
+ );
93
+ expect(tier).toBe('empty');
94
+ expect(results).toEqual([]);
95
+ });
96
+ });
@@ -0,0 +1,97 @@
1
+ import { createDatabase, type Database } from '../../src/db.js';
2
+ import { join } from 'path';
3
+ import { mkdtempSync } from 'fs';
4
+ import { tmpdir } from 'os';
5
+
6
+ /**
7
+ * Create a temporary seeded database for tests.
8
+ * Schema matches src/db.ts initSchema exactly.
9
+ */
10
+ export function createTestDatabase(): Database {
11
+ const dir = mkdtempSync(join(tmpdir(), 'ch-livestock-test-'));
12
+ const dbPath = join(dir, 'test.db');
13
+ const db = createDatabase(dbPath);
14
+
15
+ // welfare_standards: 2 records
16
+ db.run(
17
+ `INSERT INTO welfare_standards (species, production_system, requirement, min_space_m2, details, language, jurisdiction)
18
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
19
+ ['Rinder', 'TSchV-Minimum', 'Liegeflaeche Milchkuh', 4.5, 'Mindestens 4.5 m2 pro Milchkuh im Laufstall gemaess TSchV Anhang 1', 'DE', 'CH']
20
+ );
21
+ db.run(
22
+ `INSERT INTO welfare_standards (species, production_system, requirement, min_space_m2, details, language, jurisdiction)
23
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
24
+ ['Schweine', 'RAUS', 'Auslauf Mastschwein', 1.3, 'Mastschweine muessen taeglich Zugang zum Auslauf haben, mindestens 1.3 m2', 'DE', 'CH']
25
+ );
26
+
27
+ // stocking_densities: 2 records
28
+ db.run(
29
+ `INSERT INTO stocking_densities (species, age_class, housing_type, animals_per_m2, regulatory_minimum, language, jurisdiction)
30
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
31
+ ['Rinder', 'Milchkuh', 'Laufstall', 0.22, 'TSchV Anhang 1 Tabelle 1', 'DE', 'CH']
32
+ );
33
+ db.run(
34
+ `INSERT INTO stocking_densities (species, age_class, housing_type, animals_per_m2, regulatory_minimum, language, jurisdiction)
35
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
36
+ ['Gefluegel', 'Legehenne', 'Voliere', 7.0, 'TSchV Anhang 1 Tabelle 9', 'DE', 'CH']
37
+ );
38
+
39
+ // housing_requirements: 1 record
40
+ db.run(
41
+ `INSERT INTO housing_requirements (species, age_class, system, space, ventilation, flooring, temperature, language, jurisdiction)
42
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
43
+ ['Rinder', 'Milchkuh', 'Laufstall', '4.5 m2 Liegeflaeche', 'Natuerliche Belueftung oder Zwangsbelueftung', 'Rutschfester Belag, perforiert max 3%', '0-25 C', 'DE', 'CH']
44
+ );
45
+
46
+ // movement_rules: 1 record
47
+ db.run(
48
+ `INSERT INTO movement_rules (species, rule_type, description, language, jurisdiction)
49
+ VALUES (?, ?, ?, ?, ?)`,
50
+ ['Rinder', 'TVD', 'Alle Rinder muessen innerhalb von 3 Tagen nach Geburt bei der TVD gemeldet werden. Ohrmarken sind Pflicht.', 'DE', 'CH']
51
+ );
52
+
53
+ // breeds: 1 record
54
+ db.run(
55
+ `INSERT INTO breeds (species, name, purpose, notes, language, jurisdiction)
56
+ VALUES (?, ?, ?, ?, ?, ?)`,
57
+ ['Rinder', 'Braunvieh', 'Zweinutzung (Milch und Fleisch)', 'Traditionsrasse der Schweizer Alpen, hohe Milchleistung bei guter Fleischqualitaet', 'DE', 'CH']
58
+ );
59
+
60
+ // feed_requirements: 1 record
61
+ db.run(
62
+ `INSERT INTO feed_requirements (species, age_class, production_stage, feed_type, quantity_kg_day, energy_mj, protein_g, notes, language, jurisdiction)
63
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
64
+ ['Rinder', 'Milchkuh', 'Laktation', 'Grundfutter + Kraftfutter', 22.0, 115.0, 2800.0, 'Bei 30 kg Milchleistung, GMF-konform: mind. 75% Grundfutter', 'DE', 'CH']
65
+ );
66
+
67
+ // animal_health: 1 record
68
+ db.run(
69
+ `INSERT INTO animal_health (species, condition, symptoms, prevention, regulatory_status, details, language, jurisdiction)
70
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
71
+ ['Rinder', 'BVD (Bovine Virusdiarrhoe)', 'Durchfall, Fieber, Schleimhautlaesionen, Fruchtbarkeitsstoerungen', 'Impfung, PI-Tier-Erkennung und Merzung', 'Meldepflichtige Tierseuche (TSV Art. 5)', 'BVD-Ausrottungsprogramm seit 2008, PI-Tiere muessen innert 10 Tagen entfernt werden', 'DE', 'CH']
72
+ );
73
+
74
+ // search_index (FTS5): 2 entries matching CH jurisdiction
75
+ db.run(
76
+ `INSERT INTO search_index (title, body, species, category, jurisdiction)
77
+ VALUES (?, ?, ?, ?, ?)`,
78
+ ['Liegeflaeche Milchkuh Laufstall', 'Mindestens 4.5 m2 Liegeflaeche pro Milchkuh im Laufstall gemaess TSchV Anhang 1', 'Rinder', 'welfare', 'CH']
79
+ );
80
+ db.run(
81
+ `INSERT INTO search_index (title, body, species, category, jurisdiction)
82
+ VALUES (?, ?, ?, ?, ?)`,
83
+ ['Auslauf Mastschwein RAUS', 'Mastschweine muessen taeglich Zugang zum Auslauf haben, mindestens 1.3 m2 pro Tier', 'Schweine', 'welfare', 'CH']
84
+ );
85
+
86
+ // db_metadata: ingest date for freshness tests
87
+ db.run(
88
+ `INSERT OR REPLACE INTO db_metadata (key, value) VALUES (?, ?)`,
89
+ ['last_ingest', '2026-04-05']
90
+ );
91
+ db.run(
92
+ `INSERT OR REPLACE INTO db_metadata (key, value) VALUES (?, ?)`,
93
+ ['build_date', '2026-04-05']
94
+ );
95
+
96
+ return db;
97
+ }
@@ -0,0 +1,41 @@
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('exports SUPPORTED_JURISDICTIONS with CH', () => {
39
+ expect(SUPPORTED_JURISDICTIONS).toContain('CH');
40
+ });
41
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { handleAbout } from '../../src/tools/about.js';
3
+
4
+ describe('about tool', () => {
5
+ it('returns server metadata', () => {
6
+ const result = handleAbout();
7
+ expect(result.name).toBe('Switzerland Livestock MCP');
8
+ expect(result.version).toBe('0.1.0');
9
+ expect(result.jurisdiction).toContain('CH');
10
+ expect(result.tools_count).toBe(11);
11
+ });
12
+
13
+ it('includes data_sources', () => {
14
+ const result = handleAbout();
15
+ expect(result.data_sources.length).toBeGreaterThan(0);
16
+ expect(result.data_sources.some((s: string) => s.includes('TSchV'))).toBe(true);
17
+ });
18
+
19
+ it('includes links', () => {
20
+ const result = handleAbout();
21
+ expect(result.links.homepage).toContain('ansvar.eu');
22
+ expect(result.links.repository).toContain('ch-livestock-mcp');
23
+ expect(result.links.mcp_network).toContain('ansvar.ai');
24
+ });
25
+
26
+ it('includes _meta with disclaimer', () => {
27
+ const result = handleAbout();
28
+ expect(result._meta).toBeDefined();
29
+ expect(result._meta.disclaimer).toBeTruthy();
30
+ expect(result._meta.server).toBe('ch-livestock-mcp');
31
+ });
32
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect, afterEach } 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
+ afterEach(() => {
9
+ db?.close();
10
+ });
11
+
12
+ describe('check_data_freshness tool', () => {
13
+ it('returns fresh status for recent data', () => {
14
+ db = createTestDatabase();
15
+ const result = handleCheckFreshness(db);
16
+ expect(result.status).toBe('fresh');
17
+ expect(result.last_ingest).toBe('2026-04-05');
18
+ expect(result.days_since_ingest).toBeTypeOf('number');
19
+ });
20
+
21
+ it('includes schema_version', () => {
22
+ db = createTestDatabase();
23
+ const result = handleCheckFreshness(db);
24
+ expect(result.schema_version).toBe('1.0');
25
+ });
26
+
27
+ it('includes refresh_command', () => {
28
+ db = createTestDatabase();
29
+ const result = handleCheckFreshness(db);
30
+ expect(result.refresh_command).toContain('ingest.yml');
31
+ expect(result.refresh_command).toContain('ch-livestock-mcp');
32
+ });
33
+
34
+ it('includes staleness threshold', () => {
35
+ db = createTestDatabase();
36
+ const result = handleCheckFreshness(db);
37
+ expect(result.staleness_threshold_days).toBe(90);
38
+ });
39
+
40
+ it('includes _meta', () => {
41
+ db = createTestDatabase();
42
+ const result = handleCheckFreshness(db);
43
+ expect(result._meta).toBeDefined();
44
+ expect(result._meta.disclaimer).toBeTruthy();
45
+ });
46
+
47
+ it('returns unknown for missing ingest date', () => {
48
+ db = createTestDatabase();
49
+ db.run("DELETE FROM db_metadata WHERE key = 'last_ingest'");
50
+ const result = handleCheckFreshness(db);
51
+ expect(result.status).toBe('unknown');
52
+ expect(result.last_ingest).toBeNull();
53
+ expect(result.days_since_ingest).toBeNull();
54
+ });
55
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect, afterEach } 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
+ afterEach(() => {
9
+ db?.close();
10
+ });
11
+
12
+ describe('list_sources tool', () => {
13
+ it('returns 4 data sources', () => {
14
+ db = createTestDatabase();
15
+ const result = handleListSources(db);
16
+ expect(result.sources.length).toBe(4);
17
+ });
18
+
19
+ it('includes TSchV source', () => {
20
+ db = createTestDatabase();
21
+ const result = handleListSources(db);
22
+ const tschv = result.sources.find(s => s.name.includes('TSchV'));
23
+ expect(tschv).toBeDefined();
24
+ expect(tschv!.authority).toContain('BLV');
25
+ expect(tschv!.official_url).toContain('fedlex.admin.ch');
26
+ });
27
+
28
+ it('includes DZV RAUS/BTS source', () => {
29
+ db = createTestDatabase();
30
+ const result = handleListSources(db);
31
+ const dzv = result.sources.find(s => s.name.includes('DZV'));
32
+ expect(dzv).toBeDefined();
33
+ expect(dzv!.authority).toContain('BLW');
34
+ });
35
+
36
+ it('includes TVD source', () => {
37
+ db = createTestDatabase();
38
+ const result = handleListSources(db);
39
+ const tvd = result.sources.find(s => s.name.includes('TVD'));
40
+ expect(tvd).toBeDefined();
41
+ expect(tvd!.authority).toContain('Identitas');
42
+ });
43
+
44
+ it('includes last_retrieved from db_metadata', () => {
45
+ db = createTestDatabase();
46
+ const result = handleListSources(db);
47
+ expect(result.sources[0].last_retrieved).toBe('2026-04-05');
48
+ });
49
+
50
+ it('includes _meta', () => {
51
+ db = createTestDatabase();
52
+ const result = handleListSources(db);
53
+ expect(result._meta).toBeDefined();
54
+ expect(result._meta.disclaimer).toBeTruthy();
55
+ });
56
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { createTestDatabase } from '../helpers/seed-db.js';
3
+ import { handleSearchLivestockGuidance } from '../../src/tools/search-livestock-guidance.js';
4
+ import type { Database } from '../../src/db.js';
5
+
6
+ let db: Database;
7
+
8
+ afterEach(() => {
9
+ db?.close();
10
+ });
11
+
12
+ describe('search_livestock_guidance tool', () => {
13
+ it('returns results for a matching query', () => {
14
+ db = createTestDatabase();
15
+ const result = handleSearchLivestockGuidance(db, { query: 'Milchkuh' });
16
+ expect(result).toHaveProperty('results');
17
+ expect(result).toHaveProperty('results_count');
18
+ if ('results' in result) {
19
+ expect((result as { results_count: number }).results_count).toBeGreaterThan(0);
20
+ }
21
+ });
22
+
23
+ it('filters by species', () => {
24
+ db = createTestDatabase();
25
+ const result = handleSearchLivestockGuidance(db, { query: 'Auslauf', species: 'Schweine' });
26
+ if ('results' in result && Array.isArray((result as Record<string, unknown>).results)) {
27
+ const results = (result as { results: Array<{ species: string }> }).results;
28
+ results.forEach(r => {
29
+ expect(r.species).toBe('Schweine');
30
+ });
31
+ }
32
+ });
33
+
34
+ it('respects limit', () => {
35
+ db = createTestDatabase();
36
+ const result = handleSearchLivestockGuidance(db, { query: 'Milchkuh', limit: 1 });
37
+ if ('results' in result && Array.isArray((result as Record<string, unknown>).results)) {
38
+ expect((result as { results: unknown[] }).results.length).toBeLessThanOrEqual(1);
39
+ }
40
+ });
41
+
42
+ it('caps limit at 50', () => {
43
+ db = createTestDatabase();
44
+ const result = handleSearchLivestockGuidance(db, { query: 'Milchkuh', limit: 100 });
45
+ // Should not error -- limit is capped internally
46
+ expect(result).toBeDefined();
47
+ });
48
+
49
+ it('rejects unsupported jurisdiction', () => {
50
+ db = createTestDatabase();
51
+ const result = handleSearchLivestockGuidance(db, { query: 'test', jurisdiction: 'DE' });
52
+ expect(result).toHaveProperty('error');
53
+ expect((result as { error: string }).error).toBe('jurisdiction_not_supported');
54
+ });
55
+
56
+ it('includes _meta in response', () => {
57
+ db = createTestDatabase();
58
+ const result = handleSearchLivestockGuidance(db, { query: 'Milchkuh' });
59
+ if ('_meta' in result) {
60
+ expect((result as { _meta: { disclaimer: string } })._meta.disclaimer).toBeTruthy();
61
+ }
62
+ });
63
+ });
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
+ }