Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@
# Get your API key from 1Password Device Trust Dashboard > Settings > API Keys
KOLIDE_API_KEY=your-api-key-here

# MCP Server Configuration (optional)
MCP_HOST=0.0.0.0
MCP_PORT=8000
# Kolide API base URL — defaults to https://api.kolide.com.
# Override for local development (e.g. http://localhost:3000).
# KOLIDE_API_URL=https://api.kolide.com

# MCP authentication token (required)
# Generate one with: python -c "import secrets; print(secrets.token_hex(32))"
# Must also be set in your MCP client config (Cursor, Claude Desktop, etc.)
MCP_AUTH_TOKEN=

# Server bind address — defaults to 127.0.0.1 (loopback only).
# Only change this if you intentionally need remote access.
# MCP_HOST=127.0.0.1
# MCP_PORT=8000

# CORS — comma-separated origins allowed for browser-based MCP clients.
# Native clients (Claude Desktop, Cursor) don't trigger CORS and are unaffected.
# MCP_CORS_ALLOWED_ORIGINS=http://localhost,http://127.0.0.1

# Enrichment cap — max records enriched per enrich_device_owner call (default 500).
# MCP_MAX_ENRICH_RECORDS=500

# Structured audit log file (in addition to stdout). Unset = stdout only.
# MCP_LOG_FILE=

# Starlette debug mode — development only, never enable in production.
# MCP_DEBUG=false
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,4 @@ ENV/
.DS_Store
Thumbs.db

# uv
uv.lock
# uv artifacts (uv.lock is intentionally tracked for reproducible installs)
66 changes: 49 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ An MCP (Model Context Protocol) server that exposes the 1Password Device Trust A

## Features

- Full coverage of 1Password Device Trust (Kolide K2) API endpoints (56 endpoint tools + 2 composite analytical tools)
- Full coverage of 1Password Device Trust (Kolide K2) API endpoints (56 endpoint tools + 3 composite analytical tools)
- **Auto-pagination** (`fetch_all`) to retrieve complete datasets in a single tool call
- **Field projection** (`fields`) to return only the columns you need, reducing response size
- **Device owner enrichment** (`enrich_device_owner`) to automatically resolve device IDs to owner names/emails
- **Composite analytical tools** for common aggregation tasks (resolution time stats, grouped counts)
- **Dynamic reporting table validation** — table names are fetched from the API at startup and refreshable on demand
- **MCP Resources** providing search syntax docs, reporting table guides, and workflow references
- **Bearer token authentication** on all MCP endpoints
- **Structured JSON audit logging** of every tool invocation
- Binds to localhost only by default; configurable CORS allowlist
- Streamable HTTP transport for easy integration with AI tools
- API key loaded fresh on each request (supports `.env` file updates without restart)

Expand All @@ -33,18 +37,31 @@ pip install -e .

### Environment Variables

Create a `.env` file in the project directory (or set environment variables):
Copy the example and fill in your values:

```bash
# Required: Your Kolide API key
KOLIDE_API_KEY=your-api-key-here

# Optional: Server configuration
MCP_HOST=0.0.0.0 # Default: 0.0.0.0
MCP_PORT=8000 # Default: 8000
cp .env.example .env
```

The API key is read fresh on each tool call, so you can update it in the `.env` file without restarting the server.
**Required variables:**

| Variable | Description |
|---|---|
| `KOLIDE_API_KEY` | Your Kolide API key (Dashboard > Settings > API Keys) |
| `MCP_AUTH_TOKEN` | Bearer token for MCP endpoint access. Generate one with: `python -c "import secrets; print(secrets.token_hex(32))"` |

**Optional variables:**

| Variable | Default | Description |
|---|---|---|
| `MCP_HOST` | `127.0.0.1` | Bind address. Only change if you need remote access. |
| `MCP_PORT` | `8000` | Listen port |
| `MCP_CORS_ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated origins for browser-based MCP clients |
| `MCP_MAX_ENRICH_RECORDS` | `500` | Max records enriched per `enrich_device_owner` call |
| `MCP_LOG_FILE` | *(unset)* | File path for structured audit logs (in addition to stdout) |
| `MCP_DEBUG` | `false` | Starlette debug mode (development only) |

The Kolide API key is read fresh on each tool call, so you can update it in the `.env` file without restarting the server.

## Running the Server

Expand All @@ -62,11 +79,13 @@ python -m kolide_mcp.server

The server will start and display:
```
Starting 1Password Device Trust MCP server on 0.0.0.0:8000
MCP endpoint: http://0.0.0.0:8000/mcp
Health check: http://0.0.0.0:8000/health
Starting 1Password Device Trust MCP server on 127.0.0.1:8000
MCP endpoint: http://127.0.0.1:8000/mcp
Health check: http://127.0.0.1:8000/health
```

> **Note:** The server refuses to start if `MCP_AUTH_TOKEN` is not set.

## Connecting AI Tools

### Cursor
Expand All @@ -77,29 +96,41 @@ Add to `.cursor/mcp.json` in your project or global config:
{
"mcpServers": {
"kolide": {
"url": "http://localhost:8000/mcp"
"url": "http://localhost:8000/mcp",
"headers": {
"Authorization": "Bearer YOUR_MCP_AUTH_TOKEN"
}
}
}
}
```

