--- name: MCP rules description: Rules for FastMCP tools, resources, and prompts applyTo: "finn_eiendom/mcp_server.py,finn_eiendom/**/*mcp*.py" --- # MCP server rules The MCP server is a **thin wrapper** over `service.py`. It owns: * Tool registration with `@mcp.tool()` and annotations. * Pydantic input schemas (these double as tool documentation). * Error wrapping at the protocol boundary. * JSON / markdown response formatting via `formatting.py`. It does **not** own: * Parsing, scraping, scoring, cache, or HTTP fetching logic. * SQLite or `httpx` access. * Any orchestration of "check cache, else fetch, else save" — that's `service.py`. ## Server bootstrap ```python # finn_eiendom/mcp_server.py import sys, logging from mcp.server.fastmcp import FastMCP logging.basicConfig(stream=sys.stderr, level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") mcp = FastMCP("finn_eiendom_mcp") # ... tools registered here ... def main() -> None: mcp.run(transport="stdio") if __name__ == "__main__": main() ``` stdio servers **must** log to stderr only — anything on stdout breaks the JSON-RPC frame. ## Tool naming All tools use the `finn_` prefix so they don't collide with other MCP servers running in the same Claude Desktop: * `finn_analyze_search` * `finn_get_ad` * `finn_compare_ads` * `finn_save_feedback` * `finn_get_shortlist` * `finn_get_new_ads_since_last_run` * `finn_resolve_eiendom_unit` * `finn_get_eiendom_unit` * `finn_enrich_ad` * `finn_build_unit_vector` * `finn_decode_unit_vector` * `finn_get_similar_units` * `finn_find_similar_to_liked_ad` * `finn_analyze_ad_against_comps` ## Tool body shape Every tool body looks like this: ```python @mcp.tool( annotations=ToolAnnotations( title="Analyze a FINN search URL", readOnlyHint=True, destructiveHint=False, openWorldHint=True, ) ) async def finn_analyze_search(input: AnalyzeSearchInput) -> str: """Analyze a FINN search URL and return a ranked shortlist.""" try: result = await service.analyze_search( search_url=input.search_url, max_pages=input.max_pages, detail_limit=input.detail_limit, include_details=input.include_details, include_eiendom_no=input.include_eiendom_no, include_similar_units_for_shortlist=input.include_similar_units_for_shortlist, ) return formatting.render_shortlist(result, input.response_format) except Exception as e: log.exception("finn_analyze_search failed") return json.dumps({ "error": True, "code": type(e).__name__, "message": str(e), }) ``` Notes: * Every tool delegates to `service.` in one call. * Every tool wraps in try/except and returns the error envelope as a JSON string. * Output rendering goes through `formatting.py`, never inline. * If the tool body needs more than ~20 lines, logic has leaked out of the service layer — push it back down. ## Input schemas Every tool has a Pydantic v2 input model. Schemas live with the tool in `mcp_server.py` (they document the tool to LLM clients). Reuse from `models.py` only when the same shape is also a domain object — otherwise keep them as tool-local input types. ```python class AnalyzeSearchInput(BaseModel): search_url: str = Field(..., description="Full FINN search URL") max_pages: int = Field(default=3, ge=1, le=10) detail_limit: int = Field(default=20, ge=1, le=100) include_details: bool = True include_eiendom_no: bool = True include_similar_units_for_shortlist: bool = False response_format: Literal["json", "markdown"] = "json" ``` ## Annotations Set the right hints: * Read-only tools (most of them): `readOnlyHint=True, destructiveHint=False, openWorldHint=True`. * `finn_save_feedback`: `readOnlyHint=False, destructiveHint=False, idempotentHint=False`. ## Response format Tools accept a `response_format` parameter (`"json"` or `"markdown"`): * `"json"` — return `json.dumps(result_dict)`. * `"markdown"` — return `formatting.render_(result, "markdown")`. Errors are always returned as the JSON error envelope regardless of `response_format`. ## What stays out of mcp_server.py * `import httpx` — never. * `import sqlite3` — never. * `from .ad import ...`, `from .search import ...`, `from .eiendom_no import ...`, `from .scoring import ...`, `from .cache import ...`, `from .http import ...` — never. Go through `service`. * Output formatting logic — goes in `formatting.py`. * Cache management — goes in `service.py`. Allowed imports in `mcp_server.py`: ```python import json, logging, sys from typing import Literal, Optional from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp.utilities import ToolAnnotations from pydantic import BaseModel, Field from . import service, formatting from .models import FinnAd, EiendomUnit, SimilarUnit # only if needed for type hints from . import config ``` `tests/test_architecture.py` enforces this. ## Resources and prompts When you add resources or prompts, they follow the same rule: thin wrappers over `service.py` and `formatting.py`. Resources: ``` finn://preferences/current finn://search-runs/latest finn://search-runs/{id} finn://ads/{finnkode} finn://ads/{finnkode}/enriched finn://shortlist/latest finn://feedback/{finnkode} finn://eiendom-units/{unitCode} finn://eiendom-units/{unitCode}/similar/{listingStatus} ``` Prompts: `evaluate_property_for_user`, `compare_properties_for_user`, `refine_search_from_feedback`, `find_more_like_this`. ## When uncertain about FastMCP Use `context7` for FastMCP / MCP SDK questions instead of guessing: ``` context7:resolve-library-id → "modelcontextprotocol/python-sdk" or similar context7:query-docs(id, "FastMCP tool annotations") → snippets ``` See `docs.instructions.md`. ## Transports * Default: stdio. `finn-eiendom-mcp` is the entry point. * Optional: Streamable HTTP via `finn-eiendom serve --transport http --port 8010`. Path: `POST /mcp`. Operational endpoints: `GET /health`, `GET /version`, `GET /debug/config`. * Keep tools transport-agnostic. No request/response shape depends on the transport.