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: revoke without rotating on refresh

A refresh no longer voids the client's earlier tokens. Rotation
mostly guards against a stolen refresh token used alongside the real
one, which matters little for a server open to a few listed accounts,
and it cost a reconnect whenever a refresh was retried or raced, or a
request was under way with the old access token. Only a new sign-in
and /mcp/revoke move the time a client's tokens are good from.
chadtmiller.com committed on Oct 22, 2026 parent f250d3f commit 2a5a5ec
Showing 2 changed files with 41 additions and 32 deletions
crates/knotless-mcp/src/http.rs+16−12
@@ -12,10 +12,9 @@
//! 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 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.
//! are good only from a time kept on disk, which a new sign-in or a
//! revocation (RFC 7009) moves on. 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};
@@ -236,7 +235,7 @@ impl Mcp {
}
/// Whether a token is still good: for an account on the list, and not
/// revoked or replaced since it was made.
/// revoked 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)
@@ -335,7 +334,7 @@ impl Mcp {
fn tokens(&self, form: &HashMap<String, String>) -> Result<Value, Refusal> {
let client_id = self.authenticate(form)?;
let (did, issued) = match form.get("grant_type").map(String::as_str) {
let (did, refreshing) = match form.get("grant_type").map(String::as_str) {
Some("authorization_code") => (self.redeem(&client_id, form)?, None),
Some("refresh_token") => {
let grant = form
@@ -358,13 +357,18 @@ impl Mcp {
"{did} may no longer use this server"
)));
}
// 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)?;
match refreshing {
Some(at) if at < self.not_before(&did, &client_id) => {
return Err(invalid_grant("This refresh token was revoked"));
}
// A refresh leaves the client's other tokens be, so a retried or
// parallel refresh, or a request under way, still works.
Some(_) => {}
// A new sign-in replaces every earlier token this client has for
// the account.
None => 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();
crates/knotless-mcp/tests/http.rs+25−20
@@ -418,23 +418,12 @@ fn an_assistant_signs_in_and_works_as_its_user() {
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);
assert!(
body["error_description"]
.as_str()
.unwrap()
.contains("used or revoked"),
"{body}"
);
assert_eq!(rpc(&srv, Some(&access), "tools/list", json!({})).0, 401);
assert_eq!(
rpc(&srv, Some(&fresh_access), "tools/list", json!({})).0,
200
);
// A refresh leaves the client's other tokens be: a retried or parallel
// refresh still works, and so does a request under way.
assert_eq!(refreshed(&refresh).0, 200);
assert_eq!(rpc(&srv, Some(&access), "tools/list", json!({})).0, 200);
// A server that cannot keep the time makes no tokens.
// A server that cannot keep a revocation says so.
let revoked = ctx(&s)
.env
.config_dir
@@ -443,10 +432,16 @@ fn an_assistant_signs_in_and_works_as_its_user() {
.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"))
revoke(
&srv,
&[
("client_id", &id),
("client_secret", &secret),
("token", &fresh_access)
]
),
500
);
std::fs::remove_file(&revoked).unwrap();
@@ -486,7 +481,17 @@ fn an_assistant_signs_in_and_works_as_its_user() {
rpc(&srv, Some(&fresh_access), "tools/list", json!({})).0,
401
);
assert_eq!(refreshed(&fresh_refresh).0, 400);
assert_eq!(rpc(&srv, Some(&access), "tools/list", json!({})).0, 401);
let (status, body) = refreshed(&fresh_refresh);
assert_eq!(status, 400);
assert!(
body["error_description"]
.as_str()
.unwrap()
.contains("revoked"),
"{body}"
);
assert_eq!(refreshed(&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.