From 34f1734b5347d288dfa039472ff3f00c588152e4 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 7 Nov 2026 18:56:11 +0000 Subject: [PATCH 1/3] fix(peer): re-linking revokes the previous link session on the other machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linking an environment again (the documented way to renew a link) signed in to the other machine afresh and dropped only the local copy of the old token. The other machine kept every earlier session valid for up to 30 days, so each relink added another identical "T3 Code · " row under its authorized clients. The other machine now accepts an RFC 7009 revocation at /oauth/mcp/revoke for its MCP client tokens, and linking again sends it the token it replaces before forgetting it. The call is best-effort: a machine without the endpoint keeps the old session until it expires, as before. Generated with Claude Opus 5.5 in T3 Code Nightly Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/EnvironmentAuth.ts | 21 ++++++++++++--- apps/server/src/auth/McpOAuth.test.ts | 16 +++++++++++- apps/server/src/auth/McpOAuth.ts | 20 ++++++++++++++ apps/server/src/auth/mcpOAuthHttp.ts | 3 ++- apps/server/src/peer/PeerLinks.test.ts | 29 ++++++++++++++++++++ apps/server/src/peer/PeerLinks.ts | 32 ++++++++++++++++++++--- docs/internals/remote.md | 4 ++- packages/contracts/src/auth.ts | 6 +++++ packages/contracts/src/environmentHttp.ts | 7 +++++ 9 files changed, 128 insertions(+), 10 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index b6bf63792bf62..1a99525df343d 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -546,6 +546,9 @@ export class EnvironmentAuth extends Context.Service< readonly authenticateMcpClient: ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect; + readonly authenticateMcpClientToken: ( + token: string, + ) => Effect.Effect; readonly issueMcpClientSession: (input: { readonly label: string; readonly access: AuthMcpClientAccess; @@ -1295,10 +1298,8 @@ export const make = Effect.gen(function* () { return yield* authenticateRequest(request); }); - const authenticateMcpClient: EnvironmentAuth["Service"]["authenticateMcpClient"] = (request) => { - const token = parseBearerToken(request); - if (token === null) return Effect.fail(new ServerAuthMissingCredentialError({})); - return sessions.verify(token).pipe( + const verifyMcpClientToken = (token: string) => + sessions.verify(token).pipe( mapSessionVerificationErrors, Effect.flatMap((session) => session.subject === MCP_CLIENT_SUBJECT && session.method === "bearer-access-token" @@ -1316,10 +1317,21 @@ export const make = Effect.gen(function* () { }), ), ), + ); + + const authenticateMcpClient: EnvironmentAuth["Service"]["authenticateMcpClient"] = (request) => { + const token = parseBearerToken(request); + if (token === null) return Effect.fail(new ServerAuthMissingCredentialError({})); + return verifyMcpClientToken(token).pipe( Effect.withSpan("EnvironmentAuth.authenticateMcpClient"), ); }; + const authenticateMcpClientToken: EnvironmentAuth["Service"]["authenticateMcpClientToken"] = ( + token, + ) => + verifyMcpClientToken(token).pipe(Effect.withSpan("EnvironmentAuth.authenticateMcpClientToken")); + const issueMcpClientSession: EnvironmentAuth["Service"]["issueMcpClientSession"] = (input) => sessions .issue({ @@ -1404,6 +1416,7 @@ export const make = Effect.gen(function* () { issueWebSocketTicket, issueStartupPairingUrl, authenticateMcpClient, + authenticateMcpClientToken, issueMcpClientSession, consumeMcpApprovalCode, authenticateBrowserSession, diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index d2d6c1d3b9967..6d06c1576493b 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -245,7 +245,7 @@ it.live("registers loopback and https clients and never redirects for an unverif ), ); -it.live("signs in with a pairing code and issues a token only /mcp accepts", () => +it.live("signs in with a pairing code, issues a token only /mcp accepts, and revokes only it", () => withRoutes((handler, auth) => Effect.gen(function* () { const clientId = yield* registeredClientId(handler); @@ -316,6 +316,20 @@ it.live("signs in with a pairing code and issues a token only /mcp accepts", () }), ); expect(ticket.status).toBe(401); + + // RFC 7009 answers alike for any token, so it reveals nothing about one. + const revoke = (presented: string) => + handler(at("/oauth/mcp/revoke", form({ token: presented }))).pipe( + Effect.map((response) => response.status), + ); + const other = yield* auth.issueSession(); + expect(yield* revoke("not-a-token")).toBe(200); + expect(yield* revoke(other.token)).toBe(200); + expect(yield* revoke(token.access_token)).toBe(200); + expect(yield* revoke(token.access_token)).toBe(200); + expect((yield* auth.listSessions()).map((listed) => listed.sessionId)).toEqual([ + other.sessionId, + ]); }), ), ); diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 0be7c1ebb2624..4595200331e84 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -227,6 +227,10 @@ export class McpOAuth extends Context.Service< readonly urls: McpOAuthUrls; readonly token: AuthMcpTokenRequest; }) => Effect.Effect; + /** RFC 7009: ends the MCP client session `token` belongs to. Any other token is left alone. */ + readonly revoke: ( + token: Redacted.Redacted, + ) => Effect.Effect; } >()("t3/auth/McpOAuth") {} @@ -557,6 +561,21 @@ const make = Effect.gen(function* () { }, ); + const revoke: McpOAuth["Service"]["revoke"] = Effect.fn("McpOAuth.revoke")(function* (token) { + yield* environmentAuth.authenticateMcpClientToken(Redacted.value(token)).pipe( + Effect.flatMap((client) => + environmentAuth.revokeSession(client.sessionId).pipe( + Effect.andThen( + Effect.logInfo("Revoked an MCP client session at its holder's request.", { + client: client.label, + }), + ), + ), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () => Effect.void), + ); + }); + const managedPolicy = serverSettings.managedPolicy; const maxRuntimeMode = managedPolicy?.limits?.maxRuntimeMode; return McpOAuth.of({ @@ -570,6 +589,7 @@ const make = Effect.gen(function* () { approve, deny, exchangeCode, + revoke, }); }); diff --git a/apps/server/src/auth/mcpOAuthHttp.ts b/apps/server/src/auth/mcpOAuthHttp.ts index c62b25cc2a610..adf364b580bfa 100644 --- a/apps/server/src/auth/mcpOAuthHttp.ts +++ b/apps/server/src/auth/mcpOAuthHttp.ts @@ -153,6 +153,7 @@ export const layer = HttpApiBuilder.group( yield* noStore; return yield* oauth.exchangeCode({ request, urls: yield* requestUrls, token: payload }); }), - ); + ) + .handle("revoke", ({ payload }) => oauth.revoke(payload.token).pipe(Effect.orDie)); }), ); diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index 36c9b09fb4459..5cbf4a16d4a5d 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -31,6 +31,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { descriptorOf, layerLinkingEnvironment, + linkTo, servePeer, type ServedPeer, } from "./PeerLinks.testkit.ts"; @@ -203,6 +204,34 @@ it.effect("links with a pairing code, and the peer holds its agents to the limit ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect("linking again ends the session it replaces on the peer", () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const a = yield* makeA(laptop); + yield* linkTo(a.links, b); + yield* probe(a); + const replaced = (yield* Ref.get(b.bearers)).at(-1)!; + + yield* linkTo(a.links, b, "read-only"); + yield* linkTo(a.links, b, "read-only"); + expect(yield* linkedSessions(b)).toHaveLength(1); + const presented = yield* b.auth + .authenticateMcpClient( + HttpServerRequest.fromWeb( + new Request(`${b.url}/mcp`, { headers: { authorization: replaced } }), + ), + ) + .pipe(Effect.flip); + expect(presented._tag).toBe("ServerAuthInvalidCredentialError"); + expect(yield* probe(a)).toEqual({ + caller: "T3 Code · Laptop", + modes: "approval-required/default", + }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect( "each machine's Linked environments beta gates links, and a link waits while it is off", () => diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index dfcab9f11f273..5cbbd2181afbf 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -394,6 +394,28 @@ const make = Effect.gen(function* () { return { primary: first.origin, peer: first.descriptor, origins }; }); + /** + * Ends the session a replaced link's token holds, at the address a call with that token would + * have used. A peer without the endpoint keeps it until it expires. + */ + const revokeReplaced = Effect.fn("PeerLinks.revokeReplaced")(function* (row: PeerLinkRow) { + const stored = yield* secrets.get(row.secret_name); + if (Option.isNone(stored)) return; + const urls = yield* decodeUrls(row.urls_json); + const origin = yield* Effect.findFirst(urls, (candidate) => + describe(candidate).pipe( + Effect.map( + (found) => Option.isSome(found) && found.value.environmentId === row.environment_id, + ), + ), + ); + if (Option.isNone(origin)) return; + const client = yield* clientFor(origin.value); + yield* client.mcpOAuth + .revoke({ payload: { token: Redacted.make(new TextDecoder().decode(stored.value)) } }) + .pipe(Effect.timeout(probeTimeout(origin.value))); + }); + /** The peer at `url`, with every address checked for credentials first. */ const answeringAt = Effect.fn("PeerLinks.answeringAt")(function* (urls: ReadonlyArray) { const origins = yield* Effect.forEach(urls, peerOrigin); @@ -480,10 +502,14 @@ const make = Effect.gen(function* () { `.pipe(Effect.catch(failStorage)); return previous; }).pipe(Effect.onError(() => secrets.remove(secretName).pipe(Effect.ignore))); - // Linking again replaces the old token. A racing link may leave its own - // behind; it is unused either way. + // Linking again replaces the old token, here and on the peer. A racing + // link may leave its own behind; it is unused either way. if (Option.isSome(previous) && previous.value.secret_name !== secretName) { - yield* secrets.remove(previous.value.secret_name).pipe(Effect.ignore); + const replaced = previous.value.secret_name; + yield* revokeReplaced(previous.value).pipe( + Effect.ignore, + Effect.ensuring(secrets.remove(replaced).pipe(Effect.ignore)), + ); } return linked; }); diff --git a/docs/internals/remote.md b/docs/internals/remote.md index 0c328c50317c5..956b8432c7036 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -74,7 +74,9 @@ agents work in another. The linking environment signs in to the other's `/mcp` as an outside MCP client, with a pairing code from it, and keeps that session's token in its own secret store. The other environment needs nothing new: it sees one more OAuth client, lists it in Sharing & access, and revokes it there. -Unlinking on the linking side only forgets the token. +Linking again sends the replaced token to `/oauth/mcp/revoke` at the first of +the old link's addresses that answers as that peer, best-effort, so an older +peer keeps that session until it expires. Unlinking on the linking side only forgets the token. - **The Linked environments beta (`linkedEnvironmentsEnabled`) gates both sides.** Off on the linking side, `PeerLinks.link`, link requests and diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 555fa7e90b268..b286c08bf6270 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -560,6 +560,12 @@ export const AuthMcpTokenResult = Schema.Struct({ }); export type AuthMcpTokenResult = typeof AuthMcpTokenResult.Type; +/** RFC 7009. The token revokes itself, so presenting it is the authority. */ +export const AuthMcpTokenRevocationRequest = Schema.Struct({ + token: Schema.RedactedFromValue(Schema.String), +}).pipe(HttpApiSchema.asFormUrlEncoded()); +export type AuthMcpTokenRevocationRequest = typeof AuthMcpTokenRevocationRequest.Type; + export class AuthMcpTokenError extends Schema.Error("AuthMcpTokenError")( { error: Schema.Literals([ diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 46dfcde197ac2..7853ffdb7a1eb 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -27,6 +27,7 @@ import { AuthMcpTokenError, AuthMcpTokenRequest, AuthMcpTokenResult, + AuthMcpTokenRevocationRequest, AuthPairingCredentialResult, AuthRevokeClientSessionInput, AuthRevokePairingLinkInput, @@ -667,6 +668,12 @@ class EnvironmentMcpOAuthHttpApi extends HttpApiGroup.make("mcpOAuth") success: AuthMcpTokenResult, error: AuthMcpTokenError, }), + ) + .add( + HttpApiEndpoint.post("revoke", "/oauth/mcp/revoke", { + payload: AuthMcpTokenRevocationRequest, + success: HttpApiSchema.Empty(200), + }), ) {} const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ From 6ee35a49bc53cbd4ed331890b803a13d62cef465 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 13 Nov 2026 09:43:40 +0000 Subject: [PATCH 2/3] fix(peer): relink revoke finds the old link's address with main's peer probe --- apps/server/src/peer/PeerLinks.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index 335fedb28fa61..bd2fa1223f857 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -483,7 +483,8 @@ const make = Effect.gen(function* () { if (Option.isNone(stored)) return; const urls = yield* decodeUrls(row.urls_json); const origin = yield* Effect.findFirst(urls, (candidate) => - describe(candidate).pipe( + probe(candidate).pipe( + Effect.option, Effect.map( (found) => Option.isSome(found) && found.value.environmentId === row.environment_id, ), From a411e4176f35a37c14a40d901b86ce247f0e7272 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 05:48:34 +0000 Subject: [PATCH 3/3] docs(remote): rewrap relink revocation note Co-Authored-By: Claude Opus 5.5 --- docs/internals/remote.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/internals/remote.md b/docs/internals/remote.md index 7c2626f28b084..2d1722847b84c 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -76,7 +76,8 @@ token in its own secret store. The other environment needs nothing new: it sees one more OAuth client, lists it in Sharing & access, and revokes it there. Linking again sends the replaced token to `/oauth/mcp/revoke` at the first of the old link's addresses that answers as that peer, best-effort, so an older -peer keeps that session until it expires. Unlinking on the linking side only forgets the token. +peer keeps that session until it expires. Unlinking on the linking side only +forgets the token. - **The Linked environments beta (`linkedEnvironmentsEnabled`) gates both sides.** Off on the linking side, `PeerLinks.link`, link requests and