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: show where sign-in goes; revoke and rotate tokens

The consent page now names the host the assistant's redirect goes to.
A client's name is whatever it registered with, so "Connect Claude"
alone let anyone register as Claude, redirect to their own host, and
take a listed user's code.

Tokens could not be revoked short of taking a DID off the list. Each
client's tokens for an account are now good only from a time kept on
disk under the account's directory. A sign-in, a refresh, or a
revocation at the new /mcp/revoke (RFC 7009) moves it on, so only the
newest pair works and a refresh token works once. Tokens from before
carry no time and keep working until the client next refreshes.
chadtmiller.com committed on Oct 22, 2026 parent 5167b3e commit f250d3f
Showing 2 changed files with 208 additions and 48 deletions
crates/knotless-mcp/src/http.rs+102−25
@@ -11,8 +11,11 @@
//!
//! 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.
//! stops its tokens at their next use. Each client's tokens for an account
//! are good only from a time kept on disk, which every sign-in, refresh and
//! revocation (RFC 7009) moves on: only the newest pair works, and a
//! refresh token works once. 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};
@@ -40,6 +43,7 @@ use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@@ -90,11 +94,14 @@ struct Client {
secret: bool,
}
/// Whom a token is for.
#[derive(Serialize, Deserialize)]
/// Whom a token is for, and when it was made.
#[derive(Deserialize)]
struct Grant {
sub: String,
client_id: String,
/// In microseconds. Tokens from before revocation carry none.
#[serde(default)]
issued: u64,
}
struct Pending {
@@ -197,6 +204,44 @@ impl Mcp {
self.open("client", id)
}
/// Where the time `client_id`'s tokens for `did` are good from is kept.
fn revoked(&self, did: &str, client_id: &str) -> PathBuf {
self.ctx_for(did)
.env
.config_dir
.join("revoked")
.join(oauth::sha256(client_id))
}
/// When `client_id`'s tokens for `did` are good from, in microseconds.
fn not_before(&self, did: &str, client_id: &str) -> u64 {
std::fs::read_to_string(self.revoked(did, client_id))
.ok()
.and_then(|at| at.parse().ok())
.unwrap_or(0)
}
/// Voids `client_id`'s tokens for `did` made before `at`.
fn revoke_before(&self, did: &str, client_id: &str, at: u64) -> Result<(), Refusal> {
let path = self.revoked(did, client_id);
std::fs::create_dir_all(path.parent().expect("a file is in a directory"))
.and_then(|()| std::fs::write(&path, at.to_string()))
.map_err(|e| {
Refusal(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
format!("Keeping a revocation: {e}"),
)
})
}
/// Whether a token is still good: for an account on the list, and not
/// revoked or replaced since it was made.
fn live(&self, grant: &Grant) -> bool {
self.dids.contains(&grant.sub)
&& grant.issued >= self.not_before(&grant.sub, &grant.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");
@@ -290,14 +335,16 @@ impl Mcp {
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"))?,
let (did, issued) = match form.get("grant_type").map(String::as_str) {
Some("authorization_code") => (self.redeem(&client_id, form)?, None),
Some("refresh_token") => {
let grant = form
.get("refresh_token")
.and_then(|t| self.open::<Grant>("refresh", t))
.filter(|g| g.client_id == client_id)
.ok_or_else(|| invalid_grant("Invalid refresh token"))?;
(grant.sub, Some(grant.issued))
}
other => {
return Err(Refusal(
StatusCode::BAD_REQUEST,
@@ -311,11 +358,15 @@ impl Mcp {
"{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");
// A refresh token works once: the tokens it made replaced it.
if issued.is_some_and(|at| at < self.not_before(&did, &client_id)) {
return Err(invalid_grant("This refresh token was used or revoked"));
}
// These replace every earlier token this client has for the account.
let issued = keys::now_micros();
self.revoke_before(&did, &client_id, issued)?;
let grant = json!({ "sub": did, "client_id": client_id, "issued": issued });
let mut access = grant.clone();
access["aud"] = self.resource().into();
Ok(json!({
"access_token": self.sign("access", ACCESS_TTL, &access),
@@ -358,11 +409,12 @@ pub fn router(mcp: Arc<Mcp>) -> Router {
.route("/mcp/authorize", get(authorize).post(sign_in))
.route(CALLBACK, get(callback))
.route("/mcp/token", post(token))
.route("/mcp/revoke", post(revoke))
.with_state(mcp)
.merge(guarded)
}
/// Lets in a request with a token for an account still on the list.
/// Lets in a request with a token that is still good.
async fn bearer(State(mcp): State<Arc<Mcp>>, mut req: Request, next: Next) -> Response {
let grant = req
.headers()
@@ -370,7 +422,7 @@ async fn bearer(State(mcp): State<Arc<Mcp>>, mut req: Request, next: Next) -> Re
.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));
.filter(|g| mcp.live(g));
let Some(grant) = grant else {
let metadata = format!("{}/.well-known/oauth-protected-resource{MCP}", mcp.url);
return (
@@ -442,10 +494,12 @@ async fn server_metadata(State(mcp): State<Arc<Mcp>>) -> Json<Value> {
"authorization_endpoint": format!("{url}/mcp/authorize"),
"token_endpoint": format!("{url}/mcp/token"),
"registration_endpoint": format!("{url}/mcp/register"),
"revocation_endpoint": format!("{url}/mcp/revoke"),
"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"],
"revocation_endpoint_auth_methods_supported": ["none", "client_secret_post"],
"authorization_response_iss_parameter_supported": true,
}))
}
@@ -577,7 +631,7 @@ async fn authorize(
challenge: challenge.clone(),
};
let signed = mcp.sign("ask", SIGN_IN.as_secs(), &ask);
page(StatusCode::OK, &form(&ask.name, &signed, None))
page(StatusCode::OK, &form(&ask, &signed, None))
}
#[derive(Deserialize)]
@@ -600,7 +654,7 @@ async fn sign_in(State(mcp): State<Arc<Mcp>>, Form(f): Form<SignIn>) -> Response
let problem = format!("No account answers to {handle}.");
return page(
StatusCode::BAD_REQUEST,
&form(&ask.name, &f.ask, Some(&problem)),
&form(&ask, &f.ask, Some(&problem)),
);
};
if !mcp.dids.contains(&id.did) {
@@ -681,6 +735,24 @@ async fn token(State(mcp): State<Arc<Mcp>>, Form(form): Form<HashMap<String, Str
}
}
/// Revokes a token (RFC 7009), and with it every other this client has for
/// the account. A token that is not good here is answered the same.
async fn revoke(
State(mcp): State<Arc<Mcp>>,
Form(form): Form<HashMap<String, String>>,
) -> Result<StatusCode, Refusal> {
let client_id = mcp.authenticate(&form)?;
let token = form.get("token").map(String::as_str).unwrap_or_default();
let grant = mcp
.open::<Grant>("access", token)
.or_else(|| mcp.open::<Grant>("refresh", token))
.filter(|g| g.client_id == client_id);
if let Some(grant) = grant {
mcp.revoke_before(&grant.sub, &client_id, keys::now_micros())?;
}
Ok(StatusCode::OK)
}
fn escape(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
@@ -688,18 +760,23 @@ fn escape(text: &str) -> String {
.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);
/// Asks who the user is, for the assistant `ask` is from. Its name is
/// whatever it registered with, so the page also says where signing in
/// sends the user: that, and not the name, says who the assistant is.
fn form(ask: &Ask, signed: &str, problem: Option<&str>) -> String {
let name = escape(&ask.name);
let to = reqwest::Url::parse(&ask.redirect_uri).expect("a registered redirect is a URL");
let to = escape(to.host_str().unwrap_or_default());
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.\
<p>Signing in sends you back to <strong>{to}</strong>. Any assistant can call itself anything, so if that isn't where you meant to connect, stop here.\
{problem}\
<form method=post action=/mcp/authorize>\
<input type=hidden name=ask value=\"{ask}\">\
<input type=hidden name=ask value=\"{signed}\">\
<label>Handle <input name=handle autocomplete=username placeholder=you.bsky.social required autofocus></label>\
<button>Sign in</button></form>"
)
crates/knotless-mcp/tests/http.rs+106−23
@@ -110,6 +110,16 @@ fn token(s: &Server, form: &[(&str, &str)]) -> (u16, Value) {
(res.status().as_u16(), res.json().unwrap())
}
fn revoke(s: &Server, form: &[(&str, &str)]) -> u16 {
client()
.post(format!("{}/mcp/revoke", s.base))
.form(form)
.send()
.unwrap()
.status()
.as_u16()
}
/// The sign-in form's signed request, from its page.
fn ask(page: &str) -> String {
let at = page.find("name=ask value=\"").unwrap() + 16;
@@ -186,6 +196,10 @@ fn an_assistant_signs_in_and_works_as_its_user() {
.json()
.unwrap();
assert_eq!(meta["token_endpoint"], format!("{}/mcp/token", srv.url));
assert_eq!(
meta["revocation_endpoint"],
format!("{}/mcp/revoke", srv.url)
);
// Registration: https, or http on the assistant's own machine.
let (redirect, back) = assistant();
@@ -256,20 +270,23 @@ fn an_assistant_signs_in_and_works_as_its_user() {
params.push(("resource", &resource));
// The user says who they are; only an account on the list goes on.
// The page names where signing in sends them, as any name can be taken.
let page = authorize(&srv, &params).text().unwrap();
assert!(
page.contains("Connect Claude &lt;b&gt; to knotless"),
"{page}"
);
assert!(
page.contains("sends you back to <strong>127.0.0.1</strong>"),
"{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 text = res.text().unwrap();
assert!(text.contains("No account answers to nobody.invalid"), "{text}");
assert!(text.contains("<strong>127.0.0.1</strong>"), "{text}");
let res = sign_in(&srv, &signed, &other.handle);
assert_eq!(res.status(), 403);
@@ -385,26 +402,91 @@ fn an_assistant_signs_in_and_works_as_its_user() {
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),
],
);
let refreshed = |t: &str| {
token(
&srv,
&[
("grant_type", "refresh_token"),
("client_id", &id),
("client_secret", &secret),
("refresh_token", t),
],
)
};
assert_eq!(refreshed(&access).0, 400);
let (status, fresh) = refreshed(&refresh);
assert_eq!(status, 200, "{fresh}");
let fresh_access = fresh["access_token"].as_str().unwrap().to_string();
let fresh_refresh = fresh["refresh_token"].as_str().unwrap().to_string();
// They replace the old ones: a refresh token works once.
let (status, body) = refreshed(&refresh);
assert_eq!(status, 400);
let (status, fresh) = token(
&srv,
&[
("grant_type", "refresh_token"),
("client_id", &id),
("client_secret", &secret),
("refresh_token", &refresh),
],
assert!(
body["error_description"]
.as_str()
.unwrap()
.contains("used or revoked"),
"{body}"
);
assert_eq!(status, 200, "{fresh}");
assert_eq!(rpc(&srv, Some(&access), "tools/list", json!({})).0, 401);
assert_eq!(
rpc(&srv, Some(&fresh_access), "tools/list", json!({})).0,
200
);
// A server that cannot keep the time makes no tokens.
let revoked = ctx(&s)
.env
.config_dir
.join("mcp")
.join(allowed.did.replace(':', "_"))
.join("revoked");
std::fs::remove_dir_all(&revoked).unwrap();
std::fs::write(&revoked, "").unwrap();
let (status, body) = refreshed(&fresh_refresh);
assert_eq!(
(status, body["error"].as_str()),
(500, Some("server_error"))
);
std::fs::remove_file(&revoked).unwrap();
// Revoking a token (RFC 7009) ends every other the client has for the
// account; one that is not good here is answered the same.
assert_eq!(
revoke(&srv, &[("client_id", "nope"), ("token", &fresh_access)]),
401
);
assert_eq!(
revoke(
&srv,
&[
("client_id", &id),
("client_secret", &secret),
("token", "nope")
]
),
200
);
assert_eq!(
rpc(&srv, Some(&fresh_access), "tools/list", json!({})).0,
200
);
assert_eq!(
revoke(
&srv,
&[
("client_id", &id),
("client_secret", &secret),
("token", &fresh_access)
]
),
200
);
assert_eq!(
rpc(&srv, Some(&fresh_access), "tools/list", json!({})).0,
401
);
assert_eq!(refreshed(&fresh_refresh).0, 400);
// 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.
@@ -508,6 +590,7 @@ fn a_pds_that_will_not_take_the_sign_in() {
)
.text()
.unwrap();
assert!(page.contains("<strong>claude.test</strong>"), "{page}");
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"));