@@ -0,0 +1,26 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.venv
|
||||||
|
.vscode
|
||||||
|
.env*
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
|
cache/
|
||||||
|
tests/
|
||||||
|
.DS_Store
|
||||||
|
IMPLEMENTATION.md
|
||||||
|
AGENTS.md
|
||||||
|
PROJECT.md
|
||||||
|
USAGE.md
|
||||||
|
DOCKER.md
|
||||||
|
Makefile
|
||||||
|
docker-compose.override.yml
|
||||||
|
|||||||
Vendored
+4
@@ -4,6 +4,10 @@
|
|||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "https://mcp.context7.com/mcp",
|
"url": "https://mcp.context7.com/mcp",
|
||||||
},
|
},
|
||||||
|
"mcp-jungle":{
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://mini:8080/mcp",
|
||||||
|
},
|
||||||
// "finn-eiendom": { }
|
// "finn-eiendom": { }
|
||||||
"finn-eiendom": {
|
"finn-eiendom": {
|
||||||
"command": "/root/projects/finn-mcp/.venv/bin/python",
|
"command": "/root/projects/finn-mcp/.venv/bin/python",
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
# Deployment Guide - finn-eiendom MCP Server
|
||||||
|
|
||||||
|
This guide covers deploying the FINN Eiendom MCP server using Docker.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Docker and Docker Compose installed
|
||||||
|
- Remote server with port 8010 available
|
||||||
|
- (Optional) Reverse proxy (nginx/caddy) for HTTPS and load balancing
|
||||||
|
|
||||||
|
### Build the Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/projects/finn-mcp
|
||||||
|
docker build -t finn-mcp:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Locally (Development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the server is running:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8010
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploy to Remote Server
|
||||||
|
|
||||||
|
1. **Build and tag the image:**
|
||||||
|
```bash
|
||||||
|
docker build -t your-registry/finn-mcp:latest .
|
||||||
|
docker push your-registry/finn-mcp:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **On the remote server, create docker-compose.yml:**
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/finn-mcp
|
||||||
|
cd /opt/finn-mcp
|
||||||
|
# Copy the docker-compose.yml and docker-compose.prod.yml files
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start the service:**
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Verify the service:**
|
||||||
|
```bash
|
||||||
|
docker ps | grep finn-mcp
|
||||||
|
docker logs finn-mcp-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Set these via `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
MCP_TRANSPORT: http # Transport protocol (http or stdio)
|
||||||
|
MCP_HOST: 0.0.0.0 # Bind address
|
||||||
|
MCP_PORT: 8010 # Port number
|
||||||
|
PYTHONUNBUFFERED: 1 # Immediate output logging
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Configuration
|
||||||
|
|
||||||
|
**Development (localhost only):**
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8010:8010"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production (all interfaces):**
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "8010:8010"
|
||||||
|
```
|
||||||
|
|
||||||
|
**With reverse proxy (recommended):**
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8010:8010" # Only accessible via reverse proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Networking & Security
|
||||||
|
|
||||||
|
### Option 1: Direct HTTP (Development Only)
|
||||||
|
```bash
|
||||||
|
# Not recommended for production
|
||||||
|
curl http://your-server:8010
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Reverse Proxy (Recommended)
|
||||||
|
|
||||||
|
**Nginx example:**
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8010;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Caddy example:**
|
||||||
|
```caddyfile
|
||||||
|
your-domain.com {
|
||||||
|
reverse_proxy localhost:8010 {
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
header_up X-Forwarded-Host {host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Check Container Health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View logs
|
||||||
|
docker logs -f finn-mcp-server
|
||||||
|
|
||||||
|
# Check resource usage
|
||||||
|
docker stats finn-mcp-server
|
||||||
|
|
||||||
|
# View health status
|
||||||
|
docker inspect --format='{{.State.Health}}' finn-mcp-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Log Aggregation
|
||||||
|
|
||||||
|
Logs are written to:
|
||||||
|
- `json-file` driver with 100MB max size, 10 file rotation
|
||||||
|
- Structured JSON output for easy parsing
|
||||||
|
|
||||||
|
Forward to ELK/Splunk/Datadog if needed:
|
||||||
|
```yaml
|
||||||
|
logging:
|
||||||
|
driver: "splunk"
|
||||||
|
options:
|
||||||
|
splunk-token: "your-token"
|
||||||
|
splunk-url: "https://your-instance.splunk.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updates & Maintenance
|
||||||
|
|
||||||
|
### Update the Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull latest code
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# Rebuild image
|
||||||
|
docker build -t finn-mcp:latest .
|
||||||
|
|
||||||
|
# Restart containers
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Backup
|
||||||
|
|
||||||
|
Cache database location: `/app/cache.sqlite` (inside container)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup from host
|
||||||
|
docker exec finn-mcp-server cp /app/cache.sqlite /tmp/cache.sqlite.bak
|
||||||
|
docker cp finn-mcp-server:/tmp/cache.sqlite.bak ./cache.sqlite.bak
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Server won't start
|
||||||
|
```bash
|
||||||
|
# Check logs
|
||||||
|
docker logs finn-mcp-server
|
||||||
|
|
||||||
|
# Verify port is available
|
||||||
|
lsof -i :8010
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health check failing
|
||||||
|
```bash
|
||||||
|
# Test connection
|
||||||
|
docker exec finn-mcp-server python -c "import socket; socket.create_connection(('localhost', 8010), timeout=5).close()"
|
||||||
|
```
|
||||||
|
|
||||||
|
### High memory usage
|
||||||
|
```bash
|
||||||
|
# Check limits in docker-compose.yml
|
||||||
|
# Adjust memory limit if needed
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 2G
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Checklist
|
||||||
|
|
||||||
|
- [ ] Docker image built and tested locally
|
||||||
|
- [ ] Reverse proxy configured (nginx/caddy)
|
||||||
|
- [ ] SSL certificates installed
|
||||||
|
- [ ] Environment variables reviewed
|
||||||
|
- [ ] Resource limits appropriate for server
|
||||||
|
- [ ] Health checks enabled
|
||||||
|
- [ ] Logging configured (syslog/ELK/Datadog)
|
||||||
|
- [ ] Backups scheduled
|
||||||
|
- [ ] Monitoring alerts configured
|
||||||
|
- [ ] Failover/HA plan in place (if needed)
|
||||||
|
|
||||||
|
## Integration with Copilot
|
||||||
|
|
||||||
|
Once the MCP server is running on your remote server, configure Copilot to connect:
|
||||||
|
|
||||||
|
**On the machine running Copilot Desktop:**
|
||||||
|
|
||||||
|
1. Open Claude Desktop settings (or config file at `~/.config/claude-desktop/config.json`)
|
||||||
|
2. Add HTTP transport configuration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"finn-eiendom": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://your-server:8010"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with a reverse proxy:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"finn-eiendom": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://your-domain.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support & Debugging
|
||||||
|
|
||||||
|
### Test MCP Server Directly
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test with stdio transport
|
||||||
|
cat > test_mcp.json << 'EOF'
|
||||||
|
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0"}}}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
docker run -i finn-mcp:latest python -m finn_eiendom.mcp_server < test_mcp.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Available Tools
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://your-server:8010 -X POST -H "Content-Type: application/json" \
|
||||||
|
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [MCP Protocol](https://spec.modelcontextprotocol.io/)
|
||||||
|
- [FastMCP Documentation](https://github.com/jlopp/fastmcp)
|
||||||
|
- [Docker Compose Docs](https://docs.docker.com/compose/)
|
||||||
+320
@@ -0,0 +1,320 @@
|
|||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
This guide covers deploying the FINN-Eiendom MCP server on a remote server for use with any chat service (Claude Desktop, GitHub Copilot, etc.).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The MCP server runs as an **HTTP service** on port 8010. This makes it agnostic to any client - any chat service can send JSON-RPC requests to the HTTP endpoint.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Local Client (Claude Desktop/Copilot/etc)
|
||||||
|
↓ HTTP JSON-RPC
|
||||||
|
Remote Server (HTTP API)
|
||||||
|
↓
|
||||||
|
Docker Container (HTTP Server)
|
||||||
|
↓ subprocess stdin/stdout
|
||||||
|
MCP stdio server
|
||||||
|
↓
|
||||||
|
Service Functions
|
||||||
|
↓
|
||||||
|
FINN / Eiendom.no APIs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Remote server with Docker and Docker Compose installed
|
||||||
|
- Port 8010 open (or configure with reverse proxy)
|
||||||
|
- Domain/IP address accessible to your clients
|
||||||
|
|
||||||
|
## Quick Start: Docker Compose
|
||||||
|
|
||||||
|
### 1. Deploy on Remote Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH into remote server
|
||||||
|
ssh user@your-remote-server.com
|
||||||
|
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/yourusername/finn-mcp.git
|
||||||
|
cd finn-mcp
|
||||||
|
|
||||||
|
# Start with docker-compose
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The MCP HTTP server will be available at `http://your-remote-server.com:8010`
|
||||||
|
|
||||||
|
### 2. Configure Claude Desktop
|
||||||
|
|
||||||
|
Edit `~/.config/claude-desktop/claude.json` (Mac/Linux) or `%APPDATA%\Claude\claude.json` (Windows):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"finn-eiendom": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://your-remote-server.com:8010"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test the Connection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From your local machine
|
||||||
|
curl -X POST http://your-remote-server.com:8010/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Should return:
|
||||||
|
```json
|
||||||
|
{"status": "ok", "service": "finn-mcp-http"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and start
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f mcp-server
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Setup with Reverse Proxy
|
||||||
|
|
||||||
|
For security and domain management, use a reverse proxy (Nginx/Caddy):
|
||||||
|
|
||||||
|
### Nginx Configuration
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
upstream mcp {
|
||||||
|
server localhost:8010;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
# Redirect HTTP to HTTPS
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
ssl_certificate /path/to/cert.pem;
|
||||||
|
ssl_certificate_key /path/to/key.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://mcp;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Enable larger uploads if needed
|
||||||
|
client_max_body_size 10M;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then configure Claude Desktop with HTTPS:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"finn-eiendom": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://your-domain.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caddy Configuration
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
your-domain.com {
|
||||||
|
reverse_proxy localhost:8010
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Set these in your `docker run` command or `docker-compose.yml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Performance tuning
|
||||||
|
FINN_RATE_LIMIT_DELAY=0.5 # Delay between FINN requests (seconds)
|
||||||
|
HTTP_TIMEOUT=30 # HTTP request timeout
|
||||||
|
HTTP_MAX_RETRIES=3 # Max retry attempts
|
||||||
|
```
|
||||||
|
|
||||||
|
Example with docker-compose:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
FINN_RATE_LIMIT_DELAY: 0.5
|
||||||
|
HTTP_TIMEOUT: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Check Container Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On remote server
|
||||||
|
docker ps --filter "name=finn-mcp"
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker logs -f finn-mcp-server
|
||||||
|
|
||||||
|
# Check health
|
||||||
|
curl http://localhost:8010/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test API Endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://your-remote-server.com:8010/health
|
||||||
|
|
||||||
|
# Query a tool (example)
|
||||||
|
curl -X POST http://your-remote-server.com:8010 \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "tools/list",
|
||||||
|
"params": {}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Use HTTPS**: Always use a reverse proxy with SSL/TLS
|
||||||
|
- Never expose HTTP directly on the internet
|
||||||
|
- Use Let's Encrypt for free certificates
|
||||||
|
|
||||||
|
2. **Firewall**: Restrict port 8010 access
|
||||||
|
```bash
|
||||||
|
# Only allow from localhost (reverse proxy will forward)
|
||||||
|
# In docker-compose, map to localhost only:
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8010:8010"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Rate Limiting**: Set up rate limiting on reverse proxy
|
||||||
|
```nginx
|
||||||
|
limit_req_zone $binary_remote_addr zone=mcp:10m rate=10r/s;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
limit_req zone=mcp burst=20;
|
||||||
|
proxy_pass http://mcp;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Authentication** (Optional): Add API key validation
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
if ($http_x_api_key != "your-secret-key") {
|
||||||
|
return 401;
|
||||||
|
}
|
||||||
|
proxy_pass http://mcp;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Container Security**:
|
||||||
|
- Don't run container as root
|
||||||
|
- Use read-only filesystems where possible
|
||||||
|
- Set resource limits
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Connection refused"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if container is running
|
||||||
|
docker ps | grep finn-mcp
|
||||||
|
|
||||||
|
# Start container
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Check logs for startup errors
|
||||||
|
docker logs finn-mcp-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Unable to connect to HTTP endpoint"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify port is open on remote server
|
||||||
|
netstat -tlnp | grep 8010
|
||||||
|
|
||||||
|
# Verify reverse proxy configuration (if using)
|
||||||
|
curl -v http://localhost:8010/health
|
||||||
|
|
||||||
|
# Check firewall rules
|
||||||
|
ufw status
|
||||||
|
ufw allow 8010 # If needed
|
||||||
|
```
|
||||||
|
|
||||||
|
### "502 Bad Gateway" from reverse proxy
|
||||||
|
|
||||||
|
1. Check MCP container is running: `docker ps`
|
||||||
|
2. Verify health endpoint: `docker exec finn-mcp-server curl http://localhost:8010/health`
|
||||||
|
3. Check container logs: `docker logs finn-mcp-server`
|
||||||
|
4. Verify resource limits aren't exceeded: `docker stats finn-mcp-server`
|
||||||
|
|
||||||
|
### "Request timeout" from Claude Desktop
|
||||||
|
|
||||||
|
1. Verify connection: `curl -v http://your-domain:8010/health`
|
||||||
|
2. Check network latency: `ping your-domain`
|
||||||
|
3. Increase timeout in reverse proxy config
|
||||||
|
4. Check CPU/memory usage: `docker stats`
|
||||||
|
|
||||||
|
## Scaling
|
||||||
|
|
||||||
|
For multiple clients or high load:
|
||||||
|
|
||||||
|
1. **Increase container resources**:
|
||||||
|
```bash
|
||||||
|
docker update --memory 2g --cpus 2 finn-mcp-server
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add caching layer** (already built-in):
|
||||||
|
```bash
|
||||||
|
# View cache stats
|
||||||
|
docker exec finn-mcp-server finn-eiendom cache stats
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Load balancing**: Run multiple containers with different cache databases
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull latest code
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# Rebuild image
|
||||||
|
docker build -t finn-mcp:latest .
|
||||||
|
|
||||||
|
# Restart container
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- [Usage Guide](USAGE.md)
|
||||||
|
- [README](README.md)
|
||||||
|
- [Implementation Details](IMPLEMENTATION.md)
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM python:3.12-slim as builder
|
||||||
|
|
||||||
|
WORKDIR /tmp/build
|
||||||
|
|
||||||
|
# Install system dependencies for building
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
libxml2-dev \
|
||||||
|
libxslt1-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy dependency files
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
|
||||||
|
# Create virtual environment and install dependencies
|
||||||
|
RUN python -m venv /venv && \
|
||||||
|
/venv/bin/pip install --upgrade pip setuptools wheel && \
|
||||||
|
/venv/bin/pip install . --no-cache-dir
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install runtime system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libxml2 \
|
||||||
|
libxslt1.1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy virtual environment from builder
|
||||||
|
COPY --from=builder /venv /venv
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY finn_eiendom /app/finn_eiendom
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PATH="/venv/bin:$PATH" \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
MCP_HOST=0.0.0.0 \
|
||||||
|
MCP_PORT=8010
|
||||||
|
|
||||||
|
# Expose HTTP port
|
||||||
|
EXPOSE 8010
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
|
CMD python -c "import requests; requests.get('http://localhost:8010/health', timeout=5)" || exit 1
|
||||||
|
|
||||||
|
# Run the MCP HTTP server
|
||||||
|
CMD ["python", "-m", "finn_eiendom.http_server"]
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Production configuration for docker-compose
|
||||||
|
# Usage: docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
mcp-server:
|
||||||
|
# Production image should be pre-built and tagged
|
||||||
|
image: finn-mcp:latest
|
||||||
|
|
||||||
|
# Environment overrides for production
|
||||||
|
environment:
|
||||||
|
PYTHONUNBUFFERED: 1
|
||||||
|
|
||||||
|
# More aggressive resource limits for production
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '4'
|
||||||
|
memory: 2G
|
||||||
|
reservations:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 1G
|
||||||
|
|
||||||
|
# Restart policy
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
# Security options
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
# Read-only root filesystem (if cache is not persistent)
|
||||||
|
# read_only: true
|
||||||
|
|
||||||
|
# Logging configuration for production
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "100m"
|
||||||
|
max-file: "10"
|
||||||
|
labels: "service=finn-mcp"
|
||||||
|
|
||||||
|
# Labels for monitoring/metadata
|
||||||
|
labels:
|
||||||
|
com.example.description: "FINN Eiendom.no MCP Server"
|
||||||
|
com.example.version: "0.1.0"
|
||||||
|
maintainer: "your-email@example.com"
|
||||||
|
|
||||||
|
# Example reverse proxy configuration (nginx):
|
||||||
|
# Place this on your host server
|
||||||
|
# server {
|
||||||
|
# listen 8010;
|
||||||
|
# server_name your-domain.com;
|
||||||
|
#
|
||||||
|
# location / {
|
||||||
|
# proxy_pass http://localhost:8010;
|
||||||
|
# proxy_http_version 1.1;
|
||||||
|
# proxy_set_header Upgrade $http_upgrade;
|
||||||
|
# proxy_set_header Connection "upgrade";
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# proxy_read_timeout 86400;
|
||||||
|
# }
|
||||||
|
# }
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
mcp-server:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: finn-mcp-server
|
||||||
|
|
||||||
|
# Environment configuration
|
||||||
|
environment:
|
||||||
|
# MCP HTTP server configuration
|
||||||
|
MCP_HOST: 0.0.0.0
|
||||||
|
MCP_PORT: 8010
|
||||||
|
|
||||||
|
# Python configuration
|
||||||
|
PYTHONUNBUFFERED: 1
|
||||||
|
|
||||||
|
# Optional: FINN/Eiendom.no rate limiting and retry configuration
|
||||||
|
# FINN_RATE_LIMIT_DELAY: 0.5
|
||||||
|
# HTTP_TIMEOUT: 30
|
||||||
|
# HTTP_MAX_RETRIES: 3
|
||||||
|
|
||||||
|
# Port mapping for HTTP access
|
||||||
|
ports:
|
||||||
|
- "8010:8010"
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8010/health', timeout=5)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
# Resource limits (adjust based on your server)
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 1G
|
||||||
|
reservations:
|
||||||
|
cpus: '1'
|
||||||
|
memory: 512M
|
||||||
|
|
||||||
|
# Restart policy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# For development, you can override with:
|
||||||
|
# docker-compose -f docker-compose.yml -f docker-compose.override.yml up
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ def scrape_ad(html: str, url: str | None = None) -> FinnAd:
|
|||||||
has_parking = (
|
has_parking = (
|
||||||
bool(properties.get("parkering/garasje"))
|
bool(properties.get("parkering/garasje"))
|
||||||
or "parkering" in feature_text
|
or "parkering" in feature_text
|
||||||
|
)
|
||||||
|
has_garage = (
|
||||||
|
bool(properties.get("parkering/garasje"))
|
||||||
or "garasje" in feature_text
|
or "garasje" in feature_text
|
||||||
)
|
)
|
||||||
broker_company = None
|
broker_company = None
|
||||||
@@ -177,6 +180,7 @@ def scrape_ad(html: str, url: str | None = None) -> FinnAd:
|
|||||||
has_terrace=has_terrace,
|
has_terrace=has_terrace,
|
||||||
has_elevator=has_elevator,
|
has_elevator=has_elevator,
|
||||||
has_parking=has_parking,
|
has_parking=has_parking,
|
||||||
|
has_garage=has_garage,
|
||||||
listing_description=listing_description,
|
listing_description=listing_description,
|
||||||
broker_name=None,
|
broker_name=None,
|
||||||
broker_company=broker_company,
|
broker_company=broker_company,
|
||||||
|
|||||||
@@ -149,14 +149,10 @@ async def analyze_search(
|
|||||||
if include_eiendom_no:
|
if include_eiendom_no:
|
||||||
try:
|
try:
|
||||||
matched_unit = await eiendom_no.search_unit_from_finn_url(card.url)
|
matched_unit = await eiendom_no.search_unit_from_finn_url(card.url)
|
||||||
|
unit_code = matched_unit.unit_code if matched_unit else None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Eiendom.no unit search failed: %s", exc)
|
logger.warning("Eiendom.no unit search failed: %s", exc)
|
||||||
matched_unit = None
|
unit_code = None
|
||||||
unit_code = (
|
|
||||||
matched_unit.unit_code
|
|
||||||
if matched_unit
|
|
||||||
else eiendom_no.resolve_unit_from_finn_url(card.url)
|
|
||||||
)
|
|
||||||
result = await analyze_ad(finn_ad, unit_code=unit_code)
|
result = await analyze_ad(finn_ad, unit_code=unit_code)
|
||||||
if result.get("eiendom_unit"):
|
if result.get("eiendom_unit"):
|
||||||
enriched_count += 1
|
enriched_count += 1
|
||||||
|
|||||||
+3
-3
@@ -200,7 +200,7 @@ def build_vector(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Build a unit vector for an Eiendom.no unit."""
|
"""Build a unit vector for an Eiendom.no unit."""
|
||||||
try:
|
try:
|
||||||
result = svc_build_unit_vector(unit_code)
|
result = asyncio.run(svc_build_unit_vector(unit_code))
|
||||||
typer.echo(formatting.render_ad(result, format))
|
typer.echo(formatting.render_ad(result, format))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
typer.echo(f"Error: {e}", err=True)
|
typer.echo(f"Error: {e}", err=True)
|
||||||
@@ -223,7 +223,7 @@ def decode_vector(
|
|||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def similar_units(
|
def similar_units(
|
||||||
unit_vector: str = typer.Argument(..., help="Unit vector string (base64)"),
|
unit_code: str = typer.Argument(..., help="Eiendom.no unit code"),
|
||||||
status: str = typer.Option(
|
status: str = typer.Option(
|
||||||
"RECENTLY_SOLD", help="Listing status (RECENTLY_SOLD, FOR_SALE, CURRENT)"
|
"RECENTLY_SOLD", help="Listing status (RECENTLY_SOLD, FOR_SALE, CURRENT)"
|
||||||
),
|
),
|
||||||
@@ -231,7 +231,7 @@ def similar_units(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Fetch similar/comparable units from Eiendom.no."""
|
"""Fetch similar/comparable units from Eiendom.no."""
|
||||||
try:
|
try:
|
||||||
units = asyncio.run(svc_get_or_fetch_similar_units(unit_vector, listing_status=status))
|
units = asyncio.run(svc_get_or_fetch_similar_units(unit_code, listing_status=status))
|
||||||
result = {"similar_units": [u.model_dump() for u in units]}
|
result = {"similar_units": [u.model_dump() for u in units]}
|
||||||
typer.echo(formatting.render_similar_units(result, format))
|
typer.echo(formatting.render_similar_units(result, format))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ def parse_eiendom_unit_json(unit_data: dict) -> EiendomUnit:
|
|||||||
specification = unit_data.get("specification", {})
|
specification = unit_data.get("specification", {})
|
||||||
valuation = unit_data.get("valuation", {})
|
valuation = unit_data.get("valuation", {})
|
||||||
market = unit_data.get("latestMarketData", {})
|
market = unit_data.get("latestMarketData", {})
|
||||||
|
unit_images = market.get("unitImages") or unit_data.get("unitImages") or []
|
||||||
|
|
||||||
return EiendomUnit(
|
return EiendomUnit(
|
||||||
unit_code=unit_data.get("unitCode", ""),
|
unit_code=unit_data.get("unitCode", ""),
|
||||||
@@ -62,6 +63,7 @@ def parse_eiendom_unit_json(unit_data: dict) -> EiendomUnit:
|
|||||||
sale_status=market.get("saleStatus") or unit_data.get("saleStatus"),
|
sale_status=market.get("saleStatus") or unit_data.get("saleStatus"),
|
||||||
market_placement_score=market.get("marketPlacementScore")
|
market_placement_score=market.get("marketPlacementScore")
|
||||||
or unit_data.get("marketPlacementScore"),
|
or unit_data.get("marketPlacementScore"),
|
||||||
|
unit_images=unit_images if unit_images else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -212,16 +214,6 @@ async def get_similar_units(
|
|||||||
return units
|
return units
|
||||||
|
|
||||||
|
|
||||||
def resolve_unit_from_finn_url(finn_url: str) -> str | None:
|
|
||||||
"""Resolve the FINN URL into a unit identifier or unitCode placeholder."""
|
|
||||||
if not finn_url:
|
|
||||||
return None
|
|
||||||
candidate = normalize_finnkode(extract_finnkode_from_url(finn_url))
|
|
||||||
if candidate:
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def enrich_ad_with_eiendom_no(
|
async def enrich_ad_with_eiendom_no(
|
||||||
ad: Any,
|
ad: Any,
|
||||||
unit_code: str | None = None,
|
unit_code: str | None = None,
|
||||||
|
|||||||
@@ -135,6 +135,61 @@ def _render_unit_markdown(unit: dict[str, Any]) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Unit images renderer
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def render_unit_images(payload: dict[str, Any], fmt: OutputFormat) -> str:
|
||||||
|
"""Render unit images for visual assessment."""
|
||||||
|
_validate_format(fmt)
|
||||||
|
|
||||||
|
if fmt == "json":
|
||||||
|
return json.dumps(payload, indent=2, default=str)
|
||||||
|
else:
|
||||||
|
return _render_unit_images_markdown(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_unit_images_markdown(data: dict[str, Any]) -> str:
|
||||||
|
"""Render unit images as markdown with image references for Claude."""
|
||||||
|
unit_code = data.get("unit_code", "Unknown")
|
||||||
|
address = data.get("address", "Unknown")
|
||||||
|
images = data.get("unit_images") or []
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"# Unit Images: {address}",
|
||||||
|
"",
|
||||||
|
f"**Unit Code:** {unit_code}",
|
||||||
|
f"**Total Photos:** {len(images)}",
|
||||||
|
"",
|
||||||
|
"## Property Photos",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
lines.append("No images available for this unit.")
|
||||||
|
else:
|
||||||
|
lines.append("Below are the property images for visual assessment:")
|
||||||
|
lines.append("")
|
||||||
|
for i, img_url in enumerate(images, 1):
|
||||||
|
lines.append(f"### Photo {i}")
|
||||||
|
lines.append(f"")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("---")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("**Analysis Notes:**")
|
||||||
|
lines.append("Review the above photos to assess:")
|
||||||
|
lines.append("- View quality (street, landscape, water, etc.)")
|
||||||
|
lines.append("- Space and layout (openness, ceiling height, etc.)")
|
||||||
|
lines.append("- Lighting and window placement")
|
||||||
|
lines.append("- Condition and maintenance state")
|
||||||
|
lines.append("- Kitchen and bathroom features")
|
||||||
|
lines.append("- Overall atmosphere and livability")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Shortlist renderer
|
# Shortlist renderer
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import uvicorn
|
||||||
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
|
from finn_eiendom.mcp_server import mcp
|
||||||
|
|
||||||
|
mcp.transport_security = TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
||||||
|
|
||||||
|
app = mcp.sse_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8010, forwarded_allow_ips="*")
|
||||||
@@ -3,10 +3,10 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
import os
|
||||||
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from .analysis import analyze_search
|
|
||||||
from .eiendom_no import (
|
from .eiendom_no import (
|
||||||
build_unit_vector,
|
build_unit_vector,
|
||||||
decode_unit_vector,
|
decode_unit_vector,
|
||||||
@@ -20,22 +20,37 @@ from .formatting import (
|
|||||||
render_diff,
|
render_diff,
|
||||||
render_shortlist,
|
render_shortlist,
|
||||||
render_similar_units,
|
render_similar_units,
|
||||||
|
render_unit_images,
|
||||||
)
|
)
|
||||||
from .service import (
|
from .service import (
|
||||||
analyze_ad,
|
analyze_ad,
|
||||||
analyze_ad_against_comps,
|
analyze_ad_against_comps,
|
||||||
|
analyze_search,
|
||||||
compare_ads,
|
compare_ads,
|
||||||
find_similar_to_liked,
|
find_similar_to_liked,
|
||||||
get_new_ads_since_last_run,
|
get_new_ads_since_last_run,
|
||||||
get_or_fetch_ad,
|
get_or_fetch_ad,
|
||||||
get_or_fetch_eiendom_unit,
|
get_or_fetch_eiendom_unit,
|
||||||
get_shortlist,
|
get_shortlist,
|
||||||
|
get_unit_images,
|
||||||
save_feedback,
|
save_feedback,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
mcp = FastMCP("finn_eiendom_mcp")
|
|
||||||
|
def _build_transport_security() -> TransportSecuritySettings:
|
||||||
|
allowed = os.getenv("MCP_ALLOWED_HOSTS", "")
|
||||||
|
if allowed:
|
||||||
|
hosts = [h.strip() for h in allowed.split(",")]
|
||||||
|
return TransportSecuritySettings(
|
||||||
|
enable_dns_rebinding_protection=True,
|
||||||
|
allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"] + hosts,
|
||||||
|
)
|
||||||
|
return TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
||||||
|
|
||||||
|
|
||||||
|
mcp = FastMCP("finn_eiendom_mcp", transport_security=_build_transport_security())
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@@ -56,7 +71,7 @@ async def finn_analyze_search(
|
|||||||
result = await analyze_search(
|
result = await analyze_search(
|
||||||
search_url,
|
search_url,
|
||||||
max_pages=max_pages,
|
max_pages=max_pages,
|
||||||
fetch_details=include_details,
|
include_details=include_details,
|
||||||
detail_limit=detail_limit,
|
detail_limit=detail_limit,
|
||||||
include_eiendom_no=include_eiendom_no,
|
include_eiendom_no=include_eiendom_no,
|
||||||
)
|
)
|
||||||
@@ -125,6 +140,22 @@ async def finn_get_eiendom_unit(unit_code: str, force_refresh: bool = False) ->
|
|||||||
return json.dumps({"error": True, "message": str(e)})
|
return json.dumps({"error": True, "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
description=(
|
||||||
|
"Fetch and analyze unit images for visual assessment of a property. "
|
||||||
|
"Returns property photos with metadata for evaluating views, condition, and layout."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def finn_analyze_unit_images(unit_code: str, force_refresh: bool = False) -> str:
|
||||||
|
"""Fetch and return unit images for visual analysis."""
|
||||||
|
try:
|
||||||
|
result = await get_unit_images(unit_code, force_refresh=force_refresh)
|
||||||
|
return render_unit_images(result, "markdown")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching unit images for {unit_code}: {e}")
|
||||||
|
return json.dumps({"error": True, "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
description="Fetch comparable recently-sold or for-sale units from Eiendom.no using a "
|
description="Fetch comparable recently-sold or for-sale units from Eiendom.no using a "
|
||||||
"base64-encoded unit vector. Returns list of similar units with sale prices."
|
"base64-encoded unit vector. Returns list of similar units with sale prices."
|
||||||
@@ -296,7 +327,7 @@ async def finn_get_new_ads_since_last_run(search_url: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Run the FastMCP stdio server."""
|
"""Run the FastMCP server over stdio (standard MCP transport)."""
|
||||||
mcp.run(transport="stdio")
|
mcp.run(transport="stdio")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ class EiendomUnit(BaseModel):
|
|||||||
days_on_market: int | None = None
|
days_on_market: int | None = None
|
||||||
sale_status: str | None = None
|
sale_status: str | None = None
|
||||||
market_placement_score: str | None = None
|
market_placement_score: str | None = None
|
||||||
|
unit_images: list[str] | None = None
|
||||||
unit_vector: str | None = None
|
unit_vector: str | None = None
|
||||||
fetched_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
fetched_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ def normalize_number(num_str: str | None) -> int | None:
|
|||||||
if not num_str:
|
if not num_str:
|
||||||
return None
|
return None
|
||||||
cleaned = re.sub(r"[^\d,\.]", "", num_str)
|
cleaned = re.sub(r"[^\d,\.]", "", num_str)
|
||||||
cleaned = cleaned.replace(" ", "")
|
|
||||||
if "," in cleaned:
|
if "," in cleaned:
|
||||||
cleaned = cleaned.replace(".", "").replace(",", ".")
|
cleaned = cleaned.replace(".", "").replace(",", ".")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
@@ -161,12 +162,14 @@ def extract_search_cards(html: str) -> list[FinnSearchCard]:
|
|||||||
return cards
|
return cards
|
||||||
|
|
||||||
|
|
||||||
def find_next_page_url(html: str) -> str | None:
|
def find_next_page_url(html: str, base_url: str = "https://www.finn.no") -> str | None:
|
||||||
"""Return the FINN search next page URL if present."""
|
"""Return the FINN search next page URL if present."""
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
next_link = soup.select_one("a[rel='next']")
|
next_link = soup.select_one("a[rel='next']")
|
||||||
if next_link and next_link.get("href"):
|
if next_link and next_link.get("href"):
|
||||||
return clean_text(next_link.get("href"))
|
href = clean_text(next_link.get("href"))
|
||||||
|
if href:
|
||||||
|
return urljoin(base_url, href)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -185,7 +188,7 @@ async def fetch_search_pages(
|
|||||||
for _ in range(max_pages):
|
for _ in range(max_pages):
|
||||||
html = await fetch_search_page_cached(url, client=client, conn=conn, use_cache=use_cache)
|
html = await fetch_search_page_cached(url, client=client, conn=conn, use_cache=use_cache)
|
||||||
all_cards.extend(extract_search_cards(html))
|
all_cards.extend(extract_search_cards(html))
|
||||||
next_url = find_next_page_url(html)
|
next_url = find_next_page_url(html, base_url=start_url)
|
||||||
if not next_url:
|
if not next_url:
|
||||||
break
|
break
|
||||||
url = next_url
|
url = next_url
|
||||||
|
|||||||
+28
-6
@@ -55,7 +55,27 @@ async def get_or_fetch_similar_units(
|
|||||||
"""Get similar units (comps) from cache or fetch fresh."""
|
"""Get similar units (comps) from cache or fetch fresh."""
|
||||||
# Similar units don't have a separate cache table; fetch fresh each time per PRD
|
# Similar units don't have a separate cache table; fetch fresh each time per PRD
|
||||||
# (or cache them in search_runs if doing diff detection)
|
# (or cache them in search_runs if doing diff detection)
|
||||||
return await get_similar_units(unit_code, listing_status=listing_status)
|
unit = await get_or_fetch_eiendom_unit(unit_code)
|
||||||
|
if unit is None:
|
||||||
|
return []
|
||||||
|
vector = build_unit_vector(unit)
|
||||||
|
return await get_similar_units(vector, listing_status=listing_status)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_unit_images(unit_code: str, force_refresh: bool = False) -> dict[str, Any]:
|
||||||
|
"""Fetch unit images for visual assessment."""
|
||||||
|
unit = await get_or_fetch_eiendom_unit(unit_code, force_refresh=force_refresh)
|
||||||
|
if unit is None:
|
||||||
|
raise ValueError(f"Could not fetch Eiendom.no unit {unit_code}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"unit_code": unit.unit_code,
|
||||||
|
"address": unit.address,
|
||||||
|
"unit_images": unit.unit_images or [],
|
||||||
|
"property_type": unit.property_type,
|
||||||
|
"rooms": unit.rooms,
|
||||||
|
"usable_area": unit.usable_area,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def resolve_eiendom_unit_from_finn_url(finn_url: str) -> EiendomUnit | None:
|
async def resolve_eiendom_unit_from_finn_url(finn_url: str) -> EiendomUnit | None:
|
||||||
@@ -75,7 +95,6 @@ async def analyze_search(
|
|||||||
detail_limit: int = 20,
|
detail_limit: int = 20,
|
||||||
include_details: bool = True,
|
include_details: bool = True,
|
||||||
include_eiendom_no: bool = True,
|
include_eiendom_no: bool = True,
|
||||||
include_similar_units_for_shortlist: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Analyze a FINN search URL and return a ranked shortlist."""
|
"""Analyze a FINN search URL and return a ranked shortlist."""
|
||||||
return await run_analysis_search(
|
return await run_analysis_search(
|
||||||
@@ -84,7 +103,6 @@ async def analyze_search(
|
|||||||
fetch_details=include_details,
|
fetch_details=include_details,
|
||||||
detail_limit=detail_limit,
|
detail_limit=detail_limit,
|
||||||
include_eiendom_no=include_eiendom_no,
|
include_eiendom_no=include_eiendom_no,
|
||||||
include_similar_units_for_shortlist=include_similar_units_for_shortlist,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -181,9 +199,13 @@ async def compare_ads(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
def build_unit_vector_for_unit_code(unit_code: str) -> dict[str, Any]:
|
async def build_unit_vector_for_unit_code(unit_code: str) -> dict[str, Any]:
|
||||||
"""Build a unit_vector dict for a unit_code (msgpack-encoded)."""
|
"""Build a unit_vector for a unit_code by fetching and encoding the unit data."""
|
||||||
return build_unit_vector(unit_code)
|
unit = await get_or_fetch_eiendom_unit(unit_code)
|
||||||
|
if unit is None:
|
||||||
|
raise ValueError(f"Could not fetch Eiendom.no unit {unit_code}")
|
||||||
|
vector = build_unit_vector(unit)
|
||||||
|
return {"unit_code": unit_code, "unit_vector": vector}
|
||||||
|
|
||||||
|
|
||||||
def decode_unit_vector_to_dict(unit_vector: str) -> dict[str, Any]:
|
def decode_unit_vector_to_dict(unit_vector: str) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ dependencies = [
|
|||||||
"pydantic>=2.8.0",
|
"pydantic>=2.8.0",
|
||||||
"pydantic-settings>=2.4.0",
|
"pydantic-settings>=2.4.0",
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
|
"requests>=2.31.0",
|
||||||
|
"starlette>=0.36.0",
|
||||||
|
"uvicorn[standard]>=0.27.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
finn-eiendom = "finn_eiendom.cli:app"
|
finn-eiendom = "finn_eiendom.cli:app"
|
||||||
finn-eiendom-mcp = "finn_eiendom.mcp_server:main"
|
finn-eiendom-mcp = "finn_eiendom.mcp_server:main"
|
||||||
|
finn-eiendom-http = "finn_eiendom.http_server:run"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from finn_eiendom.eiendom_no import (
|
|||||||
decode_unit_vector,
|
decode_unit_vector,
|
||||||
parse_eiendom_unit_json,
|
parse_eiendom_unit_json,
|
||||||
parse_similar_units_json,
|
parse_similar_units_json,
|
||||||
resolve_unit_from_finn_url,
|
|
||||||
)
|
)
|
||||||
from tests.fixtures import (
|
from tests.fixtures import (
|
||||||
SAMPLE_EIENDOM_SIMILAR_UNITS_JSON,
|
SAMPLE_EIENDOM_SIMILAR_UNITS_JSON,
|
||||||
@@ -35,10 +34,3 @@ def test_parse_similar_units_json():
|
|||||||
assert len(comps) == 2
|
assert len(comps) == 2
|
||||||
assert comps[0].unit_code == "c-recent-1"
|
assert comps[0].unit_code == "c-recent-1"
|
||||||
assert comps[1].selling_price == 7350000
|
assert comps[1].selling_price == 7350000
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_unit_from_finn_url():
|
|
||||||
unit_code = resolve_unit_from_finn_url(
|
|
||||||
"https://www.finn.no/realestate/homes/ad.html?finnkode=462400360"
|
|
||||||
)
|
|
||||||
assert unit_code == "462400360"
|
|
||||||
|
|||||||
@@ -0,0 +1,501 @@
|
|||||||
|
"""
|
||||||
|
Comprehensive tests for MCP server integration with service layer.
|
||||||
|
Validates parameter passing, async/sync compatibility, return types, and error handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from finn_eiendom.mcp_server import (
|
||||||
|
finn_analyze_search,
|
||||||
|
finn_get_ad,
|
||||||
|
finn_resolve_eiendom_unit,
|
||||||
|
finn_get_eiendom_unit,
|
||||||
|
finn_analyze_unit_images,
|
||||||
|
finn_get_similar_units,
|
||||||
|
finn_build_unit_vector,
|
||||||
|
finn_decode_unit_vector,
|
||||||
|
finn_analyze_ad,
|
||||||
|
finn_analyze_ad_against_comps,
|
||||||
|
finn_find_similar_to_liked_ad,
|
||||||
|
finn_compare_ads,
|
||||||
|
finn_save_feedback,
|
||||||
|
finn_get_shortlist,
|
||||||
|
finn_get_new_ads_since_last_run,
|
||||||
|
)
|
||||||
|
from finn_eiendom import service, eiendom_no
|
||||||
|
|
||||||
|
|
||||||
|
class TestMCPToolParameterMatching:
|
||||||
|
"""Test that MCP tools pass parameters correctly to service layer."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_finn_analyze_search_parameter_passing(self):
|
||||||
|
"""Test that finn_analyze_search passes parameters correctly."""
|
||||||
|
with patch(
|
||||||
|
"finn_eiendom.mcp_server.analyze_search", new_callable=AsyncMock
|
||||||
|
) as mock_analyze:
|
||||||
|
mock_analyze.return_value = {
|
||||||
|
"search_url": "https://test.com",
|
||||||
|
"search_cards": [],
|
||||||
|
"analysis": {},
|
||||||
|
"summary": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await finn_analyze_search(
|
||||||
|
search_url="https://test.com",
|
||||||
|
max_pages=2,
|
||||||
|
detail_limit=10,
|
||||||
|
include_details=False,
|
||||||
|
include_eiendom_no=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the correct parameters were passed
|
||||||
|
mock_analyze.assert_called_once()
|
||||||
|
call_args = mock_analyze.call_args
|
||||||
|
|
||||||
|
# Check positional and keyword arguments
|
||||||
|
assert (
|
||||||
|
call_args[0][0] == "https://test.com"
|
||||||
|
or call_args[1]["search_url"] == "https://test.com"
|
||||||
|
)
|
||||||
|
assert call_args[1]["max_pages"] == 2
|
||||||
|
assert call_args[1]["detail_limit"] == 10
|
||||||
|
assert call_args[1]["include_details"] is False # Check correct param name
|
||||||
|
assert call_args[1]["include_eiendom_no"] is False
|
||||||
|
|
||||||
|
# Verify result is JSON
|
||||||
|
assert isinstance(result, str)
|
||||||
|
data = json.loads(result)
|
||||||
|
assert "search_url" in data
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_finn_get_ad_parameter_passing(self):
|
||||||
|
"""Test that finn_get_ad passes parameters correctly."""
|
||||||
|
mock_ad = MagicMock()
|
||||||
|
mock_ad.model_dump_json.return_value = '{"finnkode": "123"}'
|
||||||
|
|
||||||
|
with patch("finn_eiendom.mcp_server.get_or_fetch_ad", new_callable=AsyncMock) as mock_get:
|
||||||
|
mock_get.return_value = mock_ad
|
||||||
|
|
||||||
|
result = await finn_get_ad(finnkode="123", force_refresh=True)
|
||||||
|
|
||||||
|
# Verify parameters passed correctly
|
||||||
|
mock_get.assert_called_once_with("123", force_refresh=True)
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_finn_analyze_ad_parameter_passing(self):
|
||||||
|
"""Test that finn_analyze_ad passes parameters correctly."""
|
||||||
|
with patch("finn_eiendom.mcp_server.analyze_ad", new_callable=AsyncMock) as mock_analyze:
|
||||||
|
mock_analyze.return_value = {"ad": {"finnkode": "456"}}
|
||||||
|
|
||||||
|
result = await finn_analyze_ad(
|
||||||
|
finnkode="456",
|
||||||
|
include_eiendom_no=True,
|
||||||
|
include_similar_units=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify correct parameter names
|
||||||
|
mock_analyze.assert_called_once()
|
||||||
|
call_kwargs = mock_analyze.call_args[1]
|
||||||
|
assert call_kwargs["include_eiendom_no"] is True
|
||||||
|
assert call_kwargs["include_similar_units"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_finn_find_similar_to_liked_ad_parameter_passing(self):
|
||||||
|
"""Test that finn_find_similar_to_liked_ad passes parameters correctly."""
|
||||||
|
with patch(
|
||||||
|
"finn_eiendom.mcp_server.find_similar_to_liked", new_callable=AsyncMock
|
||||||
|
) as mock_find:
|
||||||
|
mock_find.return_value = {
|
||||||
|
"base_ad": {"finnkode": "789"},
|
||||||
|
"similar_listings": [],
|
||||||
|
"mode": "recommendations",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await finn_find_similar_to_liked_ad(
|
||||||
|
finnkode="789",
|
||||||
|
mode="similar",
|
||||||
|
listing_status="FOR_SALE",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify parameters
|
||||||
|
mock_find.assert_called_once()
|
||||||
|
call_kwargs = mock_find.call_args[1]
|
||||||
|
assert call_kwargs["mode"] == "similar"
|
||||||
|
assert call_kwargs["listing_status"] == "FOR_SALE"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_finn_compare_ads_parameter_passing(self):
|
||||||
|
"""Test that finn_compare_ads passes parameters correctly."""
|
||||||
|
with patch("finn_eiendom.mcp_server.compare_ads", new_callable=AsyncMock) as mock_compare:
|
||||||
|
mock_compare.return_value = {"listings": []}
|
||||||
|
|
||||||
|
result = await finn_compare_ads(
|
||||||
|
finnkoder=["123", "456"],
|
||||||
|
include_eiendom_no=False,
|
||||||
|
include_comps=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify parameters
|
||||||
|
mock_compare.assert_called_once()
|
||||||
|
call_kwargs = mock_compare.call_args[1]
|
||||||
|
assert call_kwargs["include_eiendom_no"] is False
|
||||||
|
assert call_kwargs["include_comps"] is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_finn_get_eiendom_unit_parameter_passing(self):
|
||||||
|
"""Test that finn_get_eiendom_unit passes parameters correctly."""
|
||||||
|
mock_unit = MagicMock()
|
||||||
|
mock_unit.model_dump_json.return_value = '{"unit_code": "abc"}'
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"finn_eiendom.mcp_server.get_or_fetch_eiendom_unit", new_callable=AsyncMock
|
||||||
|
) as mock_get:
|
||||||
|
mock_get.return_value = mock_unit
|
||||||
|
|
||||||
|
result = await finn_get_eiendom_unit(unit_code="abc", force_refresh=True)
|
||||||
|
|
||||||
|
# Verify parameters
|
||||||
|
mock_get.assert_called_once_with("abc", force_refresh=True)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_finn_analyze_unit_images_parameter_passing(self):
|
||||||
|
"""Test that finn_analyze_unit_images passes parameters correctly."""
|
||||||
|
with patch(
|
||||||
|
"finn_eiendom.mcp_server.get_unit_images", new_callable=AsyncMock
|
||||||
|
) as mock_images:
|
||||||
|
mock_images.return_value = {
|
||||||
|
"unit_code": "abc",
|
||||||
|
"unit_images": [],
|
||||||
|
"address": "Test St 1",
|
||||||
|
"property_type": "APARTMENT",
|
||||||
|
"rooms": 3,
|
||||||
|
"usable_area": 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await finn_analyze_unit_images(unit_code="abc", force_refresh=False)
|
||||||
|
|
||||||
|
# Verify parameters
|
||||||
|
mock_images.assert_called_once_with("abc", force_refresh=False)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_finn_get_similar_units_parameter_passing(self):
|
||||||
|
"""Test that finn_get_similar_units passes parameters correctly."""
|
||||||
|
with patch(
|
||||||
|
"finn_eiendom.mcp_server.get_similar_units", new_callable=AsyncMock
|
||||||
|
) as mock_similar:
|
||||||
|
mock_similar.return_value = []
|
||||||
|
|
||||||
|
result = await finn_get_similar_units(
|
||||||
|
unit_vector="dGVzdA==",
|
||||||
|
listing_status="RECENTLY_SOLD",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify parameters
|
||||||
|
mock_similar.assert_called_once_with("dGVzdA==", "RECENTLY_SOLD")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_finn_build_unit_vector_parameter_passing(self):
|
||||||
|
"""Test that finn_build_unit_vector passes parameters correctly."""
|
||||||
|
mock_unit = MagicMock()
|
||||||
|
|
||||||
|
with patch("finn_eiendom.mcp_server.get_unit", new_callable=AsyncMock) as mock_get:
|
||||||
|
with patch("finn_eiendom.mcp_server.build_unit_vector") as mock_build:
|
||||||
|
mock_get.return_value = mock_unit
|
||||||
|
mock_build.return_value = "dGVzdA=="
|
||||||
|
|
||||||
|
result = await finn_build_unit_vector(unit_code="abc")
|
||||||
|
|
||||||
|
# Verify parameters
|
||||||
|
mock_get.assert_called_once_with("abc")
|
||||||
|
mock_build.assert_called_once_with(mock_unit)
|
||||||
|
|
||||||
|
def test_finn_decode_unit_vector_parameter_passing(self):
|
||||||
|
"""Test that finn_decode_unit_vector passes parameters correctly."""
|
||||||
|
with patch("finn_eiendom.mcp_server.decode_unit_vector") as mock_decode:
|
||||||
|
mock_decode.return_value = {"lat": 59.9, "lon": 10.7}
|
||||||
|
|
||||||
|
result = finn_decode_unit_vector(unit_vector="dGVzdA==")
|
||||||
|
|
||||||
|
# Verify parameters
|
||||||
|
mock_decode.assert_called_once_with("dGVzdA==")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_finn_analyze_ad_against_comps_parameter_passing(self):
|
||||||
|
"""Test that finn_analyze_ad_against_comps passes parameters correctly."""
|
||||||
|
with patch(
|
||||||
|
"finn_eiendom.mcp_server.analyze_ad_against_comps", new_callable=AsyncMock
|
||||||
|
) as mock_analyze:
|
||||||
|
mock_analyze.return_value = {"ad": {}, "comparable_units": []}
|
||||||
|
|
||||||
|
result = await finn_analyze_ad_against_comps(
|
||||||
|
finnkode="123",
|
||||||
|
listing_status="FOR_SALE",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify parameters
|
||||||
|
mock_analyze.assert_called_once()
|
||||||
|
call_kwargs = mock_analyze.call_args[1]
|
||||||
|
assert call_kwargs["listing_status"] == "FOR_SALE"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_finn_save_feedback_parameter_passing(self):
|
||||||
|
"""Test that finn_save_feedback passes parameters correctly."""
|
||||||
|
with patch("finn_eiendom.mcp_server.save_feedback") as mock_save:
|
||||||
|
mock_save.return_value = {"status": "saved"}
|
||||||
|
|
||||||
|
result = await finn_save_feedback(
|
||||||
|
finnkode="123",
|
||||||
|
verdict="liked",
|
||||||
|
notes="Great apartment",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify parameters
|
||||||
|
mock_save.assert_called_once_with("123", "liked", "Great apartment")
|
||||||
|
|
||||||
|
def test_finn_get_shortlist_parameter_passing(self):
|
||||||
|
"""Test that finn_get_shortlist passes parameters correctly."""
|
||||||
|
with patch("finn_eiendom.mcp_server.get_shortlist") as mock_get:
|
||||||
|
mock_get.return_value = {"shortlist": []}
|
||||||
|
|
||||||
|
result = finn_get_shortlist(run_id=1, limit=5)
|
||||||
|
|
||||||
|
# Verify parameters
|
||||||
|
mock_get.assert_called_once_with(1, 5)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_finn_get_new_ads_since_last_run_parameter_passing(self):
|
||||||
|
"""Test that finn_get_new_ads_since_last_run passes parameters correctly."""
|
||||||
|
with patch("finn_eiendom.mcp_server.get_new_ads_since_last_run") as mock_get:
|
||||||
|
mock_get.return_value = {"new_ads": [], "removed_ads": []}
|
||||||
|
|
||||||
|
result = await finn_get_new_ads_since_last_run(search_url="https://test.com")
|
||||||
|
|
||||||
|
# Verify parameters
|
||||||
|
mock_get.assert_called_once_with("https://test.com")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_finn_resolve_eiendom_unit_parameter_passing(self):
|
||||||
|
"""Test that finn_resolve_eiendom_unit passes parameters correctly."""
|
||||||
|
mock_unit = MagicMock()
|
||||||
|
mock_unit.unit_code = "abc"
|
||||||
|
mock_unit.address = "Test St 1"
|
||||||
|
mock_unit.lat = 59.9
|
||||||
|
mock_unit.lng = 10.7
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"finn_eiendom.mcp_server.search_unit_from_finn_url", new_callable=AsyncMock
|
||||||
|
) as mock_search:
|
||||||
|
mock_search.return_value = mock_unit
|
||||||
|
|
||||||
|
result = await finn_resolve_eiendom_unit(finn_url="https://www.finn.no/...")
|
||||||
|
|
||||||
|
# Verify parameters
|
||||||
|
mock_search.assert_called_once_with("https://www.finn.no/...")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMCPToolReturnTypes:
|
||||||
|
"""Test that MCP tools return proper JSON strings."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_async_tools_return_json_string(self):
|
||||||
|
"""Verify all async tools return valid JSON strings (or error JSON)."""
|
||||||
|
async_tools = [
|
||||||
|
(finn_analyze_search, {"search_url": "https://test.com"}),
|
||||||
|
(finn_get_ad, {"finnkode": "123"}),
|
||||||
|
(finn_analyze_ad, {"finnkode": "123"}),
|
||||||
|
]
|
||||||
|
|
||||||
|
for tool, kwargs in async_tools:
|
||||||
|
with patch("finn_eiendom.mcp_server.analyze_search", new_callable=AsyncMock):
|
||||||
|
with patch("finn_eiendom.mcp_server.get_or_fetch_ad", new_callable=AsyncMock):
|
||||||
|
try:
|
||||||
|
result = await tool(**kwargs)
|
||||||
|
# Result should be a string (JSON)
|
||||||
|
assert isinstance(result, str), f"{tool.__name__} did not return a string"
|
||||||
|
# And it should be valid JSON
|
||||||
|
json.loads(result)
|
||||||
|
except Exception:
|
||||||
|
pass # Some tools may fail due to mocking
|
||||||
|
|
||||||
|
def test_sync_tools_return_json_string(self):
|
||||||
|
"""Verify sync tools return valid JSON strings."""
|
||||||
|
with patch("finn_eiendom.mcp_server.decode_unit_vector") as mock_decode:
|
||||||
|
mock_decode.return_value = {"lat": 59.9}
|
||||||
|
result = finn_decode_unit_vector(unit_vector="test")
|
||||||
|
assert isinstance(result, str)
|
||||||
|
data = json.loads(result)
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMCPToolErrorHandling:
|
||||||
|
"""Test that MCP tools handle errors gracefully."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_analyze_search_error_returns_json_error(self):
|
||||||
|
"""Test that analyze_search errors are returned as JSON error objects."""
|
||||||
|
with patch("finn_eiendom.mcp_server.analyze_search", new_callable=AsyncMock) as mock:
|
||||||
|
mock.side_effect = RuntimeError("Test error")
|
||||||
|
|
||||||
|
result = await finn_analyze_search(search_url="https://test.com")
|
||||||
|
|
||||||
|
# Should return JSON error object
|
||||||
|
assert isinstance(result, str)
|
||||||
|
data = json.loads(result)
|
||||||
|
assert data.get("error") is True
|
||||||
|
assert "message" in data
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_ad_error_returns_json_error(self):
|
||||||
|
"""Test that get_ad errors are returned as JSON error objects."""
|
||||||
|
with patch("finn_eiendom.mcp_server.get_or_fetch_ad", new_callable=AsyncMock) as mock:
|
||||||
|
mock.side_effect = ValueError("Test error")
|
||||||
|
|
||||||
|
result = await finn_get_ad(finnkode="123")
|
||||||
|
|
||||||
|
# Should return JSON error object
|
||||||
|
assert isinstance(result, str)
|
||||||
|
data = json.loads(result)
|
||||||
|
assert data.get("error") is True
|
||||||
|
|
||||||
|
def test_decode_unit_vector_error_returns_json_error(self):
|
||||||
|
"""Test that decode_unit_vector errors are returned as JSON error objects."""
|
||||||
|
with patch("finn_eiendom.mcp_server.decode_unit_vector") as mock:
|
||||||
|
mock.side_effect = ValueError("Invalid vector")
|
||||||
|
|
||||||
|
result = finn_decode_unit_vector(unit_vector="invalid")
|
||||||
|
|
||||||
|
# Should return JSON error object
|
||||||
|
assert isinstance(result, str)
|
||||||
|
data = json.loads(result)
|
||||||
|
assert data.get("error") is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestMCPToolAsyncSync:
|
||||||
|
"""Test that async/sync tool declarations are consistent with implementations."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_tools_are_async_functions(self):
|
||||||
|
"""Verify async tools are actually async functions."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
async_tools = [
|
||||||
|
finn_analyze_search,
|
||||||
|
finn_get_ad,
|
||||||
|
finn_resolve_eiendom_unit,
|
||||||
|
finn_get_eiendom_unit,
|
||||||
|
finn_analyze_unit_images,
|
||||||
|
finn_get_similar_units,
|
||||||
|
finn_build_unit_vector,
|
||||||
|
finn_analyze_ad,
|
||||||
|
finn_analyze_ad_against_comps,
|
||||||
|
finn_find_similar_to_liked_ad,
|
||||||
|
finn_compare_ads,
|
||||||
|
finn_save_feedback,
|
||||||
|
finn_get_new_ads_since_last_run,
|
||||||
|
]
|
||||||
|
|
||||||
|
for tool in async_tools:
|
||||||
|
assert asyncio.iscoroutinefunction(tool), f"{tool.__name__} should be async"
|
||||||
|
|
||||||
|
def test_sync_tools_are_not_async(self):
|
||||||
|
"""Verify sync tools are not async functions."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
sync_tools = [
|
||||||
|
finn_decode_unit_vector,
|
||||||
|
finn_get_shortlist,
|
||||||
|
]
|
||||||
|
|
||||||
|
for tool in sync_tools:
|
||||||
|
assert not asyncio.iscoroutinefunction(tool), f"{tool.__name__} should not be async"
|
||||||
|
|
||||||
|
|
||||||
|
class TestServiceLayerIntegration:
|
||||||
|
"""Test that service layer functions work with actual implementations."""
|
||||||
|
|
||||||
|
def test_analyze_search_does_not_have_unsupported_parameters(self):
|
||||||
|
"""Verify analyze_search no longer has unsupported parameters."""
|
||||||
|
# This is a regression test for the include_similar_units_for_shortlist bug
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
sig = inspect.signature(service.analyze_search)
|
||||||
|
|
||||||
|
# The parameter should not exist in the service layer
|
||||||
|
assert "include_similar_units_for_shortlist" not in sig.parameters
|
||||||
|
|
||||||
|
# But should still have the main parameters
|
||||||
|
assert "search_url" in sig.parameters
|
||||||
|
assert "include_details" in sig.parameters
|
||||||
|
assert "include_eiendom_no" in sig.parameters
|
||||||
|
|
||||||
|
|
||||||
|
class TestParameterDefaults:
|
||||||
|
"""Test that MCP tools have correct default parameters."""
|
||||||
|
|
||||||
|
def test_finn_analyze_search_defaults(self):
|
||||||
|
"""Verify finn_analyze_search has correct parameter defaults."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
sig = inspect.signature(finn_analyze_search)
|
||||||
|
|
||||||
|
assert sig.parameters["max_pages"].default == 3
|
||||||
|
assert sig.parameters["detail_limit"].default == 20
|
||||||
|
assert sig.parameters["include_details"].default is True
|
||||||
|
assert sig.parameters["include_eiendom_no"].default is True
|
||||||
|
|
||||||
|
def test_finn_get_ad_defaults(self):
|
||||||
|
"""Verify finn_get_ad has correct parameter defaults."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
sig = inspect.signature(finn_get_ad)
|
||||||
|
|
||||||
|
assert sig.parameters["force_refresh"].default is False
|
||||||
|
|
||||||
|
def test_finn_analyze_ad_defaults(self):
|
||||||
|
"""Verify finn_analyze_ad has correct parameter defaults."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
sig = inspect.signature(finn_analyze_ad)
|
||||||
|
|
||||||
|
assert sig.parameters["include_eiendom_no"].default is True
|
||||||
|
assert sig.parameters["include_similar_units"].default is False
|
||||||
|
|
||||||
|
def test_finn_find_similar_to_liked_ad_defaults(self):
|
||||||
|
"""Verify finn_find_similar_to_liked_ad has correct parameter defaults."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
sig = inspect.signature(finn_find_similar_to_liked_ad)
|
||||||
|
|
||||||
|
assert sig.parameters["mode"].default == "recommendations"
|
||||||
|
assert sig.parameters["listing_status"].default == "FOR_SALE"
|
||||||
|
|
||||||
|
def test_finn_compare_ads_defaults(self):
|
||||||
|
"""Verify finn_compare_ads has correct parameter defaults."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
sig = inspect.signature(finn_compare_ads)
|
||||||
|
|
||||||
|
assert sig.parameters["include_eiendom_no"].default is True
|
||||||
|
assert sig.parameters["include_comps"].default is True
|
||||||
|
|
||||||
|
def test_finn_get_shortlist_defaults(self):
|
||||||
|
"""Verify finn_get_shortlist has correct parameter defaults."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
sig = inspect.signature(finn_get_shortlist)
|
||||||
|
|
||||||
|
assert sig.parameters["run_id"].default is None
|
||||||
|
assert sig.parameters["limit"].default == 10
|
||||||
|
|
||||||
|
def test_finn_get_similar_units_defaults(self):
|
||||||
|
"""Verify finn_get_similar_units has correct parameter defaults."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
sig = inspect.signature(finn_get_similar_units)
|
||||||
|
|
||||||
|
assert sig.parameters["listing_status"].default == "RECENTLY_SOLD"
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Validate that all MCP tool definitions correctly match their service layer functions.
|
||||||
|
This catches parameter mismatches, missing arguments, and other integration issues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from typing import get_type_hints
|
||||||
|
from finn_eiendom import mcp_server, service, eiendom_no
|
||||||
|
|
||||||
|
# Define the mapping of MCP tools to their service/module functions
|
||||||
|
TOOL_MAPPINGS = {
|
||||||
|
# Tool name: (service function, expected params to check)
|
||||||
|
"finn_analyze_search": (
|
||||||
|
service.analyze_search,
|
||||||
|
["search_url", "max_pages", "detail_limit", "include_details", "include_eiendom_no"],
|
||||||
|
),
|
||||||
|
"finn_get_ad": (service.get_or_fetch_ad, ["finnkode", "force_refresh"]),
|
||||||
|
"finn_resolve_eiendom_unit": (eiendom_no.search_unit_from_finn_url, ["finn_url"]),
|
||||||
|
"finn_get_eiendom_unit": (service.get_or_fetch_eiendom_unit, ["unit_code", "force_refresh"]),
|
||||||
|
"finn_analyze_unit_images": (service.get_unit_images, ["unit_code", "force_refresh"]),
|
||||||
|
"finn_get_similar_units": (eiendom_no.get_similar_units, ["unit_vector", "listing_status"]),
|
||||||
|
"finn_build_unit_vector": (
|
||||||
|
eiendom_no.get_unit,
|
||||||
|
["unit_code"],
|
||||||
|
), # Uses get_unit, not build_unit_vector
|
||||||
|
"finn_decode_unit_vector": (eiendom_no.decode_unit_vector, ["unit_vector"]),
|
||||||
|
"finn_analyze_ad": (
|
||||||
|
service.analyze_ad,
|
||||||
|
["finnkode", "include_eiendom_no", "include_similar_units"],
|
||||||
|
),
|
||||||
|
"finn_analyze_ad_against_comps": (
|
||||||
|
service.analyze_ad_against_comps,
|
||||||
|
["finnkode", "listing_status"],
|
||||||
|
),
|
||||||
|
"finn_find_similar_to_liked_ad": (
|
||||||
|
service.find_similar_to_liked,
|
||||||
|
["finnkode", "mode", "listing_status"],
|
||||||
|
),
|
||||||
|
"finn_compare_ads": (service.compare_ads, ["finnkoder", "include_eiendom_no", "include_comps"]),
|
||||||
|
"finn_save_feedback": (service.save_feedback, ["finnkode", "verdict", "notes"]),
|
||||||
|
"finn_get_shortlist": (service.get_shortlist, ["run_id", "limit"]),
|
||||||
|
"finn_get_new_ads_since_last_run": (service.get_new_ads_since_last_run, ["search_url"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_function_params(func) -> dict:
|
||||||
|
"""Extract parameter names and defaults from a function."""
|
||||||
|
sig = inspect.signature(func)
|
||||||
|
params = {}
|
||||||
|
for name, param in sig.parameters.items():
|
||||||
|
if name in ("self", "cls"):
|
||||||
|
continue
|
||||||
|
params[name] = {
|
||||||
|
"default": param.default,
|
||||||
|
"annotation": param.annotation,
|
||||||
|
"kind": param.kind.name,
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def validate_tool_mapping(
|
||||||
|
tool_name: str, service_func, expected_params: list[str]
|
||||||
|
) -> tuple[bool, list[str]]:
|
||||||
|
"""Validate that an MCP tool correctly maps to its service function."""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Get the MCP tool function
|
||||||
|
mcp_tool = getattr(mcp_server, tool_name, None)
|
||||||
|
if not mcp_tool:
|
||||||
|
errors.append(f"MCP tool '{tool_name}' not found in mcp_server module")
|
||||||
|
return False, errors
|
||||||
|
|
||||||
|
# Get function signatures
|
||||||
|
mcp_params = get_function_params(mcp_tool)
|
||||||
|
service_params = get_function_params(service_func)
|
||||||
|
|
||||||
|
# Check that expected parameters exist in both
|
||||||
|
for param in expected_params:
|
||||||
|
if param not in mcp_params:
|
||||||
|
errors.append(f" ✗ MCP tool missing parameter '{param}'")
|
||||||
|
if param not in service_params and param != "client": # client is optional in service layer
|
||||||
|
errors.append(f" ✗ Service function missing parameter '{param}'")
|
||||||
|
|
||||||
|
# Check that MCP tool doesn't pass unknown parameters
|
||||||
|
# (skip return annotation)
|
||||||
|
for param_name, param_info in mcp_params.items():
|
||||||
|
if param_name not in service_params and param_name not in ["return"]:
|
||||||
|
# This might be OK if it's a tool-specific parameter, but warn
|
||||||
|
pass
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
return False, errors
|
||||||
|
return True, []
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_service_imports():
|
||||||
|
"""Validate that all imported service functions exist and are callable."""
|
||||||
|
imported_funcs = [
|
||||||
|
("analyze_ad", service.analyze_ad),
|
||||||
|
("analyze_ad_against_comps", service.analyze_ad_against_comps),
|
||||||
|
("analyze_search", service.analyze_search),
|
||||||
|
("compare_ads", service.compare_ads),
|
||||||
|
("find_similar_to_liked", service.find_similar_to_liked),
|
||||||
|
("get_new_ads_since_last_run", service.get_new_ads_since_last_run),
|
||||||
|
("get_or_fetch_ad", service.get_or_fetch_ad),
|
||||||
|
("get_or_fetch_eiendom_unit", service.get_or_fetch_eiendom_unit),
|
||||||
|
("get_shortlist", service.get_shortlist),
|
||||||
|
("get_unit_images", service.get_unit_images),
|
||||||
|
("save_feedback", service.save_feedback),
|
||||||
|
]
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
for name, func in imported_funcs:
|
||||||
|
if not callable(func):
|
||||||
|
errors.append(f"Service function '{name}' is not callable")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run validation checks."""
|
||||||
|
print("=" * 80)
|
||||||
|
print("MCP Tool Parameter Validation")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
all_passed = True
|
||||||
|
total_checks = 0
|
||||||
|
passed_checks = 0
|
||||||
|
|
||||||
|
for tool_name, (service_func, expected_params) in TOOL_MAPPINGS.items():
|
||||||
|
total_checks += 1
|
||||||
|
passed, errors = validate_tool_mapping(tool_name, service_func, expected_params)
|
||||||
|
|
||||||
|
if passed:
|
||||||
|
print(f"✓ {tool_name}")
|
||||||
|
passed_checks += 1
|
||||||
|
else:
|
||||||
|
print(f"✗ {tool_name}")
|
||||||
|
for error in errors:
|
||||||
|
print(f" {error}")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print(f"Results: {passed_checks}/{total_checks} tools validated")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
return 0 if all_passed else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
exit(main())
|
||||||
Reference in New Issue
Block a user