### Claude Desktop

Claude Desktop's `claude_desktop_config.json` only supports stdio (subprocess) servers — it does not connect to remote HTTP URLs configured in the JSON file. To bridge the gap, use [`mcp-remote`](https://www.npmjs.com/package/mcp-remote), which wraps the HTTP server as a stdio process that Claude Desktop can manage.

Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:

```json
{
"mcpServers": {
"kolide": {
"url": "http://localhost:8000/mcp"
"command": "npx",
"args": [
"-y", "mcp-remote",
"http://localhost:8000/mcp",
"--header", "Authorization: Bearer YOUR_MCP_AUTH_TOKEN"
]
}
}
}
```

### Other MCP Clients

Connect to the MCP endpoint at `http://localhost:8000/mcp`. The server uses the Streamable HTTP transport which accepts both GET and POST requests.
Connect to the MCP endpoint at `http://localhost:8000/mcp` with an `Authorization: Bearer <token>` header. The server uses the Streamable HTTP transport. Clients that only support stdio can use `mcp-remote` as shown in the Claude Desktop example above.

Replace `YOUR_MCP_AUTH_TOKEN` in all examples with the same value you set in `MCP_AUTH_TOKEN`.

## Enhanced Parameters for List Tools

Expand Down Expand Up @@ -142,12 +173,13 @@ Automatically resolve `device_id` or `device_information` fields to the register

## Available Tools

The server exposes 58 tools: 56 endpoint tools covering all API functionality, plus 2 composite analytical tools.
The server exposes 59 tools: 56 endpoint tools covering all API functionality, plus 3 composite/utility tools.

### Composite Analytical Tools
### Composite & Utility Tools

- `kolide_issue_resolution_stats` - Compute resolution time statistics (avg, median, min, max, p90) for issues of a specific check. Automatically fetches all pages.
- `kolide_count_table_records_by_field` - Count records in a reporting table grouped by a field. Returns ranked results. Useful for "which device has the most extensions?" style questions.
- `kolide_refresh_reporting_tables` - Refresh the cached list of valid reporting table names from the API. Use if a table name is unexpectedly rejected.

### Organization
- `kolide_whoami` - Get organization information
Expand Down
16 changes: 13 additions & 3 deletions src/kolide_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ class KolideClient:
connections when the server shuts down.
"""

BASE_URL = "https://api.kolide.com"
DEFAULT_BASE_URL = "https://api.kolide.com"
API_VERSION = "2023-05-26"

def __init__(self) -> None:
load_dotenv(override=True)
self._http = httpx.AsyncClient(base_url=self.BASE_URL, timeout=30.0)
self.base_url = os.getenv("KOLIDE_API_URL", self.DEFAULT_BASE_URL)
self._http = httpx.AsyncClient(base_url=self.base_url, timeout=30.0)

def _get_headers(self) -> dict[str, str]:
api_key = os.getenv("KOLIDE_API_KEY")
Expand Down Expand Up @@ -62,7 +63,16 @@ async def request(
)

if response.status_code >= 400:
raise KolideAPIError(response.status_code, response.text)
try:
body = response.json()
message = (
body.get("message")
or body.get("error")
or f"HTTP {response.status_code}"
)
except Exception:
message = f"HTTP {response.status_code}"
raise KolideAPIError(response.status_code, message)

if response.status_code == 204:
return {"success": True}
Expand Down
98 changes: 98 additions & 0 deletions src/kolide_mcp/composite_tools.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
"""Composite analytical tools that combine multiple API calls with aggregation."""

from __future__ import annotations

import logging
import time
from collections import Counter
from datetime import datetime
from statistics import median
Expand All @@ -9,6 +13,70 @@

from .client import KolideClient

logger = logging.getLogger("kolide_mcp")


class ReportingTableRegistry:
"""Dynamically loaded registry of valid reporting table names.

