# ntro-mcp — MCP Server for the ntro platform

## Project Overview

This is the MCP (Model Context Protocol) server for the **ntro** platform. It exposes the Workspace API as MCP tools that Claude can call directly.

| Install | Run | Transport |
|---------|-----|-----------|
| `pip install ntro-mcp` | `ntro-mcp` | Streamable HTTP (for claude.ai) + stdio (for Claude Code) |

The MCP server is a thin wrapper over the `ntro` SDK. It imports `ntro.workspace.Client` and maps each SDK method to an MCP tool. No business logic lives here.

---

## Architecture

```
┌──────────────────┐
│    Claude.ai      │   ← Connects via Streamable HTTP
│    Claude Code    │   ← Connects via stdio
│    Claude Desktop │   ← Connects via stdio
└────────┬─────────┘
         │ MCP protocol
         ▼
┌──────────────────┐
│    ntro-mcp       │   ← This repo (FastMCP server)
│    localhost:8000  │
└────────┬─────────┘
         │ imports ntro SDK
         ▼
┌──────────────────┐
│  ntro SDK         │   ← pip install ntro
│  ntro.workspace   │
└────────┬─────────┘
         │ HTTP/REST
         ▼
┌──────────────────┐
│  Workspace API    │   ← localhost:3000 (NestJS)
└──────────────────┘
```

---

## Repository Structure

```
ntro-mcp/
├── CLAUDE.md                    # ← This file
├── pyproject.toml
├── README.md
│
├── src/
│   └── ntro_mcp/
│       ├── __init__.py
│       ├── server.py            # FastMCP server, tool/resource/prompt definitions
│       └── main.py              # Entry point, CLI args (--transport, --port, --connection)
│
└── tests/
    └── test_server.py
```

---

## Package Configuration

```toml
# pyproject.toml
[project]
name = "ntro-mcp"
version = "0.1.0"
description = "MCP server for the ntro platform"
requires-python = ">=3.11"
dependencies = [
    "ntro>=0.1.0",
    "mcp>=1.26",
]

[project.scripts]
ntro-mcp = "ntro_mcp.main:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/ntro_mcp"]
```

---

## Server Implementation

### Entry Point

The server supports two transports: **Streamable HTTP** (for claude.ai) and **stdio** (for Claude Code / Claude Desktop).

```python
# src/ntro_mcp/main.py
import argparse
import os
from ntro_mcp.server import create_server

def main():
    parser = argparse.ArgumentParser(description="ntro MCP server")
    parser.add_argument(
        "--transport", choices=["http", "stdio"], default="http",
        help="Transport type (default: http)"
    )
    parser.add_argument(
        "--port", type=int, default=8000,
        help="HTTP port (default: 8000)"
    )
    parser.add_argument(
        "--host", default="0.0.0.0",
        help="HTTP host (default: 0.0.0.0)"
    )
    parser.add_argument(
        "--connection", "-c", default=None,
        help="ntro config connection name (reads ~/.ntro/config.toml)"
    )
    parser.add_argument(
        "--ntro-host", default=None,
        help="Workspace API URL (overrides config)"
    )
    parser.add_argument(
        "--ntro-api-key", default=None,
        help="API key (overrides config)"
    )
    args = parser.parse_args()

    # Set env vars if provided via CLI (SDK reads these)
    if args.ntro_host:
        os.environ["NTRO_HOST"] = args.ntro_host
    if args.ntro_api_key:
        os.environ["NTRO_API_KEY"] = args.ntro_api_key

    mcp = create_server(connection=args.connection)

    if args.transport == "http":
        mcp.run(transport="streamable-http", host=args.host, port=args.port)
    else:
        mcp.run(transport="stdio")
```

### Server Definition

