Remove embedded OAuth implementation and flatten directory structure

Simplified the repository to have a single OAuth pattern with separate auth and MCP servers at the top level. Preserved embedded OAuth documentation as an alternative pattern.
This commit is contained in:
Basil Hosmer 2025-10-06 21:07:18 -04:00
commit c03a9b4e77
No known key found for this signature in database
3 changed files with 1556 additions and 15846 deletions

View file

@ -0,0 +1,87 @@
import crypto from "crypto";
import { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
/**
* Generates a PKCE code challenge from a verifier string.
* Uses S256 method as specified in RFC 7636.
* @param verifier The code verifier string
* @returns Base64url-encoded SHA256 hash of the verifier
*/
export function generatePKCEChallenge(verifier: string): string {
const buffer = Buffer.from(verifier);
const hash = crypto.createHash("sha256").update(buffer);
return hash.digest("base64url");
}
/**
* Generates a cryptographically secure random token.
* @returns 64-character hexadecimal string
*/
export function generateToken(): string {
return crypto.randomBytes(32).toString("hex");
}
/**
* Computes SHA256 hash of input data.
* @param data The string to hash
* @returns Hexadecimal representation of the hash
*/
export function sha256(data: string): string {
return crypto.createHash("sha256").update(data).digest("hex");
}
/**
* Encrypts a string using AES-256-CBC encryption.
* @param text The plaintext to encrypt
* @param key The encryption key (64 hex characters)
* @returns Encrypted string in format "iv:ciphertext"
*/
export function encryptString({ text, key }: { text: string; key: string }): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", Buffer.from(key, "hex"), iv);
let encrypted = cipher.update(text, "utf-8", "hex");
encrypted += cipher.final("hex");
return `${iv.toString("hex")}:${encrypted}`;
}
/**
* Decrypts a string encrypted with encryptString.
* @param encryptedText The encrypted string in format "iv:ciphertext"
* @param key The encryption key (64 hex characters)
* @returns Decrypted plaintext
*/
export function decryptString({
encryptedText,
key,
}: {
encryptedText: string;
key: string;
}): string {
const [ivHex, encrypted] = encryptedText.split(":");
const iv = Buffer.from(ivHex, "hex");
const decipher = crypto.createDecipheriv("aes-256-cbc", Buffer.from(key, "hex"), iv);
let decrypted = decipher.update(encrypted, "hex", "utf-8");
decrypted += decipher.final("utf-8");
return decrypted;
}
/**
* Access token expiry time in seconds (1 hour)
*/
export const ACCESS_TOKEN_EXPIRY_SEC = 60 * 60;
/**
* Generates a complete set of MCP OAuth tokens.
* @returns OAuth tokens with access token, refresh token, and expiry
*/
export function generateMcpTokens(): OAuthTokens {
const mcpAccessToken = generateToken();
const mcpRefreshToken = generateToken();
return {
access_token: mcpAccessToken,
refresh_token: mcpRefreshToken,
expires_in: ACCESS_TOKEN_EXPIRY_SEC,
token_type: "Bearer",
};
}

View file

