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:
parent
40451b7d4c
commit
05aea9530b
|
|
@ -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
|
||||
245
src/main.rs
245
src/main.rs
|
|
@ -5,7 +5,7 @@
|
|||
use axum::{
|
||||
body::Body,
|
||||
extract::Query,
|
||||
http::{header, StatusCode},
|
||||
http::{header, HeaderMap, StatusCode, Uri},
|
||||
response::{Html, IntoResponse, Response, Json},
|
||||
routing::get,
|
||||
Router,
|
||||
|
|
@ -77,7 +77,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.route("/raw", get(get_raw))
|
||||
.route("/stream", get(get_stream))
|
||||
.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);
|
||||
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 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
async fn get_cameras() -> Response {
|
||||
match tokio::task::spawn_blocking(list_cameras).await {
|
||||
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!({
|
||||
"mcp": {
|
||||
"spec_version": "2026-01-21",
|
||||
"status": "active",
|
||||
"servers": [],
|
||||
"tools": [{
|
||||
"name": "camera-qrng",
|
||||
"description": "High-throughput quantum RNG using thermal noise from covered camera sensor",
|
||||
"url_template": "{origin}/random?bytes={bytes}&hex={hex}",
|
||||
"tools": [
|
||||
{
|
||||
"name": "get-random",
|
||||
"description": "Get cryptographically secure random bytes from camera sensor entropy",
|
||||
"url_template": random_url,
|
||||
"capabilities": ["random-generation", "entropy-source", "quantum"],
|
||||
"auth": { "type": "none" },
|
||||
"parameters": {
|
||||
"bytes": { "type": "integer", "default": 32, "max": 1048576 },
|
||||
"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 {
|
||||
let current = ACTIVE_REQUESTS.fetch_add(1, Ordering::SeqCst);
|
||||
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 SKILL_MD: &str = include_str!("../skill.md");
|
||||
|
|
|
|||
Loading…
Reference in New Issue