@ansvar/ch-farm-grants-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 (108) hide show
  1. package/.github/workflows/check-freshness.yml +18 -0
  2. package/.github/workflows/ci.yml +21 -0
  3. package/.github/workflows/codeql.yml +25 -0
  4. package/.github/workflows/ghcr-build.yml +45 -0
  5. package/.github/workflows/gitleaks.yml +18 -0
  6. package/.github/workflows/ingest.yml +28 -0
  7. package/.github/workflows/publish.yml +24 -0
  8. package/CHANGELOG.md +22 -0
  9. package/CODEOWNERS +1 -0
  10. package/COVERAGE.md +45 -0
  11. package/DISCLAIMER.md +39 -0
  12. package/Dockerfile +26 -0
  13. package/LICENSE +17 -0
  14. package/PRIVACY.md +36 -0
  15. package/README.md +80 -0
  16. package/SECURITY.md +31 -0
  17. package/TOOLS.md +154 -0
  18. package/data/coverage.json +19 -0
  19. package/data/database.db +0 -0
  20. package/data/sources.yml +29 -0
  21. package/dist/db.d.ts +25 -0
  22. package/dist/db.d.ts.map +1 -0
  23. package/dist/db.js +167 -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 +261 -0
  28. package/dist/http-server.js.map +1 -0
  29. package/dist/jurisdiction.d.ts +18 -0
  30. package/dist/jurisdiction.d.ts.map +1 -0
  31. package/dist/jurisdiction.js +16 -0
  32. package/dist/jurisdiction.js.map +1 -0
  33. package/dist/metadata.d.ts +10 -0
  34. package/dist/metadata.d.ts.map +1 -0
  35. package/dist/metadata.js +22 -0
  36. package/dist/metadata.js.map +1 -0
  37. package/dist/server.d.ts +3 -0
  38. package/dist/server.d.ts.map +1 -0
  39. package/dist/server.js +207 -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 +28 -0
  44. package/dist/tools/about.js.map +1 -0
  45. package/dist/tools/check-eligibility.d.ts +31 -0
  46. package/dist/tools/check-eligibility.d.ts.map +1 -0
  47. package/dist/tools/check-eligibility.js +57 -0
  48. package/dist/tools/check-eligibility.js.map +1 -0
  49. package/dist/tools/check-freshness.d.ts +15 -0
  50. package/dist/tools/check-freshness.d.ts.map +1 -0
  51. package/dist/tools/check-freshness.js +26 -0
  52. package/dist/tools/check-freshness.js.map +1 -0
  53. package/dist/tools/get-application-deadlines.d.ts +27 -0
  54. package/dist/tools/get-application-deadlines.d.ts.map +1 -0
  55. package/dist/tools/get-application-deadlines.js +40 -0
  56. package/dist/tools/get-application-deadlines.js.map +1 -0
  57. package/dist/tools/get-grant-details.d.ts +42 -0
  58. package/dist/tools/get-grant-details.d.ts.map +1 -0
  59. package/dist/tools/get-grant-details.js +24 -0
  60. package/dist/tools/get-grant-details.js.map +1 -0
  61. package/dist/tools/get-payment-rates.d.ts +49 -0
  62. package/dist/tools/get-payment-rates.d.ts.map +1 -0
  63. package/dist/tools/get-payment-rates.js +37 -0
  64. package/dist/tools/get-payment-rates.js.map +1 -0
  65. package/dist/tools/list-grant-options.d.ts +56 -0
  66. package/dist/tools/list-grant-options.d.ts.map +1 -0
  67. package/dist/tools/list-grant-options.js +39 -0
  68. package/dist/tools/list-grant-options.js.map +1 -0
  69. package/dist/tools/list-sources.d.ts +18 -0
  70. package/dist/tools/list-sources.d.ts.map +1 -0
  71. package/dist/tools/list-sources.js +51 -0
  72. package/dist/tools/list-sources.js.map +1 -0
  73. package/dist/tools/search-application-guidance.d.ts +36 -0
  74. package/dist/tools/search-application-guidance.d.ts.map +1 -0
  75. package/dist/tools/search-application-guidance.js +52 -0
  76. package/dist/tools/search-application-guidance.js.map +1 -0
  77. package/dist/tools/search-grants.d.ts +25 -0
  78. package/dist/tools/search-grants.d.ts.map +1 -0
  79. package/dist/tools/search-grants.js +26 -0
  80. package/dist/tools/search-grants.js.map +1 -0
  81. package/docker-compose.yml +12 -0
  82. package/eslint.config.js +26 -0
  83. package/package.json +54 -0
  84. package/scripts/ingest.ts +742 -0
  85. package/server.json +41 -0
  86. package/src/db.ts +208 -0
  87. package/src/http-server.ts +293 -0
  88. package/src/jurisdiction.ts +30 -0
  89. package/src/metadata.ts +32 -0
  90. package/src/server.ts +230 -0
  91. package/src/tools/about.ts +29 -0
  92. package/src/tools/check-eligibility.ts +81 -0
  93. package/src/tools/check-freshness.ts +42 -0
  94. package/src/tools/get-application-deadlines.ts +55 -0
  95. package/src/tools/get-grant-details.ts +51 -0
  96. package/src/tools/get-payment-rates.ts +60 -0
  97. package/src/tools/list-grant-options.ts +63 -0
  98. package/src/tools/list-sources.ts +65 -0
  99. package/src/tools/search-application-guidance.ts +59 -0
  100. package/src/tools/search-grants.ts +35 -0
  101. package/tests/db.test.ts +69 -0
  102. package/tests/helpers/seed-db.ts +188 -0
  103. package/tests/jurisdiction.test.ts +35 -0
  104. package/tests/tools/about.test.ts +23 -0
  105. package/tests/tools/check-freshness.test.ts +53 -0
  106. package/tests/tools/list-sources.test.ts +47 -0
  107. package/tests/tools/search-grants.test.ts +57 -0
  108. package/tsconfig.json +19 -0
