Files
finn-mcp/.github/instructions/mcp.instructions.md
T
2026-05-16 06:54:17 +00:00

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 httpx access.
  • 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_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:

@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" — return json.dumps(result_dict).
  • "markdown" — return formatting.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 through service.
  • 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-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.