Skip to content

Commit 70fb680

Browse files
committed
Merge branch 'feature/mcp-integration' into develop
2 parents 19943d5 + 7d33b2b commit 70fb680

20 files changed

Lines changed: 1040 additions & 80 deletions

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ OLD_DB_NAME=geoportal_production_20251030
4646
# =============================================================================
4747

4848
POSTGRES_PASSWORD="<secret>"
49-
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:2345/btaa_geospatial_api
49+
# IMPORTANT: keep the password in DATABASE_URL in sync with POSTGRES_PASSWORD.
50+
DATABASE_URL=postgresql+asyncpg://postgres:<secret>@localhost:2345/btaa_geospatial_api
5051

5152
# =============================================================================
5253
# Elasticsearch

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,18 @@ In addition to the website and API, this repository contains a **QGIS Plugin** t
129129
- **Source code**: Located in the `qgis-plugin/` directory.
130130
- **Testing & Development**: See `qgis-plugin/docs/testing.md` for instructions on running tests and linting.
131131

132+
## Claude Desktop / MCP
133+
134+
This repository now includes the same MCP bridge layer that existed in the older `ogm-api` project:
135+
136+
- `mcp/run_mcp_service.py` runs the stdio MCP server from the repo root
137+
- `mcp/mcp_http_bridge.js` forwards stdio MCP traffic to `POST /api/v1/mcp`
138+
- `mcp/mcp_websocket_bridge.js` forwards stdio MCP traffic to `/api/v1/mcp/ws`
139+
- `mcp/run_mcp_websocket_bridge.py` launches the WebSocket bridge with Node 18+ automatically
140+
- `mcp/claude_mcp_config.json` is a Claude Desktop template (`cwd` = your clone root; see `docs/mcp/`)
141+
142+
More detail is in `docs/mcp/README.md` and `docs/mcp/claude_desktop.md`.
143+
132144
## Documentation (for staff who want details)
133145

134146
All documentation is now in the top-level `docs/` folder:
@@ -137,6 +149,7 @@ All documentation is now in the top-level `docs/` folder:
137149
- **Search**: `docs/backend/search.md`
138150
- **Service tiers / API keys / rate limiting**: `docs/backend/service_tiers_runbook.md`
139151
- **Scripts (Python utilities)**: `docs/backend/scripts.md`
152+
- **MCP / Claude Desktop**: `docs/mcp/`
140153
- **Frontend docs**: `docs/frontend/`
141154
- **QGIS plugin docs**: `qgis-plugin/docs/`
142155
- **Developer Make tasks**: `docs/make_tasks.md`

backend/app/api/v1/endpoint_modules/mcp.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import logging
22

3-
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
4-
from fastapi.responses import JSONResponse
3+
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
4+
from fastapi.responses import JSONResponse, Response
55

6-
from app.services.mcp_service import get_mcp_service_info
6+
from app.services.mcp_service import get_mcp_service_info, handle_mcp_message
77

88
logger = logging.getLogger(__name__)
99
router = APIRouter()
@@ -15,6 +15,27 @@ async def mcp_endpoint():
1515
return JSONResponse(content=get_mcp_service_info())
1616

1717

18+
@router.post("/mcp")
19+
async def mcp_http_transport(request: Request):
20+
"""Handle JSON-RPC-over-HTTP requests for MCP bridge compatibility."""
21+
try:
22+
payload = await request.json()
23+
except Exception:
24+
return JSONResponse(
25+
content={
26+
"jsonrpc": "2.0",
27+
"id": None,
28+
"error": {"code": -32700, "message": "Parse error"},
29+
}
30+
)
31+
32+
response = await handle_mcp_message(payload)
33+
if response is None:
34+
return Response(status_code=204)
35+
36+
return JSONResponse(content=response)
37+
38+
1839
@router.websocket("/mcp/ws")
1940
async def mcp_websocket_endpoint(websocket: WebSocket):
2041
"""WebSocket endpoint for MCP service."""

