Add MCP self-referencing docs and /docs endpoint

- Enhanced /.well-known/mcp.json to extract hostname from request headers (SNI/Host)
- Added self-referencing URLs that work with localhost, domains, and reverse proxies
- Documented all 5 API endpoints (random, raw, stream, cameras, health) as MCP tools
- Added /docs endpoint serving HTML documentation from skill.md
- Added /docs/skill.md for raw markdown access
- Added /docs/mcp.json as alias to /.well-known/mcp.json
- Created skill.md with comprehensive API documentation
- Markdown to HTML conversion happens at build time

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leopere 2026-02-05 16:34:28 -05:00
parent 40451b7d4c
commit 05aea9530b
Signed by: colin
SSH Key Fingerprint: SHA256:nRPCQTeMFLdGytxRQmPVK9VXY3/ePKQ5lGRyJhT5DY8
2 changed files with 427 additions and 13 deletions

189
skill.md Normal file
View File

@ -0,0 +1,189 @@
# Camera TRNG API Skill Documentation
This document describes how to use the Camera TRNG (True Random Number Generator) API as an MCP-compatible service.
## Overview
The Camera TRNG server provides quantum-based random number generation using thermal noise from a covered camera sensor. The API is accessible via HTTP and self-documents its capabilities through the `.well-known/mcp.json` endpoint.
## Discovery
The API can be discovered by fetching the MCP endpoint:
```bash
curl http://localhost:8787/.well-known/mcp.json
```
The response includes self-referencing URLs that use the request's hostname (from SNI/HTTP Host header), making it work correctly whether accessed via `localhost`, a domain name, or behind a reverse proxy.
## API Endpoints
### 1. Get Random Bytes (`/random`)
Generate cryptographically secure random bytes from camera sensor entropy.
**Parameters:**
- `bytes` (integer, default: 32, max: 1048576): Number of random bytes to generate
- `hex` (boolean, default: false): Return bytes as hexadecimal string instead of binary
**Examples:**
```bash
# Get 32 bytes as binary
curl "http://localhost:8787/random?bytes=32" -o random.bin
# Get 64 bytes as hex string
curl "http://localhost:8787/random?bytes=64&hex=true"
# Get 1KB of random data
curl "http://localhost:8787/random?bytes=1024" -o random.bin
```
### 2. Get Raw Data (`/raw`)
Get raw LSB (least significant bit) data from camera sensor without cryptographic extraction. Useful for testing or custom post-processing.
**Parameters:**
- `bytes` (integer, default: 65536, max: 1048576): Number of raw bytes to extract
**Example:**
```bash
curl "http://localhost:8787/raw?bytes=1024" -o raw.bin
```
### 3. Stream Random Bytes (`/stream`)
Stream continuous random bytes using Server-Sent Events (SSE) format. Useful for high-throughput applications.
**Parameters:**
- `bytes` (integer, optional): Total bytes to stream (omit for unlimited)
- `hex` (boolean, default: false): Stream as hexadecimal strings
**Examples:**
```bash
# Stream unlimited random bytes
curl -N "http://localhost:8787/stream"
# Stream exactly 1MB
curl -N "http://localhost:8787/stream?bytes=1048576"
# Stream as hex
curl -N "http://localhost:8787/stream?hex=true"
```
### 4. List Cameras (`/cameras`)
List available camera devices on the system.
**Example:**
```bash
curl "http://localhost:8787/cameras"
```
**Response:**
```json
{
"cameras": [
{
"index": 0,
"human_name": "FaceTime HD Camera",
"description": "Built-in camera",
"misc": ""
}
]
}
```
### 5. Health Check (`/health`)
Check if the TRNG server is running.
**Example:**
```bash
curl "http://localhost:8787/health"
```
**Response:** `ok`
## MCP Integration
The API implements the Model Context Protocol (MCP) specification, allowing AI assistants and other MCP clients to discover and use the TRNG service automatically.
### MCP Tools
The following tools are exposed via MCP:
1. **get-random**: Generate random bytes (cryptographically secure)
2. **get-raw**: Extract raw sensor data
3. **get-stream**: Stream random bytes continuously
4. **list-cameras**: Discover available cameras
5. **health-check**: Verify server status
### Self-Referencing URLs
The MCP endpoint (`/.well-known/mcp.json`) automatically detects the request's hostname from:
- HTTP `Host` header
- `X-Forwarded-Proto` header (for reverse proxy scenarios)
This ensures URLs in the MCP response always reference the correct origin, whether accessed via:
- `localhost:8787`
- `trng.example.com`
- Behind a reverse proxy with forwarded headers
## Usage Examples
### Python
```python
import requests
# Get 32 random bytes
response = requests.get("http://localhost:8787/random?bytes=32")
random_bytes = response.content
# Get hex string
response = requests.get("http://localhost:8787/random?bytes=64&hex=true")
hex_string = response.text
```
### JavaScript/Node.js
```javascript
// Get random bytes
const response = await fetch("http://localhost:8787/random?bytes=32");
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);
// Stream random data
const stream = await fetch("http://localhost:8787/stream?bytes=1024");
const reader = stream.body.getReader();
// ... read chunks
```
### curl
```bash
# Basic usage
curl "http://localhost:8787/random?bytes=32&hex=true"
# Save to file
curl "http://localhost:8787/random?bytes=1024" -o random.bin
# Stream
curl -N "http://localhost:8787/stream"
```
## Limitations
- Maximum 1MB per request (`/random` and `/raw`)
- Maximum 4 concurrent requests
- Requires a camera device with covered lens
- Performance depends on camera frame rate
## Security Notes
- No authentication required (intended for local/trusted networks)
- Random data is cryptographically secure when using `/random` endpoint
- Raw endpoint (`/raw`) provides unprocessed sensor data
- Consider rate limiting in production deployments
## See Also
- `TECHNICAL.md` - Technical implementation details
- `README.md` - Project overview and setup instructions