```python
# src/ntro_mcp/server.py
import json
from mcp.server.fastmcp import FastMCP
from ntro.workspace import Client

def create_server(connection: str | None = None) -> FastMCP:
    mcp = FastMCP(
        "ntro",
        instructions="""You are connected to the ntro platform for fund operations automation.
Use the available tools to manage tenants, entities, workflows, and task runs.
Use resources to load current platform state before taking actions.

Key concepts:
- Tenant = a client organisation (fund admin or asset manager)
- Entity = an SPV or fund within a tenant
- Workflow = a repeatable process (NAV calc, document ingestion, period close)
- Task = a running instance of a workflow
- Integration = a data platform connection (Databricks, Snowflake)
""",
    )

    # Initialise the SDK client lazily
    _client: Client | None = None

    def get_client() -> Client:
        nonlocal _client
        if _client is None:
            if connection:
                _client = Client.from_config(connection=connection)
            else:
                _client = Client.from_config()
        return _client

    # ══════════════════════════════════════════════════════════════
    #  TOOLS (write operations)
    # ══════════════════════════════════════════════════════════════

    @mcp.tool()
    async def ntro_integration_create(
        name: str,
        provider: str,
        region: str,
        config: dict,
        ownership: str = "SELF_MANAGED",
    ) -> dict:
        """Register a new data platform (Databricks, Snowflake, etc).

        Args:
            name: Display name (e.g., "Acme Databricks UK")
            provider: DATABRICKS, SNOWFLAKE, MICROSOFT_FABRIC, or GCP_BIGQUERY
            region: Deployment region (e.g., "UK-South")
            config: Provider-specific config (workspace_url, catalog, auth_type, client_id, client_secret for Databricks)
            ownership: SELF_MANAGED (customer owns) or MANAGED (ntro owns)
        """
        c = get_client()
        result = await c.integrations.create_data_platform(
            name=name, provider=provider, region=region,
            config=config, ownership=ownership,
        )
        return result.model_dump()

    @mcp.tool()
    async def ntro_integration_test(id: str) -> dict:
        """Test connectivity to a registered data platform. Returns pass/fail with diagnostics.

        Args:
            id: Data platform config ID
        """
        c = get_client()
        result = await c.integrations.test_connection(id)
        return result.model_dump() if hasattr(result, 'model_dump') else result

    @mcp.tool()
    async def ntro_tenant_create(
        name: str,
        slug: str,
        data_platform_config_id: str,
    ) -> dict:
        """Create a new tenant (client cell). A tenant represents a fund admin or asset manager.

        Args:
            name: Tenant display name (e.g., "Acme Fund Administration")
            slug: URL-safe identifier (e.g., "acme-fund-admin")
            data_platform_config_id: ID of the data platform to link
        """
        c = get_client()
        tenant = await c.tenants.create(
            name=name, slug=slug,
            data_platform_config_id=data_platform_config_id,
        )
        return tenant.model_dump()

    @mcp.tool()
    async def ntro_entity_create(
        tenant_id: str,
        name: str,
        slug: str,
        type: str,
        jurisdiction: str,
        currency: str,
        schema: str,
    ) -> dict:
        """Create an entity (SPV/fund) within a tenant. Each entity gets its own schema in the customer's data platform.

        Args:
            tenant_id: Tenant slug or ID
            name: Entity display name (e.g., "Acme Commercial SPV 1")
            slug: URL-safe identifier (e.g., "acme-commercial-spv1")
            type: Entity type (e.g., "real-estate-spv", "private-credit-fund")
            jurisdiction: Legal jurisdiction (e.g., "Jersey", "Luxembourg", "ADGM")
            currency: Base currency (e.g., "GBP", "EUR", "USD")
            schema: Target schema name in the data platform
        """
        c = get_client()
        entity = await c.entities.create(
            tenant_id=tenant_id, name=name, slug=slug,
            type=type, jurisdiction=jurisdiction,
            currency=currency, schema=schema,
        )
        return entity.model_dump()

    @mcp.tool()
    async def ntro_workflow_create(
        name: str,
        tenant_id: str,
        description: str = "",
        schedule: str | None = None,
        timezone: str = "Europe/London",
    ) -> dict:
        """Register a new workflow definition.

        Args:
            name: Workflow name (e.g., "nav-monthly")
            tenant_id: Tenant slug or ID this workflow belongs to
            description: Human-readable description
            schedule: Cron expression for scheduled runs (e.g., "0 8 5 * *" for 8am on 5th of each month). None for manual-only.
            timezone: Timezone for the schedule (default: Europe/London)
        """
        c = get_client()
        workflow = await c.workflows.create(
            name=name, tenant_id=tenant_id, description=description,
            schedule=schedule, timezone=timezone,
        )
        return workflow.model_dump()

    @mcp.tool()
    async def ntro_workflow_deploy(
        workflow_id: str,
        version_id: str | None = None,
        tenant_ids: list[str] | None = None,
    ) -> dict:
        """Deploy a workflow version to tenant cells.

        Args:
            workflow_id: Workflow ID to deploy
            version_id: Specific version to deploy (latest if omitted)
            tenant_ids: List of tenant IDs to deploy to (all if omitted)
        """
        c = get_client()
        deployment = await c.deployments.create(
            workflow_id=workflow_id,
            version_id=version_id,
            tenant_ids=tenant_ids,
        )
        return deployment.model_dump()

    @mcp.tool()
    async def ntro_workflow_run(
        tenant_id: str,
        entity_id: str,
        workflow_id: str,
        period: str | None = None,
        dry_run: bool = False,
    ) -> dict:
        """Trigger a workflow run for a specific entity. Returns a task ID for tracking progress.

        Args:
            tenant_id: Tenant slug or ID
            entity_id: Entity slug or ID
            workflow_id: Workflow name or ID (e.g., "nav-monthly", "coa-import", "document-ingest")
            period: Accounting period (e.g., "2026-03")
            dry_run: If true, validate without committing
        """
        c = get_client()
        task = await c.tasks.create(
            tenant_id=tenant_id,
            entity_id=entity_id,
            workflow_id=workflow_id,
            context={"period": period, "dry_run": dry_run},
        )
        return task.model_dump()

    # ══════════════════════════════════════════════════════════════
    #  RESOURCES (read-only context)
    # ══════════════════════════════════════════════════════════════

    @mcp.resource("ntro://whoami")
    async def whoami() -> str:
        """Current authenticated user and workspace info."""
        c = get_client()
        user = await c.identity.whoami()
        return json.dumps(user.model_dump(), indent=2)

    @mcp.resource("ntro://integrations")
    async def list_integrations() -> str:
        """All configured data platforms with connection status."""
        c = get_client()
        platforms = await c.integrations.list_data_platforms()
        return json.dumps([p.model_dump() for p in platforms], indent=2)

    @mcp.resource("ntro://integrations/{id}")
    async def get_integration(id: str) -> str:
        """Detail for a specific data platform including connection health."""
        c = get_client()
        platform = await c.integrations.get_data_platform(id)
        return json.dumps(platform.model_dump(), indent=2)

    @mcp.resource("ntro://integrations/{id}/schemas")
    async def discover_schemas(id: str) -> str:
        """Discover schemas and tables in a customer's data platform. Use this to understand the data model."""
        c = get_client()
        schemas = await c.integrations.discover_schemas(id)
        return json.dumps(schemas, indent=2)

    @mcp.resource("ntro://tenants")
    async def list_tenants() -> str:
        """All tenants with status, region, and entity counts."""
        c = get_client()
        tenants = await c.tenants.list()
        return json.dumps([t.model_dump() for t in tenants], indent=2)

    @mcp.resource("ntro://tenants/{slug}")
    async def get_tenant(slug: str) -> str:
        """Detail for a specific tenant including entities and data platform link."""
        c = get_client()
        tenant = await c.tenants.get(slug)
        return json.dumps(tenant.model_dump(), indent=2)

    @mcp.resource("ntro://entities")
    async def list_entities() -> str:
        """All entities (SPVs/funds) across all tenants."""
        c = get_client()
        entities = await c.entities.list()
        return json.dumps([e.model_dump() for e in entities], indent=2)

    @mcp.resource("ntro://workflows")
    async def list_workflows() -> str:
        """All registered workflow definitions with latest version info."""
        c = get_client()
        workflows = await c.workflows.list()
        return json.dumps([w.model_dump() for w in workflows], indent=2)

    @mcp.resource("ntro://workflows/{id}")
    async def get_workflow(id: str) -> str:
        """Workflow definition detail including version history and deployment status."""
        c = get_client()
        workflow = await c.workflows.get(id)
        return json.dumps(workflow.model_dump(), indent=2)

    @mcp.resource("ntro://tasks/{id}")
    async def get_task(id: str) -> str:
        """Task (workflow run) status including step progress and pending HITL reviews."""
        c = get_client()
        task = await c.tasks.get(id)
        return json.dumps(task.model_dump(), indent=2)

    @mcp.resource("ntro://schedule")
    async def get_schedule() -> str:
        """All tasks sorted by urgency across all entities."""
        c = get_client()
        tasks = await c.tasks.list_schedule()
        return json.dumps([t.model_dump() for t in tasks], indent=2)

    # ══════════════════════════════════════════════════════════════
    #  PROMPTS (guided multi-step workflows)
    # ══════════════════════════════════════════════════════════════

    @mcp.prompt()
    def onboard_spv(
        tenant_slug: str,
        spv_name: str,
        jurisdiction: str = "Jersey",
        currency: str = "GBP",
    ) -> str:
        """Guide through setting up a new SPV entity with all configuration."""
        return f"""You are onboarding a new SPV onto the ntro platform.

Tenant: {tenant_slug}
SPV Name: {spv_name}
Jurisdiction: {jurisdiction}
Currency: {currency}

Follow these steps:
1. Load ntro://tenants/{tenant_slug} to verify the tenant exists and get its data platform config
2. Create the entity using ntro_entity_create with an appropriate schema name
3. Ask the user for their Chart of Accounts (CSV file or details)
4. Trigger the coa-import workflow using ntro_workflow_run
5. Ask if they want to configure email integrations for document collection
6. Register the nav-monthly workflow using ntro_workflow_create
7. Offer to run a dry-run test

Confirm success at each step before proceeding."""

    @mcp.prompt()
    def run_nav(
        tenant_slug: str,
        entity_slug: str,
        period: str,
    ) -> str:
        """Guide through executing a monthly NAV calculation."""
        return f"""Execute the monthly NAV workflow:

Tenant: {tenant_slug}
Entity: {entity_slug}
Period: {period}

Steps:
1. Load ntro://tenants/{tenant_slug} to verify the entity exists
2. Trigger nav-monthly using ntro_workflow_run with the period
3. Monitor progress via ntro://tasks/{{task_id}}
4. When steps show BLOCKED status, inform the user what needs HITL approval
5. Once complete, summarise the result"""

    @mcp.prompt()
    def platform_status() -> str:
        """Get a comprehensive overview of the current platform state."""
        return """Provide a status overview of the ntro platform:

1. Load ntro://whoami to confirm connection
2. Load ntro://tenants to list all clients
3. Load ntro://entities to list all SPVs/funds
4. Load ntro://workflows to list registered workflows
5. Load ntro://schedule to check pending/active tasks

Summarise: how many tenants, entities, workflows, and active tasks. Flag anything that needs attention (failed runs, pending reviews, etc.)."""

    return mcp
```

