Skip to content
Merged
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
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ Or add it directly to your Claude Desktop config (`claude_desktop_config.json`):

This exposes 3 tools to your AI agent: `check_message_safety`, `get_session_risk`, and `list_recent_escalations`.

For HTTP MCP, the server binds to `127.0.0.1` by default. If you expose it
beyond localhost, set a bearer token first:

```bash
export HUMANE_PROXY_ADMIN_KEY=your-secret-token
humane-proxy mcp-serve --transport http --host 0.0.0.0 --port 3000
```

---

## Available On
Expand Down Expand Up @@ -441,16 +449,27 @@ curl -X DELETE http://localhost:8000/admin/sessions/user-42 \
```bash
pip install humane-proxy[mcp]
humane-proxy mcp-serve # stdio (default)
humane-proxy mcp-serve --transport http --port 3000 # HTTP
humane-proxy mcp-serve --transport http --port 3000 # HTTP on 127.0.0.1
```

HTTP MCP is local-only by default. To bind publicly, pass `--host 0.0.0.0`
explicitly and protect tool access with a bearer token:

```bash
export HUMANE_PROXY_ADMIN_KEY=your-secret-token
humane-proxy mcp-serve --transport http --host 0.0.0.0 --port 3000
```

Clients must send `Authorization: Bearer your-secret-token` when the token is
configured. Leave `HUMANE_PROXY_ADMIN_KEY` unset for stdio/local-only MCP.

Exposes three tools via Model Context Protocol:

| Tool | Description |
|---|---|
| `check_message_safety` | Full pipeline classification |
| `get_session_risk` | Session trajectory (trend, spike, category counts) |
| `list_recent_escalations` | Audit log query |
| `get_session_risk` | Read-only session trajectory snapshot (trend, spike, category counts) |
| `list_recent_escalations` | Bounded audit log query |