View File

@ -5,7 +5,7 @@
use axum::{ use axum::{
body::Body, body::Body,
extract::Query, extract::Query,
http::{header, StatusCode}, http::{header, HeaderMap, StatusCode, Uri},
response::{Html, IntoResponse, Response, Json}, response::{Html, IntoResponse, Response, Json},
routing::get, routing::get,
Router, Router,
@ -77,7 +77,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/raw", get(get_raw)) .route("/raw", get(get_raw))
.route("/stream", get(get_stream)) .route("/stream", get(get_stream))
.route("/health", get(health)) .route("/health", get(health))
.route("/.well-known/mcp.json", get(mcp_wellknown)); .route("/.well-known/mcp.json", get(mcp_wellknown))
.route("/docs", get(get_docs))
.route("/docs/skill.md", get(get_skill_md))
.route("/docs/mcp.json", get(mcp_wellknown));
let addr = format!("0.0.0.0:{}", port); let addr = format!("0.0.0.0:{}", port);
println!("Camera QRNG (LavaRnd-style) on http://{}", addr); println!("Camera QRNG (LavaRnd-style) on http://{}", addr);
@ -89,6 +92,156 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
async fn index() -> Html<&'static str> { Html(INDEX_HTML) } async fn index() -> Html<&'static str> { Html(INDEX_HTML) }
async fn health() -> &'static str { "ok" } async fn health() -> &'static str { "ok" }
async fn get_docs() -> Html<String> {
// Convert markdown to HTML (simple version)
let html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Camera TRNG API Documentation</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; line-height: 1.6; }}
h1 {{ border-bottom: 2px solid #333; padding-bottom: 10px; }}
h2 {{ margin-top: 30px; border-bottom: 1px solid #ccc; padding-bottom: 5px; }}
code {{ background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: "Monaco", "Courier New", monospace; }}
pre {{ background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }}
pre code {{ background: none; padding: 0; }}
a {{ color: #0066cc; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
.endpoint {{ background: #e8f4f8; padding: 15px; margin: 15px 0; border-left: 4px solid #0066cc; border-radius: 3px; }}
</style>
</head>
<body>
<h1>Camera TRNG API Documentation</h1>
<p><a href="/docs/skill.md">View as Markdown</a> | <a href="/docs/mcp.json">View MCP JSON</a> | <a href="/">Back to Home</a></p>
<hr>
{}
</body>
</html>"#,
markdown_to_html(SKILL_MD)
);
Html(html)
}
async fn get_skill_md() -> Response {
Response::builder()
.header(header::CONTENT_TYPE, "text/markdown; charset=utf-8")
.body(Body::from(SKILL_MD))
.unwrap()
}
fn markdown_to_html(md: &str) -> String {
use std::fmt::Write;
let mut html = String::new();
let mut in_code_block = false;
let mut code_content = String::new();
for line in md.lines() {
if line.starts_with("```") {
if in_code_block {
write!(html, "<pre><code>{}</code></pre>
", escape_html(&code_content)).unwrap();
code_content.clear();
in_code_block = false;
} else {
in_code_block = true;
}
continue;
}
if in_code_block {
code_content.push_str(line);
code_content.push_str("\n");
continue;
}
let trimmed = line.trim();
if trimmed.is_empty() {
html.push_str("<br>\n");
} else if trimmed.starts_with("### ") {
write!(html, "<h3>{}</h3>
", escape_html(&trimmed[4..])).unwrap();
} else if trimmed.starts_with("## ") {
write!(html, "<h2>{}</h2>
", escape_html(&trimmed[3..])).unwrap();
} else if trimmed.starts_with("# ") {
write!(html, "<h1>{}</h1>
", escape_html(&trimmed[2..])).unwrap();
} else {
// Simple inline processing
let mut processed = String::new();
let mut chars = trimmed.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'`' => {
let mut code = String::new();
while let Some(&next) = chars.peek() {
if next == '`' {
chars.next();
write!(processed, "<code>{}</code>", escape_html(&code)).unwrap();
break;
}
code.push(chars.next().unwrap());
}
}
'*' if chars.peek() == Some(&'*') => {
chars.next();
let mut bold = String::new();
while let Some(&next) = chars.peek() {
if next == '*' && chars.clone().nth(1) == Some('*') {
chars.next();
chars.next();
write!(processed, "<strong>{}</strong>", escape_html(&bold)).unwrap();
break;
}
bold.push(chars.next().unwrap());
}
}
'[' => {
let mut text = String::new();
let mut url = String::new();
while let Some(&next) = chars.peek() {
if next == ']' {
chars.next();
if chars.peek() == Some(&'(') {
chars.next();
while let Some(&next) = chars.peek() {
if next == ')' {
chars.next();
write!(processed, "<a href=\"{}\">{}</a>", url, escape_html(&text)).unwrap();
break;
}
url.push(chars.next().unwrap());
}
}
break;
}
text.push(chars.next().unwrap());
}
}
_ => processed.push(ch),
}
}
write!(html, "<p>{}</p>
", processed).unwrap();
}
}
html
}
fn escape_html(s: &str) -> String {
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
}
async fn get_cameras() -> Response { async fn get_cameras() -> Response {
match tokio::task::spawn_blocking(list_cameras).await { match tokio::task::spawn_blocking(list_cameras).await {
Ok(Ok(cameras)) => Json(serde_json::json!({ "cameras": cameras })).into_response(), Ok(Ok(cameras)) => Json(serde_json::json!({ "cameras": cameras })).into_response(),
@ -97,27 +250,98 @@ async fn get_cameras() -> Response {
} }
} }
async fn mcp_wellknown() -> Json<serde_json::Value> { async fn mcp_wellknown(headers: HeaderMap, _uri: Uri) -> Json<serde_json::Value> {
// Extract origin from request headers (Host header + scheme)
let host = headers
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("localhost:8787");
// Determine scheme from X-Forwarded-Proto or default to http
let scheme = headers
.get("x-forwarded-proto")
.and_then(|h| h.to_str().ok())
.unwrap_or("http");
let origin = format!("{}://{}", scheme, host);
// Build URL templates as strings first
let random_url = format!("{}/random?bytes={{bytes}}&hex={{hex}}", origin);
let raw_url = format!("{}/raw?bytes={{bytes}}", origin);
let stream_url = format!("{}/stream?bytes={{bytes}}&hex={{hex}}", origin);
let cameras_url = format!("{}/cameras", origin);
let health_url = format!("{}/health", origin);
let mcp_url = format!("{}/.well-known/mcp.json", origin);
Json(json!({ Json(json!({
"mcp": { "mcp": {
"spec_version": "2026-01-21", "spec_version": "2026-01-21",
"status": "active", "status": "active",
"servers": [], "servers": [],
"tools": [{ "tools": [
"name": "camera-qrng", {
"description": "High-throughput quantum RNG using thermal noise from covered camera sensor", "name": "get-random",
"url_template": "{origin}/random?bytes={bytes}&hex={hex}", "description": "Get cryptographically secure random bytes from camera sensor entropy",
"capabilities": ["random-generation", "entropy-source", "quantum"], "url_template": random_url,
"auth": { "type": "none" }, "capabilities": ["random-generation", "entropy-source", "quantum"],
"parameters": { "auth": { "type": "none" },
"bytes": { "type": "integer", "default": 32, "max": 1048576 }, "parameters": {
"hex": { "type": "boolean", "default": false } "bytes": { "type": "integer", "default": 32, "min": 1, "max": 1048576, "description": "Number of random bytes to generate (max 1MB)" },
"hex": { "type": "boolean", "default": false, "description": "Return bytes as hexadecimal string instead of binary" }
}
},
{
"name": "get-raw",
"description": "Get raw LSB (least significant bit) data from camera sensor without cryptographic extraction",
"url_template": raw_url,
"capabilities": ["entropy-source", "raw-data"],
"auth": { "type": "none" },
"parameters": {
"bytes": { "type": "integer", "default": 65536, "min": 1, "max": 1048576, "description": "Number of raw bytes to extract (max 1MB)" }
}
},
{
"name": "get-stream",
"description": "Stream continuous random bytes (SSE format). Use ?bytes=N to limit total bytes, ?hex=true for hex output",
"url_template": stream_url,
"capabilities": ["random-generation", "entropy-source", "quantum", "streaming"],
"auth": { "type": "none" },
"parameters": {
"bytes": { "type": "integer", "optional": true, "description": "Total bytes to stream (omit for unlimited)" },
"hex": { "type": "boolean", "default": false, "description": "Stream as hexadecimal strings" }
}
},
{
"name": "list-cameras",
"description": "List available camera devices",
"url_template": cameras_url,
"capabilities": ["device-discovery"],
"auth": { "type": "none" },
"parameters": {}
},
{
"name": "health-check",
"description": "Check if the TRNG server is running",
"url_template": health_url,
"capabilities": ["health"],
"auth": { "type": "none" },
"parameters": {}
} }
}] ],
"resources": [
{
"uri": mcp_url,
"name": "MCP Documentation",
"description": "This MCP endpoint documentation",
"mimeType": "application/json"
}
]
} }
})) }))
} }
async fn get_random(Query(params): Query<RandomQuery>) -> Response { async fn get_random(Query(params): Query<RandomQuery>) -> Response {
let current = ACTIVE_REQUESTS.fetch_add(1, Ordering::SeqCst); let current = ACTIVE_REQUESTS.fetch_add(1, Ordering::SeqCst);
if current >= MAX_CONCURRENT { if current >= MAX_CONCURRENT {
@ -222,3 +446,4 @@ async fn get_stream(Query(params): Query<StreamQuery>) -> Response {
const INDEX_HTML: &str = include_str!("index.html"); const INDEX_HTML: &str = include_str!("index.html");
const SKILL_MD: &str = include_str!("../skill.md");