Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# MCP
|
||||
|
||||
Skills for building, testing, and deploying MCP (Model Context Protocol) servers.
|
||||
@@ -0,0 +1,300 @@
|
||||
---
|
||||
name: fastmcp
|
||||
description: Build, test, and deploy Python MCP servers.
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [MCP, FastMCP, Python, Tools, Resources, Prompts, Deployment]
|
||||
homepage: https://gofastmcp.com
|
||||
related_skills: [hermes-agent, mcporter]
|
||||
prerequisites:
|
||||
commands: [python]
|
||||
---
|
||||
|
||||
# FastMCP
|
||||
|
||||
Build MCP servers in Python with FastMCP, validate them locally, install them into MCP clients, and deploy them as HTTP endpoints.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when the task is to:
|
||||
|
||||
- create a new MCP server in Python
|
||||
- wrap an API, database, CLI, or file-processing workflow as MCP tools
|
||||
- expose resources or prompts in addition to tools
|
||||
- smoke-test a server with the FastMCP CLI before wiring it into Hermes or another client
|
||||
- install a server into Claude Code, Claude Desktop, Cursor, or a similar MCP client
|
||||
- prepare a FastMCP server repo for HTTP deployment
|
||||
|
||||
Use `native-mcp` when the server already exists and only needs to be connected to Hermes. Use `mcporter` when the goal is ad-hoc CLI access to an existing MCP server instead of building one.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install FastMCP in the working environment first:
|
||||
|
||||
```bash
|
||||
pip install fastmcp
|
||||
fastmcp version
|
||||
```
|
||||
|
||||
For the API template, install `httpx` if it is not already present:
|
||||
|
||||
```bash
|
||||
pip install httpx
|
||||
```
|
||||
|
||||
## Included Files
|
||||
|
||||
### Templates
|
||||
|
||||
- `templates/api_wrapper.py` - REST API wrapper with auth header support
|
||||
- `templates/database_server.py` - read-only SQLite query server
|
||||
- `templates/file_processor.py` - text-file inspection and search server
|
||||
|
||||
### Scripts
|
||||
|
||||
- `scripts/scaffold_fastmcp.py` - copy a starter template and replace the server name placeholder
|
||||
|
||||
### References
|
||||
|
||||
- `references/fastmcp-cli.md` - FastMCP CLI workflow, installation targets, and deployment checks
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Pick the Smallest Viable Server Shape
|
||||
|
||||
Choose the narrowest useful surface area first:
|
||||
|
||||
- API wrapper: start with 1-3 high-value endpoints, not the whole API
|
||||
- database server: expose read-only introspection and a constrained query path
|
||||
- file processor: expose deterministic operations with explicit path arguments
|
||||
- prompts/resources: add only when the client needs reusable prompt templates or discoverable documents
|
||||
|
||||
Prefer a thin server with good names, docstrings, and schemas over a large server with vague tools.
|
||||
|
||||
### 2. Scaffold from a Template
|
||||
|
||||
Copy a template directly or use the scaffold helper:
|
||||
|
||||
```bash
|
||||
python ~/.hermes/skills/mcp/fastmcp/scripts/scaffold_fastmcp.py \
|
||||
--template api_wrapper \
|
||||
--name "Acme API" \
|
||||
--output ./acme_server.py
|
||||
```
|
||||
|
||||
Available templates:
|
||||
|
||||
```bash
|
||||
python ~/.hermes/skills/mcp/fastmcp/scripts/scaffold_fastmcp.py --list
|
||||
```
|
||||
|
||||
If copying manually, replace `__SERVER_NAME__` with a real server name.
|
||||
|
||||
### 3. Implement Tools First
|
||||
|
||||
Start with `@mcp.tool` functions before adding resources or prompts.
|
||||
|
||||
Rules for tool design:
|
||||
|
||||
- Give every tool a concrete verb-based name
|
||||
- Write docstrings as user-facing tool descriptions
|
||||
- Keep parameters explicit and typed
|
||||
- Return structured JSON-safe data where possible
|
||||
- Validate unsafe inputs early
|
||||
- Prefer read-only behavior by default for first versions
|
||||
|
||||
Good tool examples:
|
||||
|
||||
- `get_customer`
|
||||
- `search_tickets`
|
||||
- `describe_table`
|
||||
- `summarize_text_file`
|
||||
|
||||
Weak tool examples:
|
||||
|
||||
- `run`
|
||||
- `process`
|
||||
- `do_thing`
|
||||
|
||||
### 4. Add Resources and Prompts Only When They Help
|
||||
|
||||
Add `@mcp.resource` when the client benefits from fetching stable read-only content such as schemas, policy docs, or generated reports.
|
||||
|
||||
Add `@mcp.prompt` when the server should provide a reusable prompt template for a known workflow.
|
||||
|
||||
Do not turn every document into a prompt. Prefer:
|
||||
|
||||
- tools for actions
|
||||
- resources for data/document retrieval
|
||||
- prompts for reusable LLM instructions
|
||||
|
||||
### 5. Test the Server Before Integrating It Anywhere
|
||||
|
||||
Use the FastMCP CLI for local validation:
|
||||
|
||||
```bash
|
||||
fastmcp inspect acme_server.py:mcp
|
||||
fastmcp list acme_server.py --json
|
||||
fastmcp call acme_server.py search_resources query=router limit=5 --json
|
||||
```
|
||||
|
||||
For fast iterative debugging, run the server locally:
|
||||
|
||||
```bash
|
||||
fastmcp run acme_server.py:mcp
|
||||
```
|
||||
|
||||
To test HTTP transport locally:
|
||||
|
||||
```bash
|
||||
fastmcp run acme_server.py:mcp --transport http --host 127.0.0.1 --port 8000
|
||||
fastmcp list http://127.0.0.1:8000/mcp --json
|
||||
fastmcp call http://127.0.0.1:8000/mcp search_resources query=router --json
|
||||
```
|
||||
|
||||
Always run at least one real `fastmcp call` against each new tool before claiming the server works.
|
||||
|
||||
### 6. Install into a Client When Local Validation Passes
|
||||
|
||||
FastMCP can register the server with supported MCP clients:
|
||||
|
||||
```bash
|
||||
fastmcp install claude-code acme_server.py
|
||||
fastmcp install claude-desktop acme_server.py
|
||||
fastmcp install cursor acme_server.py -e .
|
||||
```
|
||||
|
||||
Use `fastmcp discover` to inspect named MCP servers already configured on the machine.
|
||||
|
||||
When the goal is Hermes integration, either:
|
||||
|
||||
- configure the server in `~/.hermes/config.yaml` using the `native-mcp` skill, or
|
||||
- keep using FastMCP CLI commands during development until the interface stabilizes
|
||||
|
||||
### 7. Deploy After the Local Contract Is Stable
|
||||
|
||||
For managed hosting, Prefect Horizon is the path FastMCP documents most directly. Before deployment:
|
||||
|
||||
```bash
|
||||
fastmcp inspect acme_server.py:mcp
|
||||
```
|
||||
|
||||
Make sure the repo contains:
|
||||
|
||||
- a Python file with the FastMCP server object
|
||||
- `requirements.txt` or `pyproject.toml`
|
||||
- any environment-variable documentation needed for deployment
|
||||
|
||||
For generic HTTP hosting, validate the HTTP transport locally first, then deploy on any Python-compatible platform that can expose the server port.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### API Wrapper Pattern
|
||||
|
||||
Use when exposing a REST or HTTP API as MCP tools.
|
||||
|
||||
Recommended first slice:
|
||||
|
||||
- one read path
|
||||
- one list/search path
|
||||
- optional health check
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- keep auth in environment variables, not hardcoded
|
||||
- centralize request logic in one helper
|
||||
- surface API errors with concise context
|
||||
- normalize inconsistent upstream payloads before returning them
|
||||
|
||||
Start from `templates/api_wrapper.py`.
|
||||
|
||||
### Database Pattern
|
||||
|
||||
Use when exposing safe query and inspection capabilities.
|
||||
|
||||
Recommended first slice:
|
||||
|
||||
- `list_tables`
|
||||
- `describe_table`
|
||||
- one constrained read query tool
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- default to read-only DB access
|
||||
- reject non-`SELECT` SQL in early versions
|
||||
- limit row counts
|
||||
- return rows plus column names
|
||||
|
||||
Start from `templates/database_server.py`.
|
||||
|
||||
### File Processor Pattern
|
||||
|
||||
Use when the server needs to inspect or transform files on demand.
|
||||
|
||||
Recommended first slice:
|
||||
|
||||
- summarize file contents
|
||||
- search within files
|
||||
- extract deterministic metadata
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- accept explicit file paths
|
||||
- check for missing files and encoding failures
|
||||
- cap previews and result counts
|
||||
- avoid shelling out unless a specific external tool is required
|
||||
|
||||
Start from `templates/file_processor.py`.
|
||||
|
||||
## Quality Bar
|
||||
|
||||
Before handing off a FastMCP server, verify all of the following:
|
||||
|
||||
- server imports cleanly
|
||||
- `fastmcp inspect <file.py:mcp>` succeeds
|
||||
- `fastmcp list <server spec> --json` succeeds
|
||||
- every new tool has at least one real `fastmcp call`
|
||||
- environment variables are documented
|
||||
- the tool surface is small enough to understand without guesswork
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### FastMCP command missing
|
||||
|
||||
Install the package in the active environment:
|
||||
|
||||
```bash
|
||||
pip install fastmcp
|
||||
fastmcp version
|
||||
```
|
||||
|
||||
### `fastmcp inspect` fails
|
||||
|
||||
Check that:
|
||||
|
||||
- the file imports without side effects that crash
|
||||
- the FastMCP instance is named correctly in `<file.py:object>`
|
||||
- optional dependencies from the template are installed
|
||||
|
||||
### Tool works in Python but not through CLI
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
fastmcp list server.py --json
|
||||
fastmcp call server.py your_tool_name --json
|
||||
```
|
||||
|
||||
This usually exposes naming mismatches, missing required arguments, or non-serializable return values.
|
||||
|
||||
### Hermes cannot see the deployed server
|
||||
|
||||
The server-building part may be correct while the Hermes config is not. Load the `native-mcp` skill and configure the server in `~/.hermes/config.yaml`, then restart Hermes.
|
||||
|
||||
## References
|
||||
|
||||
For CLI details, install targets, and deployment checks, read `references/fastmcp-cli.md`.
|
||||
@@ -0,0 +1,110 @@
|
||||
# FastMCP CLI Reference
|
||||
|
||||
Use this file when the task needs exact FastMCP CLI workflows rather than the higher-level guidance in `SKILL.md`.
|
||||
|
||||
## Install and Verify
|
||||
|
||||
```bash
|
||||
pip install fastmcp
|
||||
fastmcp version
|
||||
```
|
||||
|
||||
FastMCP documents `pip install fastmcp` and `fastmcp version` as the baseline installation and verification path.
|
||||
|
||||
## Run a Server
|
||||
|
||||
Run a server object from a Python file:
|
||||
|
||||
```bash
|
||||
fastmcp run server.py:mcp
|
||||
```
|
||||
|
||||
Run the same server over HTTP:
|
||||
|
||||
```bash
|
||||
fastmcp run server.py:mcp --transport http --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
## Inspect a Server
|
||||
|
||||
Inspect what FastMCP will expose:
|
||||
|
||||
```bash
|
||||
fastmcp inspect server.py:mcp
|
||||
```
|
||||
|
||||
This is also the check FastMCP recommends before deploying to Prefect Horizon.
|
||||
|
||||
## List and Call Tools
|
||||
|
||||
List tools from a Python file:
|
||||
|
||||
```bash
|
||||
fastmcp list server.py --json
|
||||
```
|
||||
|
||||
List tools from an HTTP endpoint:
|
||||
|
||||
```bash
|
||||
fastmcp list http://127.0.0.1:8000/mcp --json
|
||||
```
|
||||
|
||||
Call a tool with key-value arguments:
|
||||
|
||||
```bash
|
||||
fastmcp call server.py search_resources query=router limit=5 --json
|
||||
```
|
||||
|
||||
Call a tool with a full JSON input payload:
|
||||
|
||||
```bash
|
||||
fastmcp call server.py create_item '{"name": "Widget", "tags": ["sale"]}' --json
|
||||
```
|
||||
|
||||
## Discover Named MCP Servers
|
||||
|
||||
Find named servers already configured in local MCP-aware tools:
|
||||
|
||||
```bash
|
||||
fastmcp discover
|
||||
```
|
||||
|
||||
FastMCP documents name-based resolution for Claude Desktop, Claude Code, Cursor, Gemini, Goose, and `./mcp.json`.
|
||||
|
||||
## Install into MCP Clients
|
||||
|
||||
Register a server with common clients:
|
||||
|
||||
```bash
|
||||
fastmcp install claude-code server.py
|
||||
fastmcp install claude-desktop server.py
|
||||
fastmcp install cursor server.py -e .
|
||||
```
|
||||
|
||||
FastMCP notes that client installs run in isolated environments, so declare dependencies explicitly when needed with flags such as `--with`, `--env-file`, or editable installs.
|
||||
|
||||
## Deployment Checks
|
||||
|
||||
### Prefect Horizon
|
||||
|
||||
Before pushing to Horizon:
|
||||
|
||||
```bash
|
||||
fastmcp inspect server.py:mcp
|
||||
```
|
||||
|
||||
FastMCP’s Horizon docs expect:
|
||||
|
||||
- a GitHub repo
|
||||
- a Python file containing the FastMCP server object
|
||||
- dependencies declared in `requirements.txt` or `pyproject.toml`
|
||||
- an entrypoint like `main.py:mcp`
|
||||
|
||||
### Generic HTTP Hosting
|
||||
|
||||
Before shipping to any other host:
|
||||
|
||||
1. Start the server locally with HTTP transport.
|
||||
2. Verify `fastmcp list` against the local `/mcp` URL.
|
||||
3. Verify at least one `fastmcp call`.
|
||||
4. Document required environment variables.
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Copy a FastMCP starter template into a working file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
SKILL_DIR = SCRIPT_DIR.parent
|
||||
TEMPLATE_DIR = SKILL_DIR / "templates"
|
||||
PLACEHOLDER = "__SERVER_NAME__"
|
||||
|
||||
|
||||
def list_templates() -> list[str]:
|
||||
return sorted(path.stem for path in TEMPLATE_DIR.glob("*.py"))
|
||||
|
||||
|
||||
def render_template(template_name: str, server_name: str) -> str:
|
||||
template_path = TEMPLATE_DIR / f"{template_name}.py"
|
||||
if not template_path.exists():
|
||||
available = ", ".join(list_templates())
|
||||
raise SystemExit(f"Unknown template '{template_name}'. Available: {available}")
|
||||
return template_path.read_text(encoding="utf-8").replace(PLACEHOLDER, server_name)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--template", help="Template name without .py suffix")
|
||||
parser.add_argument("--name", help="FastMCP server display name")
|
||||
parser.add_argument("--output", help="Destination Python file path")
|
||||
parser.add_argument("--force", action="store_true", help="Overwrite an existing output file")
|
||||
parser.add_argument("--list", action="store_true", help="List available templates and exit")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
for name in list_templates():
|
||||
print(name)
|
||||
return 0
|
||||
|
||||
if not args.template or not args.name or not args.output:
|
||||
parser.error("--template, --name, and --output are required unless --list is used")
|
||||
|
||||
output_path = Path(args.output).expanduser()
|
||||
if output_path.exists() and not args.force:
|
||||
raise SystemExit(f"Refusing to overwrite existing file: {output_path}")
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(render_template(args.template, args.name), encoding="utf-8")
|
||||
print(f"Wrote {output_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
|
||||
|
||||
mcp = FastMCP("__SERVER_NAME__")
|
||||
|
||||
API_BASE_URL = os.getenv("API_BASE_URL", "https://api.example.com")
|
||||
API_TOKEN = os.getenv("API_TOKEN")
|
||||
REQUEST_TIMEOUT = float(os.getenv("API_TIMEOUT_SECONDS", "20"))
|
||||
|
||||
|
||||
def _headers() -> dict[str, str]:
|
||||
headers = {"Accept": "application/json"}
|
||||
if API_TOKEN:
|
||||
headers["Authorization"] = f"Bearer {API_TOKEN}"
|
||||
return headers
|
||||
|
||||
|
||||
def _request(method: str, path: str, *, params: dict[str, Any] | None = None) -> Any:
|
||||
url = f"{API_BASE_URL.rstrip('/')}/{path.lstrip('/')}"
|
||||
with httpx.Client(timeout=REQUEST_TIMEOUT, headers=_headers()) as client:
|
||||
response = client.request(method, url, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def health_check() -> dict[str, Any]:
|
||||
"""Check whether the upstream API is reachable."""
|
||||
payload = _request("GET", "/health")
|
||||
return {"base_url": API_BASE_URL, "result": payload}
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def get_resource(resource_id: str) -> dict[str, Any]:
|
||||
"""Fetch one resource by ID from the upstream API."""
|
||||
payload = _request("GET", f"/resources/{resource_id}")
|
||||
return {"resource_id": resource_id, "data": payload}
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def search_resources(query: str, limit: int = 10) -> dict[str, Any]:
|
||||
"""Search upstream resources by query string."""
|
||||
payload = _request("GET", "/resources", params={"q": query, "limit": limit})
|
||||
return {"query": query, "limit": limit, "results": payload}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
|
||||
mcp = FastMCP("__SERVER_NAME__")
|
||||
|
||||
DATABASE_PATH = os.getenv("SQLITE_PATH", "./app.db")
|
||||
MAX_ROWS = int(os.getenv("SQLITE_MAX_ROWS", "200"))
|
||||
TABLE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
return sqlite3.connect(f"file:{DATABASE_PATH}?mode=ro", uri=True)
|
||||
|
||||
|
||||
def _reject_mutation(sql: str) -> None:
|
||||
normalized = sql.strip().lower()
|
||||
if not normalized.startswith("select"):
|
||||
raise ValueError("Only SELECT queries are allowed")
|
||||
|
||||
|
||||
def _validate_table_name(table_name: str) -> str:
|
||||
if not TABLE_NAME_RE.fullmatch(table_name):
|
||||
raise ValueError("Invalid table name")
|
||||
return table_name
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def list_tables() -> list[str]:
|
||||
"""List user-defined SQLite tables."""
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
).fetchall()
|
||||
return [row[0] for row in rows]
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def describe_table(table_name: str) -> list[dict[str, Any]]:
|
||||
"""Describe columns for a SQLite table."""
|
||||
safe_table_name = _validate_table_name(table_name)
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(f"PRAGMA table_info({safe_table_name})").fetchall()
|
||||
return [
|
||||
{
|
||||
"cid": row[0],
|
||||
"name": row[1],
|
||||
"type": row[2],
|
||||
"notnull": bool(row[3]),
|
||||
"default": row[4],
|
||||
"pk": bool(row[5]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def query(sql: str, limit: int = 50) -> dict[str, Any]:
|
||||
"""Run a read-only SELECT query and return rows plus column names."""
|
||||
_reject_mutation(sql)
|
||||
safe_limit = max(0, min(limit, MAX_ROWS))
|
||||
wrapped_sql = f"SELECT * FROM ({sql.strip().rstrip(';')}) LIMIT {safe_limit}"
|
||||
with _connect() as conn:
|
||||
cursor = conn.execute(wrapped_sql)
|
||||
columns = [column[0] for column in cursor.description or []]
|
||||
rows = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||
return {"limit": safe_limit, "columns": columns, "rows": rows}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
|
||||
mcp = FastMCP("__SERVER_NAME__")
|
||||
|
||||
|
||||
def _read_text(path: str) -> str:
|
||||
file_path = Path(path).expanduser()
|
||||
try:
|
||||
return file_path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError as exc:
|
||||
raise ValueError(f"File not found: {file_path}") from exc
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError(f"File is not valid UTF-8 text: {file_path}") from exc
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def summarize_text_file(path: str, preview_chars: int = 1200) -> dict[str, int | str]:
|
||||
"""Return basic metadata and a preview for a UTF-8 text file."""
|
||||
file_path = Path(path).expanduser()
|
||||
text = _read_text(path)
|
||||
return {
|
||||
"path": str(file_path),
|
||||
"characters": len(text),
|
||||
"lines": len(text.splitlines()),
|
||||
"preview": text[:preview_chars],
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def search_text_file(path: str, needle: str, max_matches: int = 20) -> dict[str, Any]:
|
||||
"""Find matching lines in a UTF-8 text file."""
|
||||
file_path = Path(path).expanduser()
|
||||
matches: list[dict[str, Any]] = []
|
||||
for line_number, line in enumerate(_read_text(path).splitlines(), start=1):
|
||||
if needle.lower() in line.lower():
|
||||
matches.append({"line_number": line_number, "line": line})
|
||||
if len(matches) >= max_matches:
|
||||
break
|
||||
return {"path": str(file_path), "needle": needle, "matches": matches}
|
||||
|
||||
|
||||
@mcp.resource("file://{path}")
|
||||
def read_file_resource(path: str) -> str:
|
||||
"""Expose a text file as a resource."""
|
||||
return _read_text(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
@@ -0,0 +1,371 @@
|
||||
---
|
||||
name: mcp-oauth-remote-gateway
|
||||
description: Manual OAuth for remote MCP servers on headless gateways.
|
||||
version: 1.0.0
|
||||
author: Ben Barclay (benbarclay), Hermes Agent
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [MCP, OAuth, PKCE, Remote-Deployment]
|
||||
related_skills: [hermes-agent, mcporter, fastmcp]
|
||||
---
|
||||
|
||||
# MCP OAuth on a Remote Hermes Gateway
|
||||
|
||||
## Overview
|
||||
|
||||
Hermes' built-in MCP OAuth client runs a one-shot HTTP listener on `127.0.0.1:<port>`
|
||||
inside the Hermes process and registers that loopback address as the OAuth
|
||||
`redirect_uri`. That works perfectly for a local CLI on the user's own machine.
|
||||
It breaks completely when Hermes runs as a remote gateway (container, VPS,
|
||||
messaging bot), because the user's browser resolves `127.0.0.1` to the user's own
|
||||
laptop, not the remote container — so the authorization code never reaches Hermes.
|
||||
|
||||
This skill does the OAuth dance by hand and writes the resulting tokens into the
|
||||
exact files Hermes' token storage expects, so a subsequent `/reload-mcp` finds
|
||||
cached tokens and skips the browser flow entirely.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when **all** of the following are true:
|
||||
|
||||
1. The user wants to add a remote HTTP MCP server that requires OAuth (not a static Bearer token).
|
||||
2. Hermes is running as a **remote gateway** (container, VPS, Docker, managed service) — NOT a local CLI on the user's laptop.
|
||||
3. The server supports OAuth 2.1 with PKCE and RFC 7591 Dynamic Client Registration (most modern MCP servers do — Better Stack, Linear, Cloudflare, Datadog, etc.). If it doesn't support DCR (GitHub is the notable exception), this skill does not apply — use a pre-registered OAuth App or a Personal Access Token instead.
|
||||
|
||||
Do NOT use this for:
|
||||
- **Local CLI Hermes** — just set `auth: oauth` in `mcp_servers.<name>` and `/reload-mcp`. The built-in flow opens a browser and captures the callback on localhost. Works perfectly.
|
||||
- **Servers that accept a static Bearer token (API key)** — always prefer `headers.Authorization: "Bearer <token>"` when the user is willing. Simpler, no refresh dance.
|
||||
- **GitHub Copilot MCP** (`api.githubcopilot.com/mcp/`) — GitHub does not expose DCR. Use a PAT or a pre-registered OAuth App (see pitfall 12).
|
||||
|
||||
## Why the Built-in OAuth Flow Fails on a Remote Gateway
|
||||
|
||||
Hermes' native MCP OAuth client (`tools/mcp_oauth.py`):
|
||||
|
||||
1. Picks a free local port `P`.
|
||||
2. Registers a dynamic OAuth client with the AS, sending `redirect_uri = http://127.0.0.1:P/callback`.
|
||||
3. Starts an HTTP server on `127.0.0.1:P` **inside the Hermes process**.
|
||||
4. Prints the authorize URL and waits for the code at its local endpoint.
|
||||
|
||||
When Hermes runs remotely, the `127.0.0.1` in the `redirect_uri` is the remote
|
||||
container's loopback, not the user's. After authorizing, the user's browser 302s
|
||||
to `http://127.0.0.1:P/callback?code=...`, which resolves to the user's own
|
||||
laptop and fails to connect. The callback never reaches the Hermes process, the
|
||||
flow times out, and `/reload-mcp` returns "No MCP tools available" with no detail.
|
||||
|
||||
Symptoms to recognize: `[xdg-open] <defunct>` processes under the hermes user, an
|
||||
empty or missing tokens directory (`$HERMES_HOME/mcp-tokens/`), and a reload that
|
||||
responds without any "Added/Reconnected: X" line in `change_detail`.
|
||||
|
||||
## Cheap First Fallbacks: the Built-in Flow's Own Escape Hatches
|
||||
|
||||
Before any manual token surgery, check whether the built-in flow's fallbacks
|
||||
already cover the deployment. When Hermes detects a remote session it prints two
|
||||
options alongside the authorize URL (`tools/mcp_oauth.py`):
|
||||
|
||||
1. **Paste-back** — on an interactive TTY, a stdin reader races the HTTP
|
||||
listener. The user authorizes, the browser fails to connect to
|
||||
`127.0.0.1:<port>`, and they paste the full address-bar URL
|
||||
(`?code=...&state=...`) back at the prompt. Works for SSH'd-in CLI sessions.
|
||||
2. **SSH port-forward** — `ssh -N -L <port>:127.0.0.1:<port> <user>@<host>`
|
||||
makes the redirect reach the remote listener normally.
|
||||
|
||||
Both require an interactive terminal to the Hermes host. The rest of this skill
|
||||
is for when there is NO interactive TTY — Hermes running purely as a messaging
|
||||
gateway/bot where `/reload-mcp` triggers the flow with nobody at a prompt.
|
||||
|
||||
## Preferred Front Door: the Hermes Dashboard (try this BEFORE manual token surgery)
|
||||
|
||||
A remote Hermes gateway often also runs the **dashboard** web UI as a SEPARATE
|
||||
process (e.g. `hermes dashboard --host 0.0.0.0 --port <port>`; check with
|
||||
`ps aux | grep 'hermes dashboard'`). It exposes a connector/MCP console —
|
||||
endpoints like `/api/mcp/servers`, `/api/mcp/status`, and `/connectors` (all
|
||||
login-gated; a cookieless curl returning 401/302 confirms they exist).
|
||||
|
||||
**Why the dashboard solves the core problem:** when the user drives OAuth from
|
||||
the dashboard *in their own browser*, the redirect lands in a context the
|
||||
dashboard can capture — sidestepping the `127.0.0.1`-callback failure that breaks
|
||||
the CLI/manual flow. So the correct escalation order for "add or re-auth an OAuth
|
||||
MCP server on a remote gateway" is:
|
||||
|
||||
1. **Dashboard, in the user's browser** — the intended front door. Add servers, run OAuth, reload, all authenticated as the user. No copy-paste-callback dance, no hand-writing token files.
|
||||
2. **Manual token surgery (the rest of this skill)** — the FALLBACK for when there's no browser session to the dashboard (pure-chat/headless context).
|
||||
|
||||
**Finding the dashboard's PUBLIC URL.** The dashboard binds internally to
|
||||
`0.0.0.0:<port>`, but the user needs the externally-reachable URL. Most deploy
|
||||
platforms inject it into the environment — grep for it rather than making the
|
||||
user hunt:
|
||||
|
||||
```bash
|
||||
env | grep -iE "HERMES_DASHBOARD_PUBLIC_URL|RAILWAY_PUBLIC_DOMAIN|RAILWAY_STATIC_URL|RAILWAY_SERVICE_.*_URL|PUBLIC_URL|BASE_URL|DOMAIN" \
|
||||
| sed -E 's/(TOKEN|SECRET|KEY|PASSWORD)=.*/\1=***REDACTED***/I'
|
||||
```
|
||||
|
||||
`HERMES_DASHBOARD_PUBLIC_URL` is authoritative when present. On Railway also check
|
||||
`RAILWAY_PUBLIC_DOMAIN` / `RAILWAY_STATIC_URL` (the `*.up.railway.app` host) and
|
||||
`RAILWAY_SERVICE_*_URL` vars, which sometimes carry a friendlier custom domain.
|
||||
Hand the user the full `https://` URL and point them at the Connectors/MCP
|
||||
section. ALWAYS pipe through the `sed` redaction above — these env greps sit next
|
||||
to `*_TOKEN`/`*_SECRET` vars.
|
||||
|
||||
**What the dashboard does NOT fix (still host-side / shell):** stdio servers that
|
||||
need shell auth state (a CLI `login` command whose credentials may not persist
|
||||
across restarts) and anything reading credentials from `$HERMES_HOME/.env`. Those
|
||||
are out of the dashboard's scope regardless.
|
||||
|
||||
## The Workaround
|
||||
|
||||
Do the OAuth dance manually, then write the resulting tokens into the exact files
|
||||
Hermes' `HermesTokenStorage` would have written, so on `/reload-mcp` Hermes finds
|
||||
cached tokens and skips the browser flow entirely.
|
||||
|
||||
Run the shell commands below through the `terminal` tool on the gateway host and
|
||||
do the Python steps (PKCE generation, token exchange, file writes) via
|
||||
`execute_code` or a `terminal` python3 invocation — file writes must happen in
|
||||
the SAME code block as the token exchange (see pitfall 16).
|
||||
|
||||
### 1. Confirm it's a remote gateway
|
||||
|
||||
```bash
|
||||
env | grep -iE "HERMES|RAILWAY|CONTAINER"
|
||||
echo "$DISPLAY $WAYLAND_DISPLAY $SSH_CLIENT"
|
||||
```
|
||||
|
||||
No display + a remote indicator = remote gateway. `tools/mcp_oauth.py::_can_open_browser()`
|
||||
uses these same env vars, so if Hermes' own auto-detect says "headless", the
|
||||
built-in flow won't work.
|
||||
|
||||
### 2. Find HERMES_HOME and the config path
|
||||
|
||||
```bash
|
||||
HERMES_HOME=$(python3 -c 'from hermes_constants import get_hermes_home; print(get_hermes_home())')
|
||||
echo "config: $HERMES_HOME/config.yaml"
|
||||
echo "tokens: $HERMES_HOME/mcp-tokens/"
|
||||
```
|
||||
|
||||
### 3. Discover OAuth metadata from the MCP server
|
||||
|
||||
MCP servers advertise their OAuth setup via RFC 9728 (OAuth 2.0 Protected
|
||||
Resource Metadata). The `WWW-Authenticate` header on a 401 tells you where to look:
|
||||
|
||||
```bash
|
||||
curl -sI https://mcp.example.com | grep -i www-authenticate
|
||||
# → Bearer realm="mcp", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"
|
||||
```
|
||||
|
||||
**Not every server returns `WWW-Authenticate`.** Some return a bare
|
||||
`{"errors":["Unauthorized"]}` 401 with no auth-discovery hint. When that happens,
|
||||
probe well-known paths directly:
|
||||
|
||||
```bash
|
||||
for p in \
|
||||
/.well-known/oauth-protected-resource \
|
||||
/.well-known/oauth-authorization-server \
|
||||
/.well-known/openid-configuration ; do
|
||||
echo "=== $p ==="
|
||||
curl -s -A "python-httpx/0.27" "https://mcp.example.com$p" | head -c 400; echo
|
||||
done
|
||||
```
|
||||
|
||||
Fetch the resource metadata to get `authorization_servers`, then fetch the AS's
|
||||
`/.well-known/oauth-authorization-server` to get `authorization_endpoint`,
|
||||
`token_endpoint`, and `registration_endpoint`.
|
||||
|
||||
Pitfall: many servers sit behind Cloudflare and 403 bare `urllib` user agents.
|
||||
Always set `User-Agent: python-httpx/0.27` (or similar) on requests in this flow.
|
||||
|
||||
### 4. Dynamic Client Registration (RFC 7591)
|
||||
|
||||
POST to the `registration_endpoint` with:
|
||||
|
||||
```json
|
||||
{
|
||||
"client_name": "Hermes Agent (manual OAuth)",
|
||||
"redirect_uris": ["http://127.0.0.1:8765/callback"],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"scope": "<scopes_from_resource_metadata>"
|
||||
}
|
||||
```
|
||||
|
||||
Omit `scope` entirely if the AS's `scopes_supported` is empty — see step 5
|
||||
pitfall. Use port `8765` (or any port — nothing will listen).
|
||||
`token_endpoint_auth_method: none` marks this as a public PKCE client. Save the
|
||||
returned `client_id`.
|
||||
|
||||
### 5. Build the authorize URL with PKCE
|
||||
|
||||
Generate:
|
||||
- `code_verifier`: `secrets.token_urlsafe(64)[:128]`
|
||||
- `code_challenge`: `base64url(sha256(code_verifier))` (no padding)
|
||||
- `state`: `secrets.token_urlsafe(24)`
|
||||
|
||||
Query params: `response_type=code`, `client_id`, `redirect_uri`, `code_challenge`,
|
||||
`code_challenge_method=S256`, `state`, plus `resource=<mcp_server_url>` (RFC 8707 —
|
||||
many servers require this to bind the token to the specific MCP resource). Include
|
||||
`scope=<space-separated>` ONLY if the AS metadata's `scopes_supported` is a
|
||||
non-empty array AND/OR the resource metadata declares specific scopes. If
|
||||
`scopes_supported: []`, omit the `scope` parameter — the server grants its full
|
||||
default set on its own. Fabricating scope strings against an empty
|
||||
`scopes_supported` can cause `invalid_scope` errors on some ASes.
|
||||
|
||||
**Stash `code_verifier` and `state` to disk** (e.g. `/tmp/.mcp-oauth-work/<server>.json`,
|
||||
0600 perms). You need them for step 7, possibly across multiple chat turns.
|
||||
|
||||
### 6. Give the user the authorize URL
|
||||
|
||||
```
|
||||
Open this URL in your browser:
|
||||
<authorize_url>
|
||||
|
||||
After approving, your browser will try to load http://127.0.0.1:8765/callback
|
||||
and fail to connect — THAT'S EXPECTED. Just copy the entire URL from the
|
||||
address bar (it will contain ?code=...&state=...) and paste it back here.
|
||||
```
|
||||
|
||||
### 7. Exchange the code for tokens
|
||||
|
||||
When the user pastes the callback URL:
|
||||
|
||||
1. Parse `code` and `state` from the query string.
|
||||
2. **Verify `state` matches the stashed value** (CSRF check — do not skip).
|
||||
3. POST `application/x-www-form-urlencoded` to the `token_endpoint`:
|
||||
- `grant_type=authorization_code`
|
||||
- `code=<from callback>`
|
||||
- `redirect_uri=<same as step 4>`
|
||||
- `client_id=<from step 4>`
|
||||
- `code_verifier=<stashed>`
|
||||
- `resource=<mcp_server_url>` (if the AS required it in step 5, include here too)
|
||||
4. Response contains `access_token`, `refresh_token`, `token_type`, `expires_in`, `scope`.
|
||||
|
||||
### 8. Write tokens in Hermes' exact schema
|
||||
|
||||
`tools/mcp_oauth.py::HermesTokenStorage` expects two files under
|
||||
`$HERMES_HOME/mcp-tokens/` (create dir with `0o700`, files with `0o600`):
|
||||
|
||||
**`<server_name>.json`** — the `OAuthToken` pydantic model:
|
||||
```json
|
||||
{
|
||||
"access_token": "...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 7200,
|
||||
"refresh_token": "...",
|
||||
"scope": "read write"
|
||||
}
|
||||
```
|
||||
|
||||
**`<server_name>.client.json`** — the `OAuthClientInformationFull` model:
|
||||
```json
|
||||
{
|
||||
"client_id": "...",
|
||||
"redirect_uris": ["http://127.0.0.1:8765/callback"],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"scope": "read write",
|
||||
"client_name": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Write each file via `json.dumps(..., indent=2)`. Sanitize the filename with
|
||||
`re.sub(r'[^\w\-]', '_', server_name)[:128]` — this matches `_safe_filename()` in
|
||||
Hermes' token storage.
|
||||
|
||||
### 9. Add the server to config.yaml
|
||||
|
||||
```yaml
|
||||
mcp_servers:
|
||||
<name>:
|
||||
url: "https://mcp.example.com"
|
||||
auth: oauth
|
||||
timeout: 180
|
||||
connect_timeout: 60
|
||||
```
|
||||
|
||||
### 10. Smoke-test the token BEFORE asking the user to reload
|
||||
|
||||
Manually POST an MCP `initialize` request to confirm the token works end-to-end —
|
||||
this catches scope misconfigurations, wrong `resource` values, and CF blocks
|
||||
before the user is confused by another "No MCP tools available" reload:
|
||||
|
||||
```python
|
||||
body = json.dumps({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "hermes-debug", "version": "1.0"},
|
||||
},
|
||||
}).encode()
|
||||
# POST to the MCP URL with:
|
||||
# Authorization: Bearer <access_token>
|
||||
# Accept: application/json, text/event-stream
|
||||
# Content-Type: application/json
|
||||
# MCP-Protocol-Version: 2025-06-18
|
||||
# User-Agent: python-httpx/0.27
|
||||
```
|
||||
|
||||
Expect HTTP 200 with `Content-Type: text/event-stream` and a JSON-RPC result
|
||||
containing `serverInfo` and `capabilities`. **Do not use `urllib` with its default
|
||||
UA** — Cloudflare will 403 you even though Hermes (which uses httpx) will succeed.
|
||||
`scripts/diagnose-oauth-mcp.py` automates this smoke test.
|
||||
|
||||
### 11. Tell the user to run `/reload-mcp`
|
||||
|
||||
On reload, Hermes sees `auth: oauth`, calls `HermesTokenStorage.get_tokens()`,
|
||||
finds your cached tokens, skips the browser flow, and registers `mcp_<name>_*`
|
||||
tools. Refresh happens automatically before `expires_in` elapses.
|
||||
|
||||
## Pitfalls & Lessons Learned
|
||||
|
||||
1. **Do not assume "headless" means "OAuth impossible."** The built-in flow works fine for local CLI; the issue is strictly remote deployments where the user's browser and the Hermes process are on different machines. Check the execution environment before claiming OAuth isn't an option.
|
||||
|
||||
2. **Read the source, not just the skill docs.** `tools/mcp_oauth.py` and the MCP config reference in `website/docs/` are the authoritative references. Grep the tree before telling the user a feature "doesn't exist."
|
||||
|
||||
3. **Cloudflare UA filter.** Many MCP/OAuth providers front their infra with Cloudflare, which 403s `python-urllib/*` user agents on metadata endpoints even though those endpoints are public. Set `User-Agent: python-httpx/0.27` (or any browser-like string) on every request in this flow. Hermes itself uses httpx, so this is never a problem in the real connection path.
|
||||
|
||||
4. **Include `resource` in both authorize and token requests.** RFC 8707 resource indicators are not optional for most modern MCP servers — they bind the issued token to the specific MCP resource URL. Leaving it out sometimes still works but may yield a token that later fails at the MCP server with a scope/audience error.
|
||||
|
||||
5. **Trailing slash matters.** Some servers advertise the resource as `https://mcp.example.com/` with a trailing slash and reject tokens issued against the no-slash variant. Copy the `resource` value verbatim from the `.well-known/oauth-protected-resource` response.
|
||||
|
||||
6. **`/reload-mcp` is silent on failure.** If the reload shows "No MCP tools available" with no `change_detail` line, a server is in config but failed to connect and no error bubbled up. Tail the error log, smoke-test the token directly with a manual `initialize` POST, and — if everything looks good — ask for a full process restart.
|
||||
|
||||
7. **Circuit breaker can survive `/reload-mcp`.** `tools/mcp_tool.py` keeps a module-level error-count dict with a small threshold. Once tripped (e.g. after token expiry produces several consecutive failures), the tool handler can short-circuit before calling the server, so no successful call resets the counter. Symptom: reload says "Reconnected: X" but subsequent calls still fail with "server unreachable" in the same conversation. Recovery order: try `/reload-mcp` FIRST (cheap, no chat-process blip) — on current builds it can clear the counter; only escalate to a full gateway process restart if a live call STILL short-circuits after reload. Do not lead with "you must restart."
|
||||
|
||||
8. **Refresh on an expired access_token + a tripped breaker is a deadlock.** The auto-refresh logic runs inside the MCP call path, which the breaker short-circuits once tripped. Manually refreshing the token on disk does not help by itself — pair a manual token refresh with a full restart, not a `/reload-mcp`.
|
||||
|
||||
9. **`invalid_grant` on a manual refresh means the refresh token is DEAD — re-auth is the only fix, do not loop.** When the access_token has been expired long enough, the refresh_token can also be revoked/expired server-side. A `grant_type=refresh_token` POST then returns HTTP 400 `{"error":"invalid_grant",...}` (wording varies: "Grant not found", "Token expired", "refresh token is invalid"). There is NO recovery from the gateway side. Hand back to the user with two options: (a) re-run the full manual OAuth dance (steps 3–10), or (b) if the provider offers a static personal API key, switch to that — no refresh/expiry cycle, more durable for an unattended remote gateway. Detect early: before any create/update operation against an OAuth MCP, check `expires_at` vs `time.time()`; if already expired, attempt the refresh first and surface `invalid_grant` immediately rather than failing mid-task.
|
||||
|
||||
10. **A successful refresh that STILL yields a rejected token = server-side SESSION revocation; only a fresh authorization_code flow fixes it.** Distinct from pitfall 9. The stored token file can look healthy (`expires_at` well out, refresh_token present), yet a live `initialize` POST returns `401 invalid_token` with a JSON-RPC body like `{"error":{"code":-32002,"message":"Session expired. Please re-authenticate."}}`. The `grant_type=refresh_token` POST may **succeed** (HTTP 200, new access_token) — yet the brand-new token gets the SAME `-32002`. The provider revoked the underlying MCP *session* server-side; the OAuth refresh chain re-mints credentials but cannot re-establish a revoked session. Decision rule when an OAuth MCP reports "not connected": (1) smoke-test the stored access_token with a manual `initialize` POST; (2) if `401 invalid_token`, attempt a refresh and smoke-test the NEW token; (3a) new token works → write it + restart to clear the breaker; (3b) new token STILL gets `-32002`/"Session expired" → stop, this is session revocation, hand the user the authorize URL for a full re-auth. `scripts/diagnose-oauth-mcp.py` automates steps 1–2 and prints which branch you're in. For an unattended gateway whose session keeps getting revoked, prefer a static Personal API key. See `references/stripe-mcp-oauth-revocation.md` for a worked example of a provider that revokes weekly.
|
||||
|
||||
11. **Client info file is NOT optional.** Hermes needs `<server>.client.json` to know the `client_id` for refresh grants. Skipping it means the first refresh fails and the user has to re-auth — writing both files is the whole point of this skill.
|
||||
|
||||
12. **Never hand-type the redirect URL for the user to open.** Generate the authorize URL programmatically with `urllib.parse.urlencode()`. Spaces in scopes and special chars in `state` break string-concatenated URLs.
|
||||
|
||||
13. **Security: the stash file contains the `code_verifier`.** Delete `/tmp/.mcp-oauth-work/<server>.json` immediately after successful token exchange. There's no reason to keep a proof-of-identity secret around once it's consumed.
|
||||
|
||||
14. **Write what the token endpoint actually returned.** The AS may grant a narrower (or wider) scope than requested. Write the `scope` from the token-exchange response to `<server>.json`, not what you asked for in step 5. When `scopes_supported: []`, the explicit scope list you send IS authoritative both ways: some servers grant exactly what you list (pass narrow scopes for least-privilege, or enumerate the full set if the user needs everything), and some won't echo the granted scope back at registration time — only the token-exchange response is authoritative.
|
||||
|
||||
15. **OAuth tokens often double as Bearer tokens against the provider's public REST API.** The access_token in `<server>.json` is frequently not "MCP-only" — `Authorization: Bearer <token>` against the provider's documented REST API succeeds whenever the corresponding resource scope was granted. This is the OAuth 2.0 spec, not a provider quirk. When the MCP server is read-only but you need a write operation, check whether the OAuth token can hit the provider's REST API directly before suggesting a separate API key.
|
||||
|
||||
16. **Secret redaction can mask tokens in tool output.** If secret redaction is enabled, tokens and long opaque strings render as `***` in tool-result output, so you cannot `print(response)` to keep the access_token visible across turns. Combined with single-use `code` values from authorization_code grants: if you print the token-exchange response, you may lose the token AND consume the code, forcing a restart with a fresh authorize URL. **Always write the access_token directly to its final destination file in the SAME code block that performs the token exchange.** If you must print for debugging, print only `len(access_token)`, `token_type`, `scope`, `expires_in` — never the secret.
|
||||
|
||||
17. **GitHub MCP (`api.githubcopilot.com/mcp/`) uses a pre-registered confidential OAuth App, not DCR + PKCE-public.** Its client info ships with a real `client_secret` and `token_endpoint_auth_method: client_secret_post`. The token-exchange POST to `https://github.com/login/oauth/access_token` must include `client_secret` as a form field alongside `client_id`, `code`, `code_verifier`, and `redirect_uri` (PKCE is still honored on top of the secret). The redirect URI is **fixed** in the OAuth App config — you cannot change it, so the manual listener-port trick doesn't apply; the user just lets the browser fail to connect on that port and pastes the address-bar URL back.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- **Don't use `mcp-remote` as a fallback.** It runs an npx subprocess whose OAuth callback server ALSO sits on the remote container's localhost — same problem. `mcp-remote` only helps when the MCP client doesn't speak remote HTTP at all (Hermes does natively).
|
||||
- **Don't push "paste your API token and I'll add headers"** if the user explicitly asked for OAuth. Offer the static-token shortcut only after explaining why the native OAuth flow fails in remote deployments. Respect the user's choice to do the extra legwork for rotation-free, scope-limited access.
|
||||
- **Don't claim Hermes doesn't support a feature without reading the source.** Grep the source tree before making capability claims.
|
||||
|
||||
## Quick Reference Files
|
||||
|
||||
- `scripts/diagnose-oauth-mcp.py` — re-runnable, read-only-by-default diagnostic. Given a server name, it smoke-tests the stored access_token, attempts a refresh, smoke-tests the new token, and prints exactly which recovery branch you're in (`TOKEN_OK` = breaker/restart, `REFRESH_FIXED` = persist+restart, `SESSION_REVOKED` = full re-auth, `REFRESH_DEAD` = full re-auth/API key). Pass `--write` to persist a working refreshed token atomically. Never prints secret values. **Run this FIRST when an OAuth MCP server reports "not connected"** — it encodes the pitfall 7/9/10 decision tree.
|
||||
- `references/stripe-mcp-oauth-revocation.md` — a worked example (Stripe) of a provider that revokes its OAuth session on a recurring basis, and the durable fix: switch to a static restricted API key.
|
||||
|
||||
## Related
|
||||
|
||||
- `native-mcp` — general guide to configuring MCP in Hermes. Authoritative config reference lives there.
|
||||
- `mcporter` — the external CLI bridge, for ad-hoc MCP calls outside of Hermes' config.
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# Stripe MCP (`mcp.stripe.com`) — recurring OAuth session revocation, fix with a restricted key
|
||||
|
||||
A worked example of pitfall 9/10 in SKILL.md: a provider whose OAuth session dies
|
||||
on a recurring basis, where the durable fix is to drop OAuth for a static API key.
|
||||
|
||||
## Symptom (the "dies after a while" complaint)
|
||||
Stripe MCP works for a few days then goes "not connected." Auto-refresh is healthy
|
||||
in between (the access token is 1h-lived and rotates fine), so it LOOKS like a
|
||||
refresh-token expiry or a max-session cap — it is neither. Roughly weekly, Stripe
|
||||
**revokes the entire OAuth grant server-side**. The next `grant_type=refresh_token`
|
||||
POST returns:
|
||||
|
||||
```
|
||||
HTTP 400 {"error":"invalid_grant","error_description":"Invalid refresh token"}
|
||||
```
|
||||
|
||||
The whole grant is dead — not just the short-lived access token — so auto-refresh
|
||||
cannot recover it. It requires a fresh interactive browser consent flow, which a
|
||||
headless remote gateway cannot drive. Don't be fooled by a green smoke-test at any
|
||||
given moment: the failure is intermittent revocation, not a permanently broken token.
|
||||
|
||||
## Why the three usual hypotheses are all wrong
|
||||
Per Stripe's OAuth docs (https://docs.stripe.com/stripe-apps/api-authentication/oauth):
|
||||
- **Access tokens** expire in **1 hour**.
|
||||
- **Refresh tokens** expire after **1 year**, and are **rolled on every exchange** — so
|
||||
as long as you refresh at least once a year they never naturally expire.
|
||||
- Hermes auto-refreshes independently of whether you call Stripe tools, so "not using
|
||||
the tools enough" is irrelevant.
|
||||
|
||||
So a *recurring* death cannot be refresh-token expiry (1yr) or "max OAuth session
|
||||
length" (no clean documented cap). It is server-side session revocation. Do NOT
|
||||
loop on refresh.
|
||||
|
||||
## The durable fix: drop OAuth, use a restricted API key as a Bearer token
|
||||
Stripe's MCP docs (https://docs.stripe.com/mcp) are explicit that for
|
||||
non-interactive / agent use, OAuth is the wrong tool — `mcp.stripe.com` accepts a
|
||||
**static restricted key** (`rk_live_...`) as a Bearer token. A restricted key has
|
||||
**no session, no refresh, no expiry** — it works until revoked, ending the re-auth
|
||||
cycle entirely.
|
||||
|
||||
config.yaml change (no token files needed — delete the OAuth dance for this server):
|
||||
```yaml
|
||||
mcp_servers:
|
||||
stripe:
|
||||
url: https://mcp.stripe.com
|
||||
headers:
|
||||
Authorization: *** rk_live_..." # restricted key from Dashboard
|
||||
# Stripe-Account: "acct_xxx" # only for Connect platform / connected-account calls
|
||||
```
|
||||
|
||||
Generate the key in Stripe Dashboard → Developers → API keys → **Restricted keys**.
|
||||
Grant least-privilege scopes for what the bot actually does:
|
||||
- account reads: **read** on Charges, Customers, Subscriptions, Coupons/Promotion codes
|
||||
- refunds / writes: add the corresponding **write** scopes
|
||||
Then `/reload-mcp` (full restart only if the breaker is tripped, per pitfall 7).
|
||||
|
||||
## Decision rule
|
||||
For ANY unattended remote-gateway MCP server that keeps getting its session revoked
|
||||
(`invalid_grant` on refresh, or `-32002 "Session expired"` after a successful refresh),
|
||||
and whose provider offers a static API key — prefer the static key over OAuth. OAuth's
|
||||
refresh dance is for interactive clients; it is a liability for a headless gateway.
|
||||
Stripe (restricted key) and Linear (Personal API key) both fit this rule.
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Diagnose an OAuth-gated remote MCP server's connection state.
|
||||
|
||||
Decides which recovery branch you're in WITHOUT mutating disk by default:
|
||||
1. Smoke-test the stored access_token against the MCP endpoint (initialize).
|
||||
2. If 401, attempt a refresh and smoke-test the freshly-minted token.
|
||||
|
||||
Branches printed at the end:
|
||||
TOKEN_OK -> stored token works; "not connected" is the circuit
|
||||
breaker (SKILL pitfall 7) -> restart the gateway.
|
||||
REFRESH_FIXED -> refresh minted a working token; pass --write to persist
|
||||
it (atomic, 0600), then restart to clear the breaker.
|
||||
SESSION_REVOKED -> refresh succeeds but new token STILL gets -32002
|
||||
"Session expired" (pitfall 10) -> full interactive
|
||||
re-auth required; refresh loop will NOT help.
|
||||
REFRESH_DEAD -> refresh grant itself returns invalid_grant (pitfall 9)
|
||||
-> full interactive re-auth, or switch to a static API key.
|
||||
|
||||
Usage:
|
||||
python3 diagnose-oauth-mcp.py <server_name> [--mcp-url URL] [--token-endpoint URL] [--write]
|
||||
|
||||
<server_name> matches the files in $HERMES_HOME/mcp-tokens/<server>.json etc.
|
||||
If --mcp-url / --token-endpoint are omitted, they're read from the token's
|
||||
`resource` field and the AS .well-known metadata respectively.
|
||||
|
||||
NEVER prints secret values — only lengths, scope, expiry, and HTTP status.
|
||||
"""
|
||||
import json, os, sys, time, argparse, urllib.request, urllib.error, urllib.parse
|
||||
|
||||
UA = "python-httpx/0.27" # CF blocks default urllib UA on many providers
|
||||
|
||||
|
||||
def _hermes_home():
|
||||
# Prefer Hermes' own resolver (profile-safe); fall back to env then ~/.hermes.
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
return str(get_hermes_home())
|
||||
except Exception:
|
||||
return os.environ.get("HERMES_HOME") or os.path.expanduser("~/.hermes")
|
||||
|
||||
|
||||
def _tokens_dir():
|
||||
return os.path.join(_hermes_home(), "mcp-tokens")
|
||||
|
||||
|
||||
def _post(url, data=None, headers=None, form=False, timeout=30):
|
||||
if form:
|
||||
body = urllib.parse.urlencode(data).encode()
|
||||
else:
|
||||
body = json.dumps(data).encode() if data is not None else None
|
||||
req = urllib.request.Request(url, data=body, method="POST")
|
||||
for k, v in (headers or {}).items():
|
||||
req.add_header(k, v)
|
||||
req.add_header("User-Agent", UA)
|
||||
try:
|
||||
r = urllib.request.urlopen(req, timeout=timeout)
|
||||
return r.status, dict(r.headers), r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, dict(e.headers), e.read()
|
||||
|
||||
|
||||
def _get_json(url, timeout=20):
|
||||
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
||||
return json.loads(urllib.request.urlopen(req, timeout=timeout).read())
|
||||
|
||||
|
||||
def _mcp_initialize(mcp_url, access_token):
|
||||
status, hdrs, body = _post(
|
||||
mcp_url,
|
||||
data={
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "hermes-diag", "version": "1.0"}},
|
||||
},
|
||||
headers={
|
||||
"Authorization": "Bearer " + access_token,
|
||||
"Accept": "application/json, text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
"MCP-Protocol-Version": "2025-06-18",
|
||||
},
|
||||
)
|
||||
txt = body[:400].decode(errors="replace")
|
||||
ok = status == 200 and ("serverInfo" in txt or '"result"' in txt)
|
||||
expired = "-32002" in txt or "Session expired" in txt or "invalid_token" in (hdrs.get("WWW-Authenticate") or "")
|
||||
return ok, expired, status, txt
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("server")
|
||||
ap.add_argument("--mcp-url")
|
||||
ap.add_argument("--token-endpoint")
|
||||
ap.add_argument("--write", action="store_true", help="persist a working refreshed token")
|
||||
args = ap.parse_args()
|
||||
|
||||
tdir = _tokens_dir()
|
||||
tpath = os.path.join(tdir, args.server + ".json")
|
||||
cpath = os.path.join(tdir, args.server + ".client.json")
|
||||
tok = json.load(open(tpath))
|
||||
client = json.load(open(cpath))
|
||||
|
||||
mcp_url = args.mcp_url or tok.get("resource")
|
||||
if not mcp_url:
|
||||
print("FATAL: no --mcp-url and no `resource` in token file"); sys.exit(2)
|
||||
resource = tok.get("resource") or mcp_url
|
||||
|
||||
print(f"server={args.server} mcp_url={mcp_url}")
|
||||
exp = tok.get("expires_at")
|
||||
if isinstance(exp, (int, float)):
|
||||
print(f"stored expires_at in {round((exp - time.time())/60)} min")
|
||||
|
||||
# Step 1: stored token
|
||||
ok, expired, status, txt = _mcp_initialize(mcp_url, tok["access_token"])
|
||||
print(f"[1] stored-token initialize -> HTTP {status} ok={ok} expired={expired}")
|
||||
if ok:
|
||||
print("BRANCH=TOKEN_OK -> stored token works; 'not connected' is the breaker (7). Restart the gateway.")
|
||||
return
|
||||
|
||||
# Step 2: refresh
|
||||
if not tok.get("refresh_token"):
|
||||
print("BRANCH=REFRESH_DEAD -> no refresh_token present; full re-auth required.")
|
||||
return
|
||||
token_ep = args.token_endpoint
|
||||
if not token_ep:
|
||||
# derive AS metadata from the host root
|
||||
host = urllib.parse.urlsplit(mcp_url)
|
||||
as_url = f"{host.scheme}://{host.netloc}/.well-known/oauth-authorization-server"
|
||||
try:
|
||||
token_ep = _get_json(as_url)["token_endpoint"]
|
||||
except Exception as e:
|
||||
print(f"FATAL: cannot discover token_endpoint ({e}); pass --token-endpoint"); sys.exit(2)
|
||||
print(f"[2] token_endpoint={token_ep}")
|
||||
|
||||
rstatus, _rh, rbody = _post(
|
||||
token_ep, form=True,
|
||||
data={"grant_type": "refresh_token", "refresh_token": tok["refresh_token"],
|
||||
"client_id": client["client_id"], "resource": resource},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
if rstatus != 200:
|
||||
print(f"[2] refresh -> HTTP {rstatus} {rbody[:200].decode(errors='replace')}")
|
||||
print("BRANCH=REFRESH_DEAD -> refresh grant rejected (9). Full re-auth or switch to static API key.")
|
||||
return
|
||||
j = json.loads(rbody)
|
||||
new_at = j["access_token"]
|
||||
print(f"[2] refresh -> HTTP 200 new_token_len={len(new_at)} scope={j.get('scope')} "
|
||||
f"expires_in={j.get('expires_in')} rotated_refresh={bool(j.get('refresh_token'))}")
|
||||
|
||||
# Step 2b: smoke-test the freshly-minted token
|
||||
ok2, expired2, status2, txt2 = _mcp_initialize(mcp_url, new_at)
|
||||
print(f"[2b] new-token initialize -> HTTP {status2} ok={ok2} expired={expired2}")
|
||||
if ok2:
|
||||
if args.write:
|
||||
new = dict(tok)
|
||||
new.update({"access_token": new_at, "token_type": j.get("token_type", "Bearer"),
|
||||
"expires_in": j.get("expires_in", tok.get("expires_in")),
|
||||
"scope": j.get("scope", tok.get("scope")),
|
||||
"expires_at": time.time() + float(j.get("expires_in", 3600))})
|
||||
if j.get("refresh_token"):
|
||||
new["refresh_token"] = j["refresh_token"]
|
||||
tmp = tpath + ".tmp"
|
||||
open(tmp, "w").write(json.dumps(new, indent=2))
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, tpath)
|
||||
print(f" wrote {tpath} (0600). NOW RESTART the gateway to clear the breaker.")
|
||||
print("BRANCH=REFRESH_FIXED -> refreshed token works. Persist (--write) + restart gateway.")
|
||||
return
|
||||
|
||||
if expired2:
|
||||
print("BRANCH=SESSION_REVOKED -> refresh succeeds but new token STILL -32002 'Session expired' (10).")
|
||||
print(" Refresh loop will NOT help. Full interactive authorization_code re-auth required.")
|
||||
print(" For an unattended gateway, prefer a static Personal API key instead.")
|
||||
return
|
||||
print(f"BRANCH=UNKNOWN -> new token failed for a non-session reason: {txt2[:200]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
name: mcporter
|
||||
description: List, auth, and call MCP servers/tools from the terminal.
|
||||
version: 1.0.0
|
||||
author: community
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [MCP, Tools, API, Integrations, Interop]
|
||||
homepage: https://mcporter.dev
|
||||
prerequisites:
|
||||
commands: [npx]
|
||||
---
|
||||
|
||||
# mcporter
|
||||
|
||||
Use `mcporter` to discover, call, and manage [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) servers and tools directly from the terminal.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Requires Node.js:
|
||||
```bash
|
||||
# No install needed (runs via npx)
|
||||
npx mcporter list
|
||||
|
||||
# Or install globally
|
||||
npm install -g mcporter
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# List MCP servers already configured on this machine
|
||||
mcporter list
|
||||
|
||||
# List tools for a specific server with schema details
|
||||
mcporter list <server> --schema
|
||||
|
||||
# Call a tool
|
||||
mcporter call <server.tool> key=value
|
||||
```
|
||||
|
||||
## Discovering MCP Servers
|
||||
|
||||
mcporter auto-discovers servers configured by other MCP clients (Claude Desktop, Cursor, etc.) on the machine. To find new servers to use, browse registries like [mcpfinder.dev](https://mcpfinder.dev) or [mcp.so](https://mcp.so), then connect ad-hoc:
|
||||
|
||||
```bash
|
||||
# Connect to any MCP server by URL (no config needed)
|
||||
mcporter list --http-url https://some-mcp-server.com --name my_server
|
||||
|
||||
# Or run a stdio server on the fly
|
||||
mcporter list --stdio "npx -y @modelcontextprotocol/server-filesystem" --name fs
|
||||
```
|
||||
|
||||
## Calling Tools
|
||||
|
||||
```bash
|
||||
# Key=value syntax
|
||||
mcporter call linear.list_issues team=ENG limit:5
|
||||
|
||||
# Function syntax
|
||||
mcporter call "linear.create_issue(title: \"Bug fix needed\")"
|
||||
|
||||
# Ad-hoc HTTP server (no config needed)
|
||||
mcporter call https://api.example.com/mcp.fetch url=https://example.com
|
||||
|
||||
# Ad-hoc stdio server
|
||||
mcporter call --stdio "bun run ./server.ts" scrape url=https://example.com
|
||||
|
||||
# JSON payload
|
||||
mcporter call <server.tool> --args '{"limit": 5}'
|
||||
|
||||
# Machine-readable output (recommended for Hermes)
|
||||
mcporter call <server.tool> key=value --output json
|
||||
```
|
||||
|
||||
## Auth and Config
|
||||
|
||||
```bash
|
||||
# OAuth login for a server
|
||||
mcporter auth <server | url> [--reset]
|
||||
|
||||
# Manage config
|
||||
mcporter config list
|
||||
mcporter config get <key>
|
||||
mcporter config add <server>
|
||||
mcporter config remove <server>
|
||||
mcporter config import <path>
|
||||
```
|
||||
|
||||
Config file location: `./config/mcporter.json` (override with `--config`).
|
||||
|
||||
## Daemon
|
||||
|
||||
For persistent server connections:
|
||||
```bash
|
||||
mcporter daemon start
|
||||
mcporter daemon status
|
||||
mcporter daemon stop
|
||||
mcporter daemon restart
|
||||
```
|
||||
|
||||
## Code Generation
|
||||
|
||||
```bash
|
||||
# Generate a CLI wrapper for an MCP server
|
||||
mcporter generate-cli --server <name>
|
||||
mcporter generate-cli --command <url>
|
||||
|
||||
# Inspect a generated CLI
|
||||
mcporter inspect-cli <path> [--json]
|
||||
|
||||
# Generate TypeScript types/client
|
||||
mcporter emit-ts <server> --mode client
|
||||
mcporter emit-ts <server> --mode types
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Use `--output json` for structured output that's easier to parse
|
||||
- Ad-hoc servers (HTTP URL or `--stdio` command) work without any config — useful for one-off calls
|
||||
- OAuth auth may require interactive browser flow — use `terminal(command="mcporter auth <server>", pty=true)` if needed
|
||||
Reference in New Issue
Block a user