6.1 KiB
name, description, applyTo
| name | description | applyTo |
|---|---|---|
| MCP rules | Rules for FastMCP tools, resources, and prompts | 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
httpxaccess. - Any orchestration of "check cache, else fetch, else save" — that's
service.py.
Server bootstrap
# 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_searchfinn_get_adfinn_compare_adsfinn_save_feedbackfinn_get_shortlistfinn_get_new_ads_since_last_runfinn_resolve_eiendom_unitfinn_get_eiendom_unitfinn_enrich_adfinn_build_unit_vectorfinn_decode_unit_vectorfinn_get_similar_unitsfinn_find_similar_to_liked_adfinn_analyze_ad_against_comps
Tool body shape
Every tool body looks like this:
@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.<function>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.
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"— returnjson.dumps(result_dict)."markdown"— returnformatting.render_<thing>(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 throughservice.- Output formatting logic — goes in
formatting.py. - Cache management — goes in
service.py.
Allowed imports in mcp_server.py:
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-mcpis 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.