@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.
- package/.github/workflows/check-freshness.yml +52 -0
- package/.github/workflows/ci.yml +21 -0
- package/.github/workflows/codeql.yml +31 -0
- package/.github/workflows/ghcr-build.yml +45 -0
- package/.github/workflows/gitleaks.yml +23 -0
- package/.github/workflows/ingest.yml +58 -0
- package/.github/workflows/publish.yml +24 -0
- package/CHANGELOG.md +24 -0
- package/CODEOWNERS +1 -0
- package/COVERAGE.md +62 -0
- package/DISCLAIMER.md +41 -0
- package/Dockerfile +26 -0
- package/LICENSE +17 -0
- package/PRIVACY.md +28 -0
- package/README.md +145 -0
- package/SECURITY.md +33 -0
- package/TOOLS.md +261 -0
- package/data/coverage.json +23 -0
- package/data/database.db +0 -0
- package/data/sources.yml +29 -0
- package/dist/db.d.ts +26 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +220 -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 +294 -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 +21 -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 +240 -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-breeding-guidance.d.ts +37 -0
- package/dist/tools/get-breeding-guidance.d.ts.map +1 -0
- package/dist/tools/get-breeding-guidance.js +39 -0
- package/dist/tools/get-breeding-guidance.js.map +1 -0
- package/dist/tools/get-feed-requirements.d.ts +40 -0
- package/dist/tools/get-feed-requirements.d.ts.map +1 -0
- package/dist/tools/get-feed-requirements.js +35 -0
- package/dist/tools/get-feed-requirements.js.map +1 -0
- package/dist/tools/get-housing-requirements.d.ts +39 -0
- package/dist/tools/get-housing-requirements.d.ts.map +1 -0
- package/dist/tools/get-housing-requirements.js +35 -0
- package/dist/tools/get-housing-requirements.js.map +1 -0
- package/dist/tools/get-movement-rules.d.ts +36 -0
- package/dist/tools/get-movement-rules.d.ts.map +1 -0
- package/dist/tools/get-movement-rules.js +31 -0
- package/dist/tools/get-movement-rules.js.map +1 -0
- package/dist/tools/get-stocking-density.d.ts +37 -0
- package/dist/tools/get-stocking-density.d.ts.map +1 -0
- package/dist/tools/get-stocking-density.js +35 -0
- package/dist/tools/get-stocking-density.js.map +1 -0
- package/dist/tools/get-welfare-standards.d.ts +38 -0
- package/dist/tools/get-welfare-standards.d.ts.map +1 -0
- package/dist/tools/get-welfare-standards.js +32 -0
- package/dist/tools/get-welfare-standards.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-animal-health.d.ts +28 -0
- package/dist/tools/search-animal-health.d.ts.map +1 -0
- package/dist/tools/search-animal-health.js +33 -0
- package/dist/tools/search-animal-health.js.map +1 -0
- package/dist/tools/search-livestock-guidance.d.ts +27 -0
- package/dist/tools/search-livestock-guidance.d.ts.map +1 -0
- package/dist/tools/search-livestock-guidance.js +25 -0
- package/dist/tools/search-livestock-guidance.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 +553 -0
- package/server.json +10 -0
- package/src/db.ts +268 -0
- package/src/http-server.ts +327 -0
- package/src/jurisdiction.ts +30 -0
- package/src/metadata.ts +31 -0
- package/src/server.ts +264 -0
- package/src/tools/about.ts +28 -0
- package/src/tools/check-freshness.ts +42 -0
- package/src/tools/get-breeding-guidance.ts +53 -0
- package/src/tools/get-feed-requirements.ts +53 -0
- package/src/tools/get-housing-requirements.ts +52 -0
- package/src/tools/get-movement-rules.ts +45 -0
- package/src/tools/get-stocking-density.ts +52 -0
- package/src/tools/get-welfare-standards.ts +47 -0
- package/src/tools/list-sources.ts +65 -0
- package/src/tools/search-animal-health.ts +48 -0
- package/src/tools/search-livestock-guidance.ts +33 -0
- package/tests/db.test.ts +96 -0
- package/tests/helpers/seed-db.ts +97 -0
- package/tests/jurisdiction.test.ts +41 -0
- package/tests/tools/about.test.ts +32 -0
- package/tests/tools/check-freshness.test.ts +55 -0
- package/tests/tools/list-sources.test.ts +56 -0
- package/tests/tools/search-livestock-guidance.test.ts +63 -0
- package/tsconfig.json +19 -0
package/src/db.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import BetterSqlite3 from 'better-sqlite3';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
export interface Database {
|
|
6
|
+
get<T>(sql: string, params?: unknown[]): T | undefined;
|
|
7
|
+
all<T>(sql: string, params?: unknown[]): T[];
|
|
8
|
+
run(sql: string, params?: unknown[]): void;
|
|
9
|
+
close(): void;
|
|
10
|
+
readonly instance: BetterSqlite3.Database;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createDatabase(dbPath?: string): Database {
|
|
14
|
+
const resolvedPath =
|
|
15
|
+
dbPath ??
|
|
16
|
+
join(dirname(fileURLToPath(import.meta.url)), '..', 'data', 'database.db');
|
|
17
|
+
const db = new BetterSqlite3(resolvedPath);
|
|
18
|
+
|
|
19
|
+
db.pragma('journal_mode = DELETE');
|
|
20
|
+
db.pragma('foreign_keys = ON');
|
|
21
|
+
|
|
22
|
+
initSchema(db);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
get<T>(sql: string, params: unknown[] = []): T | undefined {
|
|
26
|
+
return db.prepare(sql).get(...params) as T | undefined;
|
|
27
|
+
},
|
|
28
|
+
all<T>(sql: string, params: unknown[] = []): T[] {
|
|
29
|
+
return db.prepare(sql).all(...params) as T[];
|
|
30
|
+
},
|
|
31
|
+
run(sql: string, params: unknown[] = []): void {
|
|
32
|
+
db.prepare(sql).run(...params);
|
|
33
|
+
},
|
|
34
|
+
close(): void {
|
|
35
|
+
db.close();
|
|
36
|
+
},
|
|
37
|
+
get instance() {
|
|
38
|
+
return db;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function initSchema(db: BetterSqlite3.Database): void {
|
|
44
|
+
db.exec(`
|
|
45
|
+
CREATE TABLE IF NOT EXISTS welfare_standards (
|
|
46
|
+
id INTEGER PRIMARY KEY,
|
|
47
|
+
species TEXT NOT NULL,
|
|
48
|
+
production_system TEXT NOT NULL,
|
|
49
|
+
requirement TEXT NOT NULL,
|
|
50
|
+
min_space_m2 REAL,
|
|
51
|
+
details TEXT,
|
|
52
|
+
language TEXT NOT NULL DEFAULT 'DE',
|
|
53
|
+
jurisdiction TEXT NOT NULL DEFAULT 'CH'
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS stocking_densities (
|
|
57
|
+
id INTEGER PRIMARY KEY,
|
|
58
|
+
species TEXT NOT NULL,
|
|
59
|
+
age_class TEXT NOT NULL,
|
|
60
|
+
housing_type TEXT NOT NULL,
|
|
61
|
+
animals_per_m2 REAL,
|
|
62
|
+
regulatory_minimum TEXT,
|
|
63
|
+
language TEXT NOT NULL DEFAULT 'DE',
|
|
64
|
+
jurisdiction TEXT NOT NULL DEFAULT 'CH'
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
CREATE TABLE IF NOT EXISTS housing_requirements (
|
|
68
|
+
id INTEGER PRIMARY KEY,
|
|
69
|
+
species TEXT NOT NULL,
|
|
70
|
+
age_class TEXT NOT NULL,
|
|
71
|
+
system TEXT NOT NULL,
|
|
72
|
+
space TEXT,
|
|
73
|
+
ventilation TEXT,
|
|
74
|
+
flooring TEXT,
|
|
75
|
+
temperature TEXT,
|
|
76
|
+
language TEXT NOT NULL DEFAULT 'DE',
|
|
77
|
+
jurisdiction TEXT NOT NULL DEFAULT 'CH'
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
CREATE TABLE IF NOT EXISTS movement_rules (
|
|
81
|
+
id INTEGER PRIMARY KEY,
|
|
82
|
+
species TEXT NOT NULL,
|
|
83
|
+
rule_type TEXT NOT NULL,
|
|
84
|
+
description TEXT NOT NULL,
|
|
85
|
+
language TEXT NOT NULL DEFAULT 'DE',
|
|
86
|
+
jurisdiction TEXT NOT NULL DEFAULT 'CH'
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
CREATE TABLE IF NOT EXISTS breeds (
|
|
90
|
+
id INTEGER PRIMARY KEY,
|
|
91
|
+
species TEXT NOT NULL,
|
|
92
|
+
name TEXT NOT NULL,
|
|
93
|
+
purpose TEXT,
|
|
94
|
+
notes TEXT,
|
|
95
|
+
language TEXT NOT NULL DEFAULT 'DE',
|
|
96
|
+
jurisdiction TEXT NOT NULL DEFAULT 'CH'
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
CREATE TABLE IF NOT EXISTS feed_requirements (
|
|
100
|
+
id INTEGER PRIMARY KEY,
|
|
101
|
+
species TEXT NOT NULL,
|
|
102
|
+
age_class TEXT NOT NULL,
|
|
103
|
+
production_stage TEXT,
|
|
104
|
+
feed_type TEXT,
|
|
105
|
+
quantity_kg_day REAL,
|
|
106
|
+
energy_mj REAL,
|
|
107
|
+
protein_g REAL,
|
|
108
|
+
notes TEXT,
|
|
109
|
+
language TEXT NOT NULL DEFAULT 'DE',
|
|
110
|
+
jurisdiction TEXT NOT NULL DEFAULT 'CH'
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
CREATE TABLE IF NOT EXISTS animal_health (
|
|
114
|
+
id INTEGER PRIMARY KEY,
|
|
115
|
+
species TEXT NOT NULL,
|
|
116
|
+
condition TEXT NOT NULL,
|
|
117
|
+
symptoms TEXT,
|
|
118
|
+
prevention TEXT,
|
|
119
|
+
regulatory_status TEXT,
|
|
120
|
+
details TEXT,
|
|
121
|
+
language TEXT NOT NULL DEFAULT 'DE',
|
|
122
|
+
jurisdiction TEXT NOT NULL DEFAULT 'CH'
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
|
|
126
|
+
title, body, species, category, jurisdiction
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
CREATE TABLE IF NOT EXISTS db_metadata (
|
|
130
|
+
key TEXT PRIMARY KEY,
|
|
131
|
+
value TEXT
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
INSERT OR IGNORE INTO db_metadata (key, value) VALUES ('schema_version', '1.0');
|
|
135
|
+
INSERT OR IGNORE INTO db_metadata (key, value) VALUES ('mcp_name', 'Switzerland Livestock MCP');
|
|
136
|
+
INSERT OR IGNORE INTO db_metadata (key, value) VALUES ('jurisdiction', 'CH');
|
|
137
|
+
`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const FTS_COLUMNS = ['title', 'body', 'species', 'category', 'jurisdiction'];
|
|
141
|
+
|
|
142
|
+
export function ftsSearch(
|
|
143
|
+
db: Database,
|
|
144
|
+
query: string,
|
|
145
|
+
limit: number = 20,
|
|
146
|
+
species?: string
|
|
147
|
+
): { title: string; body: string; species: string; category: string; jurisdiction: string; rank: number }[] {
|
|
148
|
+
const { results } = tieredFtsSearch(db, 'search_index', FTS_COLUMNS, query, limit, species);
|
|
149
|
+
return results as { title: string; body: string; species: string; category: string; jurisdiction: string; rank: number }[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Tiered FTS5 search with automatic fallback.
|
|
154
|
+
* Tiers: exact phrase -> AND -> prefix -> stemmed prefix -> OR -> LIKE
|
|
155
|
+
*/
|
|
156
|
+
export function tieredFtsSearch(
|
|
157
|
+
db: Database,
|
|
158
|
+
table: string,
|
|
159
|
+
columns: string[],
|
|
160
|
+
query: string,
|
|
161
|
+
limit: number = 20,
|
|
162
|
+
species?: string
|
|
163
|
+
): { tier: string; results: Record<string, unknown>[] } {
|
|
164
|
+
const sanitized = sanitizeFtsInput(query);
|
|
165
|
+
if (!sanitized.trim()) return { tier: 'empty', results: [] };
|
|
166
|
+
|
|
167
|
+
const columnList = columns.join(', ');
|
|
168
|
+
const select = `SELECT ${columnList}, rank FROM ${table}`;
|
|
169
|
+
const order = `ORDER BY rank LIMIT ?`;
|
|
170
|
+
|
|
171
|
+
// Tier 1: Exact phrase
|
|
172
|
+
const phrase = `"${sanitized}"`;
|
|
173
|
+
let results = tryFts(db, select, table, order, phrase, limit, species);
|
|
174
|
+
if (results.length > 0) return { tier: 'phrase', results };
|
|
175
|
+
|
|
176
|
+
// Tier 2: AND
|
|
177
|
+
const words = sanitized.split(/\s+/).filter(w => w.length > 1);
|
|
178
|
+
if (words.length > 1) {
|
|
179
|
+
const andQuery = words.join(' AND ');
|
|
180
|
+
results = tryFts(db, select, table, order, andQuery, limit, species);
|
|
181
|
+
if (results.length > 0) return { tier: 'and', results };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Tier 3: Prefix
|
|
185
|
+
const prefixQuery = words.map(w => `${w}*`).join(' AND ');
|
|
186
|
+
results = tryFts(db, select, table, order, prefixQuery, limit, species);
|
|
187
|
+
if (results.length > 0) return { tier: 'prefix', results };
|
|
188
|
+
|
|
189
|
+
// Tier 4: Stemmed prefix
|
|
190
|
+
const stemmed = words.map(w => stemWord(w) + '*');
|
|
191
|
+
const stemmedQuery = stemmed.join(' AND ');
|
|
192
|
+
if (stemmedQuery !== prefixQuery) {
|
|
193
|
+
results = tryFts(db, select, table, order, stemmedQuery, limit, species);
|
|
194
|
+
if (results.length > 0) return { tier: 'stemmed', results };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Tier 5: OR
|
|
198
|
+
if (words.length > 1) {
|
|
199
|
+
const orQuery = words.join(' OR ');
|
|
200
|
+
results = tryFts(db, select, table, order, orQuery, limit, species);
|
|
201
|
+
if (results.length > 0) return { tier: 'or', results };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Tier 6: LIKE fallback on search_index content
|
|
205
|
+
const likeConditions = words.map(() =>
|
|
206
|
+
`(title LIKE ? OR body LIKE ? OR species LIKE ?)`
|
|
207
|
+
).join(' AND ');
|
|
208
|
+
const likeParams = words.flatMap(w => [`%${w}%`, `%${w}%`, `%${w}%`]);
|
|
209
|
+
if (species) {
|
|
210
|
+
try {
|
|
211
|
+
const likeResults = db.all<Record<string, unknown>>(
|
|
212
|
+
`SELECT title, body, species, category, jurisdiction FROM search_index WHERE ${likeConditions} AND species LIKE ? LIMIT ?`,
|
|
213
|
+
[...likeParams, `%${species}%`, limit]
|
|
214
|
+
);
|
|
215
|
+
if (likeResults.length > 0) return { tier: 'like', results: likeResults };
|
|
216
|
+
} catch {
|
|
217
|
+
// LIKE fallback failed
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const likeResults = db.all<Record<string, unknown>>(
|
|
222
|
+
`SELECT title, body, species, category, jurisdiction FROM search_index WHERE ${likeConditions} LIMIT ?`,
|
|
223
|
+
[...likeParams, limit]
|
|
224
|
+
);
|
|
225
|
+
if (likeResults.length > 0) return { tier: 'like', results: likeResults };
|
|
226
|
+
} catch {
|
|
227
|
+
// LIKE fallback failed
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { tier: 'none', results: [] };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function tryFts(
|
|
234
|
+
db: Database, select: string, table: string,
|
|
235
|
+
order: string, matchExpr: string, limit: number,
|
|
236
|
+
species?: string
|
|
237
|
+
): Record<string, unknown>[] {
|
|
238
|
+
try {
|
|
239
|
+
if (species) {
|
|
240
|
+
const filtered = db.all(
|
|
241
|
+
`${select} WHERE ${table} MATCH ? ${order}`,
|
|
242
|
+
[matchExpr, limit * 3]
|
|
243
|
+
);
|
|
244
|
+
return (filtered as Record<string, unknown>[]).filter(
|
|
245
|
+
r => (r.species as string || '').toLowerCase().includes(species.toLowerCase())
|
|
246
|
+
).slice(0, limit);
|
|
247
|
+
}
|
|
248
|
+
return db.all(
|
|
249
|
+
`${select} WHERE ${table} MATCH ? ${order}`,
|
|
250
|
+
[matchExpr, limit]
|
|
251
|
+
);
|
|
252
|
+
} catch {
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function sanitizeFtsInput(query: string): string {
|
|
258
|
+
return query
|
|
259
|
+
.replace(/["\u201C\u201D\u2018\u2019\uFF0C\u3001\uFF1B\u3002]/g, '"')
|
|
260
|
+
.replace(/[^a-zA-Z0-9\s*"_\u00C0-\u024F-]/g, ' ')
|
|
261
|
+
.replace(/\s+/g, ' ')
|
|
262
|
+
.trim();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function stemWord(word: string): string {
|
|
266
|
+
return word
|
|
267
|
+
.replace(/(ung|heit|keit|lich|isch|ieren|tion|ment|ness|able|ible|ous|ive|ing|ers|ed|es|er|en|ly|s)$/i, '');
|
|
268
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'http';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
|
+
import {
|
|
6
|
+
ListToolsRequestSchema,
|
|
7
|
+
CallToolRequestSchema,
|
|
8
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { createDatabase, type Database } from './db.js';
|
|
11
|
+
import { handleAbout } from './tools/about.js';
|
|
12
|
+
import { handleListSources } from './tools/list-sources.js';
|
|
13
|
+
import { handleCheckFreshness } from './tools/check-freshness.js';
|
|
14
|
+
import { handleSearchLivestockGuidance } from './tools/search-livestock-guidance.js';
|
|
15
|
+
import { handleGetWelfareStandards } from './tools/get-welfare-standards.js';
|
|
16
|
+
import { handleGetStockingDensity } from './tools/get-stocking-density.js';
|
|
17
|
+
import { handleGetFeedRequirements } from './tools/get-feed-requirements.js';
|
|
18
|
+
import { handleSearchAnimalHealth } from './tools/search-animal-health.js';
|
|
19
|
+
import { handleGetHousingRequirements } from './tools/get-housing-requirements.js';
|
|
20
|
+
import { handleGetMovementRules } from './tools/get-movement-rules.js';
|
|
21
|
+
import { handleGetBreedingGuidance } from './tools/get-breeding-guidance.js';
|
|
22
|
+
|
|
23
|
+
const SERVER_NAME = 'ch-livestock-mcp';
|
|
24
|
+
const SERVER_VERSION = '0.1.0';
|
|
25
|
+
const PORT = parseInt(process.env.PORT ?? '3000', 10);
|
|
26
|
+
|
|
27
|
+
const SearchArgsSchema = z.object({
|
|
28
|
+
query: z.string(),
|
|
29
|
+
species: z.string().optional(),
|
|
30
|
+
jurisdiction: z.string().optional(),
|
|
31
|
+
limit: z.number().optional(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const WelfareArgsSchema = z.object({
|
|
35
|
+
species: z.string(),
|
|
36
|
+
production_system: z.string().optional(),
|
|
37
|
+
jurisdiction: z.string().optional(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const StockingArgsSchema = z.object({
|
|
41
|
+
species: z.string(),
|
|
42
|
+
age_class: z.string().optional(),
|
|
43
|
+
housing_type: z.string().optional(),
|
|
44
|
+
jurisdiction: z.string().optional(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const FeedArgsSchema = z.object({
|
|
48
|
+
species: z.string(),
|
|
49
|
+
age_class: z.string().optional(),
|
|
50
|
+
production_stage: z.string().optional(),
|
|
51
|
+
jurisdiction: z.string().optional(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const HealthSearchArgsSchema = z.object({
|
|
55
|
+
query: z.string(),
|
|
56
|
+
species: z.string().optional(),
|
|
57
|
+
jurisdiction: z.string().optional(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const HousingArgsSchema = z.object({
|
|
61
|
+
species: z.string(),
|
|
62
|
+
age_class: z.string().optional(),
|
|
63
|
+
system: z.string().optional(),
|
|
64
|
+
jurisdiction: z.string().optional(),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const MovementArgsSchema = z.object({
|
|
68
|
+
species: z.string(),
|
|
69
|
+
rule_type: z.string().optional(),
|
|
70
|
+
jurisdiction: z.string().optional(),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const BreedingArgsSchema = z.object({
|
|
74
|
+
species: z.string(),
|
|
75
|
+
topic: z.string().optional(),
|
|
76
|
+
jurisdiction: z.string().optional(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const TOOLS = [
|
|
80
|
+
{
|
|
81
|
+
name: 'about',
|
|
82
|
+
description: 'Get server metadata: name, version, coverage, data sources, and links.',
|
|
83
|
+
inputSchema: { type: 'object' as const, properties: {} },
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'list_sources',
|
|
87
|
+
description: 'List all data sources with authority, URL, license, and freshness info.',
|
|
88
|
+
inputSchema: { type: 'object' as const, properties: {} },
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'check_data_freshness',
|
|
92
|
+
description: 'Check when data was last ingested, staleness status, and how to trigger a refresh.',
|
|
93
|
+
inputSchema: { type: 'object' as const, properties: {} },
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'search_livestock_guidance',
|
|
97
|
+
description: 'Search across all Swiss livestock topics: welfare, housing, feeding, health, transport, breeds.',
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: 'object' as const,
|
|
100
|
+
properties: {
|
|
101
|
+
query: { type: 'string', description: 'Free-text search query (German or English)' },
|
|
102
|
+
species: { type: 'string', description: 'Filter by species (e.g. Rinder, Schweine, Gefluegel, Schafe, Ziegen, Pferde)' },
|
|
103
|
+
jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
|
|
104
|
+
limit: { type: 'number', description: 'Max results (default: 20, max: 50)' },
|
|
105
|
+
},
|
|
106
|
+
required: ['query'],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'get_welfare_standards',
|
|
111
|
+
description: 'Get legal minimum welfare requirements and RAUS/BTS programme standards for a species.',
|
|
112
|
+
inputSchema: {
|
|
113
|
+
type: 'object' as const,
|
|
114
|
+
properties: {
|
|
115
|
+
species: { type: 'string', description: 'Species: Rinder, Schweine, Gefluegel, Schafe, Ziegen, Pferde' },
|
|
116
|
+
production_system: { type: 'string', description: 'Production system (e.g. TSchV-Minimum, RAUS, BTS)' },
|
|
117
|
+
jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
|
|
118
|
+
},
|
|
119
|
+
required: ['species'],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'get_stocking_density',
|
|
124
|
+
description: 'Get animals per m2 and space requirements by species, age class, and housing type.',
|
|
125
|
+
inputSchema: {
|
|
126
|
+
type: 'object' as const,
|
|
127
|
+
properties: {
|
|
128
|
+
species: { type: 'string', description: 'Species: Rinder, Schweine, Gefluegel, Schafe, Ziegen, Pferde' },
|
|
129
|
+
age_class: { type: 'string', description: 'Age/weight class (e.g. Milchkuh, Kalb, Mastschwein >60kg)' },
|
|
130
|
+
housing_type: { type: 'string', description: 'Housing type (e.g. Laufstall, Anbindestall, Voliere)' },
|
|
131
|
+
jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
|
|
132
|
+
},
|
|
133
|
+
required: ['species'],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'get_feed_requirements',
|
|
138
|
+
description: 'Get nutritional requirements per species and production stage. Includes GMF programme details.',
|
|
139
|
+
inputSchema: {
|
|
140
|
+
type: 'object' as const,
|
|
141
|
+
properties: {
|
|
142
|
+
species: { type: 'string', description: 'Species: Rinder, Schweine, Gefluegel, Schafe, Ziegen, Pferde' },
|
|
143
|
+
age_class: { type: 'string', description: 'Age class (e.g. Milchkuh, Mastschwein, Legehenne)' },
|
|
144
|
+
production_stage: { type: 'string', description: 'Production stage (e.g. Laktation, Mast, Aufzucht)' },
|
|
145
|
+
jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
|
|
146
|
+
},
|
|
147
|
+
required: ['species'],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'search_animal_health',
|
|
152
|
+
description: 'Search animal health topics: diseases, symptoms, prevention, regulatory reporting requirements.',
|
|
153
|
+
inputSchema: {
|
|
154
|
+
type: 'object' as const,
|
|
155
|
+
properties: {
|
|
156
|
+
query: { type: 'string', description: 'Search query (e.g. Salmonellen, BVD, Moderhinke)' },
|
|
157
|
+
species: { type: 'string', description: 'Filter by species' },
|
|
158
|
+
jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
|
|
159
|
+
},
|
|
160
|
+
required: ['query'],
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'get_housing_requirements',
|
|
165
|
+
description: 'Get detailed housing specs: space, ventilation, flooring, temperature. TSchV minimum vs. BTS.',
|
|
166
|
+
inputSchema: {
|
|
167
|
+
type: 'object' as const,
|
|
168
|
+
properties: {
|
|
169
|
+
species: { type: 'string', description: 'Species: Rinder, Schweine, Gefluegel, Schafe, Ziegen, Pferde' },
|
|
170
|
+
age_class: { type: 'string', description: 'Age class (e.g. Milchkuh, Mastschwein, Legehenne)' },
|
|
171
|
+
system: { type: 'string', description: 'Housing system (e.g. Laufstall, Anbindestall, BTS)' },
|
|
172
|
+
jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
|
|
173
|
+
},
|
|
174
|
+
required: ['species'],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: 'get_movement_rules',
|
|
179
|
+
description: 'Get TVD registration, transport rules, standstill, and Soemmerung requirements per species.',
|
|
180
|
+
inputSchema: {
|
|
181
|
+
type: 'object' as const,
|
|
182
|
+
properties: {
|
|
183
|
+
species: { type: 'string', description: 'Species: Rinder, Schweine, Gefluegel, Schafe, Ziegen, Pferde' },
|
|
184
|
+
rule_type: { type: 'string', description: 'Rule type: TVD, Transport, Soemmerung, Schlachtung' },
|
|
185
|
+
jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
|
|
186
|
+
},
|
|
187
|
+
required: ['species'],
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: 'get_breeding_guidance',
|
|
192
|
+
description: 'Get Swiss breed info, breeding calendars, AI (kuenstliche Besamung), genetics, Soemmerung guidance.',
|
|
193
|
+
inputSchema: {
|
|
194
|
+
type: 'object' as const,
|
|
195
|
+
properties: {
|
|
196
|
+
species: { type: 'string', description: 'Species: Rinder, Schweine, Schafe, Ziegen, Pferde' },
|
|
197
|
+
topic: { type: 'string', description: 'Topic filter (e.g. Zweinutzung, Milch, Fleisch, Alp)' },
|
|
198
|
+
jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
|
|
199
|
+
},
|
|
200
|
+
required: ['species'],
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
function textResult(data: unknown) {
|
|
206
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function errorResult(message: string) {
|
|
210
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify({ error: message }) }], isError: true };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function registerTools(server: Server, db: Database): void {
|
|
214
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
215
|
+
|
|
216
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
217
|
+
const { name, arguments: args = {} } = request.params;
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
switch (name) {
|
|
221
|
+
case 'about':
|
|
222
|
+
return textResult(handleAbout());
|
|
223
|
+
case 'list_sources':
|
|
224
|
+
return textResult(handleListSources(db));
|
|
225
|
+
case 'check_data_freshness':
|
|
226
|
+
return textResult(handleCheckFreshness(db));
|
|
227
|
+
case 'search_livestock_guidance':
|
|
228
|
+
return textResult(handleSearchLivestockGuidance(db, SearchArgsSchema.parse(args)));
|
|
229
|
+
case 'get_welfare_standards':
|
|
230
|
+
return textResult(handleGetWelfareStandards(db, WelfareArgsSchema.parse(args)));
|
|
231
|
+
case 'get_stocking_density':
|
|
232
|
+
return textResult(handleGetStockingDensity(db, StockingArgsSchema.parse(args)));
|
|
233
|
+
case 'get_feed_requirements':
|
|
234
|
+
return textResult(handleGetFeedRequirements(db, FeedArgsSchema.parse(args)));
|
|
235
|
+
case 'search_animal_health':
|
|
236
|
+
return textResult(handleSearchAnimalHealth(db, HealthSearchArgsSchema.parse(args)));
|
|
237
|
+
case 'get_housing_requirements':
|
|
238
|
+
return textResult(handleGetHousingRequirements(db, HousingArgsSchema.parse(args)));
|
|
239
|
+
case 'get_movement_rules':
|
|
240
|
+
return textResult(handleGetMovementRules(db, MovementArgsSchema.parse(args)));
|
|
241
|
+
case 'get_breeding_guidance':
|
|
242
|
+
return textResult(handleGetBreedingGuidance(db, BreedingArgsSchema.parse(args)));
|
|
243
|
+
default:
|
|
244
|
+
return errorResult(`Unknown tool: ${name}`);
|
|
245
|
+
}
|
|
246
|
+
} catch (err) {
|
|
247
|
+
return errorResult(err instanceof Error ? err.message : String(err));
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const db = createDatabase();
|
|
253
|
+
const sessions = new Map<string, { transport: StreamableHTTPServerTransport; server: Server }>();
|
|
254
|
+
|
|
255
|
+
function createMcpServer(): Server {
|
|
256
|
+
const mcpServer = new Server(
|
|
257
|
+
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
258
|
+
{ capabilities: { tools: {} } }
|
|
259
|
+
);
|
|
260
|
+
registerTools(mcpServer, db);
|
|
261
|
+
return mcpServer;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function handleMCPRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
265
|
+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
266
|
+
|
|
267
|
+
if (sessionId && sessions.has(sessionId)) {
|
|
268
|
+
const session = sessions.get(sessionId)!;
|
|
269
|
+
await session.transport.handleRequest(req, res);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (req.method === 'GET' || req.method === 'DELETE') {
|
|
274
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
275
|
+
res.end(JSON.stringify({ error: 'Invalid or missing session ID' }));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const mcpServer = createMcpServer();
|
|
280
|
+
const transport = new StreamableHTTPServerTransport({
|
|
281
|
+
sessionIdGenerator: () => randomUUID(),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
await mcpServer.connect(transport);
|
|
285
|
+
|
|
286
|
+
transport.onclose = () => {
|
|
287
|
+
if (transport.sessionId) {
|
|
288
|
+
sessions.delete(transport.sessionId);
|
|
289
|
+
}
|
|
290
|
+
mcpServer.close().catch(() => {});
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
await transport.handleRequest(req, res);
|
|
294
|
+
|
|
295
|
+
if (transport.sessionId) {
|
|
296
|
+
sessions.set(transport.sessionId, { transport, server: mcpServer });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const httpServer = createServer(async (req, res) => {
|
|
301
|
+
const url = new URL(req.url || '/', `http://localhost:${PORT}`);
|
|
302
|
+
|
|
303
|
+
if (url.pathname === '/health' && req.method === 'GET') {
|
|
304
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
305
|
+
res.end(JSON.stringify({ status: 'healthy', server: SERVER_NAME, version: SERVER_VERSION }));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (url.pathname === '/mcp' || url.pathname === '/') {
|
|
310
|
+
try {
|
|
311
|
+
await handleMCPRequest(req, res);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
if (!res.headersSent) {
|
|
314
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
315
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Internal server error' }));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
322
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
httpServer.listen(PORT, () => {
|
|
326
|
+
console.log(`${SERVER_NAME} v${SERVER_VERSION} listening on port ${PORT}`);
|
|
327
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const SUPPORTED_JURISDICTIONS = ['CH'] as const;
|
|
2
|
+
export type Jurisdiction = (typeof SUPPORTED_JURISDICTIONS)[number];
|
|
3
|
+
|
|
4
|
+
type ValidationSuccess = { valid: true; jurisdiction: Jurisdiction };
|
|
5
|
+
type ValidationFailure = {
|
|
6
|
+
valid: false;
|
|
7
|
+
error: {
|
|
8
|
+
error: string;
|
|
9
|
+
supported: readonly string[];
|
|
10
|
+
message: string;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
type ValidationResult = ValidationSuccess | ValidationFailure;
|
|
14
|
+
|
|
15
|
+
export function validateJurisdiction(input: string | undefined): ValidationResult {
|
|
16
|
+
const normalised = (input ?? 'CH').toUpperCase();
|
|
17
|
+
|
|
18
|
+
if (SUPPORTED_JURISDICTIONS.includes(normalised as Jurisdiction)) {
|
|
19
|
+
return { valid: true, jurisdiction: normalised as Jurisdiction };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
valid: false,
|
|
24
|
+
error: {
|
|
25
|
+
error: 'jurisdiction_not_supported',
|
|
26
|
+
supported: SUPPORTED_JURISDICTIONS,
|
|
27
|
+
message: 'This server currently covers Switzerland. More jurisdictions are planned.',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
package/src/metadata.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface Meta {
|
|
2
|
+
disclaimer: string;
|
|
3
|
+
data_age: string;
|
|
4
|
+
source_url: string;
|
|
5
|
+
copyright: string;
|
|
6
|
+
server: string;
|
|
7
|
+
version: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DISCLAIMER =
|
|
11
|
+
'Diese Daten dienen ausschliesslich der Information und stellen keine Rechtsberatung oder ' +
|
|
12
|
+
'veterinaermedizinische Beratung dar. Massgebend sind die Tierschutzverordnung (TSchV, SR 455.1), ' +
|
|
13
|
+
'die Tierschutzgesetzgebung (TSchG, SR 455) sowie die Weisungen des BLV und BLW. Vor Entscheidungen ' +
|
|
14
|
+
'zur Tierhaltung ist stets die zustaendige kantonale Veterinaerbehoerde oder eine anerkannte ' +
|
|
15
|
+
'Fachberatung zu konsultieren. / ' +
|
|
16
|
+
'This data is provided for informational purposes only and does not constitute legal or veterinary ' +
|
|
17
|
+
'advice. The authoritative sources are the Swiss Animal Welfare Ordinance (TSchV), the Animal Welfare ' +
|
|
18
|
+
'Act (TSchG), and guidance from BLV and BLW. Always consult the cantonal veterinary authority before ' +
|
|
19
|
+
'making livestock management decisions.';
|
|
20
|
+
|
|
21
|
+
export function buildMeta(overrides?: Partial<Meta>): Meta {
|
|
22
|
+
return {
|
|
23
|
+
disclaimer: DISCLAIMER,
|
|
24
|
+
data_age: overrides?.data_age ?? 'unknown',
|
|
25
|
+
source_url: overrides?.source_url ?? 'https://www.blv.admin.ch/blv/de/home/tiere/tierschutz.html',
|
|
26
|
+
copyright: 'Data: BLV, BLW, Agroscope, Zuchtorganisationen — used under public-sector information principles. Server: Apache-2.0 Ansvar Systems.',
|
|
27
|
+
server: 'ch-livestock-mcp',
|
|
28
|
+
version: '0.1.0',
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|