# CLAUDE.md - Crustdata MCP Server

This is the MCP server that exposes Crustdata's B2B data platform APIs to AI assistants.

## Project Overview

- **Framework**: MCP SDK (mcp package) with FastMCP high-level interface
- **HTTP Client**: httpx (async)
- **Validation**: Pydantic v2
- **Testing**: pytest + pytest-asyncio + respx (for HTTP mocking)
- **Python**: 3.10+

## Development Commands

```bash
# Install dependencies
uv sync --dev

# Run tests
.venv/bin/python -m pytest tests/ -v

# Run tests with coverage
.venv/bin/python -m pytest tests/ --cov=src/crustdata_mcp

# Run specific test file
.venv/bin/python -m pytest tests/test_company_tools.py -v

# Lint code
.venv/bin/ruff check src/ tests/
```

## Environment Variables

| Variable | Description |
|----------|-------------|
| `CRUSTDATA_TOKEN` | Crustdata API token (required for STDIO transport) |
| `CRUSTDATA_HTTP_HOST` | HTTP server host (default: 127.0.0.1) |
| `CRUSTDATA_HTTP_PORT` | HTTP server port (default: 8000) |
| `CRUSTDATA_HTTP_PATH` | HTTP endpoint path (default: /mcp) |

## Running the Server

**STDIO transport** (for local CLI/desktop apps):
```bash
CRUSTDATA_TOKEN=your-token uv run crustdata-mcp
```

**HTTP transport** (for web deployment):
```bash
uv run crustdata-mcp-http
```

HTTP transport requires bearer token authentication. Clients must include:
```
Authorization: Bearer <CRUSTDATA_TOKEN>
```

### Security Considerations

HTTP transport is intended for **trusted/local use only**:

- The same token is used for both Crustdata API access and MCP client authentication
- Anyone with the token can access both your MCP server AND the Crustdata API directly
- Do not expose to untrusted networks without additional security layers (VPN, reverse proxy with auth, etc.)
- For production deployments, consider running behind a reverse proxy with proper TLS

## Architecture

### Server (server.py)

- FastMCP server initialization with lifespan management
- AppContext holds shared CrustdataClient instance
- Tools access client via `get_client_for_request()` function from `client_factory`
- HTTP transport creates separate auth-enabled FastMCP instance

### Authentication (auth.py)

- `CrustdataTokenVerifier`: Validates bearer tokens for HTTP transport
- Simple string comparison (no JWT complexity)
- Same token used for both API access and MCP client auth

### Client Factory (client_factory.py)

- Factory for creating/caching CrustdataClient instances
- HTTP transport: per-request token from `get_access_token()`
- STDIO transport: env var token from `CRUSTDATA_TOKEN`
- `get_client_for_request()` is the main entry point for tools
- Weak reference caching for HTTP clients, strong ref for STDIO client

### Client (client.py)

- Async httpx client with token authentication
- Custom exception hierarchy:
  - `CrustdataAuthError` (401)
  - `CrustdataNotFoundError` (404)
  - `CrustdataRateLimitError` (429)
  - `CrustdataServerError` (5xx)

### Tools

Tools are organized by category:

- `tools/company.py`: Company identification, enrichment, search, LinkedIn posts
- `tools/people.py`: People enrichment, search, autocomplete
- `tools/watcher.py`: Webhook subscriptions for events
- `tools/web.py`: Web search and fetch
- `tools/dataset.py`: Job listings dataset
- `tools/utility.py`: Credits check

### Input Validation

All tools use Pydantic models for input validation:

```python
class CompanyEnrichInput(BaseModel):
    company_domain: Optional[str] = Field(None, description="...")
    # ... other fields

    @model_validator(mode="after")
    def check_at_least_one_identifier(self) -> "CompanyEnrichInput":
        # Validation logic
```

## API Endpoints

| Tool | Endpoint | Method |
|------|----------|--------|
| crustdata_credits_check | /user/credits | GET |
| crustdata_company_identify | /screener/identify/ | POST |
| crustdata_company_enrich | /screener/company | GET |
| crustdata_company_search_db | /screener/companydb/search | POST |
| crustdata_company_linkedin_posts | /screener/linkedin_posts/keyword_search/ | POST |
| crustdata_people_enrich | /screener/person/enrich | GET |
| crustdata_people_search_db | /screener/persondb/search | POST |
| crustdata_watcher_create | /watcher/watches/ | POST |
| crustdata_watcher_update | /watcher/watches/{id}/update/ | POST |
| crustdata_watcher_list | /watcher/watches/ | GET |
| crustdata_web_search | /screener/web-search | POST |
| crustdata_web_fetch | /screener/web-fetch | POST |
| crustdata_dataset_job_listings | /data_lab/job_listings/Table/ | POST |

## Adding New Tools

1. Create input model with Pydantic validation
2. Add tool function with `@mcp.tool()` decorator
3. Access client via `get_client_for_request()`
4. Handle errors with try/except for CrustdataAPIError hierarchy
5. Add unit tests with respx mocking

Example:

```python
from ..client_factory import get_client_for_request

@mcp.tool(name="crustdata_example_tool", description="...")
async def crustdata_example_tool(
    params: ExampleInput,
    ctx: Context,
) -> dict:
    client = get_client_for_request()
    try:
        result = await client.get("/example/endpoint", params={...})
        return result
    except CrustdataAPIError as e:
        ctx.error(f"API error: {e}")
        raise
```

## Test Patterns

Use respx for mocking HTTP responses:

```python
@pytest.mark.asyncio
@respx.mock
async def test_example():
    respx.get("https://api.crustdata.com/example").mock(
        return_value=Response(200, json={"data": "value"})
    )
    async with CrustdataClient("test-token") as client:
        result = await client.get("/example")
    assert result["data"] == "value"
```

## Design Decisions

### PassthroughTokenVerifier ("Proxy Mode")

For HTTP transport, `PassthroughTokenVerifier` operates as a token pass-through:

- **No server-side token validation**: The MCP server only validates token format (non-empty, 10+ characters)
- **Tokens passed directly to Crustdata API**: Actual authentication is delegated to Crustdata
- **Intentional design**: Enables multi-tenant support where different clients use different Crustdata accounts
- **Security implication**: Invalid tokens will fail at the Crustdata API level, not the MCP server level

### Transport Mode and Token Handling

The `ClientFactory` tracks transport mode to handle missing tokens appropriately:

- **STDIO mode** (`set_transport_mode("stdio")`): Falls back to `CRUSTDATA_TOKEN` env var if no token in request context
- **HTTP mode** (`set_transport_mode("http")`): Fails explicitly if no token in request context (no env var fallback)
- **Unset mode**: Falls back to env var for backward compatibility

This prevents silent fallback to the server's env var token in multi-tenant HTTP scenarios.

### Thread Safety

The global `_factory` singleton assumes single-process async operation:

- All tool calls run in the same event loop
- No threading or multiprocessing
- No locks or concurrent access protection needed
- Safe for typical MCP server deployments (single-process)

### Client Cleanup Behavior

Clients are managed with different cleanup strategies:

- **HTTP clients**: Stored in `WeakValueDictionary` - eligible for GC when request ends
- **STDIO client**: Strong reference kept alive for server lifetime
- **Explicit cleanup**: `close_all()` called on server shutdown
- **GC'd clients**: Rely on httpx `__del__` for connection cleanup (not explicitly closed)

For robust cleanup, `close_all()` is called in lifespan context manager's `finally` block.