---

## Running the Server

### For claude.ai (Streamable HTTP + tunnel)

```bash
# Terminal 1: Ensure Workspace API is running and start if not already running
cd api-workspace && npm run start:dev
# → Running on http://localhost:3000

# Terminal 2: Run the MCP server
cd ntro-mcp
pip install -e .  # or: uv pip install -e .
ntro-mcp --transport http --port 8000 --ntro-host http://localhost:3000/v1 --ntro-api-key ntro_dev_key
# → MCP server running on http://localhost:8000

# Terminal 3: Tunnel the MCP server
npx @anthropic-ai/mcp-proxy --port 8001 -- ntro-mcp --transport stdio --ntro-host http://localhost:3000/v1 --ntro-api-key ntro_dev_key
```

**Alternative: use cloudflared to tunnel the HTTP server directly:**

```bash
# Terminal 3: Tunnel
cloudflared tunnel --url http://localhost:8000
# → Gives you: https://random-words.trycloudflare.com
```

Then in Claude.ai: Settings → Connected Apps → Add MCP Server → paste the tunnel URL.

### For Claude Code (stdio)

Add to `.claude/mcp.json`:
```json
{
  "mcpServers": {
    "ntro": {
      "command": "ntro-mcp",
      "args": ["--transport", "stdio"],
      "env": {
        "NTRO_HOST": "http://localhost:3000/v1",
        "NTRO_API_KEY": "ntro_dev_key"
      }
    }
  }
}
```

