chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs 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>
Chad Miller and Claude Opus 5.5 committed on Oct 21, 2026 parent dddda39 commit 5167b3e
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, &params).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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// 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, &params).text().unwrap();
assert!(
page.contains("Connect Claude &lt;b&gt; 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
}