backend/app/services/mcp_service.py

Lines changed: 81 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -360,64 +360,62 @@ async def handle_call_tool(name: str, arguments: Dict[str, Any]) -> CallToolResu
360360

361361
async def _search_resources(self, arguments: Dict[str, Any]) -> CallToolResult:
362362
"""Search for resources."""
363+
query = arguments.get("query")
364+
page = arguments.get("page", 1)
365+
per_page = arguments.get("per_page", 10)
363366
try:
364-
query = arguments.get("query")
365-
page = arguments.get("page", 1)
366-
per_page = arguments.get("per_page", 10)
367367
sort = arguments.get("sort")
368-
369-
search_service = SearchService()
370-
results = await search_service.search(
371-
q=query,
372-
page=page,
373-
limit=per_page,
374-
sort=sort,
375-
request_query_params="",
376-
callback=None,
368+
params = {
369+
"q": query,
370+
"page": page,
371+
"per_page": per_page,
372+
"sort": sort,
373+
}
374+
payload = await self._api_request(
375+
"/search", params={key: value for key, value in params.items() if value is not None}
377376
)
378-
379-
# Process each resource to get full details
380-
processed_resources = []
381-
async with get_async_session()() as session:
382-
for item in results.get("data", []):
383-
try:
384-
# Extract the resource data from the search result
385-
resource_dict = item.get("attributes", {})
386-
if not resource_dict:
387-
continue
388-
389-
# Process the resource using the same logic as API endpoints
390-
from app.api.v1.utils import process_resource
391-
392-
resource_object = await process_resource(resource_dict, session)
393-
processed_resources.append(resource_object)
394-
except Exception as e:
395-
logger.error(f"Error processing search result: {str(e)}", exc_info=True)
396-
continue
397-
398-
# Return the full resource objects as JSON
399-
return CallToolResult(
400-
content=[
401-
TextContent(
402-
type="text",
403-
text=json.dumps(
404-
{
405-
"query": query,
406-
"total_results": len(results.get("data", [])),
407-
"page": page,
408-
"per_page": per_page,
409-
"resources": processed_resources,
410-
},
411-
indent=2,
412-
),
413-
)
414-
]
377+
response_data = payload.get("data")
378+
response_meta = response_data.get("meta", {}) if isinstance(response_data, dict) else {}
379+
resources = response_data.get("data", []) if isinstance(response_data, dict) else []
380+
381+
if payload["status_code"] >= 400:
382+
payload["query"] = query
383+
return await self._tool_result_json(payload, is_error=True)
384+
385+
return await self._tool_result_json(
386+
{
387+
"query": query,
388+
"total_results": response_meta.get("totalCount", len(resources)),
389+
"total_pages": response_meta.get("totalPages"),
390+
"page": response_meta.get("currentPage", page),
391+
"per_page": response_meta.get("perPage", per_page),
392+
"spelling_suggestions": response_meta.get("spellingSuggestions", []),
393+
"resources": resources,
394+
"links": (
395+
response_data.get("links", {}) if isinstance(response_data, dict) else {}
396+
),
397+
"source": {
398+
"transport": "http",
399+
"url": payload.get("url"),
400+
"status_code": payload.get("status_code"),
401+
},
402+
}
415403
)
416404
except Exception as e:
417405
logger.error(f"Error in _search_resources: {e}", exc_info=True)
418-
return CallToolResult(
419-
content=[TextContent(type="text", text=f"Error searching resources: {str(e)}")],
420-
isError=True,
406+
return await self._tool_result_json(
407+
{
408+
"error": "Search request failed",
409+
"detail": str(e),
410+
"query": query,
411+
"page": page,
412+
"per_page": per_page,
413+
"source": {
414+
"transport": "http",
415+
"url": f"{self._public_api_base()}/api/v1/search",
416+
},
417+
},
418+
is_error=True,
421419
)
422420

423421
async def _get_resource(self, arguments: Dict[str, Any]) -> CallToolResult:
@@ -622,7 +620,7 @@ async def _get_resource_viewer(self, arguments: Dict[str, Any]) -> CallToolResul
622620
embed = arguments.get("embed", False)
623621

624622
# Build the record URL for the viewer
625-
base_url = "http://localhost:8000"
623+
base_url = self._public_api_base()
626624
record_url = f"{base_url}/api/v1/resources/{resource_id}/metadata"
627625

628626
# Create the HTML content
@@ -677,7 +675,10 @@ async def _get_resource_viewer(self, arguments: Dict[str, Any]) -> CallToolResul
677675

678676
def _public_api_base(self) -> str:
679677
"""Resolve base URL for calling this API over HTTP."""
680-
base = os.getenv("APPLICATION_URL", "http://localhost:8000").rstrip("/")
678+
base = os.getenv("BTAA_GEOSPATIAL_API_BASE_URL") or os.getenv(
679+
"APPLICATION_URL", "http://localhost:8000"
680+
)
681+
base = base.rstrip("/")
681682
if base.endswith("/api/v1"):
682683
base = base[: -len("/api/v1")]
683684
return base
@@ -967,7 +968,8 @@ async def run_mcp_websocket_server(websocket):
967968
try:
968969
data = json.loads(message)
969970
response = await handle_mcp_message(data)
970-
await websocket.send_text(json.dumps(response))
971+
if response is not None:
972+
await websocket.send_text(json.dumps(response))
971973
except json.JSONDecodeError:
972974
await websocket.send_text(
973975
json.dumps(
@@ -987,12 +989,30 @@ async def run_mcp_websocket_server(websocket):
987989
logging.error(f"WebSocket error: {e}")
988990

989991

990-
async def handle_mcp_message(data: Dict[str, Any]) -> Dict[str, Any]:
992+
async def handle_mcp_message(data: Dict[str, Any]) -> Dict[str, Any] | None:
991993
"""Handle MCP protocol messages."""
994+
if not isinstance(data, dict):
995+
return {
996+
"jsonrpc": "2.0",
997+
"id": None,
998+
"error": {"code": -32600, "message": "Invalid request"},
999+
}
1000+
9921001
method = data.get("method")
9931002
msg_id = data.get("id")
9941003
params = data.get("params", {})
9951004

1005+
if not method or not isinstance(method, str):
1006+
return {
1007+
"jsonrpc": "2.0",
1008+
"id": msg_id,
1009+
"error": {"code": -32600, "message": "Invalid request"},
1010+
}
1011+
1012+
# Ignore notifications that do not expect a reply.
1013+
if method in {"notifications/initialized", "$/cancelRequest"} and msg_id is None:
1014+
return None
1015+
9961016
if method == "initialize":
9971017
return {
9981018
"jsonrpc": "2.0",
@@ -1004,6 +1024,9 @@ async def handle_mcp_message(data: Dict[str, Any]) -> Dict[str, Any]:
10041024
},
10051025
}
10061026

1027+
elif method == "ping":
1028+
return {"jsonrpc": "2.0", "id": msg_id, "result": {}}
1029+
10071030
elif method == "tools/list":
10081031
return {"jsonrpc": "2.0", "id": msg_id, "result": {"tools": mcp_service.tool_specs}}
10091032

@@ -1085,8 +1108,8 @@ def get_mcp_service_info() -> dict[str, Any]:
10851108
"connections": {
10861109
"stdio": {
10871110
"type": "stdio",
1088-
"command": "python",
1089-
"args": ["-m", "app.services.mcp_service"],
1111+
"command": "python3",
1112+
"args": ["mcp/run_mcp_service.py"],
10901113
},
10911114
"websocket": {"type": "websocket", "url": "/api/v1/mcp/ws"},
10921115
},

backend/db/config.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import os
2-
from urllib.parse import urlparse, urlunparse
2+
from urllib.parse import quote, urlparse, urlunparse
33

44
from dotenv import load_dotenv
55

@@ -10,12 +10,33 @@
1010
# In sandboxed environments, .env may be unreadable. Continue with defaults/env.
1111
pass
1212

13+
def _repair_placeholder_database_password(database_url: str | None) -> str | None:
14+
"""Replace placeholder `postgres` password with the configured env password."""
15+
if not database_url:
16+
return database_url
17+
18+
parsed = urlparse(database_url)
19+
env_password = os.getenv("POSTGRES_PASSWORD") or os.getenv("DB_PASSWORD")
20+
21+
# Only repair the common local-dev placeholder case so we do not unexpectedly
22+
# rewrite intentionally different DATABASE_URL values.
23+
if not env_password or parsed.password != "postgres" or env_password == "postgres":
24+
return database_url
25+
26+
username = parsed.username or ""
27+
password = quote(env_password, safe="")
28+
hostname = parsed.hostname or ""
29+
port = f":{parsed.port}" if parsed.port else ""
30+
netloc = f"{username}:{password}@{hostname}{port}" if username else f"{hostname}{port}"
31+
return urlunparse(parsed._replace(netloc=netloc))
32+
33+
1334
# Get database configuration from environment variables
1435
# Use DATABASE_URL if provided, otherwise construct from individual components
1536
DATABASE_URL = os.getenv("DATABASE_URL")
1637
if not DATABASE_URL:
1738
DB_USER = os.getenv("DB_USER", "postgres")
18-
DB_PASSWORD = os.getenv("DB_PASSWORD", "postgres")
39+
DB_PASSWORD = os.getenv("POSTGRES_PASSWORD") or os.getenv("DB_PASSWORD", "postgres")
1940
# Default to localhost when running outside Docker (e.g., tests)
2041
# Use paradedb (Docker service name) when running inside Docker
2142
is_docker = os.getenv("IS_DOCKER") == "true"
@@ -27,6 +48,8 @@
2748
# Construct database URL with asyncpg driver, always targeting btaa_geospatial_api
2849
DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
2950
else:
51+
DATABASE_URL = _repair_placeholder_database_password(DATABASE_URL)
52+
3053
# Convert Docker hostnames to localhost when running outside Docker
3154
# This handles cases where DATABASE_URL is set from .env with Docker service names
3255
is_docker = os.getenv("IS_DOCKER") == "true"

backend/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ dependencies = [
3131
"tomli==2.2.1",
3232
"typing_extensions>=4.14.1",
3333
"uvicorn==0.32.1",
34+
"websockets==15.0.1",
3435
"requests==2.33.0",
3536
"docker==7.1.0",
3637
"elasticsearch>=9.0.0",
@@ -130,4 +131,3 @@ directory = "coverage_html_report"
130131
pythonpath = ["."]
131132
testpaths = ["tests"]
132133
python_files = ["test_*.py"]
133-

backend/scripts/start_web_singlehost.sh

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export PATH="/opt/venv/bin:$PATH"
77

88
echo "[start_web_singlehost] starting FastAPI (uvicorn) on 127.0.0.1:8001"
99
cd /app/backend
10-
python -m uvicorn app.main:app --host 127.0.0.1 --port 8001 &
10+
python -m uvicorn app.main:app --host 127.0.0.1 --port 8001 --ws websockets &
1111
UVICORN_PID=$!
1212

1313
echo "[start_web_singlehost] starting SSR (react-router-serve) on 0.0.0.0:3000"
@@ -30,4 +30,3 @@ trap cleanup EXIT INT TERM
3030

3131
echo "[start_web_singlehost] starting nginx (public) on 0.0.0.0:8000"
3232
exec nginx -g 'daemon off;'
33-

0 commit comments

Comments
 (0)