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: 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.
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. | ||