Available on the [Official MCP Registry](https://registry.modelcontextprotocol.io).

Expand Down
36 changes: 13 additions & 23 deletions humane_proxy/api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from datetime import datetime, timezone
from typing import Any

from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import StreamingResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

Expand Down Expand Up @@ -298,32 +298,21 @@ def get_session_risk(
finally:
conn.close()

from humane_proxy.risk.trajectory import analyze
from humane_proxy.risk.trajectory import snapshot

# Build trajectory by replaying each escalation.
trajectory = None
for row in rows:
rec = _row_to_dict(row)
trajectory = analyze(
session_id + "_admin_replay", # isolated session key
rec["risk_score"],
rec.get("category", "safe"),
)
trajectory = snapshot(session_id)

return {
"session_id": session_id,
"escalation_count": len(rows),
"history": [_row_to_dict(r) for r in rows],
"trajectory": (
{
"spike_detected": trajectory.spike_detected,
"trend": trajectory.trend,
"window_scores": trajectory.window_scores,
"category_counts": trajectory.category_counts,
}
if trajectory
else None
),
"trajectory": {
"spike_detected": trajectory.spike_detected,
"trend": trajectory.trend,
"window_scores": trajectory.window_scores,
"category_counts": trajectory.category_counts,
"message_count": trajectory.message_count,
},
}


Expand Down Expand Up @@ -381,11 +370,11 @@ def get_stats(_: str = Depends(_require_admin)) -> dict:
}


@router.delete("/sessions/{session_id}", status_code=204)
@router.delete("/sessions/{session_id}", status_code=204, response_class=Response)
def delete_session_data(
session_id: str,
_: str = Depends(_require_admin),
) -> None:
) -> Response:
"""Delete all escalation records for a session (privacy right to erasure)."""
conn = _get_conn()
try:
Expand All @@ -397,3 +386,4 @@ def delete_session_data(
conn.close()

logger.info("Deleted %d records for session %s (admin request)", deleted, session_id)
return Response(status_code=204)
5 changes: 3 additions & 2 deletions humane_proxy/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,13 +555,14 @@ async def _run_all():
@click.option("--transport", "-t", default="stdio",
type=click.Choice(["stdio", "http"]),
help="Transport mode: stdio (default) or http")
@click.option("--host", default="0.0.0.0", help="HTTP bind host (default: 0.0.0.0)")
@click.option("--host", default="127.0.0.1", help="HTTP bind host (default: 127.0.0.1)")
@click.option("--port", "-p", default=3000, type=int, help="HTTP bind port (default: 3000)")
def mcp_serve(transport: str, host: str, port: int) -> None:
"""Start the MCP server (requires [mcp] extra).

Use --transport stdio (default) for local integration with agents.
Use --transport http for remote access and registry listing.
Use --transport http for HTTP access. Set HUMANE_PROXY_ADMIN_KEY
before exposing HTTP MCP beyond localhost.
"""
try:
if transport == "http":
Expand Down
29 changes: 29 additions & 0 deletions humane_proxy/escalation/query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Shared escalation query validation helpers."""

from __future__ import annotations

ALLOWED_ESCALATION_CATEGORIES = frozenset({"self_harm", "criminal_intent"})
DEFAULT_ESCALATION_LIMIT = 20
MAX_ESCALATION_LIMIT = 100


def normalize_escalation_query(
limit: int = DEFAULT_ESCALATION_LIMIT,
category: str | None = None,
) -> tuple[int, str | None]:
"""Clamp escalation query size and validate optional category filters."""
try:
normalized_limit = int(limit)
except (TypeError, ValueError):
normalized_limit = DEFAULT_ESCALATION_LIMIT

normalized_limit = max(1, min(normalized_limit, MAX_ESCALATION_LIMIT))
normalized_category = category.strip() if isinstance(category, str) else None
if normalized_category == "":
normalized_category = None

if normalized_category and normalized_category not in ALLOWED_ESCALATION_CATEGORIES:
allowed = ", ".join(sorted(ALLOWED_ESCALATION_CATEGORIES))
raise ValueError(f"category must be one of: {allowed}")

return normalized_limit, normalized_category
15 changes: 5 additions & 10 deletions humane_proxy/integrations/autogen.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,9 @@ def get_session_risk(session_id: str) -> str:
Returns:
JSON string with spike detection, trend, and category distribution.
"""
from humane_proxy.risk.trajectory import analyze
from humane_proxy.risk.trajectory import snapshot, to_dict

result = analyze(session_id, 0.0, "safe")
return json.dumps({
"spike_detected": result.spike_detected,
"trend": result.trend,
"window_scores": result.window_scores,
"category_counts": result.category_counts,
"message_count": result.message_count,
}, indent=2)
return json.dumps(to_dict(snapshot(session_id)), indent=2)


def list_recent_escalations(limit: int = 20, category: str = "") -> str:
Expand All @@ -81,11 +74,13 @@ def list_recent_escalations(limit: int = 20, category: str = "") -> str:
Returns:
JSON string with list of escalation records.
"""
from humane_proxy.escalation.query import normalize_escalation_query
from humane_proxy.storage.factory import get_store

limit, category = normalize_escalation_query(limit, category)
store = get_store()
results = store.query(
category=category if category else None,
category=category,
limit=limit,
)
return json.dumps(results, indent=2, default=str)
Expand Down
15 changes: 5 additions & 10 deletions humane_proxy/integrations/crewai.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,30 +94,25 @@ class GetSessionRiskTool(BaseTool):
args_schema: Type[BaseModel] = SessionRiskInput

def _run(self, session_id: str) -> str:
from humane_proxy.risk.trajectory import analyze
from humane_proxy.risk.trajectory import snapshot, to_dict
import json

result = analyze(session_id, 0.0, "safe")
return json.dumps({
"spike_detected": result.spike_detected,
"trend": result.trend,
"window_scores": result.window_scores,
"category_counts": result.category_counts,
"message_count": result.message_count,
}, indent=2)
return json.dumps(to_dict(snapshot(session_id)), indent=2)

class ListEscalationsTool(BaseTool):
name: str = "list_recent_escalations"
description: str = "Query recent escalation events from the safety audit log."
args_schema: Type[BaseModel] = ListEscalationsInput

def _run(self, limit: int = 20, category: str = "") -> str:
from humane_proxy.escalation.query import normalize_escalation_query
from humane_proxy.storage.factory import get_store
import json

limit, category = normalize_escalation_query(limit, category)
store = get_store()
results = store.query(
category=category if category else None,
category=category,
limit=limit,
)
return json.dumps(results, indent=2, default=str)
Expand Down
13 changes: 4 additions & 9 deletions humane_proxy/integrations/llamaindex.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,9 @@ def _get_session_risk(session_id: str) -> dict:
dict
``{"spike_detected": bool, "trend": str, "window_scores": list, ...}``
"""
from humane_proxy.risk.trajectory import analyze
from humane_proxy.risk.trajectory import snapshot, to_dict

result = analyze(session_id, 0.0, "safe")
return {
"spike_detected": result.spike_detected,
"trend": result.trend,
"window_scores": result.window_scores,
"category_counts": result.category_counts,
"message_count": result.message_count,
}
return to_dict(snapshot(session_id))


def _list_recent_escalations(limit: int = 20, category: str | None = None) -> list[dict]:
Expand All @@ -83,8 +76,10 @@ def _list_recent_escalations(limit: int = 20, category: str | None = None) -> li
list[dict]
List of escalation records.
"""
from humane_proxy.escalation.query import normalize_escalation_query
from humane_proxy.storage.factory import get_store

limit, category = normalize_escalation_query(limit, category)
store = get_store()
return store.query(category=category, limit=limit)

Expand Down
75 changes: 61 additions & 14 deletions humane_proxy/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,52 @@

from __future__ import annotations

import ipaddress
import logging
import os

logger = logging.getLogger("humane_proxy.mcp")

MCP_TOKEN_ENV = "HUMANE_PROXY_ADMIN_KEY"
MCP_DEFAULT_HOST = "127.0.0.1"


def _is_public_bind_host(host: str) -> bool:
"""Return whether an HTTP bind host may be reachable beyond localhost."""
normalized = (host or "").strip()
if normalized.startswith("[") and normalized.endswith("]"):
normalized = normalized[1:-1]

if not normalized:
return True
if normalized.lower() == "localhost":
return False

try:
address = ipaddress.ip_address(normalized)
except ValueError:
return True

return not address.is_loopback


def _get_mcp_auth_provider():
"""Return a FastMCP Bearer auth provider when HTTP MCP auth is configured."""
token = os.environ.get(MCP_TOKEN_ENV, "").strip()
if not token:
return None

try:
from fastmcp.server.auth import BearerTokenAuth # type: ignore[import]
except ImportError as exc:
raise RuntimeError(
f"{MCP_TOKEN_ENV} is set, but this FastMCP version does not expose "
"server Bearer token auth. Upgrade fastmcp to use HTTP MCP auth."
) from exc

return BearerTokenAuth(token=token)


try:
from fastmcp import FastMCP # type: ignore[import]
_MCP_AVAILABLE = True
Expand All @@ -24,8 +66,11 @@
# ---------------------------------------------------------------------------

if _MCP_AVAILABLE:
auth_provider = _get_mcp_auth_provider()
mcp_kwargs = {"auth": auth_provider} if auth_provider is not None else {}
mcp = FastMCP(
"humane-proxy"
"humane-proxy",
**mcp_kwargs,
)

@mcp.tool()
Expand Down Expand Up @@ -71,17 +116,9 @@ async def get_session_risk(session_id: str) -> dict:
``{"spike_detected": bool, "trend": str, "window_scores": list,
"category_counts": dict, "message_count": int}``
"""
from humane_proxy.risk.trajectory import analyze

# Analyze with a neutral message to get current state.
result = analyze(session_id, 0.0, "safe")
return {
"spike_detected": result.spike_detected,
"trend": result.trend,
"window_scores": result.window_scores,
"category_counts": result.category_counts,
"message_count": result.message_count,
}
from humane_proxy.risk.trajectory import snapshot, to_dict

return to_dict(snapshot(session_id))

@mcp.tool()
async def list_recent_escalations(
Expand All @@ -105,8 +142,11 @@ async def list_recent_escalations(
"""
import json
import sqlite3
from humane_proxy.escalation.query import normalize_escalation_query
from humane_proxy.escalation.local_db import _get_db_path

limit, category = normalize_escalation_query(limit, category)

conn = sqlite3.connect(_get_db_path(), check_same_thread=False)
try:
if category:
Expand Down Expand Up @@ -148,7 +188,7 @@ def serve() -> None:
mcp.run()


def serve_http(host: str = "0.0.0.0", port: int = 3000) -> None:
def serve_http(host: str = MCP_DEFAULT_HOST, port: int = 3000) -> None:
"""Start the MCP server in Streamable HTTP mode.

This exposes the MCP tools over HTTP, making the server compatible
Expand All @@ -158,14 +198,21 @@ def serve_http(host: str = "0.0.0.0", port: int = 3000) -> None:
Parameters
----------
host:
Bind address (default ``"0.0.0.0"``).
Bind address (default ``"127.0.0.1"``).
port:
Bind port (default ``3000``).
"""
if not _MCP_AVAILABLE:
raise RuntimeError(
"MCP server requires fastmcp. Install with: pip install humane-proxy[mcp]"
)
if _is_public_bind_host(host) and not os.environ.get(MCP_TOKEN_ENV, "").strip():
logger.warning(
"Starting HTTP MCP on public host %s without %s. "
"Set a bearer token before exposing this server beyond localhost.",
host,
MCP_TOKEN_ENV,
)
assert mcp is not None
mcp.run(transport="http", host=host, port=port)

Loading
Loading