@ -0,0 +1,412 @@
import { jest, describe, beforeEach, it, expect } from '@jest/globals';
import { Response } from "express";
import { AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js";
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import { InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
import { MockRedisClient, setRedisClient } from "../redis.js";
import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js";
import { FeatureReferenceAuthProvider, FeatureReferenceOAuthClientsStore } from "./provider.js";
import * as authService from "../services/auth.js";
// Helper function to create sample client
function createTestClient(): OAuthClientInformationFull {
return {
client_id: "test-client-id",
client_name: "Test Client",
client_uri: "https://example.com",
redirect_uris: ["https://example.com/callback"]
};
}
// Helper function to create a mock Response object
function createMockResponse() {
const res = {
redirect: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis()
};
return res as unknown as jest.Mocked<Response>;
}
function getMockAuthValues() {
const client = createTestClient();
// Use properly generated tokens for encryption
const accessToken = authService.generateToken();
const newTokens: OAuthTokens = {
access_token: authService.generateToken(),
refresh_token: authService.generateToken(),
token_type: "bearer",
expires_in: 3600,
};
const mcpInstallation: McpInstallation = {
mockUpstreamInstallation: {
mockUpstreamAccessToken: "fake-upstream-access-token",
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
refresh_token: authService.generateToken(),
expires_in: 3600,
},
clientId: client.client_id,
issuedAt: Date.now() / 1000,
userId: "test-user-id",
};
return {
client,
accessToken,
newTokens,
mcpInstallation,
}
}
describe("FeatureReferenceOAuthClientsStore", () => {
let clientsStore: FeatureReferenceOAuthClientsStore;
beforeEach(() => {
const mockRedis = new MockRedisClient();
setRedisClient(mockRedis);
jest.resetAllMocks();
clientsStore = new FeatureReferenceOAuthClientsStore();
});
describe("getClient", () => {
it("returns undefined for non-existent client", async () => {
const result = await clientsStore.getClient("non-existent");
expect(result).toBeUndefined();
});
it("returns client information for existing client", async () => {
const client = createTestClient();
// First save the client
await clientsStore.registerClient(client);
// Then retrieve it
const result = await clientsStore.getClient(client.client_id);
expect(result).toEqual(client);
});
});
describe("registerClient", () => {
it("saves and returns client information", async () => {
const client = createTestClient();
const result = await clientsStore.registerClient(client);
expect(result).toEqual(client);
// Verify it was saved
const retrieved = await clientsStore.getClient(client.client_id);
expect(retrieved).toEqual(client);
});
});
});
describe("FeatureReferenceAuthProvider", () => {
let provider: FeatureReferenceAuthProvider;
let mockRedis: MockRedisClient;
beforeEach(() => {
jest.resetAllMocks();
mockRedis = new MockRedisClient();
setRedisClient(mockRedis);
provider = new FeatureReferenceAuthProvider();
});
describe("authorize", () => {
it("saves pending authorization and sends HTML response", async () => {
const client = createTestClient();
// Use a type assertion to make TypeScript ignore the mismatch
const params = {
redirectUri: "https://example.com/callback",
codeChallenge: "test-challenge",
codeChallengeMethod: "S256",
} as unknown as AuthorizationParams;
const res = createMockResponse();
await provider.authorize(client, params, res);
// Verify HTML sent with redirect
expect(res.send).toHaveBeenCalled();
const sentHtml = (res.send as jest.Mock).mock.calls[0][0];
expect(sentHtml).toContain('MCP Server Authorization');
expect(sentHtml).toContain('Authorization Required');
expect(sentHtml).toContain('mock-upstream-idp/authorize?redirect_uri=/mock-upstream-idp/callback&state=');
});
});
describe("challengeForAuthorizationCode", () => {
it("returns code challenge for valid authorization code", async () => {
const client = createTestClient();
const pendingAuth: PendingAuthorization = {
redirectUri: "https://example.com/callback",
codeChallenge: "test-challenge",
codeChallengeMethod: "S256",
clientId: client.client_id,
};
// First save the pending authorization
const authCode = authService.generateToken();
await authService.savePendingAuthorization(authCode, pendingAuth);
const result = await provider.challengeForAuthorizationCode(client, authCode);
expect(result).toBe("test-challenge");
});
it("throws error for non-existent authorization code", async () => {
const client = createTestClient();
await expect(provider.challengeForAuthorizationCode(client, "test-code"))
.rejects
.toThrow("Authorization code not found");
});
it("throws error when client ID doesn't match", async () => {
const client = createTestClient();
const pendingAuth: PendingAuthorization = {
redirectUri: "https://example.com/callback",
codeChallenge: "test-challenge",
codeChallengeMethod: "S256",
clientId: "different-client-id",
};
// Save pending auth with different client ID
const authCode = authService.generateToken();
await authService.savePendingAuthorization(authCode, pendingAuth);
await expect(provider.challengeForAuthorizationCode(client, authCode))
.rejects
.toThrow("Authorization code does not match client");
});
});
describe("exchangeAuthorizationCode", () => {
it("returns tokens when exchange is successful", async () => {
const client = createTestClient();
const { mcpInstallation } = getMockAuthValues();
// Setup: save token exchange and installation
const authCode = authService.generateToken();
const tokenExchange: TokenExchange = {
mcpAccessToken: mcpInstallation.mcpTokens.access_token,
alreadyUsed: false,
};
await authService.saveTokenExchange(authCode, tokenExchange);
await authService.saveMcpInstallation(mcpInstallation.mcpTokens.access_token, mcpInstallation);
const result = await provider.exchangeAuthorizationCode(client, authCode);
expect(result).toEqual({
access_token: mcpInstallation.mcpTokens.access_token,
expires_in: mcpInstallation.mcpTokens.expires_in,
refresh_token: mcpInstallation.mcpTokens.refresh_token,
token_type: "Bearer",
});
});
it("throws error for invalid authorization code", async () => {
const client = createTestClient();
await expect(provider.exchangeAuthorizationCode(client, "test-code"))
.rejects
.toThrow("Invalid authorization code");
});
});
describe("exchangeRefreshToken", () => {
it("returns new tokens when refresh token is valid", async () => {
const {
client,
accessToken,
mcpInstallation,
} = getMockAuthValues();
// Setup: save refresh token and installation
const refreshToken = authService.generateToken();
await authService.saveRefreshToken(refreshToken, accessToken);
await authService.saveMcpInstallation(accessToken, mcpInstallation);
const result = await provider.exchangeRefreshToken(client, refreshToken);
// Should return new tokens
expect(result).toHaveProperty('access_token');
expect(result).toHaveProperty('refresh_token');
expect(result).toHaveProperty('expires_in', 3600);
expect(result).toHaveProperty('token_type', 'Bearer');
});
it("throws error for invalid refresh token", async () => {
const client = createTestClient();
await expect(provider.exchangeRefreshToken(client, "test-refresh-token"))
.rejects
.toThrow("Invalid refresh token");
});
it("throws error for refresh token with no installation", async () => {
const client = createTestClient();
const refreshToken = authService.generateToken();
const accessToken = authService.generateToken();
// Only save refresh token, not the installation
await authService.saveRefreshToken(refreshToken, accessToken);
await expect(provider.exchangeRefreshToken(client, refreshToken))
.rejects
.toThrow("Invalid refresh token");
});
it("throws error when client ID doesn't match", async () => {
const client = createTestClient();
const accessToken = authService.generateToken();
const refreshToken = authService.generateToken();
const mcpInstallation: McpInstallation = {
mockUpstreamInstallation: {
mockUpstreamAccessToken: "fake-upstream-access-token",
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
},
clientId: "different-client-id",
issuedAt: Date.now() / 1000,
userId: "test-user-id",
};
await authService.saveRefreshToken(refreshToken, accessToken);
await authService.saveMcpInstallation(accessToken, mcpInstallation);
await expect(provider.exchangeRefreshToken(client, refreshToken))
.rejects
.toThrow("Invalid client");
});
it("works correctly even when the refresh token has expired from Redis", async () => {
const client = createTestClient();
// Simulate the refresh token not being found in Redis (expired)
await expect(provider.exchangeRefreshToken(client, "expired-refresh-token"))
.rejects
.toThrow("Invalid refresh token");
});
});
describe("verifyAccessToken", () => {
it("returns auth info for valid token", async () => {
const accessToken = authService.generateToken();
const mcpInstallation: McpInstallation = {
mockUpstreamInstallation: {
mockUpstreamAccessToken: "fake-upstream-access-token",
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
},
clientId: "client-id",
issuedAt: Date.now() / 1000,
userId: "test-user-id",
};
await authService.saveMcpInstallation(accessToken, mcpInstallation);
const result = await provider.verifyAccessToken(accessToken);
expect(result).toEqual({
token: accessToken,
clientId: mcpInstallation.clientId,
scopes: ['mcp'],
expiresAt: mcpInstallation.mcpTokens.expires_in! + mcpInstallation.issuedAt,
extra: {
userId: "test-user-id"
}
});
});
it("throws error for invalid token", async () => {
await expect(provider.verifyAccessToken("invalid-token"))
.rejects
.toThrow("Invalid access token");
});
it("throws InvalidTokenError for expired token", async () => {
const accessToken = authService.generateToken();
const oneDayInSeconds = 24 * 60 * 60;
const twoDaysAgoInSeconds = Math.floor(Date.now() / 1000) - (2 * oneDayInSeconds);
const mcpInstallation: McpInstallation = {
mockUpstreamInstallation: {
mockUpstreamAccessToken: "fake-upstream-access-token",
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: oneDayInSeconds,
},
clientId: "client-id",
issuedAt: twoDaysAgoInSeconds, // 2 days ago, with 1-day expiry
userId: "test-user-id",
};
await authService.saveMcpInstallation(accessToken, mcpInstallation);
await expect(provider.verifyAccessToken(accessToken))
.rejects
.toThrow(InvalidTokenError);
});
});
describe("revokeToken", () => {
it("revokes the installation when given an access token", async () => {
const client = createTestClient();
const accessToken = authService.generateToken();
const mcpInstallation: McpInstallation = {
mockUpstreamInstallation: {
mockUpstreamAccessToken: "fake-upstream-access-token",
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
},
clientId: client.client_id,
issuedAt: Date.now() / 1000,
userId: "test-user-id",
};
// Save the installation
await authService.saveMcpInstallation(accessToken, mcpInstallation);
// Verify it exists
const saved = await authService.readMcpInstallation(accessToken);
expect(saved).toBeTruthy();
// Revoke it
const request: OAuthTokenRevocationRequest = {
token: accessToken,
token_type_hint: "access_token"
};
await provider.revokeToken(client, request);
// Verify it was revoked
const revoked = await authService.readMcpInstallation(accessToken);
expect(revoked).toBeUndefined();
});
});
});

View file

@ -0,0 +1,378 @@
import { Response } from 'express';
import { OAuthServerProvider, AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js';
import { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js';
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
import {
exchangeToken,
generateToken,
getClientRegistration,
readPendingAuthorization,
readMcpInstallation,
revokeMcpInstallation,
saveClientRegistration,
savePendingAuthorization,
readRefreshToken,
generateMcpTokens,
saveMcpInstallation,
saveRefreshToken,
} from '../services/auth.js';
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
import { logger } from '../utils/logger.js';
/**
* Implementation of the OAuthRegisteredClientsStore interface using the existing client registration system
*/
export class FeatureReferenceOAuthClientsStore implements OAuthRegisteredClientsStore {
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
const registration = await getClientRegistration(clientId);
if (!registration) {
return undefined;
}
return registration;
}
async registerClient(client: OAuthClientInformationFull): Promise<OAuthClientInformationFull> {
await saveClientRegistration(client.client_id, client);
return client;
}
}
/**
* Implementation of the OAuthServerProvider interface for upstream authentication
*/
export class FeatureReferenceAuthProvider implements OAuthServerProvider {
private _clientsStore: FeatureReferenceOAuthClientsStore;
constructor() {
this._clientsStore = new FeatureReferenceOAuthClientsStore();
}
get clientsStore(): OAuthRegisteredClientsStore {
return this._clientsStore;
}
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
// Client is validated by the MCP sdk.
// Generate authorization code
const authorizationCode = generateToken();
// Save the pending authorization with code challenge and state
await savePendingAuthorization(authorizationCode, {
redirectUri: params.redirectUri,
codeChallenge: params.codeChallenge,
codeChallengeMethod: 'S256', // Currently only support S256
clientId: client.client_id,
state: params.state,
});
logger.debug('Saved pending authorization', {
authorizationCode: authorizationCode.substring(0, 8) + '...',
clientId: client.client_id,
state: params.state?.substring(0, 8) + '...'
});
// TODO: should we use a different key, other than the authorization code, to store the pending authorization?
// You can redirect to another page, or you can send an html response directly
// res.redirect(new URL(`mock-upstream-idp/authorize?metadata=${authorizationCode}`, BASE_URI).href);
// Set permissive CSP for styling
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"style-src 'self' 'unsafe-inline'",
"script-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'"
].join('; '));
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP Server Authorization</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #000000;
color: #ffffff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-container {
background: #ffffff;
color: #000000;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(255, 255, 255, 0.1);
padding: 40px;
max-width: 500px;
width: 100%;
text-align: center;
border: 1px solid #e2e8f0;
}
.logo-container {
margin-bottom: 32px;
}
.logo {
width: 80px;
height: 80px;
margin: 0 auto 16px;
filter: invert(1);
}
.mcp-title {
font-size: 24px;
font-weight: 700;
color: #000000;
margin-bottom: 8px;
letter-spacing: 2px;
}
h1 {
color: #000000;
font-size: 32px;
font-weight: 800;
margin-bottom: 12px;
line-height: 1.2;
}
.subtitle {
color: #4a5568;
font-size: 18px;
margin-bottom: 32px;
line-height: 1.5;
}
.client-info {
background: #f8f9fa;
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
border: 2px solid #e2e8f0;
}
.client-info h3 {
color: #2d3748;
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.client-id {
background: white;
border: 2px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
font-family: 'Courier New', monospace;
font-size: 14px;
color: #4a5568;
word-break: break-all;
}
.auth-flow-info {
background: #f8f9fa;
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
border-left: 4px solid #000000;
}
.auth-flow-info h3 {
color: #2d3748;
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
}
.auth-flow-info p {
color: #4a5568;
font-size: 14px;
line-height: 1.5;
}
.btn-primary {
background: #000000;
color: #ffffff;
font-size: 18px;
font-weight: 700;
padding: 18px 36px;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
text-decoration: none;
display: inline-block;
text-align: center;
letter-spacing: 1px;
}
.btn-primary:hover {
background: #333333;
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
}
.branding {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #e2e8f0;
color: #718096;
font-size: 12px;
}
</style>
</head>
<body>
<div class="auth-container">
<div class="logo-container">
<img src="/mcp-logo.png" alt="MCP Logo" class="logo">
<div class="mcp-title">MCP</div>
</div>
<h1>Authorization Required</h1>
<p class="subtitle">This client wants to connect to your MCP server</p>
<div class="client-info">
<h3>Client Application</h3>
<div class="client-id">${client.client_id}</div>
</div>
<div class="auth-flow-info">
<h3>What happens next?</h3>
<p>You'll be redirected to authenticate with the upstream provider. Once verified, you'll be granted access to this MCP server's resources.</p>
</div>
<a href="/mock-upstream-idp/authorize?redirect_uri=/mock-upstream-idp/callback&state=${authorizationCode}" class="btn-primary">
Continue to Authentication
</a>
<div class="branding">
Model Context Protocol (MCP) Server
</div>
</div>
</body>
</html>
`);
}
async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
const pendingAuth = await readPendingAuthorization(authorizationCode);
if (!pendingAuth) {
throw new Error('Authorization code not found');
}
if (pendingAuth.clientId !== client.client_id) {
throw new Error('Authorization code does not match client');
}
return pendingAuth.codeChallenge;
}
async exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<OAuthTokens> {
const tokenData = await exchangeToken(authorizationCode);
if (!tokenData) {
throw new Error('Invalid authorization code');
}
// Get the MCP installation to retrieve the full token data including refresh token
const mcpInstallation = await readMcpInstallation(tokenData.mcpAccessToken);
if (!mcpInstallation) {
throw new Error('Failed to retrieve MCP installation');
}
// Return the full token data including refresh token
return {
access_token: mcpInstallation.mcpTokens.access_token,
refresh_token: mcpInstallation.mcpTokens.refresh_token,
expires_in: mcpInstallation.mcpTokens.expires_in,
token_type: 'Bearer',
};
}
async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, _scopes?: string[]): Promise<OAuthTokens> {
const accessToken = await readRefreshToken(refreshToken);
if (!accessToken) {
throw new Error('Invalid refresh token');
}
const mcpInstallation = await readMcpInstallation(accessToken);
if (!mcpInstallation) {
throw new Error('Invalid refresh token');
}
// Check the client_id
if (mcpInstallation.clientId !== client.client_id) {
throw new Error('Invalid client');
}
const newTokens = generateMcpTokens();
if (newTokens.refresh_token) {
await saveRefreshToken(newTokens.refresh_token, newTokens.access_token);
}
// Update the installation with the new tokens
await saveMcpInstallation(newTokens.access_token, {
...mcpInstallation,
mcpTokens: newTokens,
issuedAt: Date.now() / 1000,
userId: mcpInstallation.userId, // Preserve the user ID
});
return newTokens;
}
async verifyAccessToken(token: string): Promise<AuthInfo> {
const installation = await readMcpInstallation(token);
if (!installation) {
throw new InvalidTokenError("Invalid access token");
}
const expiresAt = (
installation.mcpTokens.expires_in
? installation.mcpTokens.expires_in + installation.issuedAt
: undefined
);
// This can be removed once in the SDK
// Check if the token is expired
if (!!expiresAt && expiresAt < Date.now() / 1000) {
throw new InvalidTokenError("Token has expired");
}
return {
token,
clientId: installation.clientId,
scopes: ['mcp'],
expiresAt,
extra: {
userId: installation.userId
}
};
}
async revokeToken(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void> {
await revokeMcpInstallation(request.token);
}
}