Skip to content

Commit 419ba6f

Browse files
rverkRob Verkuijlenclaude
authored
feature: voeg MCP server toe voor LLM-toegang tot KVK datamirror (#38)
* feature: voeg MCP server toe voor LLM-toegang tot KVK datamirror (v1.7.0) Adds a read-only MCP server (HTTP transport) exposing 11 tools to an LLM for standard KVK company data queries. Tools cover exact lookups, batch analysis, history, and unknown-question logging for future analysis. - KVKMirrorReader: ORM→Domain adapter (read-only, sentinel filtered) - KVKMirrorService: use case layer with is_actief, indNonMailing, data_quality - McpOnbekendVraagWriter + ORM: logs unanswerable questions to DB - apps/mcp-server: thin FastMCP adapters, @log_tool_call decorator, /health - docker-compose: mcp-server service on port 8001 (local + db compose) - 54 new tests: reader, service, is_actief matrix, decorator, onbekende vragen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: voeg test_tools.py toe en herstel FastMCP host/port configuratie - host/port gaan naar FastMCP constructor (niet naar run()) - test_tools.py: 25 tests voor alle 11 tool-handlers, main() wiring, health endpoint, JSON-structuur en foutpaden Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: verwijder pyodbc-specifieke connect_args timeout uit alle apps connect_args={"timeout": 30} werkt alleen met pyodbc (SQL Server) en breekt psycopg2 (PostgreSQL). pool_pre_ping=True dekt de reconnect-logica. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: stateless_http=True voor sessionloze MCP curl-aanroepen Zonder deze vlag eist streamable-http een sessie-ID per request, waardoor directe curl-aanroepen falen met 'Missing session ID'. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feature: maak MCP transport configureerbaar via MCP_TRANSPORT env var SSE vervangen door streamable-http als default transport. Transport is nu instelbaar via MCP_TRANSPORT (stdio, sse, streamable-http) zodat Claude Desktop via mcp-remote bridge kan connecten en Docker deployments het moderne streamable-http protocol gebruiken. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: voeg MCP server sectie toe aan README Beschrijft beschikbare tools, transport configuratie en hoe je Claude Desktop verbindt via mcp-remote bridge. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Rob Verkuijlen <rob.verkuijlen@outlook.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 95c78ca commit 419ba6f

26 files changed

Lines changed: 2437 additions & 11 deletions

README.md

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@
1313
- [Als ontwikkelaar](#als-ontwikkelaar)
1414
4. [Structuur van KvK](#structuur-van-kvk)
1515
5. [Data Flow](#data-flow-van-de-docker-apps)
16-
6. [Database Schema](#database-schema)
17-
7. [Functionaliteit](#functionaliteit)
18-
8. [Ontwikkelaarsgids](#ontwikkelaarsgids)
19-
9. [Roadmap](#roadmap)
20-
10. [KvK API Documentatie](#kvk-api-documentatie)
16+
6. [MCP Server](#mcp-server)
17+
7. [Database Schema](#database-schema)
18+
8. [Functionaliteit](#functionaliteit)
19+
9. [Ontwikkelaarsgids](#ontwikkelaarsgids)
20+
10. [Roadmap](#roadmap)
21+
11. [KvK API Documentatie](#kvk-api-documentatie)
2122

2223
---
2324

@@ -184,6 +185,73 @@ De vijf Docker apps werken onafhankelijk van elkaar samen om de KVK data actueel
184185
![AppsStructure](docs/apps.drawio.svg)
185186

186187

188+
## MCP Server
189+
190+
De MCP server (`apps/mcp-server/`) stelt de KVK datamirror beschikbaar voor LLMs via het [Model Context Protocol](https://modelcontextprotocol.io/). Hiermee kan een LLM zelfstandig bedrijfsgegevens opzoeken, vestigingen doorzoeken en mutaties analyseren.
191+
192+
### Tools
193+
194+
| Laag | Tool | Omschrijving |
195+
|------|------|-------------|
196+
| Exacte lookups | `get_bedrijf` | Basisprofiel voor KVK-nummer |
197+
| | `get_vestiging` | Vestigingsprofiel voor vestigingsnummer |
198+
| | `list_vestigingen` | Alle vestigingen van een KVK-nummer |
199+
| | `get_alles` | Basisprofiel + alle vestigingen in één aanroep |
200+
| | `check_doorstarter` | Zoek actieve opvolger op hetzelfde adres |
201+
| Analytisch | `zoek_op_naam_prefix` | Zoek bedrijven op naam |
202+
| | `filter_op_sbi` | Filter vestigingen op SBI-sector en gemeente |
203+
| | `check_actiefstatus_batch` | Actiefstatus voor meerdere KVK-nummers |
204+
| Historie | `get_basisprofiel_historie` | Wijzigingsgeschiedenis basisprofiel |
205+
| | `get_vestigingsprofiel_historie` | Wijzigingsgeschiedenis vestigingsprofiel |
206+
| Overig | `report_onbekende_vraag` | Registreert vragen die niet beantwoord kunnen worden |
207+
208+
### Starten
209+
210+
De MCP server draait automatisch mee met Docker Compose op poort 8001:
211+
212+
```bash
213+
docker compose -f docker-compose.local.yaml up -d
214+
curl http://localhost:8001/health # {"status": "ok"}
215+
```
216+
217+
### Transport
218+
219+
Het transport is configureerbaar via de `MCP_TRANSPORT` environment variable:
220+
221+
| Waarde | Gebruik |
222+
|--------|---------|
223+
| `streamable-http` | Default. Voor Docker en HTTP-clients (lokale LLMs) |
224+
| `stdio` | Voor directe integratie met Claude Desktop |
225+
| `sse` | Legacy, vervangen door streamable-http |
226+
227+
### Verbinden met Claude Desktop
228+
229+
De MCP server draait als HTTP-service. Claude Desktop heeft de [mcp-remote](https://www.npmjs.com/package/mcp-remote) bridge nodig om via stdio met een HTTP-endpoint te communiceren.
230+
231+
```bash
232+
npm install -g mcp-remote
233+
```
234+
235+
Voeg toe aan `claude_desktop_config.json` (Settings > Developer > Edit Config):
236+
237+
```json
238+
{
239+
"mcpServers": {
240+
"kvk-connect": {
241+
"command": "C:\\Program Files\\nodejs\\node.exe",
242+
"args": [
243+
"<pad-naar-npm-global>/node_modules/mcp-remote/dist/proxy.js",
244+
"http://localhost:8001/mcp"
245+
]
246+
}
247+
}
248+
}
249+
```
250+
251+
Zoek het globale npm pad met `npm root -g`. Herstart Claude Desktop na het aanpassen van de config.
252+
253+
---
254+
187255
## Database Schema (ORM Model)
188256

189257
![ERD](docs/erd.drawio.svg)

apps/basisprofiel/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ def main() -> None:
228228
logger.info("kvk-connect basisprofiel v%s gestart", app_version)
229229

230230
kvk_client = KVKApiClient(api_key=config.API_KEY)
231-
engine = create_engine(config.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True, connect_args={"timeout": 30})
231+
engine = create_engine(config.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True)
232232

233233
ensure_database_initialized(engine, Base)
234234

apps/mcp-server/Dockerfile

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
2+
3+
WORKDIR /app
4+
5+
ENV PYTHONDONTWRITEBYTECODE=1 \
6+
PYTHONUNBUFFERED=1 \
7+
TZ=Europe/Amsterdam
8+
9+
# Install ODBC dependencies for MSSQL support
10+
RUN apt-get update && apt-get install -y \
11+
curl \
12+
gnupg \
13+
unixodbc \
14+
unixodbc-dev \
15+
apt-transport-https \
16+
ca-certificates \
17+
&& curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /etc/apt/trusted.gpg.d/microsoft.gpg \
18+
&& echo "deb [arch=amd64] https://packages.microsoft.com/debian/12/prod bookworm main" > /etc/apt/sources.list.d/mssql-release.list \
19+
&& apt-get update \
20+
&& ACCEPT_EULA=Y apt-get install -y msodbcsql18 \
21+
&& rm -rf /var/lib/apt/lists/*
22+
23+
# Copy entire source first
24+
COPY src ./src
25+
COPY README.md pyproject.toml uv.lock ./
26+
27+
# Install dependencies with uv
28+
RUN uv sync --frozen --no-dev
29+
30+
# Copy app code
31+
COPY apps/mcp-server ./apps/mcp-server
32+
33+
ENV PATH=/app/.venv/bin:$PATH \
34+
PYTHONPATH=/app/src
35+
36+
EXPOSE 8000
37+
38+
CMD ["uv", "run", "--no-sync", "python", "apps/mcp-server/main.py"]

apps/mcp-server/main.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
# ruff: noqa: D103
2+
from __future__ import annotations
3+
4+
import argparse
5+
import json
6+
import logging
7+
import os
8+
import time
9+
from functools import wraps
10+
from importlib.metadata import PackageNotFoundError
11+
from importlib.metadata import version as pkg_version
12+
from typing import Any, Callable
13+
14+
from mcp.server.fastmcp import FastMCP
15+
from sqlalchemy import create_engine
16+
from starlette.requests import Request
17+
from starlette.responses import JSONResponse
18+
19+
from config import config
20+
from kvk_connect import logging_config
21+
from kvk_connect.db.mcp_onbekend_vraag_writer import McpOnbekendVraagWriter
22+
from kvk_connect.db.mirror_reader import KVKMirrorReader
23+
from kvk_connect.db.init import ensure_database_initialized
24+
from kvk_connect.models.orm.base import Base
25+
from kvk_connect.services.mirror_service import KVKMirrorService
26+
27+
logger = logging.getLogger(__name__)
28+
29+
_host = os.getenv("MCP_HOST", "0.0.0.0")
30+
_port = int(os.getenv("MCP_PORT", "8000"))
31+
mcp = FastMCP("kvk-connect", host=_host, port=_port, stateless_http=True)
32+
_service: KVKMirrorService | None = None
33+
34+
35+
# ---------------------------------------------------------------------------
36+
# Logging decorator
37+
# ---------------------------------------------------------------------------
38+
39+
40+
def log_tool_call(fn: Callable) -> Callable:
41+
"""Logt tool naam, resultaatstatus en uitvoertijd."""
42+
43+
@wraps(fn)
44+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
45+
first_arg = next(iter(kwargs.values()), args[0] if args else "")
46+
logger.info("→ %s(%s)", fn.__name__, str(first_arg)[:40])
47+
start = time.monotonic()
48+
try:
49+
result = await fn(*args, **kwargs)
50+
elapsed_ms = int((time.monotonic() - start) * 1000)
51+
logger.info("← %s OK [%dms]", fn.__name__, elapsed_ms)
52+
return result
53+
except Exception:
54+
elapsed_ms = int((time.monotonic() - start) * 1000)
55+
logger.error("← %s ERROR [%dms]", fn.__name__, elapsed_ms, exc_info=True)
56+
raise
57+
58+
return wrapper
59+
60+
61+
# ---------------------------------------------------------------------------
62+
# Health endpoint
63+
# ---------------------------------------------------------------------------
64+
65+
66+
@mcp.custom_route("/health", methods=["GET"])
67+
async def health(request: Request) -> JSONResponse:
68+
return JSONResponse({"status": "ok"})
69+
70+
71+
# ---------------------------------------------------------------------------
72+
# Laag 1: exacte lookups
73+
# ---------------------------------------------------------------------------
74+
75+
76+
@mcp.tool()
77+
@log_tool_call
78+
async def get_bedrijf(kvk_nummer: str, include_non_mailing: bool = False) -> str:
79+
"""Geeft basisprofiel voor KVK-nummer (8 cijfers, bijv. '12345678')."""
80+
assert _service is not None
81+
return json.dumps(_service.get_bedrijf(kvk_nummer, include_non_mailing=include_non_mailing))
82+
83+
84+
@mcp.tool()
85+
@log_tool_call
86+
async def get_vestiging(vestigingsnummer: str) -> str:
87+
"""Geeft vestigingsprofiel voor vestigingsnummer (12 cijfers, bijv. '000012345678')."""
88+
assert _service is not None
89+
return json.dumps(_service.get_vestiging(vestigingsnummer))
90+
91+
92+
@mcp.tool()
93+
@log_tool_call
94+
async def list_vestigingen(kvk_nummer: str, include_non_mailing: bool = False) -> str:
95+
"""Geeft alle vestigingsnummers en locatiedata voor KVK-nummer (8 cijfers)."""
96+
assert _service is not None
97+
return json.dumps(_service.list_vestigingen(kvk_nummer, include_non_mailing=include_non_mailing))
98+
99+
100+
@mcp.tool()
101+
@log_tool_call
102+
async def get_alles(kvk_nummer: str, include_non_mailing: bool = False) -> str:
103+
"""Geeft basisprofiel plus alle vestigingsdetails voor KVK-nummer (8 cijfers) in één aanroep."""
104+
assert _service is not None
105+
return json.dumps(_service.get_alles(kvk_nummer, include_non_mailing=include_non_mailing))
106+
107+
108+
@mcp.tool()
109+
@log_tool_call
110+
async def check_doorstarter(kvk_nummer: str) -> str:
111+
"""Zoekt actieve opvolger op hetzelfde adres voor KVK-nummer (8 cijfers)."""
112+
assert _service is not None
113+
return json.dumps(_service.check_doorstarter(kvk_nummer))
114+
115+
116+
# ---------------------------------------------------------------------------
117+
# Laag 2: analytisch
118+
# ---------------------------------------------------------------------------
119+
120+
121+
@mcp.tool()
122+
@log_tool_call
123+
async def zoek_op_naam_prefix(naam_prefix: str, limit: int = 25) -> str:
124+
"""Zoekt bedrijven op naam-prefix (bijv. 'Bakkerij'). Maximaal 100 resultaten."""
125+
assert _service is not None
126+
return json.dumps(_service.zoek_op_naam_prefix(naam_prefix, limit=limit))
127+
128+
129+
@mcp.tool()
130+
@log_tool_call
131+
async def filter_op_sbi(sbi_prefix: str, gemeente: str = "", limit: int = 100) -> str:
132+
"""Geeft actieve vestigingen voor SBI-sector (bijv. '86' voor zorg), optioneel gefilterd op gemeente."""
133+
assert _service is not None
134+
return json.dumps(_service.filter_op_sbi(sbi_prefix, gemeente=gemeente or None, limit=limit))
135+
136+
137+
@mcp.tool()
138+
@log_tool_call
139+
async def check_actiefstatus_batch(kvk_nummers: list[str]) -> str:
140+
"""Controleert actiefstatus voor een lijst KVK-nummers (maximaal 200 per aanroep)."""
141+
assert _service is not None
142+
try:
143+
return json.dumps(_service.check_actiefstatus_batch(kvk_nummers))
144+
except ValueError as e:
145+
return json.dumps({"status": "fout", "bericht": str(e), "data_quality": {"coverage_warnings": []}})
146+
147+
148+
# ---------------------------------------------------------------------------
149+
# Laag 3: onbekende vragen + historie
150+
# ---------------------------------------------------------------------------
151+
152+
153+
@mcp.tool()
154+
@log_tool_call
155+
async def report_onbekende_vraag(vraag: str) -> str:
156+
"""Registreer een vraag die niet beantwoord kan worden met de beschikbare tools."""
157+
assert _service is not None
158+
return json.dumps(_service.report_onbekende_vraag(vraag))
159+
160+
161+
@mcp.tool()
162+
@log_tool_call
163+
async def get_basisprofiel_historie(kvk_nummer: str) -> str:
164+
"""Geeft de wijzigingsgeschiedenis van een basisprofiel voor KVK-nummer (8 cijfers)."""
165+
assert _service is not None
166+
return json.dumps(_service.get_basisprofiel_historie(kvk_nummer))
167+
168+
169+
@mcp.tool()
170+
@log_tool_call
171+
async def get_vestigingsprofiel_historie(vestigingsnummer: str) -> str:
172+
"""Geeft de wijzigingsgeschiedenis van een vestigingsprofiel voor vestigingsnummer (12 cijfers)."""
173+
assert _service is not None
174+
return json.dumps(_service.get_vestigingsprofiel_historie(vestigingsnummer))
175+
176+
177+
# ---------------------------------------------------------------------------
178+
# Entrypoint
179+
# ---------------------------------------------------------------------------
180+
181+
182+
_VALID_TRANSPORTS = {"stdio", "sse", "streamable-http"}
183+
184+
185+
def main() -> None:
186+
global _service
187+
188+
transport = os.getenv("MCP_TRANSPORT", "streamable-http")
189+
if transport not in _VALID_TRANSPORTS:
190+
raise SystemExit(f"Ongeldig MCP_TRANSPORT={transport!r}, kies uit {_VALID_TRANSPORTS}")
191+
192+
parser = argparse.ArgumentParser(description="KVK-Connect MCP server.")
193+
parser.add_argument("--debug", action="store_true", help="Enable DEBUG log level.")
194+
args = parser.parse_args()
195+
196+
log_level = logging.DEBUG if args.debug else logging.INFO
197+
logging_config.configure(level=log_level)
198+
199+
try:
200+
app_version = pkg_version("kvk-connect")
201+
except PackageNotFoundError:
202+
app_version = "onbekend"
203+
logger.info("kvk-connect mcp-server v%s gestart", app_version)
204+
205+
engine = create_engine(config.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True)
206+
ensure_database_initialized(engine, Base)
207+
208+
reader = KVKMirrorReader(engine)
209+
writer = McpOnbekendVraagWriter(engine)
210+
_service = KVKMirrorService(reader, writer)
211+
212+
if transport == "stdio":
213+
logger.info("MCP server gestart in STDIO-modus")
214+
else:
215+
logger.info("MCP server luistert op %s:%d (%s)", _host, _port, transport)
216+
mcp.run(transport=transport)
217+
218+
219+
if __name__ == "__main__":
220+
main()

apps/mutatie-reader/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ def main():
150150
return
151151

152152
# Initialize database for sync modes
153-
engine = create_engine(config.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True, connect_args={"timeout": 30})
153+
engine = create_engine(config.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True)
154154
ensure_database_initialized(engine, Base)
155155
repo = SignaalReader(engine)
156156

apps/vestigingen/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ def main() -> None:
159159
logging_config.configure(level=log_level)
160160

161161
kvk_client = KVKApiClient(api_key=config.API_KEY)
162-
engine = create_engine(config.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True, connect_args={"timeout": 30})
162+
engine = create_engine(config.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True)
163163
ensure_database_initialized(engine, Base)
164164

165165
if args.daemon:

apps/vestigingsprofiel/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ def main() -> None:
188188
logging_config.configure(level=log_level)
189189

190190
kvk_client = KVKApiClient(api_key=config.API_KEY)
191-
engine = create_engine(config.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True, connect_args={"timeout": 30})
191+
engine = create_engine(config.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True)
192192
ensure_database_initialized(engine, Base)
193193

194194
if args.daemon:

0 commit comments

Comments
 (0)