chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
MCP over HTTP at /mcp, for the DIDs KNOTLESS_MCP_DIDS names
knotless-serve answers MCP over streamable HTTP, so assistants that reach a server, such as claude.ai's connectors, can use the knotless tools. It is its own OAuth authorization server, as the MCP spec has it: clients register, send their user here with PKCE, and get Bearer tokens. The user signs in at their own PDS, this server a confidential client of it, and only the listed DIDs get a token. Registrations and tokens are signed rather than stored; each account's sessions and mirrors live apart. Deploy passes KNOTLESS_MCP_DIDS through from deploy/local.env. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 15 changed files with 1351 additions and 10 deletions
Cargo.lock+33−0
| @@ -86,6 +86,17 @@ dependencies = [ | ||
"tokio", | ||
] | ||
| ||
[[package]] | ||
name = "async-trait" | ||
version = "0.1.92" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" | ||
dependencies = [ | ||
"proc-macro2", | ||
"quote", | ||
"syn 3.0.9", | ||
] | ||
| ||
[[package]] | ||
name = "atomic-waker" | ||
version = "1.1.2" | ||
| @@ -2157,6 +2168,7 @@ dependencies = [ | ||
"futures", | ||
"gix", | ||
"knotless", | ||
"knotless-mcp", | ||
"png", | ||
"pulldown-cmark", | ||
"reqwest", | ||
| @@ -2840,21 +2852,29 @@ version = "3.6.0" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "ceb42e9e47443cd83a834dea57b5816d35481417bae72ade5dc5615c59122e26" | ||
dependencies = [ | ||
"async-trait", | ||
"base64 0.23.1", | ||
"bytes", | ||
"chrono", | ||
"futures", | ||
"http", | ||
"http-body", | ||
"http-body-util", | ||
"indexmap", | ||
"libc", | ||
"pastey", | ||
"pin-project-lite", | ||
"rand 0.10.3", | ||
"rmcp-macros", | ||
"schemars", | ||
"serde", | ||
"serde_json", | ||
"sse-stream", | ||
"thiserror", | ||
"tokio", | ||
"tokio-stream", | ||
"tokio-util", | ||
"tower-service", | ||
"tracing", | ||
"uuid", | ||
] | ||
| @@ -3250,6 +3270,19 @@ dependencies = [ | ||
"wasm-bindgen", | ||
] | ||
| ||
[[package]] | ||
name = "sse-stream" | ||
version = "0.2.6" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" | ||
dependencies = [ | ||
"bytes", | ||
"futures-util", | ||
"http-body", | ||
"http-body-util", | ||
"pin-project-lite", | ||
] | ||
| ||
[[package]] | ||
name = "stable_deref_trait" | ||
version = "1.2.1" | ||
README.md+16−0
| @@ -217,6 +217,22 @@ It acts as the account `knotless login` last stored. To pin another, set | ||
`KNOTLESS_IDENTIFIER` in the server's `env`; set `KNOTLESS_APPVIEW` to a | ||
`knotless-serve` index for pulls and issues on public repositories. | ||
| ||
### Over HTTP, for claude.ai | ||
| ||
Assistants that reach a server rather than start one, such as claude.ai's | ||
custom connectors, use `/mcp` on a public `knotless-serve`. It is on for the | ||
accounts `KNOTLESS_MCP_DIDS` names, a comma-separated list of DIDs: | ||
| ||
```sh | ||
KNOTLESS_MCP_DIDS=did:plc:abc,did:plc:def knotless-serve --public-url https://knotless.example | ||
``` | ||
| ||
Add `https://knotless.example/mcp` as a connector. The assistant registers | ||
itself and sends you to sign in with your handle at your own PDS. An account | ||
not on the list is turned away there. The server keeps each account's | ||
session to act as it, apart from every other account's, and taking a DID | ||
off the list stops its tokens at their next use. | ||
| ||
| Tools | | | ||
| --- | --- | | ||
| `list_repos`, `refs`, `list_tree`, `read_file`, `search`, `log`, `diff` | read, by `handle/name` | | ||
crates/knotless-mcp/Cargo.toml+3−2
| @@ -9,8 +9,10 @@ rust-version.workspace = true | ||
[dependencies] | ||
knotless.workspace = true | ||
anyhow.workspace = true | ||
axum.workspace = true | ||
base64.workspace = true | ||
rmcp = { version = "3.6", features = ["server", "transport-io", "macros"] } | ||
reqwest.workspace = true | ||
rmcp = { version = "3.6", features = ["server", "transport-io", "transport-streamable-http-server", "macros"] } | ||
schemars = "1" | ||
serde.workspace = true | ||
serde_json.workspace = true | ||
| @@ -21,4 +23,3 @@ rmcp = { version = "3.6", features = ["client"] } | ||
tempfile.workspace = true | ||
reqwest = { workspace = true, features = ["blocking"] } | ||
knotless-serve = { path = "../knotless-serve" } | ||
axum.workspace = true | ||
crates/knotless-mcp/src/http.rsnew+718−0
| @@ -0,0 +1,718 @@ | ||
//! MCP over streamable HTTP, for assistants that reach a server rather than | ||
//! start one, such as claude.ai's connectors: at `/mcp` on a public | ||
//! `knotless-serve`, for the accounts `KNOTLESS_MCP_DIDS` names. | ||
//! | ||
//! The server is its own OAuth authorization server, as the MCP | ||
//! authorization spec has it: an assistant registers (RFC 7591), sends its | ||
//! user here with PKCE, and gets Bearer tokens for `/mcp`. The user signs in | ||
//! with atproto OAuth, this server a confidential client of their PDS, and | ||
//! the server keeps that session to act as them. Only the listed DIDs get a | ||
//! token. | ||
//! | ||
//! What it hands out is signed rather than stored: a registered client is | ||
//! its own ID, and a token holds whom it is for. Taking a DID off the list | ||
//! stops its tokens at their next use. Each account's sessions and mirrors | ||
//! live in a directory of their own, so no one reads as anyone else. | ||
| ||
use crate::Server; | ||
use axum::extract::{Form, Query, Request, State}; | ||
use axum::http::request::Parts; | ||
use axum::http::{StatusCode, header}; | ||
use axum::middleware::{self, Next}; | ||
use axum::response::{Html, IntoResponse, Redirect, Response}; | ||
use axum::routing::{get, post}; | ||
use axum::{Json, Router}; | ||
use base64::Engine; | ||
use base64::engine::general_purpose::URL_SAFE_NO_PAD; | ||
use knotless::crypto::{self, P256Key}; | ||
use knotless::env::Ctx; | ||
use knotless::session::{self, Session}; | ||
use knotless::{identity, keys, oauth}; | ||
use rmcp::model::{ | ||
CallToolRequestParams, CallToolResponse, ListToolsResult, PaginatedRequestParams, ServerConfig, | ||
Tool, | ||
}; | ||
use rmcp::service::RequestContext; | ||
use rmcp::transport::streamable_http_server::session::never::NeverSessionManager; | ||
use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService}; | ||
use rmcp::{ErrorData as McpError, RoleServer, ServerHandler}; | ||
use serde::de::DeserializeOwned; | ||
use serde::{Deserialize, Serialize}; | ||
use serde_json::{Value, json}; | ||
use std::collections::HashMap; | ||
use std::sync::{Arc, Mutex}; | ||
use std::time::{Duration, Instant}; | ||
| ||
const MCP: &str = "/mcp"; | ||
const METADATA: &str = "/mcp/oauth-client-metadata.json"; | ||
const CALLBACK: &str = "/mcp/oauth/callback"; | ||
const ACCESS_TTL: u64 = 60 * 60; | ||
const REFRESH_TTL: u64 = 90 * 24 * 60 * 60; | ||
/// A registered client lasts as long as the key that signed it. | ||
const CLIENT_TTL: u64 = 100 * 365 * 24 * 60 * 60; | ||
/// How long a sign-in may take, from the assistant sending its user here to | ||
/// the user coming back from their PDS. | ||
const SIGN_IN: Duration = Duration::from_secs(15 * 60); | ||
/// How long a code waits to be traded for tokens. | ||
const CODE: Duration = Duration::from_secs(5 * 60); | ||
| ||
/// The MCP server over HTTP, and the authorization server in front of it. | ||
pub struct Mcp { | ||
/// Config and cache under `mcp/`, apart from the rest of the server's. | ||
ctx: Ctx, | ||
/// The public URL, the issuer, with no trailing slash. | ||
url: String, | ||
dids: Vec<String>, | ||
/// Signs what this server hands out, and its assertions as a client of | ||
/// PDSes. | ||
key: P256Key, | ||
/// Sign-ins gone to a PDS, by the state of their atproto request. | ||
pending: Mutex<HashMap<String, Pending>>, | ||
codes: Mutex<HashMap<String, Code>>, | ||
} | ||
| ||
/// An assistant's authorization request, carried through the sign-in. | ||
#[derive(Serialize, Deserialize)] | ||
struct Ask { | ||
client_id: String, | ||
name: String, | ||
redirect_uri: String, | ||
state: Option<String>, | ||
challenge: String, | ||
} | ||
| ||
/// A registered assistant, which its client ID holds. | ||
#[derive(Serialize, Deserialize)] | ||
struct Client { | ||
redirect_uris: Vec<String>, | ||
name: String, | ||
/// Whether it proves itself with a secret. | ||
secret: bool, | ||
} | ||
| ||
/// Whom a token is for. | ||
#[derive(Serialize, Deserialize)] | ||
struct Grant { | ||
sub: String, | ||
client_id: String, | ||
} | ||
| ||
struct Pending { | ||
ask: Ask, | ||
request: oauth::Request, | ||
started: Instant, | ||
} | ||
| ||
struct Code { | ||
ask: Ask, | ||
did: String, | ||
issued: Instant, | ||
} | ||
| ||
/// The account a request's token is for, as the MCP service sees it. | ||
#[derive(Clone)] | ||
struct Caller(String); | ||
| ||
/// An OAuth error, as JSON. | ||
struct Refusal(StatusCode, &'static str, String); | ||
| ||
impl IntoResponse for Refusal { | ||
fn into_response(self) -> Response { | ||
( | ||
self.0, | ||
[(header::CACHE_CONTROL, "no-store")], | ||
Json(json!({ "error": self.1, "error_description": self.2 })), | ||
) | ||
.into_response() | ||
} | ||
} | ||
| ||
fn invalid_grant(why: &str) -> Refusal { | ||
Refusal(StatusCode::BAD_REQUEST, "invalid_grant", why.into()) | ||
} | ||
| ||
impl Mcp { | ||
/// The server at `url`, for `dids`, keeping what it needs under `ctx`'s | ||
/// config and cache directories. | ||
pub fn new(ctx: &Ctx, url: &str, dids: Vec<String>) -> anyhow::Result<Arc<Mcp>> { | ||
let mut env = ctx.env.clone(); | ||
env.config_dir = env.config_dir.join("mcp"); | ||
env.cache_dir = env.cache_dir.join("mcp"); | ||
let ctx = Ctx { | ||
env, | ||
http: ctx.http.clone(), | ||
}; | ||
let key = oauth::client_key(&ctx)?; | ||
Ok(Arc::new(Mcp { | ||
ctx, | ||
url: url.trim_end_matches('/').to_string(), | ||
dids, | ||
key, | ||
pending: Mutex::default(), | ||
codes: Mutex::default(), | ||
})) | ||
} | ||
| ||
fn resource(&self) -> String { | ||
format!("{}{MCP}", self.url) | ||
} | ||
| ||
/// Acting as `did`: its own config, sessions and mirrors, and never a | ||
/// password. | ||
fn ctx_for(&self, did: &str) -> Ctx { | ||
let mut env = self.ctx.env.clone(); | ||
let dir = did.replace(':', "_"); | ||
env.config_dir = env.config_dir.join(&dir); | ||
env.cache_dir = env.cache_dir.join(&dir); | ||
env.identifier = Some(did.to_string()); | ||
env.password = None; | ||
Ctx { | ||
env, | ||
http: self.ctx.http.clone(), | ||
} | ||
} | ||
| ||
/// The client this server is to PDSes. | ||
fn atproto(&self) -> oauth::Client { | ||
let key = P256Key::from_bytes(&self.key.to_bytes()).expect("a key reads back"); | ||
oauth::Client::confidential(&format!("{}{METADATA}", self.url), key) | ||
} | ||
| ||
fn sign(&self, kind: &str, ttl: u64, body: &impl Serialize) -> String { | ||
let mut claims = serde_json::to_value(body).expect("claims serialize"); | ||
claims["kind"] = kind.into(); | ||
claims["iss"] = self.url.clone().into(); | ||
claims["exp"] = (keys::now_micros() / 1_000_000 + ttl).into(); | ||
self.key.sign_jwt(&json!({ "typ": "JWT" }), &claims) | ||
} | ||
| ||
/// What this server signed as `kind`, unexpired. | ||
fn open<T: DeserializeOwned>(&self, kind: &str, token: &str) -> Option<T> { | ||
let claims: Value = crypto::verify_jwt(token, &self.key.public()).ok()?; | ||
(claims["kind"] == kind).then_some(())?; | ||
serde_json::from_value(claims).ok() | ||
} | ||
| ||
fn client(&self, id: &str) -> Option<Client> { | ||
self.open("client", id) | ||
} | ||
| ||
/// `redirect`, with `values`, the state and who answers. | ||
fn back(&self, redirect: &str, state: Option<&str>, values: &[(&str, &str)]) -> String { | ||
let mut url = reqwest::Url::parse(redirect).expect("a registered redirect is a URL"); | ||
{ | ||
let mut query = url.query_pairs_mut(); | ||
query.extend_pairs(values); | ||
query.extend_pairs(state.map(|s| ("state", s))); | ||
query.append_pair("iss", &self.url); | ||
} | ||
url.to_string() | ||
} | ||
| ||
/// Keeps the session to act as its account with. | ||
async fn keep(&self, mut session: Session) -> anyhow::Result<()> { | ||
let ctx = self.ctx_for(&session.did); | ||
// A refresh signs as this client, with the key in the session's | ||
// config directory. | ||
oauth::set_client_key(&ctx, &self.key)?; | ||
session.handle = identity::handle(&self.ctx, &session.did) | ||
.await | ||
.unwrap_or_default(); | ||
session::save(&ctx, &session) | ||
} | ||
| ||
/// The PDS's answer to a sign-in, traded for a code for the assistant. | ||
async fn finish( | ||
&self, | ||
pending: Pending, | ||
params: &HashMap<String, String>, | ||
) -> anyhow::Result<String> { | ||
let session = pending | ||
.request | ||
.redeem(&self.ctx, &self.atproto(), params) | ||
.await?; | ||
let did = session.did.clone(); | ||
self.keep(session).await?; | ||
Ok(self.code(pending.ask, did)) | ||
} | ||
| ||
fn code(&self, ask: Ask, did: String) -> String { | ||
let code = oauth::random(32); | ||
let mut codes = self.codes.lock().unwrap(); | ||
codes.retain(|_, c| c.issued.elapsed() < CODE); | ||
codes.insert( | ||
code.clone(), | ||
Code { | ||
ask, | ||
did, | ||
issued: Instant::now(), | ||
}, | ||
); | ||
code | ||
} | ||
| ||
/// The client a token request is from, with its secret if it has one. | ||
fn authenticate(&self, form: &HashMap<String, String>) -> Result<String, Refusal> { | ||
let id = form.get("client_id").cloned().unwrap_or_default(); | ||
let unknown = || { | ||
Refusal( | ||
StatusCode::UNAUTHORIZED, | ||
"invalid_client", | ||
"Unknown client".into(), | ||
) | ||
}; | ||
let client = self.client(&id).ok_or_else(unknown)?; | ||
let proved = !client.secret | ||
|| form | ||
.get("client_secret") | ||
.and_then(|s| URL_SAFE_NO_PAD.decode(s).ok()) | ||
.is_some_and(|sig| self.key.public().verify(id.as_bytes(), &sig).is_ok()); | ||
proved.then_some(id).ok_or_else(unknown) | ||
} | ||
| ||
fn redeem(&self, client_id: &str, form: &HashMap<String, String>) -> Result<String, Refusal> { | ||
let code = form.get("code").cloned().unwrap_or_default(); | ||
let mut codes = self.codes.lock().unwrap(); | ||
codes.retain(|_, c| c.issued.elapsed() < CODE); | ||
let code = codes | ||
.remove(&code) | ||
.filter(|c| c.ask.client_id == client_id) | ||
.ok_or_else(|| invalid_grant("Invalid or expired code"))?; | ||
let ok = form | ||
.get("redirect_uri") | ||
.is_none_or(|r| *r == code.ask.redirect_uri) | ||
&& form | ||
.get("code_verifier") | ||
.is_some_and(|v| oauth::sha256(v) == code.ask.challenge); | ||
ok.then_some(code.did) | ||
.ok_or_else(|| invalid_grant("The redirect_uri or code_verifier does not match")) | ||
} | ||
| ||
fn tokens(&self, form: &HashMap<String, String>) -> Result<Value, Refusal> { | ||
let client_id = self.authenticate(form)?; | ||
let did = match form.get("grant_type").map(String::as_str) { | ||
Some("authorization_code") => self.redeem(&client_id, form)?, | ||
Some("refresh_token") => form | ||
.get("refresh_token") | ||
.and_then(|t| self.open::<Grant>("refresh", t)) | ||
.filter(|g| g.client_id == client_id) | ||
.map(|g| g.sub) | ||
.ok_or_else(|| invalid_grant("Invalid refresh token"))?, | ||
other => { | ||
return Err(Refusal( | ||
StatusCode::BAD_REQUEST, | ||
"unsupported_grant_type", | ||
format!("Unsupported grant_type {other:?}"), | ||
)); | ||
} | ||
}; | ||
if !self.dids.contains(&did) { | ||
return Err(invalid_grant(&format!( | ||
"{did} may no longer use this server" | ||
))); | ||
} | ||
let grant = Grant { | ||
sub: did, | ||
client_id, | ||
}; | ||
let mut access = serde_json::to_value(&grant).expect("a grant serializes"); | ||
access["aud"] = self.resource().into(); | ||
Ok(json!({ | ||
"access_token": self.sign("access", ACCESS_TTL, &access), | ||
"token_type": "Bearer", | ||
"expires_in": ACCESS_TTL, | ||
"refresh_token": self.sign("refresh", REFRESH_TTL, &grant), | ||
})) | ||
} | ||
} | ||
| ||
/// The routes: `/mcp`, and the OAuth around it. | ||
pub fn router(mcp: Arc<Mcp>) -> Router { | ||
let tools = mcp.clone(); | ||
let service = StreamableHttpService::new( | ||
move || { | ||
Ok(Remote { | ||
tools: Server::new(tools.ctx.clone()), | ||
mcp: tools.clone(), | ||
}) | ||
}, | ||
Arc::new(NeverSessionManager::default()), | ||
// Every request carries a token, which a rebound DNS name cannot. | ||
StreamableHttpServerConfig::default() | ||
.with_legacy_session_mode(false) | ||
.with_json_response(true) | ||
.disable_allowed_hosts(), | ||
); | ||
let guarded = Router::new() | ||
.route_service(MCP, service) | ||
.layer(middleware::from_fn_with_state(mcp.clone(), bearer)); | ||
Router::new() | ||
.route("/.well-known/oauth-protected-resource", get(resource)) | ||
.route("/.well-known/oauth-protected-resource/mcp", get(resource)) | ||
.route( | ||
"/.well-known/oauth-authorization-server", | ||
get(server_metadata), | ||
) | ||
.route(METADATA, get(client_metadata)) | ||
.route("/mcp/register", post(register)) | ||
.route("/mcp/authorize", get(authorize).post(sign_in)) | ||
.route(CALLBACK, get(callback)) | ||
.route("/mcp/token", post(token)) | ||
.with_state(mcp) | ||
.merge(guarded) | ||
} | ||
| ||
/// Lets in a request with a token for an account still on the list. | ||
async fn bearer(State(mcp): State<Arc<Mcp>>, mut req: Request, next: Next) -> Response { | ||
let grant = req | ||
.headers() | ||
.get(header::AUTHORIZATION) | ||
.and_then(|v| v.to_str().ok()) | ||
.and_then(|v| v.strip_prefix("Bearer ")) | ||
.and_then(|t| mcp.open::<Grant>("access", t)) | ||
.filter(|g| mcp.dids.contains(&g.sub)); | ||
let Some(grant) = grant else { | ||
let metadata = format!("{}/.well-known/oauth-protected-resource{MCP}", mcp.url); | ||
return ( | ||
StatusCode::UNAUTHORIZED, | ||
[( | ||
header::WWW_AUTHENTICATE, | ||
format!("Bearer resource_metadata=\"{metadata}\""), | ||
)], | ||
) | ||
.into_response(); | ||
}; | ||
req.extensions_mut().insert(Caller(grant.sub)); | ||
next.run(req).await | ||
} | ||
| ||
/// The tools, each call made as the account its token is for. | ||
#[derive(Clone)] | ||
struct Remote { | ||
mcp: Arc<Mcp>, | ||
tools: Server, | ||
} | ||
| ||
impl ServerHandler for Remote { | ||
fn get_info(&self) -> ServerConfig { | ||
self.tools.get_info() | ||
} | ||
| ||
fn get_tool(&self, name: &str) -> Option<Tool> { | ||
self.tools.get_tool(name) | ||
} | ||
| ||
async fn list_tools( | ||
&self, | ||
request: Option<PaginatedRequestParams>, | ||
context: RequestContext<RoleServer>, | ||
) -> Result<ListToolsResult, McpError> { | ||
self.tools.list_tools(request, context).await | ||
} | ||
| ||
async fn call_tool( | ||
&self, | ||
request: CallToolRequestParams, | ||
context: RequestContext<RoleServer>, | ||
) -> Result<CallToolResponse, McpError> { | ||
let did = context | ||
.extensions | ||
.get::<Parts>() | ||
.and_then(|p| p.extensions.get::<Caller>()) | ||
.map(|c| c.0.clone()) | ||
.unwrap_or_default(); | ||
let server = Server::new(self.mcp.ctx_for(&did)); | ||
server.call_tool(request, context).await | ||
} | ||
} | ||
| ||
async fn resource(State(mcp): State<Arc<Mcp>>) -> Json<Value> { | ||
Json(json!({ | ||
"resource": mcp.resource(), | ||
"authorization_servers": [mcp.url], | ||
"bearer_methods_supported": ["header"], | ||
"resource_name": "knotless", | ||
})) | ||
} | ||
| ||
async fn server_metadata(State(mcp): State<Arc<Mcp>>) -> Json<Value> { | ||
let url = &mcp.url; | ||
Json(json!({ | ||
"issuer": url, | ||
"authorization_endpoint": format!("{url}/mcp/authorize"), | ||
"token_endpoint": format!("{url}/mcp/token"), | ||
"registration_endpoint": format!("{url}/mcp/register"), | ||
"response_types_supported": ["code"], | ||
"grant_types_supported": ["authorization_code", "refresh_token"], | ||
"code_challenge_methods_supported": ["S256"], | ||
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"], | ||
"authorization_response_iss_parameter_supported": true, | ||
})) | ||
} | ||
| ||
/// This server as a client of PDSes, which its client ID names. | ||
async fn client_metadata(State(mcp): State<Arc<Mcp>>) -> Json<Value> { | ||
let client = mcp.atproto(); | ||
Json(json!({ | ||
"client_id": client.id, | ||
"client_name": "knotless MCP", | ||
"application_type": "web", | ||
"grant_types": ["authorization_code", "refresh_token"], | ||
"response_types": ["code"], | ||
"redirect_uris": [format!("{}{CALLBACK}", mcp.url)], | ||
"scope": oauth::SCOPE.as_str(), | ||
"token_endpoint_auth_method": "private_key_jwt", | ||
"token_endpoint_auth_signing_alg": "ES256", | ||
"dpop_bound_access_tokens": true, | ||
"jwks": client.jwks(), | ||
})) | ||
} | ||
| ||
/// Where an assistant may be sent back to: https, or the machine it runs on. | ||
fn redirect_allowed(uri: &str) -> bool { | ||
reqwest::Url::parse(uri).is_ok_and(|u| { | ||
u.fragment().is_none() | ||
&& (u.scheme() == "https" | ||
|| u.scheme() == "http" | ||
&& matches!(u.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"))) | ||
}) | ||
} | ||
| ||
/// Registers an assistant (RFC 7591). A client that asks to prove itself | ||
/// gets a secret, sent in the token request's body. | ||
async fn register(State(mcp): State<Arc<Mcp>>, Json(m): Json<Value>) -> Response { | ||
let uris: Vec<String> = m["redirect_uris"] | ||
.as_array() | ||
.into_iter() | ||
.flatten() | ||
.filter_map(|u| u.as_str().map(String::from)) | ||
.collect(); | ||
if uris.is_empty() || !uris.iter().all(|u| redirect_allowed(u)) { | ||
return Refusal( | ||
StatusCode::BAD_REQUEST, | ||
"invalid_redirect_uri", | ||
"Each redirect_uri must be https, or http on localhost".into(), | ||
) | ||
.into_response(); | ||
} | ||
let name = m["client_name"].as_str().unwrap_or("An assistant").trim(); | ||
let client = Client { | ||
redirect_uris: uris, | ||
name: name.chars().take(60).collect(), | ||
secret: m["token_endpoint_auth_method"].as_str().unwrap_or("none") != "none", | ||
}; | ||
let id = mcp.sign("client", CLIENT_TTL, &client); | ||
let mut out = json!({ | ||
"client_id": id, | ||
"client_id_issued_at": keys::now_micros() / 1_000_000, | ||
"client_name": client.name, | ||
"redirect_uris": client.redirect_uris, | ||
"grant_types": ["authorization_code", "refresh_token"], | ||
"response_types": ["code"], | ||
"token_endpoint_auth_method": if client.secret { "client_secret_post" } else { "none" }, | ||
}); | ||
if client.secret { | ||
out["client_secret"] = URL_SAFE_NO_PAD.encode(mcp.key.sign(id.as_bytes())).into(); | ||
out["client_secret_expires_at"] = 0.into(); | ||
} | ||
(StatusCode::CREATED, Json(out)).into_response() | ||
} | ||
| ||
/// Where the assistant sends its user: checks what it asks, then asks the | ||
/// user who they are. | ||
async fn authorize( | ||
State(mcp): State<Arc<Mcp>>, | ||
Query(q): Query<HashMap<String, String>>, | ||
) -> Response { | ||
let Some(client) = q.get("client_id").and_then(|id| mcp.client(id)) else { | ||
return page( | ||
StatusCode::BAD_REQUEST, | ||
"<p>This assistant didn't say who it is, or isn't registered here. Go back to it and connect again.", | ||
); | ||
}; | ||
let redirect = q | ||
.get("redirect_uri") | ||
.or(client | ||
.redirect_uris | ||
.first() | ||
.filter(|_| client.redirect_uris.len() == 1)) | ||
.filter(|r| client.redirect_uris.contains(r)); | ||
let Some(redirect) = redirect else { | ||
return page( | ||
StatusCode::BAD_REQUEST, | ||
"<p>This assistant asked to be sent back somewhere it didn't register. Go back to it and connect again.", | ||
); | ||
}; | ||
let state = q.get("state").cloned(); | ||
let refuse = |error: &str, why: &str| { | ||
Redirect::to(&mcp.back( | ||
redirect, | ||
state.as_deref(), | ||
&[("error", error), ("error_description", why)], | ||
)) | ||
.into_response() | ||
}; | ||
if q.get("response_type").map(String::as_str) != Some("code") { | ||
return refuse("unsupported_response_type", "Only code"); | ||
} | ||
let challenge = q | ||
.get("code_challenge") | ||
.filter(|_| q.get("code_challenge_method").map(String::as_str) == Some("S256")); | ||
let Some(challenge) = challenge else { | ||
return refuse("invalid_request", "PKCE with S256 is required"); | ||
}; | ||
if q.get("resource") | ||
.is_some_and(|r| r.trim_end_matches('/') != mcp.resource()) | ||
{ | ||
return refuse( | ||
"invalid_target", | ||
&format!("This server is {}", mcp.resource()), | ||
); | ||
} | ||
let ask = Ask { | ||
client_id: q["client_id"].clone(), | ||
name: client.name, | ||
redirect_uri: redirect.clone(), | ||
state, | ||
challenge: challenge.clone(), | ||
}; | ||
let signed = mcp.sign("ask", SIGN_IN.as_secs(), &ask); | ||
page(StatusCode::OK, &form(&ask.name, &signed, None)) | ||
} | ||
| ||
#[derive(Deserialize)] | ||
struct SignIn { | ||
ask: String, | ||
handle: String, | ||
} | ||
| ||
/// The user's handle: sends them to their PDS to sign in, if the account | ||
/// may use this server. | ||
async fn sign_in(State(mcp): State<Arc<Mcp>>, Form(f): Form<SignIn>) -> Response { | ||
let Some(ask) = mcp.open::<Ask>("ask", &f.ask) else { | ||
return page( | ||
StatusCode::BAD_REQUEST, | ||
"<p>This sign-in took too long. Go back to the assistant and connect again.", | ||
); | ||
}; | ||
let handle = f.handle.trim().trim_start_matches('@').to_lowercase(); | ||
let Ok(id) = identity::resolve(&mcp.ctx, &handle).await else { | ||
let problem = format!("No account answers to {handle}."); | ||
return page( | ||
StatusCode::BAD_REQUEST, | ||
&form(&ask.name, &f.ask, Some(&problem)), | ||
); | ||
}; | ||
if !mcp.dids.contains(&id.did) { | ||
let why = format!( | ||
"<p>{} can't use this server's MCP: it lets in only the accounts it was set up for.", | ||
escape(&handle) | ||
); | ||
return page(StatusCode::FORBIDDEN, &why); | ||
} | ||
let callback = format!("{}{CALLBACK}", mcp.url); | ||
let request = oauth::authorize( | ||
&mcp.ctx, | ||
&mcp.atproto(), | ||
id, | ||
&handle, | ||
&callback, | ||
&oauth::SCOPE, | ||
) | ||
.await; | ||
let request = match request { | ||
Ok(request) => request, | ||
Err(err) => { | ||
let why = format!( | ||
"<p>{}'s server didn't take the sign-in: {}", | ||
escape(&handle), | ||
escape(&format!("{err:#}")) | ||
); | ||
return page(StatusCode::BAD_GATEWAY, &why); | ||
} | ||
}; | ||
let url = request.url.clone(); | ||
let mut pending = mcp.pending.lock().unwrap(); | ||
pending.retain(|_, p| p.started.elapsed() < SIGN_IN); | ||
pending.insert( | ||
request.state.clone(), | ||
Pending { | ||
ask, | ||
request, | ||
started: Instant::now(), | ||
}, | ||
); | ||
Redirect::to(&url).into_response() | ||
} | ||
| ||
/// Where the user's PDS sends them back: keeps the session, and sends them | ||
/// on to the assistant with a code, or why there is none. | ||
async fn callback( | ||
State(mcp): State<Arc<Mcp>>, | ||
Query(params): Query<HashMap<String, String>>, | ||
) -> Response { | ||
let state = params.get("state").cloned().unwrap_or_default(); | ||
let Some(pending) = mcp.pending.lock().unwrap().remove(&state) else { | ||
return page( | ||
StatusCode::BAD_REQUEST, | ||
"<p>No sign-in is waiting for this. Go back to the assistant and connect again.", | ||
); | ||
}; | ||
let redirect = pending.ask.redirect_uri.clone(); | ||
let state = pending.ask.state.clone(); | ||
let to = match mcp.finish(pending, ¶ms).await { | ||
Ok(code) => mcp.back(&redirect, state.as_deref(), &[("code", &code)]), | ||
Err(err) => mcp.back( | ||
&redirect, | ||
state.as_deref(), | ||
&[ | ||
("error", "access_denied"), | ||
("error_description", &format!("{err:#}")), | ||
], | ||
), | ||
}; | ||
Redirect::to(&to).into_response() | ||
} | ||
| ||
async fn token(State(mcp): State<Arc<Mcp>>, Form(form): Form<HashMap<String, String>>) -> Response { | ||
match mcp.tokens(&form) { | ||
Ok(tokens) => ([(header::CACHE_CONTROL, "no-store")], Json(tokens)).into_response(), | ||
Err(refusal) => refusal.into_response(), | ||
} | ||
} | ||
| ||
fn escape(text: &str) -> String { | ||
text.replace('&', "&") | ||
.replace('<', "<") | ||
.replace('>', ">") | ||
.replace('"', """) | ||
} | ||
| ||
/// Asks who the user is, for the assistant `name`. | ||
fn form(name: &str, ask: &str, problem: Option<&str>) -> String { | ||
let name = escape(name); | ||
let problem = problem | ||
.map(|p| format!("<p class=problem>{}", escape(p))) | ||
.unwrap_or_default(); | ||
format!( | ||
"<h1>Connect {name} to knotless</h1>\ | ||
<p>{name} asks to work on your repositories as you. Sign in with your atproto account to let it.\ | ||
{problem}\ | ||
<form method=post action=/mcp/authorize>\ | ||
<input type=hidden name=ask value=\"{ask}\">\ | ||
<label>Handle <input name=handle autocomplete=username placeholder=you.bsky.social required autofocus></label>\ | ||
<button>Sign in</button></form>" | ||
) | ||
} | ||
| ||
fn page(status: StatusCode, body: &str) -> Response { | ||
let html = format!( | ||
"<!doctype html><meta charset=utf-8><meta name=viewport content=\"width=device-width\">\ | ||
<title>knotless</title><style>\ | ||
body{{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem;color:#222;background:#fff}}\ | ||
@media(prefers-color-scheme:dark){{body{{color:#ddd;background:#161616}}}}\ | ||
label,input,button{{display:block;font:inherit;margin:.5rem 0}}input{{width:100%;padding:.4rem}}\ | ||
.problem{{color:#b00}}</style>{body}" | ||
); | ||
(status, Html(html)).into_response() | ||
} | ||
crates/knotless-mcp/src/lib.rs+2−0
| @@ -6,6 +6,8 @@ | ||
//! Everything runs as the account `knotless login` last logged in, or as | ||
//! `KNOTLESS_IDENTIFIER`. | ||
| ||
pub mod http; | ||
| ||
use knotless::commit::{self, Change, Spec}; | ||
use knotless::env::Ctx; | ||
use knotless::forge; | ||
crates/knotless-mcp/tests/http.rsnew+514−0
| @@ -0,0 +1,514 @@ | ||
//! MCP over HTTP against a reference PDS: an assistant registers, its user | ||
//! signs in with their account, and it works as them with the token it gets. | ||
| ||
#[path = "../../knotless/tests/common/mod.rs"] | ||
mod common; | ||
use common::*; | ||
| ||
use knotless_mcp::http::{Mcp, router}; | ||
use reqwest::blocking::{Client, Response}; | ||
use serde_json::{Value, json}; | ||
use std::collections::HashMap; | ||
use std::io::{BufRead, BufReader, Write}; | ||
use std::sync::mpsc; | ||
| ||
struct Server { | ||
/// Where tests reach it, and where PDSes and assistants do. | ||
base: String, | ||
url: String, | ||
_rt: tokio::runtime::Runtime, | ||
_proxy: Option<TlsProxy>, | ||
} | ||
| ||
/// The MCP server for `dids`, keeping what it needs in `s`'s directories. | ||
fn server(s: &Sandbox, dids: Vec<String>) -> Server { | ||
let rt = tokio::runtime::Runtime::new().unwrap(); | ||
let listener = rt | ||
.block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) | ||
.unwrap(); | ||
let port = listener.local_addr().unwrap().port(); | ||
// PDSes reach their clients over https. | ||
let proxy = tls_proxy(port); | ||
let mcp = Mcp::new(&ctx(s), &format!("{}/", proxy.url), dids).unwrap(); | ||
rt.spawn(async move { axum::serve(listener, router(mcp)).await.unwrap() }); | ||
Server { | ||
base: format!("http://127.0.0.1:{port}"), | ||
url: proxy.url.clone(), | ||
_rt: rt, | ||
_proxy: Some(proxy), | ||
} | ||
} | ||
| ||
fn client() -> Client { | ||
Client::builder() | ||
.redirect(reqwest::redirect::Policy::none()) | ||
.build() | ||
.unwrap() | ||
} | ||
| ||
fn location(res: &Response) -> String { | ||
res.headers()["location"].to_str().unwrap().to_string() | ||
} | ||
| ||
fn query(url: &str) -> HashMap<String, String> { | ||
reqwest::Url::parse(url) | ||
.unwrap() | ||
.query_pairs() | ||
.into_owned() | ||
.collect() | ||
} | ||
| ||
/// An assistant's loopback redirect, and the query the browser brings it. | ||
fn assistant() -> (String, mpsc::Receiver<HashMap<String, String>>) { | ||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); | ||
let uri = format!( | ||
"http://127.0.0.1:{}/back", | ||
listener.local_addr().unwrap().port() | ||
); | ||
let (tx, rx) = mpsc::channel(); | ||
std::thread::spawn(move || { | ||
for stream in listener.incoming() { | ||
let mut stream = stream.unwrap(); | ||
let mut line = String::new(); | ||
BufReader::new(&stream).read_line(&mut line).unwrap(); | ||
let target = line.split(' ').nth(1).unwrap_or_default(); | ||
stream | ||
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n") | ||
.unwrap(); | ||
let _ = tx.send(query(&format!("http://127.0.0.1{target}"))); | ||
} | ||
}); | ||
(uri, rx) | ||
} | ||
| ||
/// Approves (or rejects) the sign-in at `url` as `account`, in a browser. | ||
fn approve(account: &Account, extra: &str, url: &str) { | ||
let ok = std::process::Command::new("sh") | ||
.arg("-c") | ||
.arg(format!("{} '{url}'", browser(account, extra))) | ||
.status() | ||
.unwrap() | ||
.success(); | ||
assert!(ok); | ||
} | ||
| ||
fn register(s: &Server, body: Value) -> (u16, Value) { | ||
let res = client() | ||
.post(format!("{}/mcp/register", s.base)) | ||
.json(&body) | ||
.send() | ||
.unwrap(); | ||
(res.status().as_u16(), res.json().unwrap()) | ||
} | ||
| ||
fn token(s: &Server, form: &[(&str, &str)]) -> (u16, Value) { | ||
let res = client() | ||
.post(format!("{}/mcp/token", s.base)) | ||
.form(form) | ||
.send() | ||
.unwrap(); | ||
(res.status().as_u16(), res.json().unwrap()) | ||
} | ||
| ||
/// The sign-in form's signed request, from its page. | ||
fn ask(page: &str) -> String { | ||
let at = page.find("name=ask value=\"").unwrap() + 16; | ||
page[at..].split('"').next().unwrap().to_string() | ||
} | ||
| ||
/// The authorization page for `params`. | ||
fn authorize(s: &Server, params: &[(&str, &str)]) -> Response { | ||
client() | ||
.get(format!("{}/mcp/authorize", s.base)) | ||
.query(params) | ||
.send() | ||
.unwrap() | ||
} | ||
| ||
fn sign_in(s: &Server, ask: &str, handle: &str) -> Response { | ||
client() | ||
.post(format!("{}/mcp/authorize", s.base)) | ||
.form(&[("ask", ask), ("handle", handle)]) | ||
.send() | ||
.unwrap() | ||
} | ||
| ||
/// A JSON-RPC call to `/mcp` with `bearer`. | ||
fn rpc(s: &Server, bearer: Option<&str>, method: &str, params: Value) -> (u16, Value) { | ||
let mut req = client() | ||
.post(format!("{}/mcp", s.base)) | ||
.header("accept", "application/json, text/event-stream") | ||
.json(&json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params})); | ||
if let Some(t) = bearer { | ||
req = req.bearer_auth(t); | ||
} | ||
let res = req.send().unwrap(); | ||
let status = res.status().as_u16(); | ||
(status, res.json().unwrap_or(Value::Null)) | ||
} | ||
| ||
#[test] | ||
fn an_assistant_signs_in_and_works_as_its_user() { | ||
let s = Sandbox::new(); | ||
let allowed = account(); | ||
let other = account(); | ||
let srv = server(&s, vec![allowed.did.clone()]); | ||
| ||
// Discovery, from the 401 on. | ||
let (status, _) = rpc(&srv, None, "tools/list", json!({})); | ||
assert_eq!(status, 401); | ||
let res = client().post(format!("{}/mcp", srv.base)).send().unwrap(); | ||
let challenge = res.headers()["www-authenticate"].to_str().unwrap(); | ||
assert!( | ||
challenge.contains(&format!( | ||
"{}/.well-known/oauth-protected-resource/mcp", | ||
srv.url | ||
)), | ||
"{challenge}" | ||
); | ||
for path in ["oauth-protected-resource", "oauth-protected-resource/mcp"] { | ||
let meta: Value = client() | ||
.get(format!("{}/.well-known/{path}", srv.base)) | ||
.send() | ||
.unwrap() | ||
.json() | ||
.unwrap(); | ||
assert_eq!(meta["resource"], format!("{}/mcp", srv.url)); | ||
assert_eq!(meta["authorization_servers"][0], srv.url); | ||
} | ||
let meta: Value = client() | ||
.get(format!( | ||
"{}/.well-known/oauth-authorization-server", | ||
srv.base | ||
)) | ||
.send() | ||
.unwrap() | ||
.json() | ||
.unwrap(); | ||
assert_eq!(meta["token_endpoint"], format!("{}/mcp/token", srv.url)); | ||
| ||
// Registration: https, or http on the assistant's own machine. | ||
let (redirect, back) = assistant(); | ||
for bad in [ | ||
json!({}), | ||
json!({"redirect_uris": ["http://elsewhere.test/back"]}), | ||
json!({"redirect_uris": ["https://ok.test/back", "https://ok.test/#frag"]}), | ||
json!({"redirect_uris": ["not a url"]}), | ||
] { | ||
let (status, body) = register(&srv, bad); | ||
assert_eq!(status, 400); | ||
assert_eq!(body["error"], "invalid_redirect_uri"); | ||
} | ||
let (status, reg) = register( | ||
&srv, | ||
json!({ | ||
"redirect_uris": [redirect], | ||
"client_name": "Claude <b>", | ||
"token_endpoint_auth_method": "client_secret_post", | ||
}), | ||
); | ||
assert_eq!(status, 201, "{reg}"); | ||
assert_eq!(reg["token_endpoint_auth_method"], "client_secret_post"); | ||
let id = reg["client_id"].as_str().unwrap().to_string(); | ||
let secret = reg["client_secret"].as_str().unwrap().to_string(); | ||
| ||
// What the authorization page refuses: to the user when it cannot | ||
// trust where to send them back, else to the assistant. | ||
let verifier = "a-verifier-long-enough-for-pkce-0123456789"; | ||
let challenge = knotless::oauth::sha256(verifier); | ||
let mut params = vec![ | ||
("client_id", id.as_str()), | ||
("response_type", "code"), | ||
("code_challenge", challenge.as_str()), | ||
("code_challenge_method", "S256"), | ||
("state", "st"), | ||
]; | ||
assert_eq!(authorize(&srv, &[("client_id", "nope")]).status(), 400); | ||
let res = authorize( | ||
&srv, | ||
&[("client_id", &id), ("redirect_uri", "https://x.test/")], | ||
); | ||
assert_eq!(res.status(), 400); | ||
let refused = |extra: (&str, &str)| { | ||
let mut p = params.clone(); | ||
p.retain(|(k, _)| *k != extra.0); | ||
p.push(extra); | ||
let res = authorize(&srv, &p); | ||
assert_eq!(res.status(), 303); | ||
let q = query(&location(&res)); | ||
assert_eq!(q["state"], "st"); | ||
assert_eq!(q["iss"], srv.url); | ||
q["error"].clone() | ||
}; | ||
assert_eq!( | ||
refused(("response_type", "token")), | ||
"unsupported_response_type" | ||
); | ||
assert_eq!( | ||
refused(("code_challenge_method", "plain")), | ||
"invalid_request" | ||
); | ||
assert_eq!( | ||
refused(("resource", "https://elsewhere.test/mcp")), | ||
"invalid_target" | ||
); | ||
let resource = format!("{}/mcp/", srv.url); | ||
params.push(("resource", &resource)); | ||
| ||
// The user says who they are; only an account on the list goes on. | ||
let page = authorize(&srv, ¶ms).text().unwrap(); | ||
assert!( | ||
page.contains("Connect Claude <b> to knotless"), | ||
"{page}" | ||
); | ||
let signed = ask(&page); | ||
assert_eq!(sign_in(&srv, "forged", &allowed.handle).status(), 400); | ||
let res = sign_in(&srv, &signed, "nobody.invalid"); | ||
assert_eq!(res.status(), 400); | ||
assert!( | ||
res.text() | ||
.unwrap() | ||
.contains("No account answers to nobody.invalid") | ||
); | ||
let res = sign_in(&srv, &signed, &other.handle); | ||
assert_eq!(res.status(), 403); | ||
| ||
// A sign-in the user walks away from waits, among others. | ||
assert_eq!(sign_in(&srv, &signed, &allowed.handle).status(), 303); | ||
// A refusal at the PDS goes back to the assistant. | ||
let res = sign_in(&srv, &signed, &format!("@{}", allowed.handle)); | ||
assert_eq!(res.status(), 303); | ||
approve(&allowed, "--reject", &location(&res)); | ||
let q = back.recv().unwrap(); | ||
assert_eq!(q["error"], "access_denied"); | ||
assert_eq!(q["state"], "st"); | ||
| ||
// Approved: the assistant gets a code, and trades it for tokens. | ||
let res = sign_in(&srv, &signed, &allowed.handle); | ||
approve(&allowed, "", &location(&res)); | ||
let q = back.recv().unwrap(); | ||
assert_eq!(q["state"], "st"); | ||
assert_eq!(q["iss"], srv.url); | ||
let code = q["code"].clone(); | ||
let form = |extra: &[(&'static str, &str)]| { | ||
let mut f: Vec<(&str, String)> = vec![ | ||
("grant_type", "authorization_code".into()), | ||
("client_id", id.clone()), | ||
("client_secret", secret.clone()), | ||
("code", code.clone()), | ||
("code_verifier", verifier.into()), | ||
("redirect_uri", redirect.clone()), | ||
]; | ||
for (k, v) in extra { | ||
f.retain(|(key, _)| key != k); | ||
f.push((k, v.to_string())); | ||
} | ||
f | ||
}; | ||
let send = |f: Vec<(&str, String)>| { | ||
let f: Vec<(&str, &str)> = f.iter().map(|(k, v)| (*k, v.as_str())).collect(); | ||
token(&srv, &f) | ||
}; | ||
let (status, body) = send(form(&[("client_secret", "bm9wZQ")])); | ||
assert_eq!( | ||
(status, body["error"].as_str()), | ||
(401, Some("invalid_client")) | ||
); | ||
let (status, _) = send(form(&[("client_id", "nope")])); | ||
assert_eq!(status, 401); | ||
let (status, body) = send(form(&[("grant_type", "password")])); | ||
assert_eq!( | ||
(status, body["error"].as_str()), | ||
(400, Some("unsupported_grant_type")) | ||
); | ||
let (status, tokens) = send(form(&[])); | ||
assert_eq!(status, 200, "{tokens}"); | ||
assert_eq!(tokens["token_type"], "Bearer"); | ||
// A code is good once. | ||
let (status, body) = send(form(&[])); | ||
assert_eq!( | ||
(status, body["error"].as_str()), | ||
(400, Some("invalid_grant")) | ||
); | ||
let access = tokens["access_token"].as_str().unwrap().to_string(); | ||
let refresh = tokens["refresh_token"].as_str().unwrap().to_string(); | ||
| ||
// The tools, as the user. | ||
let (status, init) = rpc( | ||
&srv, | ||
Some(&access), | ||
"initialize", | ||
json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}), | ||
); | ||
assert_eq!(status, 200, "{init}"); | ||
assert!( | ||
init["result"]["instructions"] | ||
.as_str() | ||
.unwrap() | ||
.contains("handle/name") | ||
); | ||
let (_, list) = rpc(&srv, Some(&access), "tools/list", json!({})); | ||
assert!(list.to_string().contains("create_repo"), "{list}"); | ||
let (_, made) = rpc( | ||
&srv, | ||
Some(&access), | ||
"tools/call", | ||
json!({"name": "create_repo", "arguments": {"name": "over-http"}}), | ||
); | ||
let text = made["result"]["content"][0]["text"].as_str().unwrap(); | ||
assert!( | ||
text.contains(&format!("{}/over-http", allowed.handle)), | ||
"{made}" | ||
); | ||
// A newer client names the tool in headers too, which are checked | ||
// against its schema. | ||
let res = client() | ||
.post(format!("{}/mcp", srv.base)) | ||
.bearer_auth(&access) | ||
.header("accept", "application/json, text/event-stream") | ||
.header("mcp-protocol-version", "2026-07-28") | ||
.header("mcp-method", "tools/call") | ||
.header("mcp-name", "list_repos") | ||
.json( | ||
&json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { | ||
"name": "list_repos", | ||
"arguments": {"owner": allowed.handle}, | ||
"_meta": { | ||
"io.modelcontextprotocol/protocolVersion": "2026-07-28", | ||
"io.modelcontextprotocol/clientCapabilities": {}, | ||
"io.modelcontextprotocol/clientInfo": {"name": "t", "version": "1"}, | ||
}, | ||
}}), | ||
) | ||
.send() | ||
.unwrap(); | ||
let listed = res.text().unwrap(); | ||
assert!(listed.contains("over-http"), "{listed}"); | ||
// A refresh token is the client's alone, and makes new tokens. | ||
let (status, _) = token( | ||
&srv, | ||
&[ | ||
("grant_type", "refresh_token"), | ||
("client_id", &id), | ||
("client_secret", &secret), | ||
("refresh_token", &access), | ||
], | ||
); | ||
assert_eq!(status, 400); | ||
let (status, fresh) = token( | ||
&srv, | ||
&[ | ||
("grant_type", "refresh_token"), | ||
("client_id", &id), | ||
("client_secret", &secret), | ||
("refresh_token", &refresh), | ||
], | ||
); | ||
assert_eq!(status, 200, "{fresh}"); | ||
| ||
// A public client proves nothing but its PKCE; a code is for the | ||
// client it was issued to, with the verifier it was asked with. | ||
let (_, public) = register(&srv, json!({"redirect_uris": [redirect]})); | ||
assert_eq!(public["token_endpoint_auth_method"], "none"); | ||
assert!(public.get("client_secret").is_none()); | ||
let pid = public["client_id"].as_str().unwrap().to_string(); | ||
let mut p = params.clone(); | ||
p[0] = ("client_id", &pid); | ||
let page = authorize(&srv, &p).text().unwrap(); | ||
assert!(page.contains("Connect An assistant to knotless")); | ||
// A code the assistant never trades waits, among others. | ||
let res = sign_in(&srv, &ask(&page), &allowed.handle); | ||
approve(&allowed, "", &location(&res)); | ||
back.recv().unwrap(); | ||
let res = sign_in(&srv, &ask(&page), &allowed.handle); | ||
approve(&allowed, "", &location(&res)); | ||
let code = back.recv().unwrap()["code"].clone(); | ||
let (status, _) = token( | ||
&srv, | ||
&[ | ||
("grant_type", "authorization_code"), | ||
("client_id", &pid), | ||
("code", &code), | ||
("code_verifier", "wrong"), | ||
], | ||
); | ||
assert_eq!(status, 400); | ||
| ||
// Nothing waits for a callback nobody started. | ||
let res = client() | ||
.get(format!("{}/mcp/oauth/callback?state=nope", srv.base)) | ||
.send() | ||
.unwrap(); | ||
assert_eq!(res.status(), 400); | ||
| ||
// Off the list, its tokens stop working. | ||
let off = server(&s, vec![]); | ||
assert_eq!(rpc(&off, Some(&access), "tools/list", json!({})).0, 401); | ||
let (status, body) = token( | ||
&off, | ||
&[ | ||
("grant_type", "refresh_token"), | ||
("client_id", &id), | ||
("client_secret", &secret), | ||
("refresh_token", &refresh), | ||
], | ||
); | ||
assert_eq!(status, 400); | ||
assert!( | ||
body["error_description"] | ||
.as_str() | ||
.unwrap() | ||
.contains("no longer") | ||
); | ||
} | ||
| ||
#[test] | ||
fn a_pds_that_will_not_take_the_sign_in() { | ||
// A server whose public URL no PDS can reach is no client of theirs. | ||
let s = Sandbox::new(); | ||
let allowed = account(); | ||
let rt = tokio::runtime::Runtime::new().unwrap(); | ||
let listener = rt | ||
.block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) | ||
.unwrap(); | ||
let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); | ||
let mcp = Mcp::new( | ||
&ctx(&s), | ||
"https://nowhere.invalid", | ||
vec![allowed.did.clone()], | ||
) | ||
.unwrap(); | ||
rt.spawn(async move { axum::serve(listener, router(mcp)).await.unwrap() }); | ||
let srv = Server { | ||
base, | ||
url: String::new(), | ||
_rt: rt, | ||
_proxy: None, | ||
}; | ||
let meta: Value = client() | ||
.get(format!("{}/mcp/oauth-client-metadata.json", srv.base)) | ||
.send() | ||
.unwrap() | ||
.json() | ||
.unwrap(); | ||
assert_eq!( | ||
meta["client_id"], | ||
"https://nowhere.invalid/mcp/oauth-client-metadata.json" | ||
); | ||
let (_, reg) = register(&srv, json!({"redirect_uris": ["https://claude.test/back"]})); | ||
let id = reg["client_id"].as_str().unwrap(); | ||
let page = authorize( | ||
&srv, | ||
&[ | ||
("client_id", id), | ||
("response_type", "code"), | ||
("code_challenge", "c"), | ||
("code_challenge_method", "S256"), | ||
], | ||
) | ||
.text() | ||
.unwrap(); | ||
let res = sign_in(&srv, &ask(&page), &allowed.handle); | ||
assert_eq!(res.status(), 502); | ||
assert!(res.text().unwrap().contains("didn't take the sign-in")); | ||
} | ||
crates/knotless-serve/Cargo.toml+1−0
| @@ -9,6 +9,7 @@ rust-version.workspace = true | ||
[dependencies] | ||
reqwest.workspace = true | ||
knotless.workspace = true | ||
knotless-mcp = { path = "../knotless-mcp" } | ||
anyhow.workspace = true | ||
axum = { workspace = true, features = ["ws"] } | ||
ciborium.workspace = true | ||
crates/knotless-serve/src/lib.rs+11−1
| @@ -547,6 +547,16 @@ pub async fn run(opts: Options, ctx: Ctx) -> Result<()> { | ||
Some(path) => Some(index::Index::open(path)?), | ||
None => None, | ||
}; | ||
// MCP over HTTP, for the accounts KNOTLESS_MCP_DIDS names. | ||
let mcp = match (&opts.public_url, ctx.env.mcp_dids.clone()) { | ||
(_, dids) if dids.is_empty() => Router::new(), | ||
(Some(url), dids) => { | ||
knotless_mcp::http::router(knotless_mcp::http::Mcp::new(&ctx, url, dids)?) | ||
} | ||
(None, _) => { | ||
anyhow::bail!("KNOTLESS_MCP_DIDS takes --public-url, where assistants reach /mcp") | ||
} | ||
}; | ||
let app = App::build(ctx, opts.ttl, gateway, index, opts.private); | ||
if let (Some(service), Some(index)) = (opts.backfill, app.index.clone()) { | ||
let ctx = app.ctx.clone(); | ||
| @@ -568,7 +578,7 @@ pub async fn run(opts: Options, ctx: Ctx) -> Result<()> { | ||
// With no pages to hand on to, what this server does not answer is not found. | ||
let pages = opts.web.map(web::pages).unwrap_or_default(); | ||
let releases = releases::routes(opts.releases, opts.public_url); | ||
let routes = router(app).merge(releases).merge(pages); | ||
let routes = router(app).merge(releases).merge(mcp).merge(pages); | ||
axum::serve(listener, routes) | ||
.with_graceful_shutdown(async { | ||
let mut term = | ||
crates/knotless-serve/tests/serve.rs+14−0
| @@ -283,6 +283,14 @@ fn binary_lifecycle() { | ||
.unwrap(); | ||
assert_eq!(out.status.code(), Some(1)); | ||
assert!(String::from_utf8_lossy(&out.stderr).contains("loopback")); | ||
// MCP over HTTP is where assistants reach it. | ||
let out = s | ||
.command(exe, s.dir.path()) | ||
.env("KNOTLESS_MCP_DIDS", "did:plc:someone") | ||
.output() | ||
.unwrap(); | ||
assert_eq!(out.status.code(), Some(1)); | ||
assert!(String::from_utf8_lossy(&out.stderr).contains("--public-url")); | ||
| ||
std::fs::create_dir_all(s.path("releases")).unwrap(); | ||
std::fs::write(s.path("releases/latest"), "0.1.0\n").unwrap(); | ||
| @@ -311,6 +319,7 @@ fn binary_lifecycle() { | ||
"--releases", | ||
&s.path("releases").to_string_lossy(), | ||
]) | ||
.env("KNOTLESS_MCP_DIDS", "did:plc:someone") | ||
.stdout(std::process::Stdio::piped()) | ||
.spawn() | ||
.unwrap(); | ||
| @@ -340,6 +349,11 @@ fn binary_lifecycle() { | ||
// there, rather than handed on to the pages. | ||
assert_eq!(get(&format!("{base}/releases/latest")).1, "0.1.0\n"); | ||
assert_eq!(get(&format!("{base}/releases/nope")).0, 404); | ||
assert!( | ||
get(&format!("{base}/.well-known/oauth-protected-resource/mcp")) | ||
.1 | ||
.contains("http://localhost:1/mcp") | ||
); | ||
// The pages are handed on, here to nothing. | ||
assert_eq!(get(&format!("{base}/someone.test")).0, 502); | ||
std::process::Command::new("kill") | ||
crates/knotless/src/env.rs+13−0
| @@ -58,6 +58,9 @@ pub struct Env { | ||
pub moderator: Option<String>, | ||
/// The appview's admin password, for its operator. | ||
pub admin_password: Option<String>, | ||
/// The accounts, by DID, that may sign in to `knotless-serve`'s MCP | ||
/// server over HTTP. | ||
pub mcp_dids: Vec<String>, | ||
} | ||
| ||
impl Env { | ||
| @@ -125,6 +128,13 @@ impl Env { | ||
appview: get("KNOTLESS_APPVIEW").filter(|v| !v.is_empty()), | ||
moderator: get("KNOTLESS_MODERATOR").filter(|v| !v.is_empty()), | ||
admin_password: get("KNOTLESS_ADMIN_PASSWORD"), | ||
mcp_dids: get("KNOTLESS_MCP_DIDS") | ||
.unwrap_or_default() | ||
.split(',') | ||
.map(str::trim) | ||
.filter(|s| !s.is_empty()) | ||
.map(String::from) | ||
.collect(), | ||
} | ||
} | ||
} | ||
| @@ -178,6 +188,7 @@ mod tests { | ||
assert_eq!(e.compact_mode, CompactMode::Background); | ||
assert_eq!(e.inline_max, 192 * 1024); | ||
assert!(e.service.is_none()); | ||
assert!(e.mcp_dids.is_empty()); | ||
let bare = env(&[]); | ||
assert_eq!(bare.config_dir, PathBuf::from("./.config/knotless")); | ||
} | ||
| @@ -191,7 +202,9 @@ mod tests { | ||
("KNOTLESS_COMPACT", "off"), | ||
("KNOTLESS_INLINE_MAX", "10"), | ||
("KNOTLESS_CHUNK_SIZE", "nope"), | ||
("KNOTLESS_MCP_DIDS", "did:plc:a, did:plc:b,"), | ||
]); | ||
assert_eq!(e.mcp_dids, ["did:plc:a", "did:plc:b"]); | ||
assert_eq!(e.config_dir, PathBuf::from("/x/knotless")); | ||
assert_eq!(e.cache_dir, PathBuf::from("/c")); | ||
assert_eq!( | ||
crates/knotless/src/oauth.rs+12−3
| @@ -76,13 +76,15 @@ fn origin(url: &str) -> String { | ||
.unwrap_or_default() | ||
} | ||
| ||
fn random(bytes: usize) -> String { | ||
/// `bytes` random bytes, base64url. | ||
pub fn random(bytes: usize) -> String { | ||
let mut buf = vec![0; bytes]; | ||
rand_core::OsRng.fill_bytes(&mut buf); | ||
URL_SAFE_NO_PAD.encode(buf) | ||
} | ||
| ||
fn sha256(text: &str) -> String { | ||
/// A PKCE challenge: SHA-256, base64url. | ||
pub fn sha256(text: &str) -> String { | ||
URL_SAFE_NO_PAD.encode(Sha256::digest(text.as_bytes())) | ||
} | ||
| ||
| @@ -266,10 +268,17 @@ pub fn client_key(ctx: &Ctx) -> Result<P256Key> { | ||
return P256Key::from_bytes(&STANDARD.decode(text.trim())?); | ||
} | ||
let key = P256Key::generate(); | ||
store::write_bytes(&path, STANDARD.encode(key.to_bytes()).as_bytes(), true)?; | ||
set_client_key(ctx, &key)?; | ||
Ok(key) | ||
} | ||
| ||
/// Keeps `key` as this process's key as a confidential client, for a | ||
/// config directory whose sessions another process granted. | ||
pub fn set_client_key(ctx: &Ctx, key: &P256Key) -> Result<()> { | ||
let path = ctx.env.config_dir.join("oauth-client-key"); | ||
store::write_bytes(&path, STANDARD.encode(key.to_bytes()).as_bytes(), true) | ||
} | ||
| ||
/// Posts `form` from `client` to the server `issuer`, with a DPoP proof of | ||
/// `key`. An empty answer reads as `null`. | ||
async fn post<T: DeserializeOwned>( | ||
deploy/deploy.sh+3−1
| @@ -12,7 +12,8 @@ | ||
# | ||
# Optional: KNOTLESS_PROXY_NETWORK (knotless), the docker network the | ||
# reverse proxy reaches knotless-serve on; KNOTLESS_DATA (/var/lib/knotless), | ||
# its state on the host; KNOTLESS_JETSTREAM, the Jetstream it follows. | ||
# its state on the host; KNOTLESS_JETSTREAM, the Jetstream it follows; | ||
# KNOTLESS_MCP_DIDS, the accounts that may sign in to MCP at /mcp. | ||
| ||
set -euo pipefail | ||
cd "$(dirname "$0")/.." | ||
| @@ -46,6 +47,7 @@ $SSH " | ||
echo "KNOTLESS_PROXY_NETWORK=$NETWORK" | ||
echo "KNOTLESS_DATA=$DATA" | ||
[ -z "${KNOTLESS_JETSTREAM:-}" ] || echo "KNOTLESS_JETSTREAM=$KNOTLESS_JETSTREAM" | ||
[ -z "${KNOTLESS_MCP_DIDS:-}" ] || echo "KNOTLESS_MCP_DIDS=$KNOTLESS_MCP_DIDS" | ||
} | $SSH 'cat > /etc/knotless/.env' | ||
scp -q -o StrictHostKeyChecking=accept-new deploy/docker-compose.yml "$DEST:/etc/knotless/docker-compose.yml" | ||
| ||
deploy/docker-compose.yml+2−0
| @@ -30,6 +30,8 @@ services: | ||
# are in the config directory; mirrors and identities in the cache. | ||
KNOTLESS_CONFIG_DIR: /data/config | ||
KNOTLESS_CACHE_DIR: /data/cache | ||
# The accounts, by DID, that may sign in to MCP over HTTP at /mcp. | ||
KNOTLESS_MCP_DIDS: ${KNOTLESS_MCP_DIDS:-} | ||
env_file: | ||
# KNOTLESS_ADMIN_PASSWORD, made on the host by deploy.sh. | ||
- /etc/knotless/secrets.env | ||
deploy/local.env.example+2−0
| @@ -4,3 +4,5 @@ KNOTLESS_HOST=root@203.0.113.10 | ||
KNOTLESS_DOMAIN=example.com | ||
# KNOTLESS_PROXY_NETWORK=knotless | ||
# KNOTLESS_DATA=/var/lib/knotless | ||
# The accounts, by DID and comma-separated, that may use MCP at /mcp. | ||
# KNOTLESS_MCP_DIDS=did:plc:... | ||
harness/authorize.mjs+7−3
| @@ -1,6 +1,6 @@ | ||
// Stands in for the user's browser on the reference PDS's authorization | ||
// page: signs in, approves (or rejects) the request, and follows the | ||
// redirect back to the client's loopback listener. | ||
// redirect back to the client's loopback listener, and on from there. | ||
// | ||
// node harness/authorize.mjs <handle> <password> [--reject] [--ca <cert>] | ||
// <authorize-url> | ||
| @@ -113,6 +113,10 @@ const back = await send(done.url, { | ||
cookie: cookies(), | ||
}, | ||
}) | ||
const location = back.headers.location | ||
let location = back.headers.location | ||
if (!location) throw new Error(`redirect: ${back.status} ${back.body}`) | ||
await send(location) | ||
// The client may send the browser on again, as an MCP server does to the | ||
// assistant it signs in. | ||
for (let hops = 0; location && hops < 5; hops++) { | ||
location = (await send(location)).headers.location | ||
} | ||