Skip to main content
Glama
SamsonCyber

nostr-mcp-auth

by SamsonCyber
README.md
# nostr-mcp-auth

## Layout

```
nostr-mcp-auth/
├── src/            # package source (NIP-98 auth, gate, nonce, client)
├── tests/          # test suite
├── docs/           # design docs (incl. NONCE_PROTOCOL.md)
├── examples/       # reference config + protocol demo
├── pyproject.toml  # packaging
└── LICENSE
```

## At a glance

| | |
|--|--|
| **What** | No proof, no tools. Agents sign every HTTP call with a Nostr secret. The server checks the signature offline. Wrong key, bad sig, stale event, or body swap: 401. Tool code never runs. |
| **Who it is for** | Developers wiring Nostr-based auth into MCP or agent tooling who need a clear, testable auth path. |
| **What it is not** | Not a general Nostr client social app. Auth and MCP integration focus only. |


# Lock MCP tools to a Nostr key

**No proof, no tools.** Agents sign every HTTP call with a Nostr secret. The server checks the signature offline. Wrong key, bad sig, stale event, or body swap: **401**. Tool code never runs.

```bash
pip install -e .
nostr-mcp-auth quickstart
nostr-mcp-auth serve
# other terminal:
nostr-mcp-auth call --tool whoami
```

Crypto identity for agent tool access. No OAuth dance required for the lab or fleet path.

---

## What is Nostr?

**Nostr** is a simple open protocol for signed messages. Each user (or agent) has:

| Piece | What it is |
|-------|------------|
| **nsec** | Private key. Signs. Keep offline / in a vault. |
| **npub** | Public key. Identity. Safe to put on an allowlist. |

Messages (events) are JSON objects with a **BIP-340 Schnorr signature**. Anyone can verify who signed without a central account server.

Common uses: social apps, relays, Lightning wallet connect. The part we care about for MCP is pure **crypto identity**: prove key possession.

You do **not** need Damus, a public relay, or a social profile for this package. Verification is local.

### NIP-98 (HTTP auth)

[NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) defines how to authorize an HTTP request with a short-lived signed event:

- Kind **27235**
- Tags bind the request: URL (`u`), method (`method`), optional body hash (`payload`)
- Header: `Authorization: Nostr <base64-encoded event>`

That is the protocol. This repo turns it into a **gate in front of MCP tools**.

---

## How we use Nostr for MCP

