initial
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
---
|
||||
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.<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.
|
||||
|
||||
```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_<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`:
|
||||
|
||||
```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.
|
||||
Reference in New Issue
Block a user