package/server.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.Ansvar-Systems/ch-farm-grants",
4
+ "description": "Swiss agricultural structural improvement grants: Investitionskredite, Beitraege, Meliorationen, PRE, eligibility, payment rates",
5
+ "repository": {
6
+ "url": "https://github.com/Ansvar-Systems/ch-farm-grants-mcp",
7
+ "source": "github"
8
+ },
9
+ "homepage": "https://ansvar.eu/open-agriculture",
10
+ "version": "0.1.0",
11
+ "license": "Apache-2.0",
12
+ "keywords": [
13
+ "swiss-agriculture",
14
+ "farm-grants",
15
+ "strukturverbesserungen",
16
+ "investitionskredit",
17
+ "svv",
18
+ "blw",
19
+ "switzerland",
20
+ "mcp"
21
+ ],
22
+ "packages": [
23
+ {
24
+ "registryType": "npm",
25
+ "identifier": "@ansvar/ch-farm-grants-mcp",
26
+ "version": "0.1.0",
27
+ "transport": {
28
+ "type": "stdio"
29
+ }
30
+ },
31
+ {
32
+ "registryType": "npm",
33
+ "identifier": "@ansvar/ch-farm-grants-mcp",
34
+ "version": "0.1.0",
35
+ "transport": {
36
+ "type": "streamable-http",
37
+ "url": "https://mcp.ansvar.eu/ch-farm-grants/mcp"
38
+ }
39
+ }
40
+ ]
41
+ }
package/src/db.ts ADDED
@@ -0,0 +1,208 @@
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 grants (
46
+ id TEXT PRIMARY KEY,
47
+ name TEXT NOT NULL,
48
+ grant_type TEXT NOT NULL,
49
+ description TEXT,
50
+ federal_rate_pct REAL,
51
+ max_amount REAL,
52
+ duration_years INTEGER,
53
+ zone_bonus TEXT,
54
+ legal_basis TEXT,
55
+ language TEXT DEFAULT 'DE',
56
+ jurisdiction TEXT NOT NULL DEFAULT 'CH'
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS grant_options (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ grant_id TEXT NOT NULL REFERENCES grants(id),
62
+ option_name TEXT NOT NULL,
63
+ description TEXT,
64
+ rate TEXT,
65
+ conditions TEXT
66
+ );
67
+
68
+ CREATE TABLE IF NOT EXISTS eligibility_rules (
69
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
70
+ grant_id TEXT NOT NULL REFERENCES grants(id),
71
+ farm_type TEXT,
72
+ investment_type TEXT,
73
+ zone TEXT,
74
+ requirement TEXT NOT NULL
75
+ );
76
+
77
+ CREATE TABLE IF NOT EXISTS application_deadlines (
78
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
79
+ grant_id TEXT NOT NULL REFERENCES grants(id),
80
+ canton TEXT,
81
+ deadline_date TEXT,
82
+ notes TEXT
83
+ );
84
+
85
+ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
86
+ title, body, grant_type, jurisdiction
87
+ );
88
+
89
+ CREATE TABLE IF NOT EXISTS db_metadata (
90
+ key TEXT PRIMARY KEY,
91
+ value TEXT
92
+ );
93
+
94
+ INSERT OR IGNORE INTO db_metadata (key, value) VALUES ('schema_version', '1.0');
95
+ INSERT OR IGNORE INTO db_metadata (key, value) VALUES ('mcp_name', 'Switzerland Farm Grants MCP');
96
+ INSERT OR IGNORE INTO db_metadata (key, value) VALUES ('jurisdiction', 'CH');
97
+ `);
98
+ }
99
+
100
+ const FTS_COLUMNS = ['title', 'body', 'grant_type', 'jurisdiction'];
101
+
102
+ export function ftsSearch(
103
+ db: Database,
104
+ query: string,
105
+ limit: number = 20
106
+ ): { title: string; body: string; grant_type: string; jurisdiction: string; rank: number }[] {
107
+ const { results } = tieredFtsSearch(db, 'search_index', FTS_COLUMNS, query, limit);
108
+ return results as { title: string; body: string; grant_type: string; jurisdiction: string; rank: number }[];
109
+ }
110
+
111
+ /**
112
+ * Tiered FTS5 search with automatic fallback.
113
+ * Tiers: exact phrase -> AND -> prefix -> stemmed prefix -> OR -> LIKE
114
+ */
115
+ export function tieredFtsSearch(
116
+ db: Database,
117
+ table: string,
118
+ columns: string[],
119
+ query: string,
120
+ limit: number = 20
121
+ ): { tier: string; results: Record<string, unknown>[] } {
122
+ const sanitized = sanitizeFtsInput(query);
123
+ if (!sanitized.trim()) return { tier: 'empty', results: [] };
124
+
125
+ const columnList = columns.join(', ');
126
+ const select = `SELECT ${columnList}, rank FROM ${table}`;
127
+ const order = `ORDER BY rank LIMIT ?`;
128
+
129
+ // Tier 1: Exact phrase
130
+ const phrase = `"${sanitized}"`;
131
+ let results = tryFts(db, select, table, order, phrase, limit);
132
+ if (results.length > 0) return { tier: 'phrase', results };
133
+
134
+ // Tier 2: AND
135
+ const words = sanitized.split(/\s+/).filter(w => w.length > 1);
136
+ if (words.length > 1) {
137
+ const andQuery = words.join(' AND ');
138
+ results = tryFts(db, select, table, order, andQuery, limit);
139
+ if (results.length > 0) return { tier: 'and', results };
140
+ }
141
+
142
+ // Tier 3: Prefix
143
+ const prefixQuery = words.map(w => `${w}*`).join(' AND ');
144
+ results = tryFts(db, select, table, order, prefixQuery, limit);
145
+ if (results.length > 0) return { tier: 'prefix', results };
146
+
147
+ // Tier 4: Stemmed prefix
148
+ const stemmed = words.map(w => stemWord(w) + '*');
149
+ const stemmedQuery = stemmed.join(' AND ');
150
+ if (stemmedQuery !== prefixQuery) {
151
+ results = tryFts(db, select, table, order, stemmedQuery, limit);
152
+ if (results.length > 0) return { tier: 'stemmed', results };
153
+ }
154
+
155
+ // Tier 5: OR
156
+ if (words.length > 1) {
157
+ const orQuery = words.join(' OR ');
158
+ results = tryFts(db, select, table, order, orQuery, limit);
159
+ if (results.length > 0) return { tier: 'or', results };
160
+ }
161
+
162
+ // Tier 6: LIKE fallback
163
+ const baseCols = ['name', 'grant_type'];
164
+ const likeConditions = words.map(() =>
165
+ `(${baseCols.map(c => `${c} LIKE ?`).join(' OR ')})`
166
+ ).join(' AND ');
167
+ const likeParams = words.flatMap(w =>
168
+ baseCols.map(() => `%${w}%`)
169
+ );
170
+ try {
171
+ const likeResults = db.all<Record<string, unknown>>(
172
+ `SELECT name as title, COALESCE(description, '') as body, grant_type, jurisdiction FROM grants WHERE ${likeConditions} LIMIT ?`,
173
+ [...likeParams, limit]
174
+ );
175
+ if (likeResults.length > 0) return { tier: 'like', results: likeResults };
176
+ } catch {
177
+ // LIKE fallback failed
178
+ }
179
+
180
+ return { tier: 'none', results: [] };
181
+ }
182
+
183
+ function tryFts(
184
+ db: Database, select: string, table: string,
185
+ order: string, matchExpr: string, limit: number
186
+ ): Record<string, unknown>[] {
187
+ try {
188
+ return db.all(
189
+ `${select} WHERE ${table} MATCH ? ${order}`,
190
+ [matchExpr, limit]
191
+ );
192
+ } catch {
193
+ return [];
194
+ }
195
+ }
196
+
197
+ function sanitizeFtsInput(query: string): string {
198
+ return query
199
+ .replace(/["""'',,,,]/g, '"')
200
+ .replace(/[^a-zA-Z0-9\s*"_\u00C0-\u024F-]/g, ' ')
201
+ .replace(/\s+/g, ' ')
202
+ .trim();
203
+ }
204
+
205
+ function stemWord(word: string): string {
206
+ return word
207
+ .replace(/(ung|heit|keit|lich|isch|ieren|tion|ment|ness|able|ible|ous|ive|ing|ers|ed|es|er|en|ly|s)$/i, '');
208
+ }
@@ -0,0 +1,293 @@
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 { handleSearchGrants } from './tools/search-grants.js';
15
+ import { handleGetGrantDetails } from './tools/get-grant-details.js';
16
+ import { handleGetPaymentRates } from './tools/get-payment-rates.js';
17
+ import { handleCheckEligibility } from './tools/check-eligibility.js';
18
+ import { handleListGrantOptions } from './tools/list-grant-options.js';
19
+ import { handleGetApplicationDeadlines } from './tools/get-application-deadlines.js';
20
+ import { handleSearchApplicationGuidance } from './tools/search-application-guidance.js';
21
+
22
+ const SERVER_NAME = 'ch-farm-grants-mcp';
23
+ const SERVER_VERSION = '0.1.0';
24
+ const PORT = parseInt(process.env.PORT ?? '3000', 10);
25
+
26
+ const SearchGrantsArgsSchema = z.object({
27
+ query: z.string(),
28
+ grant_type: z.string().optional(),
29
+ jurisdiction: z.string().optional(),
30
+ limit: z.number().optional(),
31
+ });
32
+
33
+ const GrantDetailsArgsSchema = z.object({
34
+ grant_id: z.string(),
35
+ jurisdiction: z.string().optional(),
36
+ });
37
+
38
+ const PaymentRatesArgsSchema = z.object({
39
+ grant_id: z.string(),
40
+ zone: z.string().optional(),
41
+ jurisdiction: z.string().optional(),
42
+ });
43
+
44
+ const EligibilityArgsSchema = z.object({
45
+ farm_type: z.string(),
46
+ investment_type: z.string().optional(),
47
+ zone: z.string().optional(),
48
+ jurisdiction: z.string().optional(),
49
+ });
50
+
51
+ const ListGrantOptionsArgsSchema = z.object({
52
+ grant_id: z.string().optional(),
53
+ jurisdiction: z.string().optional(),
54
+ });
55
+
56
+ const DeadlinesArgsSchema = z.object({
57
+ grant_id: z.string().optional(),
58
+ canton: z.string().optional(),
59
+ jurisdiction: z.string().optional(),
60
+ });
61
+
62
+ const GuidanceArgsSchema = z.object({
63
+ query: z.string(),
64
+ jurisdiction: z.string().optional(),
65
+ });
66
+
67
+ const TOOLS = [
68
+ {
69
+ name: 'about',
70
+ description: 'Get server metadata: name, version, coverage, data sources, and links.',
71
+ inputSchema: { type: 'object' as const, properties: {} },
72
+ },
73
+ {
74
+ name: 'list_sources',
75
+ description: 'List all data sources with authority, URL, license, and freshness info.',
76
+ inputSchema: { type: 'object' as const, properties: {} },
77
+ },
78
+ {
79
+ name: 'check_data_freshness',
80
+ description: 'Check when data was last ingested, staleness status, and how to trigger a refresh.',
81
+ inputSchema: { type: 'object' as const, properties: {} },
82
+ },
83
+ {
84
+ name: 'search_grants',
85
+ description: 'Search Swiss agricultural structural improvement grants, investment credits, and funding programmes. Use for broad queries about Investitionskredite, Beitraege, Meliorationen, PRE.',
86
+ inputSchema: {
87
+ type: 'object' as const,
88
+ properties: {
89
+ query: { type: 'string', description: 'Free-text search query (German or English)' },
90
+ grant_type: { type: 'string', description: 'Filter by type: investitionskredit, beitrag, meliorationsbeitrag, pre, ressourcenprogramm, gewaesserschutz, starthilfe' },
91
+ jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
92
+ limit: { type: 'number', description: 'Max results (default: 20, max: 50)' },
93
+ },
94
+ required: ['query'],
95
+ },
96
+ },
97
+ {
98
+ name: 'get_grant_details',
99
+ description: 'Get full details for a specific grant programme: objectives, contribution rates, conditions, eligibility rules, and sub-options.',
100
+ inputSchema: {
101
+ type: 'object' as const,
102
+ properties: {
103
+ grant_id: { type: 'string', description: 'Grant ID or name (e.g. investitionskredit-oekonomiegebaeude, starthilfe-junglandwirte)' },
104
+ jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
105
+ },
106
+ required: ['grant_id'],
107
+ },
108
+ },
109
+ {
110
+ name: 'get_payment_rates',
111
+ description: 'Get federal and cantonal contribution rates for a grant programme, with zone-specific bonuses (Berggebiet, Huegelzone).',
112
+ inputSchema: {
113
+ type: 'object' as const,
114
+ properties: {
115
+ grant_id: { type: 'string', description: 'Grant ID or name' },
116
+ zone: { type: 'string', description: 'Altitude zone: talzone, huegelzone, bergzone_i-iv, soemmerungsgebiet' },
117
+ jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
118
+ },
119
+ required: ['grant_id'],
120
+ },
121
+ },
122
+ {
123
+ name: 'check_eligibility',
124
+ description: 'Check which grants a farm is eligible for based on farm type, planned investment, and altitude zone.',
125
+ inputSchema: {
126
+ type: 'object' as const,
127
+ properties: {
128
+ farm_type: { type: 'string', description: 'Farm type: milchwirtschaft, ackerbau, mutterkuhhaltung, bergbetrieb, alpwirtschaft, gemischt, gemeinschaft, junglandwirt' },
129
+ investment_type: { type: 'string', description: 'Investment type: stallbau, hofduengerlager, wohnhaus, diversifikation, alpgebaeude, wegebau, wasserversorgung, biogasanlage, kaeserei' },
130
+ zone: { type: 'string', description: 'Altitude zone: talzone, huegelzone, bergzone_i-iv, soemmerungsgebiet' },
131
+ jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
132
+ },
133
+ required: ['farm_type'],
134
+ },
135
+ },
136
+ {
137
+ name: 'list_grant_options',
138
+ description: 'List all sub-options (Massnahmen) within a grant programme, or list all grants with their option counts.',
139
+ inputSchema: {
140
+ type: 'object' as const,
141
+ properties: {
142
+ grant_id: { type: 'string', description: 'Grant ID to list options for. If omitted, lists all grants.' },
143
+ jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
144
+ },
145
+ },
146
+ },
147
+ {
148
+ name: 'get_application_deadlines',
149
+ description: 'Get application deadlines for grants, optionally filtered by canton (2-letter code: ZH, BE, LU, etc.).',
150
+ inputSchema: {
151
+ type: 'object' as const,
152
+ properties: {
153
+ grant_id: { type: 'string', description: 'Grant ID to check deadlines for' },
154
+ canton: { type: 'string', description: 'Canton code (e.g. ZH, BE, LU, SG, GR, VS, TI)' },
155
+ jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
156
+ },
157
+ },
158
+ },
159
+ {
160
+ name: 'search_application_guidance',
161
+ description: 'How to apply for Swiss farm grants: required documents, cantonal contacts, general process, and AGRIDEA advisory support.',
162
+ inputSchema: {
163
+ type: 'object' as const,
164
+ properties: {
165
+ query: { type: 'string', description: 'Free-text search query about the application process' },
166
+ jurisdiction: { type: 'string', description: 'ISO 3166-1 alpha-2 code (default: CH)' },
167
+ },
168
+ required: ['query'],
169
+ },
170
+ },
171
+ ];
172
+
173
+ function textResult(data: unknown) {
174
+ return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
175
+ }
176
+
177
+ function errorResult(message: string) {
178
+ return { content: [{ type: 'text' as const, text: JSON.stringify({ error: message }) }], isError: true };
179
+ }
180
+
181
+ function registerTools(server: Server, db: Database): void {
182
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
183
+
184
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
185
+ const { name, arguments: args = {} } = request.params;
186
+
187
+ try {
188
+ switch (name) {
189
+ case 'about':
190
+ return textResult(handleAbout());
191
+ case 'list_sources':
192
+ return textResult(handleListSources(db));
193
+ case 'check_data_freshness':
194
+ return textResult(handleCheckFreshness(db));
195
+ case 'search_grants':
196
+ return textResult(handleSearchGrants(db, SearchGrantsArgsSchema.parse(args)));
197
+ case 'get_grant_details':
198
+ return textResult(handleGetGrantDetails(db, GrantDetailsArgsSchema.parse(args)));
199
+ case 'get_payment_rates':
200
+ return textResult(handleGetPaymentRates(db, PaymentRatesArgsSchema.parse(args)));
201
+ case 'check_eligibility':
202
+ return textResult(handleCheckEligibility(db, EligibilityArgsSchema.parse(args)));
203
+ case 'list_grant_options':
204
+ return textResult(handleListGrantOptions(db, ListGrantOptionsArgsSchema.parse(args)));
205
+ case 'get_application_deadlines':
206
+ return textResult(handleGetApplicationDeadlines(db, DeadlinesArgsSchema.parse(args)));
207
+ case 'search_application_guidance':
208
+ return textResult(handleSearchApplicationGuidance(db, GuidanceArgsSchema.parse(args)));
209
+ default:
210
+ return errorResult(`Unknown tool: ${name}`);
211
+ }
212
+ } catch (err) {
213
+ return errorResult(err instanceof Error ? err.message : String(err));
214
+ }
215
+ });
216
+ }
217
+
218
+ const db = createDatabase();
219
+ const sessions = new Map<string, { transport: StreamableHTTPServerTransport; server: Server }>();
220
+
221
+ function createMcpServer(): Server {
222
+ const mcpServer = new Server(
223
+ { name: SERVER_NAME, version: SERVER_VERSION },
224
+ { capabilities: { tools: {} } }
225
+ );
226
+ registerTools(mcpServer, db);
227
+ return mcpServer;
228
+ }
229
+
230
+ async function handleMCPRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
231
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
232
+
233
+ if (sessionId && sessions.has(sessionId)) {
234
+ const session = sessions.get(sessionId)!;
235
+ await session.transport.handleRequest(req, res);
236
+ return;
237
+ }
238
+
239
+ if (req.method === 'GET' || req.method === 'DELETE') {
240
+ res.writeHead(400, { 'Content-Type': 'application/json' });
241
+ res.end(JSON.stringify({ error: 'Invalid or missing session ID' }));
242
+ return;
243
+ }
244
+
245
+ const mcpServer = createMcpServer();
246
+ const transport = new StreamableHTTPServerTransport({
247
+ sessionIdGenerator: () => randomUUID(),
248
+ });
249
+
250
+ await mcpServer.connect(transport);
251
+
252
+ transport.onclose = () => {
253
+ if (transport.sessionId) {
254
+ sessions.delete(transport.sessionId);
255
+ }
256
+ mcpServer.close().catch(() => {});
257
+ };
258
+
259
+ await transport.handleRequest(req, res);
260
+
261
+ if (transport.sessionId) {
262
+ sessions.set(transport.sessionId, { transport, server: mcpServer });
263
+ }
264
+ }
265
+
266
+ const httpServer = createServer(async (req, res) => {
267
+ const url = new URL(req.url || '/', `http://localhost:${PORT}`);
268
+
269
+ if (url.pathname === '/health' && req.method === 'GET') {
270
+ res.writeHead(200, { 'Content-Type': 'application/json' });
271
+ res.end(JSON.stringify({ status: 'healthy', server: SERVER_NAME, version: SERVER_VERSION }));
272
+ return;
273
+ }
274
+
275
+ if (url.pathname === '/mcp' || url.pathname === '/') {
276
+ try {
277
+ await handleMCPRequest(req, res);
278
+ } catch (err) {
279
+ if (!res.headersSent) {
280
+ res.writeHead(500, { 'Content-Type': 'application/json' });
281
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Internal server error' }));
282
+ }
283
+ }
284
+ return;
285
+ }
286
+
287
+ res.writeHead(404, { 'Content-Type': 'application/json' });
288
+ res.end(JSON.stringify({ error: 'Not found' }));
289
+ });
290
+
291
+ httpServer.listen(PORT, () => {
292
+ console.log(`${SERVER_NAME} v${SERVER_VERSION} listening on port ${PORT}`);
293
+ });
@@ -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
+ }
@@ -0,0 +1,32 @@
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 Zusicherung fuer die Gewaehrung ' +
12
+ 'von Foerdermitteln dar. Foerderentscheide liegen bei den zustaendigen kantonalen Landwirtschaftsaemtern ' +
13
+ 'und dem Bundesamt fuer Landwirtschaft (BLW). Vor der Einreichung eines Gesuchs ist stets die kantonale ' +
14
+ 'Fachstelle oder AGRIDEA zu konsultieren. Die Daten basieren auf der Strukturverbesserungsverordnung ' +
15
+ '(SVV, SR 913.1), den BLW-Weisungen zu Investitionskrediten und Beitraegen, sowie kantonalen ' +
16
+ 'Ausfuehrungsbestimmungen. Kantonale Beitragssaetze und Fristen koennen abweichen. / ' +
17
+ 'This data is provided for informational purposes only and does not guarantee grant approval. ' +
18
+ 'Funding decisions are made by cantonal agricultural offices and the Federal Office for Agriculture (BLW). ' +
19
+ 'Always consult the relevant cantonal authority before submitting an application. Data sourced from ' +
20
+ 'SVV (SR 913.1), BLW investment credit guidelines, and cantonal implementation rules.';
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.blw.admin.ch/blw/de/home/instrumente/strukturverbesserungen.html',
27
+ copyright: 'Data: BLW, SVV, AGRIDEA, kantonale Landwirtschaftsaemter — used under public-sector information principles. Server: Apache-2.0 Ansvar Systems.',
28
+ server: 'ch-farm-grants-mcp',
29
+ version: '0.1.0',
30
+ ...overrides,
31
+ };
32
+ }