[MCP](https://modelcontextprotocol.io/) (Model Context Protocol) is how agents call tools over stdio or HTTP. Network MCP needs auth. We put NIP-98 on **`POST /mcp`**.

| Step | What happens |
|------|----------------|
| 1 | Agent (or sidecar) builds a NIP-98 event for this exact URL, method, and body. |
| 2 | Signs with **nsec**. |
| 3 | Sends JSON-RPC (`tools/list` / `tools/call`) plus `Authorization: Nostr …`. |
| 4 | Server verifies: kind, signature, time window, URL/method match, body hash, allowlist, optional roles, replay. |
| 5 | Only then does the tool run. |

```
Agent / sidecar                         MCP server (this package)
     |                                          |
     |  POST /mcp                               |
     |  Authorization: Nostr <signed event>     |
     |  tools/call { name, arguments }          |
     |----------------------------------------->|
     |                                          |  verify NIP-98 (offline)
     |                                          |  allowlist + roles
     |                                          |  run tool  OR  401/403
     |<-----------------------------------------|
```

**What we take from Nostr**

- Keypair identity (`npub` / `nsec`)
- Event shape + Schnorr signatures
- NIP-98 request binding

**What we do not need**

- Public relays for auth
- Social graph, follows, or notes
- Publishing the auth event anywhere

**What we add**

- Fail-closed HTTP MCP surface (`tools/list`, `tools/call`)
- Allow / deny lists and optional tool roles
- Process-local replay defense
- Operator CLI: `quickstart`, `serve`, `call`, `doctor`
- Red-team tested gate (see `docs/`)

---

## Plug in

See the protocol in action with zero wiring, no network needed:

```bash
python examples/protocol_demo.py   # 5-stage security trail (challenge, replay, tamper)
```

Then get a config + passing posture check: `doctor` grades your security posture
(nonce required, origin pinned, sane TTLs, not open) so you can trust a config
before deploy. Compare your file to `examples/mcp-auth.example.yaml`.

### Install

```bash
git clone https://github.com/SamsonCyber/nostr-mcp-auth.git
cd nostr-mcp-auth
pip install -e .
```

### Bootstrap

```bash
nostr-mcp-auth quickstart
```

| File | Purpose |
|------|---------|
| `caller.nsec` | Agent secret (never commit) |
| `caller.npub` | Public identity |
| `mcp-auth.yaml` | Server config; npub already allowlisted |

### Serve

```bash
nostr-mcp-auth serve
# http://127.0.0.1:8787/mcp
# http://127.0.0.1:8787/health   (public)
```

`serve` refuses `auth.open=true` and `auth.trust_proxy=true` unless you pass
`--force-open` or `--force-trust-proxy` (lab / reverse-proxy only).

### Call

```bash
nostr-mcp-auth call --tool whoami
nostr-mcp-auth call --tool protected_echo --arg text=hello
nostr-mcp-auth list-tools
nostr-mcp-auth doctor
```

### Python

```python
from nostr_mcp_auth.client import call_tool

print(call_tool("http://127.0.0.1:8787/mcp", "caller.nsec", "whoami"))
```

See `examples/python_agent.py`.

### Agent UIs that cannot sign (Claude Desktop, etc.)

Most hosts do not implement NIP-98. Options:

1. Local **signing sidecar** that holds `nsec` and forwards signed HTTP.
2. Keep high-risk tools on this HTTP gate; use stdio only where local trust is enough.

Unsigned or plain `Bearer` clients get **401**. That is the gate doing its job.

---

## Endpoints and demo tools

| Path | Auth |
|------|------|
| `GET /health` | Public |
| `GET /ready` | Public |
| `POST /mcp` | Required |

| Tool | Access |
|------|--------|
| `whoami` | Allowlisted identity |
| `protected_echo` | Allowlisted identity |
| `admin_ping` | Role `tools:admin` |

Wire real tools behind the same pattern, or terminate network MCP at this process.

---

## Fail-closed checks

| Check | Result |
|-------|--------|
| Missing / wrong auth | 401, no tool side effects |
| Bad signature / wrong kind | 401 |
| Clock skew outside window | 401 |
| URL or method mismatch | 401 |
| Body hash mismatch | 401 |
| Empty allowlist | 401 for everyone |
| Deny list | Wins over allow |
| Replay (same event id) | 401 (process-local) |
| Missing / stale / reused challenge nonce | 401 (`nonce_replay`) |
| Spoofed Host/scheme (when `allowed_origins` pinned) | 401 before auth, no oracle |
| Nonce store saturated (`/challenge` spam) | 503 `nonce_store_saturated` |
| Missing tool role | 403 |
| Auth failure body | Generic `unauthorized` (no fine reason oracle) |
| MCP responses | `Cache-Control: no-store` (not shared-cacheable) |

Defaults: `open: false`, `trust_proxy: false`. Auth logs never print `nsec` or full
`Authorization` tokens. Multi-instance shared replay and stolen allowlisted `nsec`
remain residual (see residual risks).

More: [SPEC](docs/SPEC.md) · [residual risks](docs/RESIDUAL_RISKS.md) · [red-team](docs/REDTEAM_REPORT.md) · [wave 2](docs/REDTEAM_WAVE2_REPORT.md)

---

## Config

```yaml
auth:
  open: false
  allow_npubs:
    - npub1...
  roles:
    npub1...: [tools:admin]
tools:
  admin_ping:
    roles: [tools:admin]
server:
  host: 127.0.0.1
  port: 8787
```

Add agents by generating keys and appending npubs. Example: `examples/mcp-auth.example.yaml`.

## CLI

| Command | Job |
|---------|-----|
| `quickstart` | Identity + config |
| `serve` | Authenticated MCP HTTP (`--force-open` / `--force-trust-proxy` if needed) |
| `call` / `list-tools` | Signed client |
| `doctor` | Config + nsec + allowlist check |
| `gen-key` / `init` | Manual pieces |

## Dev

```bash
pip install -e ".[dev]"
pytest -q
```

Python 3.10+. Uses `coincurve` for BIP-340.

**Offline success signal:**

```bash
pip install -e ".[dev]"
python -m pytest tests/ -q
# expect: exit 0
```

`tests/` is the real gate. Bare product use (`serve` / `call`) needs keys and is not the offline bar.

## License

MIT