@ansvar/ch-organic-regen-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 +49 -0
- package/.github/workflows/ci.yml +21 -0
- package/.github/workflows/codeql.yml +25 -0
- package/.github/workflows/ghcr-build.yml +45 -0
- package/.github/workflows/gitleaks.yml +18 -0
- package/.github/workflows/ingest.yml +59 -0
- package/.github/workflows/publish.yml +24 -0
- package/CHANGELOG.md +15 -0
- package/CODEOWNERS +1 -0
- package/COVERAGE.md +47 -0
- package/DISCLAIMER.md +67 -0
- package/Dockerfile +26 -0
- package/LICENSE +17 -0
- package/PRIVACY.md +23 -0
- package/README.md +117 -0
- package/SECURITY.md +25 -0
- package/TOOLS.md +141 -0
- package/data/coverage.json +22 -0
- package/data/database.db +0 -0
- package/data/sources.yml +36 -0
- package/dist/db.d.ts +25 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +184 -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 +263 -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 +11 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +31 -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 +209 -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-approved-inputs.d.ts +26 -0
- package/dist/tools/get-approved-inputs.d.ts.map +1 -0
- package/dist/tools/get-approved-inputs.js +28 -0
- package/dist/tools/get-approved-inputs.js.map +1 -0
- package/dist/tools/get-conversion-requirements.d.ts +28 -0
- package/dist/tools/get-conversion-requirements.d.ts.map +1 -0
- package/dist/tools/get-conversion-requirements.js +32 -0
- package/dist/tools/get-conversion-requirements.js.map +1 -0
- package/dist/tools/get-organic-standards.d.ts +27 -0
- package/dist/tools/get-organic-standards.d.ts.map +1 -0
- package/dist/tools/get-organic-standards.js +31 -0
- package/dist/tools/get-organic-standards.js.map +1 -0
- package/dist/tools/get-organic-subsidies.d.ts +28 -0
- package/dist/tools/get-organic-subsidies.d.ts.map +1 -0
- package/dist/tools/get-organic-subsidies.js +32 -0
- package/dist/tools/get-organic-subsidies.js.map +1 -0
- package/dist/tools/get-soil-health-guidance.d.ts +25 -0
- package/dist/tools/get-soil-health-guidance.d.ts.map +1 -0
- package/dist/tools/get-soil-health-guidance.js +27 -0
- package/dist/tools/get-soil-health-guidance.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 +61 -0
- package/dist/tools/list-sources.js.map +1 -0
- package/dist/tools/search-certification-guidance.d.ts +24 -0
- package/dist/tools/search-certification-guidance.d.ts.map +1 -0
- package/dist/tools/search-certification-guidance.js +24 -0
- package/dist/tools/search-certification-guidance.js.map +1 -0
- package/dist/tools/search-organic-rules.d.ts +25 -0
- package/dist/tools/search-organic-rules.d.ts.map +1 -0
- package/dist/tools/search-organic-rules.js +26 -0
- package/dist/tools/search-organic-rules.js.map +1 -0
- package/docker-compose.yml +12 -0
- package/eslint.config.js +26 -0
- package/package.json +54 -0
- package/scripts/ingest.ts +963 -0
- package/server.json +16 -0
- package/src/db.ts +225 -0
- package/src/http-server.ts +302 -0
- package/src/jurisdiction.ts +30 -0
- package/src/metadata.ts +45 -0
- package/src/server.ts +239 -0
- package/src/tools/about.ts +28 -0
- package/src/tools/check-freshness.ts +42 -0
- package/src/tools/get-approved-inputs.ts +44 -0
- package/src/tools/get-conversion-requirements.ts +50 -0
- package/src/tools/get-organic-standards.ts +48 -0
- package/src/tools/get-organic-subsidies.ts +50 -0
- package/src/tools/get-soil-health-guidance.ts +42 -0
- package/src/tools/list-sources.ts +75 -0
- package/src/tools/search-certification-guidance.ts +41 -0
- package/src/tools/search-organic-rules.ts +35 -0
- package/tests/db.test.ts +96 -0
- package/tests/helpers/seed-db.ts +145 -0
- package/tests/jurisdiction.test.ts +35 -0
- package/tests/tools/about.test.ts +22 -0
- package/tests/tools/check-freshness.test.ts +57 -0
- package/tests/tools/list-sources.test.ts +55 -0
- package/tests/tools/search-organic-rules.test.ts +56 -0
- package/tsconfig.json +19 -0
package/server.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.Ansvar-Systems/ch-organic-regen",
|
|
4
|
+
"description": "Swiss organic and regenerative farming -- Bio Suisse Knospe, FiBL Betriebsmittelliste, Demeter, conversion, subsidies, soil health",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/Ansvar-Systems/ch-organic-regen-mcp",
|
|
7
|
+
"source": "github"
|
|
8
|
+
},
|
|
9
|
+
"version": "0.1.0",
|
|
10
|
+
"remotes": [
|
|
11
|
+
{
|
|
12
|
+
"type": "streamable-http",
|
|
13
|
+
"url": "https://mcp.ansvar.eu/ch-organic-regen/mcp"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
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 organic_standards (
|
|
46
|
+
id INTEGER PRIMARY KEY,
|
|
47
|
+
production_type TEXT NOT NULL,
|
|
48
|
+
standard TEXT NOT NULL,
|
|
49
|
+
rule TEXT NOT NULL,
|
|
50
|
+
description TEXT NOT NULL,
|
|
51
|
+
jurisdiction TEXT NOT NULL DEFAULT 'CH'
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
CREATE TABLE IF NOT EXISTS conversion_requirements (
|
|
55
|
+
id INTEGER PRIMARY KEY,
|
|
56
|
+
farm_type TEXT NOT NULL,
|
|
57
|
+
current_system TEXT NOT NULL,
|
|
58
|
+
timeline_years INTEGER NOT NULL,
|
|
59
|
+
requirements TEXT NOT NULL,
|
|
60
|
+
support_measures TEXT,
|
|
61
|
+
jurisdiction TEXT NOT NULL DEFAULT 'CH'
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS approved_inputs (
|
|
65
|
+
id INTEGER PRIMARY KEY,
|
|
66
|
+
input_type TEXT NOT NULL,
|
|
67
|
+
product_name TEXT NOT NULL,
|
|
68
|
+
description TEXT NOT NULL,
|
|
69
|
+
restrictions TEXT,
|
|
70
|
+
source TEXT NOT NULL,
|
|
71
|
+
jurisdiction TEXT NOT NULL DEFAULT 'CH'
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE TABLE IF NOT EXISTS organic_subsidies (
|
|
75
|
+
id INTEGER PRIMARY KEY,
|
|
76
|
+
subsidy_type TEXT NOT NULL,
|
|
77
|
+
zone TEXT,
|
|
78
|
+
rate_chf_ha REAL NOT NULL,
|
|
79
|
+
conditions TEXT NOT NULL,
|
|
80
|
+
stacking_rules TEXT,
|
|
81
|
+
jurisdiction TEXT NOT NULL DEFAULT 'CH'
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
CREATE TABLE IF NOT EXISTS soil_health (
|
|
85
|
+
id INTEGER PRIMARY KEY,
|
|
86
|
+
topic TEXT NOT NULL,
|
|
87
|
+
guidance TEXT NOT NULL,
|
|
88
|
+
technique TEXT NOT NULL,
|
|
89
|
+
benefits TEXT NOT NULL,
|
|
90
|
+
jurisdiction TEXT NOT NULL DEFAULT 'CH'
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
CREATE TABLE IF NOT EXISTS certification_guidance (
|
|
94
|
+
id INTEGER PRIMARY KEY,
|
|
95
|
+
step TEXT NOT NULL,
|
|
96
|
+
description TEXT NOT NULL,
|
|
97
|
+
inspector TEXT,
|
|
98
|
+
frequency TEXT,
|
|
99
|
+
cost_notes TEXT,
|
|
100
|
+
jurisdiction TEXT NOT NULL DEFAULT 'CH'
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
|
|
104
|
+
title, body, topic, jurisdiction
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
CREATE TABLE IF NOT EXISTS db_metadata (
|
|
108
|
+
key TEXT PRIMARY KEY,
|
|
109
|
+
value TEXT
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
INSERT OR IGNORE INTO db_metadata (key, value) VALUES ('schema_version', '1.0');
|
|
113
|
+
INSERT OR IGNORE INTO db_metadata (key, value) VALUES ('mcp_name', 'Switzerland Organic & Regenerative Farming MCP');
|
|
114
|
+
INSERT OR IGNORE INTO db_metadata (key, value) VALUES ('jurisdiction', 'CH');
|
|
115
|
+
`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const FTS_COLUMNS = ['title', 'body', 'topic', 'jurisdiction'];
|
|
119
|
+
|
|
120
|
+
export function ftsSearch(
|
|
121
|
+
db: Database,
|
|
122
|
+
query: string,
|
|
123
|
+
limit: number = 20
|
|
124
|
+
): { title: string; body: string; topic: string; jurisdiction: string; rank: number }[] {
|
|
125
|
+
const { results } = tieredFtsSearch(db, 'search_index', FTS_COLUMNS, query, limit);
|
|
126
|
+
return results as { title: string; body: string; topic: string; jurisdiction: string; rank: number }[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Tiered FTS5 search with automatic fallback.
|
|
131
|
+
* Tiers: exact phrase -> AND -> prefix -> stemmed prefix -> OR -> LIKE
|
|
132
|
+
*/
|
|
133
|
+
export function tieredFtsSearch(
|
|
134
|
+
db: Database,
|
|
135
|
+
table: string,
|
|
136
|
+
columns: string[],
|
|
137
|
+
query: string,
|
|
138
|
+
limit: number = 20
|
|
139
|
+
): { tier: string; results: Record<string, unknown>[] } {
|
|
140
|
+
const sanitized = sanitizeFtsInput(query);
|
|
141
|
+
if (!sanitized.trim()) return { tier: 'empty', results: [] };
|
|
142
|
+
|
|
143
|
+
const columnList = columns.join(', ');
|
|
144
|
+
const select = `SELECT ${columnList}, rank FROM ${table}`;
|
|
145
|
+
const order = `ORDER BY rank LIMIT ?`;
|
|
146
|
+
|
|
147
|
+
// Tier 1: Exact phrase
|
|
148
|
+
const phrase = `"${sanitized}"`;
|
|
149
|
+
let results = tryFts(db, select, table, order, phrase, limit);
|
|
150
|
+
if (results.length > 0) return { tier: 'phrase', results };
|
|
151
|
+
|
|
152
|
+
// Tier 2: AND
|
|
153
|
+
const words = sanitized.split(/\s+/).filter(w => w.length > 1);
|
|
154
|
+
if (words.length > 1) {
|
|
155
|
+
const andQuery = words.join(' AND ');
|
|
156
|
+
results = tryFts(db, select, table, order, andQuery, limit);
|
|
157
|
+
if (results.length > 0) return { tier: 'and', results };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Tier 3: Prefix
|
|
161
|
+
const prefixQuery = words.map(w => `${w}*`).join(' AND ');
|
|
162
|
+
results = tryFts(db, select, table, order, prefixQuery, limit);
|
|
163
|
+
if (results.length > 0) return { tier: 'prefix', results };
|
|
164
|
+
|
|
165
|
+
// Tier 4: Stemmed prefix
|
|
166
|
+
const stemmed = words.map(w => stemWord(w) + '*');
|
|
167
|
+
const stemmedQuery = stemmed.join(' AND ');
|
|
168
|
+
if (stemmedQuery !== prefixQuery) {
|
|
169
|
+
results = tryFts(db, select, table, order, stemmedQuery, limit);
|
|
170
|
+
if (results.length > 0) return { tier: 'stemmed', results };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Tier 5: OR
|
|
174
|
+
if (words.length > 1) {
|
|
175
|
+
const orQuery = words.join(' OR ');
|
|
176
|
+
results = tryFts(db, select, table, order, orQuery, limit);
|
|
177
|
+
if (results.length > 0) return { tier: 'or', results };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Tier 6: LIKE fallback across organic_standards
|
|
181
|
+
const likeConditions = words.map(() =>
|
|
182
|
+
`(rule LIKE ? OR description LIKE ? OR production_type LIKE ?)`
|
|
183
|
+
).join(' AND ');
|
|
184
|
+
const likeParams = words.flatMap(w =>
|
|
185
|
+
[`%${w}%`, `%${w}%`, `%${w}%`]
|
|
186
|
+
);
|
|
187
|
+
try {
|
|
188
|
+
const likeResults = db.all<Record<string, unknown>>(
|
|
189
|
+
`SELECT rule as title, description as body, production_type as topic, jurisdiction FROM organic_standards WHERE ${likeConditions} LIMIT ?`,
|
|
190
|
+
[...likeParams, limit]
|
|
191
|
+
);
|
|
192
|
+
if (likeResults.length > 0) return { tier: 'like', results: likeResults };
|
|
193
|
+
} catch {
|
|
194
|
+
// LIKE fallback failed
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { tier: 'none', results: [] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function tryFts(
|
|
201
|
+
db: Database, select: string, table: string,
|
|
202
|
+
order: string, matchExpr: string, limit: number
|
|
203
|
+
): Record<string, unknown>[] {
|
|
204
|
+
try {
|
|
205
|
+
return db.all(
|
|
206
|
+
`${select} WHERE ${table} MATCH ? ${order}`,
|
|
207
|
+
[matchExpr, limit]
|
|
208
|
+
);
|
|
209
|
+
} catch {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function sanitizeFtsInput(query: string): string {
|
|
215
|
+
return query
|
|
216
|
+
.replace(/["""'',,,,]/g, '"')
|
|
217
|
+
.replace(/[^a-zA-Z0-9\s*"_\u00C0-\u024F-]/g, ' ')
|
|
218
|
+
.replace(/\s+/g, ' ')
|
|
219
|
+
.trim();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function stemWord(word: string): string {
|
|
223
|
+
return word
|
|
224
|
+
.replace(/(ung|heit|keit|lich|isch|ieren|tion|ment|ness|able|ible|ous|ive|ing|ers|ed|es|er|en|ly|s)$/i, '');
|
|
225
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
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 { handleSearchOrganicRules } from './tools/search-organic-rules.js';
|
|
15
|
+
import { handleGetConversionRequirements } from './tools/get-conversion-requirements.js';
|
|
16
|
+
import { handleGetOrganicStandards } from './tools/get-organic-standards.js';
|
|
17
|
+
import { handleGetApprovedInputs } from './tools/get-approved-inputs.js';
|
|
18
|
+
import { handleGetOrganicSubsidies } from './tools/get-organic-subsidies.js';
|
|
19
|
+
import { handleGetSoilHealthGuidance } from './tools/get-soil-health-guidance.js';
|
|
20
|
+
import { handleSearchCertificationGuidance } from './tools/search-certification-guidance.js';
|
|
21
|
+
|
|
22
|
+
const SERVER_NAME = 'ch-organic-regen-mcp';
|
|
23
|
+
const SERVER_VERSION = '0.1.0';
|
|
24
|
+
const PORT = parseInt(process.env.PORT ?? '3000', 10);
|
|
25
|
+
|
|
26
|
+
const SearchOrganicRulesSchema = z.object({
|
|
27
|
+
query: z.string(),
|
|
28
|
+
topic: z.string().optional(),
|
|
29
|
+
jurisdiction: z.string().optional(),
|
|
30
|
+
limit: z.number().optional(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const ConversionArgsSchema = z.object({
|
|
34
|
+
farm_type: z.string().optional(),
|
|
35
|
+
current_system: z.string().optional(),
|
|
36
|
+
jurisdiction: z.string().optional(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const StandardsArgsSchema = z.object({
|
|
40
|
+
production_type: z.string().optional(),
|
|
41
|
+
standard: z.string().optional(),
|
|
42
|
+
jurisdiction: z.string().optional(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const InputsArgsSchema = z.object({
|
|
46
|
+
input_type: z.string().optional(),
|
|
47
|
+
jurisdiction: z.string().optional(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const SubsidiesArgsSchema = z.object({
|
|
51
|
+
subsidy_type: z.string().optional(),
|
|
52
|
+
zone: z.string().optional(),
|
|
53
|
+
jurisdiction: z.string().optional(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const SoilHealthArgsSchema = z.object({
|
|
57
|
+
topic: z.string().optional(),
|
|
58
|
+
jurisdiction: z.string().optional(),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const CertArgsSchema = z.object({
|
|
62
|
+
query: z.string(),
|
|
63
|
+
jurisdiction: z.string().optional(),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const TOOLS = [
|
|
67
|
+
{
|
|
68
|
+
name: 'about',
|
|
69
|
+
description: 'Get server metadata: name, version, coverage, data sources, and links.',
|
|
70
|
+
inputSchema: { type: 'object' as const, properties: {} },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'list_sources',
|
|
74
|
+
description: 'List all data sources with authority, URL, license, and freshness info.',
|
|
75
|
+
inputSchema: { type: 'object' as const, properties: {} },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'check_data_freshness',
|
|
79
|
+
description: 'Check when data was last ingested, staleness status, and how to trigger a refresh.',
|
|
80
|
+
inputSchema: { type: 'object' as const, properties: {} },
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'search_organic_rules',
|
|
84
|
+
description:
|
|
85
|
+
'Volltextsuche ueber Bio-Richtlinien, Umstellung, Bodenfruchtbarkeit und regenerative Landwirtschaft. ' +
|
|
86
|
+
'Search organic farming rules, conversion guidance, soil health, and regenerative agriculture topics.',
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: 'object' as const,
|
|
89
|
+
properties: {
|
|
90
|
+
query: { type: 'string', description: 'Suchbegriff / free-text search query (German or English)' },
|
|
91
|
+
topic: { type: 'string', description: 'Filter by topic (e.g. knospe, demeter, umstellung, bodenfruchtbarkeit)' },
|
|
92
|
+
jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
|
|
93
|
+
limit: { type: 'number', description: 'Max results (default: 20, max: 50)' },
|
|
94
|
+
},
|
|
95
|
+
required: ['query'],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'get_conversion_requirements',
|
|
100
|
+
description:
|
|
101
|
+
'Umstellungsanforderungen fuer den Biobetrieb: Zeitrahmen, Voraussetzungen, Unterstuetzungsmassnahmen. ' +
|
|
102
|
+
'Get organic conversion timeline, requirements, and support measures by farm type.',
|
|
103
|
+
inputSchema: {
|
|
104
|
+
type: 'object' as const,
|
|
105
|
+
properties: {
|
|
106
|
+
farm_type: { type: 'string', description: 'Betriebstyp (e.g. ackerbau, milchwirtschaft, obstbau, rebbau, gemuese)' },
|
|
107
|
+
current_system: { type: 'string', description: 'Aktuelles System (e.g. oeln, konventionell, ip)' },
|
|
108
|
+
jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'get_organic_standards',
|
|
114
|
+
description:
|
|
115
|
+
'Bio-Richtlinien nach Standard (Knospe, Bio-Verordnung, Demeter) und Produktionsbereich. ' +
|
|
116
|
+
'Get organic production rules comparing Bio Suisse Knospe, federal Bio-Verordnung, and Demeter standards.',
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: 'object' as const,
|
|
119
|
+
properties: {
|
|
120
|
+
production_type: { type: 'string', description: 'Produktionsbereich (e.g. tierhaltung, pflanzenbau, verarbeitung, futtermittel)' },
|
|
121
|
+
standard: { type: 'string', description: 'Standard: bio_verordnung, knospe, demeter' },
|
|
122
|
+
jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'get_approved_inputs',
|
|
128
|
+
description:
|
|
129
|
+
'Zugelassene Betriebsmittel fuer Biolandbau (FiBL-Liste): Duenger, Pflanzenschutz, Futtermittel. ' +
|
|
130
|
+
'Get approved inputs for organic farming from the FiBL Betriebsmittelliste.',
|
|
131
|
+
inputSchema: {
|
|
132
|
+
type: 'object' as const,
|
|
133
|
+
properties: {
|
|
134
|
+
input_type: { type: 'string', description: 'Betriebsmitteltyp: duenger, pflanzenschutz, futtermittel' },
|
|
135
|
+
jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'get_organic_subsidies',
|
|
141
|
+
description:
|
|
142
|
+
'Bio-Beitrag und weitere Direktzahlungen fuer Biobetriebe: Beitragshoehe, Bedingungen, Kumulierung. ' +
|
|
143
|
+
'Get organic farming subsidies (Bio-Beitrag) rates, conditions, and stacking rules.',
|
|
144
|
+
inputSchema: {
|
|
145
|
+
type: 'object' as const,
|
|
146
|
+
properties: {
|
|
147
|
+
subsidy_type: { type: 'string', description: 'Beitragstyp (e.g. bio_beitrag, extenso, bff, tierwohl)' },
|
|
148
|
+
zone: { type: 'string', description: 'Zone: talzone, huegelzone, bergzone_i, bergzone_ii, bergzone_iii, bergzone_iv' },
|
|
149
|
+
jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: 'get_soil_health_guidance',
|
|
155
|
+
description:
|
|
156
|
+
'Bodenfruchtbarkeit und regenerative Methoden: Kompost, Gruenduengung, Fruchtfolge, Conservation Agriculture. ' +
|
|
157
|
+
'Get soil health and regenerative agriculture guidance: composting, cover crops, rotation, agroforestry.',
|
|
158
|
+
inputSchema: {
|
|
159
|
+
type: 'object' as const,
|
|
160
|
+
properties: {
|
|
161
|
+
topic: { type: 'string', description: 'Thema (e.g. kompost, gruenduengung, fruchtfolge, agroforstwirtschaft, untersaat, direktsaat)' },
|
|
162
|
+
jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'search_certification_guidance',
|
|
168
|
+
description:
|
|
169
|
+
'Zertifizierungsprozess Bio Suisse Knospe: Schritte, Inspektionsstellen, Kosten, Haeufigkeit. ' +
|
|
170
|
+
'Search organic certification guidance: bio.inspecta, Bio Test Agro process, Knospe application steps.',
|
|
171
|
+
inputSchema: {
|
|
172
|
+
type: 'object' as const,
|
|
173
|
+
properties: {
|
|
174
|
+
query: { type: 'string', description: 'Suchbegriff (e.g. anmeldung, kontrolle, kosten, inspektion)' },
|
|
175
|
+
jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
|
|
176
|
+
},
|
|
177
|
+
required: ['query'],
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
function textResult(data: unknown) {
|
|
183
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function errorResult(message: string) {
|
|
187
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify({ error: message }) }], isError: true };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function registerTools(server: Server, db: Database): void {
|
|
191
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
192
|
+
|
|
193
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
194
|
+
const { name, arguments: args = {} } = request.params;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
switch (name) {
|
|
198
|
+
case 'about':
|
|
199
|
+
return textResult(handleAbout());
|
|
200
|
+
case 'list_sources':
|
|
201
|
+
return textResult(handleListSources(db));
|
|
202
|
+
case 'check_data_freshness':
|
|
203
|
+
return textResult(handleCheckFreshness(db));
|
|
204
|
+
case 'search_organic_rules':
|
|
205
|
+
return textResult(handleSearchOrganicRules(db, SearchOrganicRulesSchema.parse(args)));
|
|
206
|
+
case 'get_conversion_requirements':
|
|
207
|
+
return textResult(handleGetConversionRequirements(db, ConversionArgsSchema.parse(args)));
|
|
208
|
+
case 'get_organic_standards':
|
|
209
|
+
return textResult(handleGetOrganicStandards(db, StandardsArgsSchema.parse(args)));
|
|
210
|
+
case 'get_approved_inputs':
|
|
211
|
+
return textResult(handleGetApprovedInputs(db, InputsArgsSchema.parse(args)));
|
|
212
|
+
case 'get_organic_subsidies':
|
|
213
|
+
return textResult(handleGetOrganicSubsidies(db, SubsidiesArgsSchema.parse(args)));
|
|
214
|
+
case 'get_soil_health_guidance':
|
|
215
|
+
return textResult(handleGetSoilHealthGuidance(db, SoilHealthArgsSchema.parse(args)));
|
|
216
|
+
case 'search_certification_guidance':
|
|
217
|
+
return textResult(handleSearchCertificationGuidance(db, CertArgsSchema.parse(args)));
|
|
218
|
+
default:
|
|
219
|
+
return errorResult(`Unknown tool: ${name}`);
|
|
220
|
+
}
|
|
221
|
+
} catch (err) {
|
|
222
|
+
return errorResult(err instanceof Error ? err.message : String(err));
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const db = createDatabase();
|
|
228
|
+
const sessions = new Map<string, { transport: StreamableHTTPServerTransport; server: Server }>();
|
|
229
|
+
|
|
230
|
+
function createMcpServer(): Server {
|
|
231
|
+
const mcpServer = new Server(
|
|
232
|
+
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
233
|
+
{ capabilities: { tools: {} } }
|
|
234
|
+
);
|
|
235
|
+
registerTools(mcpServer, db);
|
|
236
|
+
return mcpServer;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function handleMCPRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
240
|
+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
241
|
+
|
|
242
|
+
if (sessionId && sessions.has(sessionId)) {
|
|
243
|
+
const session = sessions.get(sessionId)!;
|
|
244
|
+
await session.transport.handleRequest(req, res);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (req.method === 'GET' || req.method === 'DELETE') {
|
|
249
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
250
|
+
res.end(JSON.stringify({ error: 'Invalid or missing session ID' }));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const mcpServer = createMcpServer();
|
|
255
|
+
const transport = new StreamableHTTPServerTransport({
|
|
256
|
+
sessionIdGenerator: () => randomUUID(),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await mcpServer.connect(transport);
|
|
260
|
+
|
|
261
|
+
transport.onclose = () => {
|
|
262
|
+
if (transport.sessionId) {
|
|
263
|
+
sessions.delete(transport.sessionId);
|
|
264
|
+
}
|
|
265
|
+
mcpServer.close().catch(() => {});
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
await transport.handleRequest(req, res);
|
|
269
|
+
|
|
270
|
+
if (transport.sessionId) {
|
|
271
|
+
sessions.set(transport.sessionId, { transport, server: mcpServer });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const httpServer = createServer(async (req, res) => {
|
|
276
|
+
const url = new URL(req.url || '/', `http://localhost:${PORT}`);
|
|
277
|
+
|
|
278
|
+
if (url.pathname === '/health' && req.method === 'GET') {
|
|
279
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
280
|
+
res.end(JSON.stringify({ status: 'healthy', server: SERVER_NAME, version: SERVER_VERSION }));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (url.pathname === '/mcp' || url.pathname === '/') {
|
|
285
|
+
try {
|
|
286
|
+
await handleMCPRequest(req, res);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
if (!res.headersSent) {
|
|
289
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
290
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Internal server error' }));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
297
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
httpServer.listen(PORT, () => {
|
|
301
|
+
console.log(`${SERVER_NAME} v${SERVER_VERSION} listening on port ${PORT}`);
|
|
302
|
+
});
|
|
@@ -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,45 @@
|
|
|
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 professionelle landwirtschaftliche ' +
|
|
12
|
+
'Beratung dar. Vor der Umstellung auf biologische Landwirtschaft oder der Anwendung regenerativer Methoden ' +
|
|
13
|
+
'ist stets eine qualifizierte Fachberatung (FiBL, Bio Suisse, kantonale Fachstelle, AGRIDEA oder ' +
|
|
14
|
+
'akkreditierter Bio-Berater) hinzuzuziehen. Die Daten basieren auf den Bio Suisse Richtlinien (Knospe), ' +
|
|
15
|
+
'der Bio-Verordnung (SR 910.18), der FiBL-Betriebsmittelliste und den Demeter-Richtlinien. Kantonale ' +
|
|
16
|
+
'Abweichungen und betriebsspezifische Anpassungen sind eigenstaendig zu pruefen. / ' +
|
|
17
|
+
'This data is provided for informational purposes only and does not constitute professional agricultural ' +
|
|
18
|
+
'advice. Always consult a qualified organic farming advisor (FiBL, Bio Suisse, cantonal authority) before ' +
|
|
19
|
+
'making conversion or management decisions. Data sourced from Bio Suisse (Knospe), Swiss Bio-Verordnung, ' +
|
|
20
|
+
'FiBL Betriebsmittelliste, and Demeter standards.';
|
|
21
|
+
|
|
22
|
+
export function buildMeta(overrides?: Partial<Meta>): Meta {
|
|
23
|
+
return {
|
|
24
|
+
disclaimer: DISCLAIMER,
|
|
25
|
+
data_age: overrides?.data_age ?? 'unknown',
|
|
26
|
+
source_url: overrides?.source_url ?? 'https://www.bio-suisse.ch/de/richtlinien',
|
|
27
|
+
copyright: 'Data: Bio Suisse, FiBL, BLW, Demeter Schweiz — used under public-sector information principles. Server: Apache-2.0 Ansvar Systems.',
|
|
28
|
+
server: 'ch-organic-regen-mcp',
|
|
29
|
+
version: '0.1.0',
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildStalenessWarning(publishedDate: string): string | undefined {
|
|
35
|
+
const published = new Date(publishedDate);
|
|
36
|
+
const now = new Date();
|
|
37
|
+
const daysSincePublished = Math.floor(
|
|
38
|
+
(now.getTime() - published.getTime()) / (1000 * 60 * 60 * 24)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (daysSincePublished > 14) {
|
|
42
|
+
return `Daten sind ${daysSincePublished} Tage alt (Stand ${publishedDate}). Aktuelle Angaben bei Bio Suisse / FiBL pruefen.`;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|