### Using config file instead of env vars

If you have `~/.ntro/config.toml` set up:
```bash
ntro-mcp --transport http --port 8000 --connection local
```

---

## Tool Reference

### Write Tools

| Tool | Description |
|------|------------|
| `ntro_integration_create` | Register a data platform (Databricks, Snowflake) |
| `ntro_integration_test` | Test connectivity to a data platform |
| `ntro_tenant_create` | Create a new tenant (client cell) |
| `ntro_entity_create` | Create an SPV/fund within a tenant |
| `ntro_workflow_create` | Register a workflow definition |
| `ntro_workflow_deploy` | Deploy a workflow version to tenant cells |
| `ntro_workflow_run` | Trigger a workflow run for an entity |

### Resources

| Resource URI | Description |
|---|---|
| `ntro://whoami` | Current user and workspace info |
| `ntro://integrations` | All data platform configs |
| `ntro://integrations/{id}` | Single data platform detail |
| `ntro://integrations/{id}/schemas` | Schema discovery (Databricks tables) |
| `ntro://tenants` | All tenants |
| `ntro://tenants/{slug}` | Single tenant with entities |
| `ntro://entities` | All entities across tenants |
| `ntro://workflows` | All workflow definitions |
| `ntro://workflows/{id}` | Workflow detail with versions |
| `ntro://tasks/{id}` | Task status and step progress |
| `ntro://schedule` | All tasks by urgency |

