@@ -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",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
},
|
||||
"mcp-jungle":{
|
||||
"type": "http",
|
||||
"url": "http://mini:8080/mcp",
|
||||
},
|
||||
// "finn-eiendom": { }
|
||||
"finn-eiendom": {
|
||||
"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 = (
|
||||
bool(properties.get("parkering/garasje"))
|
||||
or "parkering" in feature_text
|
||||
)
|
||||
has_garage = (
|
||||
bool(properties.get("parkering/garasje"))
|
||||
or "garasje" in feature_text
|
||||
)
|
||||
broker_company = None
|
||||
@@ -177,6 +180,7 @@ def scrape_ad(html: str, url: str | None = None) -> FinnAd:
|
||||
has_terrace=has_terrace,
|
||||
has_elevator=has_elevator,
|
||||
has_parking=has_parking,
|
||||
has_garage=has_garage,
|
||||
listing_description=listing_description,
|
||||
broker_name=None,
|
||||
broker_company=broker_company,
|
||||
|
||||
@@ -149,14 +149,10 @@ async def analyze_search(
|
||||
if include_eiendom_no:
|
||||
try:
|
||||
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:
|
||||
logger.warning("Eiendom.no unit search failed: %s", exc)
|
||||
matched_unit = None
|
||||
unit_code = (
|
||||
matched_unit.unit_code
|
||||
if matched_unit
|
||||
else eiendom_no.resolve_unit_from_finn_url(card.url)
|
||||
)
|
||||
unit_code = None
|
||||
result = await analyze_ad(finn_ad, unit_code=unit_code)
|
||||
if result.get("eiendom_unit"):
|
||||
enriched_count += 1
|
||||
|
||||
+3
-3
@@ -200,7 +200,7 @@ def build_vector(
|
||||
) -> None:
|
||||
"""Build a unit vector for an Eiendom.no unit."""
|
||||
try:
|
||||
result = svc_build_unit_vector(unit_code)
|
||||
result = asyncio.run(svc_build_unit_vector(unit_code))
|
||||
typer.echo(formatting.render_ad(result, format))
|
||||
except Exception as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
@@ -223,7 +223,7 @@ def decode_vector(
|
||||
|
||||
@app.command()
|
||||
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(
|
||||
"RECENTLY_SOLD", help="Listing status (RECENTLY_SOLD, FOR_SALE, CURRENT)"
|
||||
),
|
||||
@@ -231,7 +231,7 @@ def similar_units(
|
||||
) -> None:
|
||||
"""Fetch similar/comparable units from Eiendom.no."""
|
||||
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]}
|
||||
typer.echo(formatting.render_similar_units(result, format))
|
||||
except Exception as e:
|
||||
|
||||
@@ -34,6 +34,7 @@ def parse_eiendom_unit_json(unit_data: dict) -> EiendomUnit:
|
||||
specification = unit_data.get("specification", {})
|
||||
valuation = unit_data.get("valuation", {})
|
||||
market = unit_data.get("latestMarketData", {})
|
||||
unit_images = market.get("unitImages") or unit_data.get("unitImages") or []
|
||||
|
||||
return EiendomUnit(
|
||||
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"),
|
||||
market_placement_score=market.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
|
||||
|
||||
|
||||
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(
|
||||
ad: Any,
|
||||
unit_code: str | None = None,
|
||||
|
||||
@@ -135,6 +135,61 @@ def _render_unit_markdown(unit: dict[str, Any]) -> str:
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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 logging
|
||||
from typing import Any
|
||||
|
||||
import os
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from .analysis import analyze_search
|
||||
from .eiendom_no import (
|
||||
build_unit_vector,
|
||||
decode_unit_vector,
|
||||
@@ -20,22 +20,37 @@ from .formatting import (
|
||||
render_diff,
|
||||
render_shortlist,
|
||||
render_similar_units,
|
||||
render_unit_images,
|
||||
)
|
||||
from .service import (
|
||||
analyze_ad,
|
||||
analyze_ad_against_comps,
|
||||
analyze_search,
|
||||
compare_ads,
|
||||
find_similar_to_liked,
|
||||
get_new_ads_since_last_run,
|
||||
get_or_fetch_ad,
|
||||
get_or_fetch_eiendom_unit,
|
||||
get_shortlist,
|
||||
get_unit_images,
|
||||
save_feedback,
|
||||
)
|
||||
|
||||
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(
|
||||
@@ -56,7 +71,7 @@ async def finn_analyze_search(
|
||||
result = await analyze_search(
|
||||
search_url,
|
||||
max_pages=max_pages,
|
||||
fetch_details=include_details,
|
||||
include_details=include_details,
|
||||
detail_limit=detail_limit,
|
||||
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)})
|
||||
|
||||
|
||||
@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(
|
||||
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."
|
||||
@@ -296,7 +327,7 @@ async def finn_get_new_ads_since_last_run(search_url: str) -> str:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the FastMCP stdio server."""
|
||||
"""Run the FastMCP server over stdio (standard MCP transport)."""
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ class EiendomUnit(BaseModel):
|
||||
days_on_market: int | None = None
|
||||
sale_status: str | None = None
|
||||
market_placement_score: str | None = None
|
||||
unit_images: list[str] | None = None
|
||||
unit_vector: str | None = None
|
||||
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:
|
||||
return None
|
||||
cleaned = re.sub(r"[^\d,\.]", "", num_str)
|
||||
cleaned = cleaned.replace(" ", "")
|
||||
if "," in cleaned:
|
||||
cleaned = cleaned.replace(".", "").replace(",", ".")
|
||||
else:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
@@ -161,12 +162,14 @@ def extract_search_cards(html: str) -> list[FinnSearchCard]:
|
||||
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."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
next_link = soup.select_one("a[rel='next']")
|
||||
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
|
||||
|
||||
|
||||
@@ -185,7 +188,7 @@ async def fetch_search_pages(
|
||||
for _ in range(max_pages):
|
||||
html = await fetch_search_page_cached(url, client=client, conn=conn, use_cache=use_cache)
|
||||
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:
|
||||
break
|
||||
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."""
|
||||
# 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)
|
||||
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:
|
||||
@@ -75,7 +95,6 @@ async def analyze_search(
|
||||
detail_limit: int = 20,
|
||||
include_details: bool = True,
|
||||
include_eiendom_no: bool = True,
|
||||
include_similar_units_for_shortlist: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Analyze a FINN search URL and return a ranked shortlist."""
|
||||
return await run_analysis_search(
|
||||
@@ -84,7 +103,6 @@ async def analyze_search(
|
||||
fetch_details=include_details,
|
||||
detail_limit=detail_limit,
|
||||
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]:
|
||||
"""Build a unit_vector dict for a unit_code (msgpack-encoded)."""
|
||||
return build_unit_vector(unit_code)
|
||||
async def build_unit_vector_for_unit_code(unit_code: str) -> dict[str, Any]:
|
||||
"""Build a unit_vector for a unit_code by fetching and encoding the unit data."""
|
||||
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]:
|
||||
|
||||
@@ -13,11 +13,15 @@ dependencies = [
|
||||
"pydantic>=2.8.0",
|
||||
"pydantic-settings>=2.4.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"requests>=2.31.0",
|
||||
"starlette>=0.36.0",
|
||||
"uvicorn[standard]>=0.27.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
finn-eiendom = "finn_eiendom.cli:app"
|
||||
finn-eiendom-mcp = "finn_eiendom.mcp_server:main"
|
||||
finn-eiendom-http = "finn_eiendom.http_server:run"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
@@ -3,7 +3,6 @@ from finn_eiendom.eiendom_no import (
|
||||
decode_unit_vector,
|
||||
parse_eiendom_unit_json,
|
||||
parse_similar_units_json,
|
||||
resolve_unit_from_finn_url,
|
||||
)
|
||||
from tests.fixtures import (
|
||||
SAMPLE_EIENDOM_SIMILAR_UNITS_JSON,
|
||||
@@ -35,10 +34,3 @@ def test_parse_similar_units_json():
|
||||
assert len(comps) == 2
|
||||
assert comps[0].unit_code == "c-recent-1"
|
||||
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