Fetched from /reporting/tables at server startup. Supports on-demand
refresh via an MCP tool and automatic single-retry on cache miss
(rate-limited to avoid excessive API calls from typos).
"""

REFRESH_COOLDOWN_SECONDS = 60

def __init__(self, client: KolideClient) -> None:
self._client = client
self._tables: set[str] = set()
self._last_refresh: float = 0.0

async def load(self) -> list[str]:
"""Fetch all reporting table names from the Kolide API."""
all_tables = await _fetch_all_pages(self._client, "/reporting/tables")
self._tables = {t["name"] for t in all_tables if "name" in t}
self._last_refresh = time.monotonic()
logger.info(
"reporting_tables_loaded",
extra={"extra": {"count": len(self._tables)}},
)
return sorted(self._tables)

async def refresh(self) -> list[str]:
"""Force-refresh the table list regardless of cooldown."""
return await self.load()

async def validate(self, table_name: str) -> bool:
"""Return True if table_name is known. Retries once on miss if
the cooldown has elapsed, so newly added tables are picked up
without a server restart."""
if table_name in self._tables:
return True
elapsed = time.monotonic() - self._last_refresh
if elapsed >= self.REFRESH_COOLDOWN_SECONDS:
await self.load()
return table_name in self._tables
return False

@property
def table_names(self) -> frozenset[str]:
return frozenset(self._tables)


_registry: ReportingTableRegistry | None = None


def create_registry(client: KolideClient) -> ReportingTableRegistry:
"""Create the module-level registry. Called once from server.py lifespan."""
global _registry
_registry = ReportingTableRegistry(client)
return _registry


def get_registry() -> ReportingTableRegistry:
assert _registry is not None, "ReportingTableRegistry not initialized"
return _registry

MAX_FETCH_ALL = 10_000
MAX_FETCH_ALL_PAGES = 50

Expand Down Expand Up @@ -92,6 +160,16 @@ async def handle_issue_resolution_stats(
}


async def handle_refresh_reporting_tables(
client: KolideClient,
args: dict[str, Any],
) -> dict[str, Any]:
"""Refresh the cached list of valid reporting table names."""
registry = get_registry()
tables = await registry.refresh()
return {"tables": tables, "count": len(tables)}


async def handle_count_table_records_by_field(
client: KolideClient,
args: dict[str, Any],
Expand All @@ -100,6 +178,13 @@ async def handle_count_table_records_by_field(
table_name = args["table_name"]
group_by = args["group_by"]

registry = get_registry()
if not await registry.validate(table_name):
return {
"error": f"Unknown table: {table_name!r}.",
"valid_tables": sorted(registry.table_names),
}

records = await _fetch_all_pages(
client, f"/reporting/tables/{table_name}/table_records"
)
Expand Down Expand Up @@ -138,6 +223,18 @@ async def handle_count_table_records_by_field(


COMPOSITE_TOOLS: list[Tool] = [
Tool(
name="kolide_refresh_reporting_tables",
description=(
"Refresh the cached list of valid reporting table names from the "
"Kolide API. Returns the full updated list. Use this if you suspect "
"the table list is outdated or if a table name was unexpectedly rejected."
),
inputSchema={
"type": "object",
"properties": {},
},
),
Tool(
name="kolide_issue_resolution_stats",
description=(
Expand Down Expand Up @@ -205,6 +302,7 @@ async def handle_count_table_records_by_field(
]

COMPOSITE_HANDLERS: dict[str, Any] = {
"kolide_refresh_reporting_tables": handle_refresh_reporting_tables,
"kolide_issue_resolution_stats": handle_issue_resolution_stats,
"kolide_count_table_records_by_field": handle_count_table_records_by_field,
}
44 changes: 44 additions & 0 deletions src/kolide_mcp/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Centralized server configuration read from environment variables."""

import os
from dataclasses import dataclass, field


def _parse_origins(raw: str) -> list[str]:
return [o.strip().rstrip("/") for o in raw.split(",") if o.strip()]


@dataclass
class ServerConfig:
# Network
host: str = field(default_factory=lambda: os.getenv("MCP_HOST", "127.0.0.1"))
port: int = field(default_factory=lambda: int(os.getenv("MCP_PORT", "8000")))
# MCP_DEBUG enables Starlette debug mode, which renders an interactive Python
# debugger in HTTP error responses. Only enable in local development.
debug: bool = field(
default_factory=lambda: os.getenv("MCP_DEBUG", "false").lower() == "true"
)

# Auth — required at startup (see server.py)
auth_token: str | None = field(
default_factory=lambda: os.getenv("MCP_AUTH_TOKEN")
)

# CORS — comma-separated list of allowed origins.
# MCP clients such as Claude Desktop are native apps and do not trigger CORS.
# Add browser-based client origins here as needed.
cors_allowed_origins: list[str] = field(
default_factory=lambda: _parse_origins(
os.getenv("MCP_CORS_ALLOWED_ORIGINS", "http://localhost,http://127.0.0.1")
)
)

# Enrichment cap — maximum number of records that enrich_device_owner will
# process in a single call to prevent runaway upstream API usage.
max_enrich_records: int = field(
default_factory=lambda: int(os.getenv("MCP_MAX_ENRICH_RECORDS", "500"))
)

# Logging — write structured JSON logs to this file path in addition to stdout.
# If unset, logs go to stdout only.
log_file: str | None = field(default_factory=lambda: os.getenv("MCP_LOG_FILE"))
Loading