### Prompts

| Prompt | Description |
|---|---|
| `onboard_spv` | Guided SPV setup (entity → COA → email → workflow → test) |
| `run_nav` | Guided monthly NAV execution |
| `platform_status` | Comprehensive platform overview |

---

## Dependencies

- **ntro** >= 0.1.0 — the SDK (from ntro-python repo)
- **mcp** >= 1.26 — MCP Python SDK (FastMCP)

---

## Build Order

1. **Scaffold** — pyproject.toml, directory structure
2. **main.py** — entry point with argparse for transport/port/connection
3. **server.py** — FastMCP server with all tools, resources, and prompts
4. **Test locally** — run with stdio, test with `mcp dev` or Claude Code
5. **Test with HTTP** — run with Streamable HTTP, tunnel, connect from claude.ai

---

## PoC Scope

### In scope
- All write tools (7 tools mapping to P0 endpoints)
- All read resources (11 resources)
- 3 guided prompts
- Streamable HTTP transport (for claude.ai)
- stdio transport (for Claude Code)
- Config via CLI args, env vars, or ~/.ntro/config.toml

### Excluded
- Authentication/OAuth for the MCP server itself (trusted local server for PoC)
- File upload tools (workflow push, document ingest — requires multipart handling)
- Rate limiting
- PyPI publishing (registered as placeholder, real release later)
