Transfer from geelen/use-mcp

This commit is contained in:
Glen Maddern 2025-06-18 21:46:43 +10:00
commit de192f4de0
3 changed files with 9512 additions and 136 deletions

View file

@ -0,0 +1,233 @@
// browser-provider.ts
import { OAuthClientInformation, OAuthMetadata, OAuthTokens, OAuthClientMetadata } from '@modelcontextprotocol/sdk/shared/auth.js'
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
// Assuming StoredState is defined in ./types.js and includes fields for provider options
import { StoredState } from './types.js' // Adjust path if necessary
/**
* Browser-compatible OAuth client provider for MCP using localStorage.
*/
export class BrowserOAuthClientProvider implements OAuthClientProvider {
readonly serverUrl: string
readonly storageKeyPrefix: string
readonly serverUrlHash: string
readonly clientName: string
readonly clientUri: string
readonly callbackUrl: string
constructor(
serverUrl: string,
options: {
storageKeyPrefix?: string
clientName?: string
clientUri?: string
callbackUrl?: string
} = {},
) {
this.serverUrl = serverUrl
this.storageKeyPrefix = options.storageKeyPrefix || 'mcp:auth'
this.serverUrlHash = this.hashString(serverUrl)
this.clientName = options.clientName || 'MCP Browser Client'
this.clientUri = options.clientUri || (typeof window !== 'undefined' ? window.location.origin : '')
this.callbackUrl =
options.callbackUrl ||
(typeof window !== 'undefined' ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback')
}
// --- SDK Interface Methods ---
get redirectUrl(): string {
return this.callbackUrl
}
get clientMetadata(): OAuthClientMetadata {
return {
redirect_uris: [this.redirectUrl],
token_endpoint_auth_method: 'none', // Public client
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
client_name: this.clientName,
client_uri: this.clientUri,
// scope: 'openid profile email mcp', // Example scopes, adjust as needed
}
}
async clientInformation(): Promise<OAuthClientInformation | undefined> {
const key = this.getKey('client_info')
const data = localStorage.getItem(key)
if (!data) return undefined
try {
// TODO: Add validation using a schema
return JSON.parse(data) as OAuthClientInformation
} catch (e) {
console.warn(`[${this.storageKeyPrefix}] Failed to parse client information:`, e)
localStorage.removeItem(key)
return undefined
}
}
// NOTE: The SDK's auth() function uses this if dynamic registration is needed.
// Ensure your OAuthClientInformationFull matches the expected structure if DCR is used.
async saveClientInformation(clientInformation: OAuthClientInformation /* | OAuthClientInformationFull */): Promise<void> {
const key = this.getKey('client_info')
// Cast needed if handling OAuthClientInformationFull specifically
localStorage.setItem(key, JSON.stringify(clientInformation))
}
async tokens(): Promise<OAuthTokens | undefined> {
const key = this.getKey('tokens')
const data = localStorage.getItem(key)
if (!data) return undefined
try {
// TODO: Add validation
return JSON.parse(data) as OAuthTokens
} catch (e) {
console.warn(`[${this.storageKeyPrefix}] Failed to parse tokens:`, e)
localStorage.removeItem(key)
return undefined
}
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
const key = this.getKey('tokens')
localStorage.setItem(key, JSON.stringify(tokens))
// Clean up code verifier and last auth URL after successful token save
localStorage.removeItem(this.getKey('code_verifier'))
localStorage.removeItem(this.getKey('last_auth_url'))
}
async saveCodeVerifier(codeVerifier: string): Promise<void> {
const key = this.getKey('code_verifier')
localStorage.setItem(key, codeVerifier)
}
async codeVerifier(): Promise<string> {
const key = this.getKey('code_verifier')
const verifier = localStorage.getItem(key)
if (!verifier) {
throw new Error(
`[${this.storageKeyPrefix}] Code verifier not found in storage for key ${key}. Auth flow likely corrupted or timed out.`,
)
}
// SDK's auth() retrieves this BEFORE exchanging code. Don't remove it here.
// It will be removed in saveTokens on success.
return verifier
}
/**
* Redirects the user agent to the authorization URL, storing necessary state.
* This now adheres to the SDK's void return type expectation for the interface.
* @param authorizationUrl The fully constructed authorization URL from the SDK.
*/
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
// Generate a unique state parameter for this authorization request
const state = crypto.randomUUID()
const stateKey = `${this.storageKeyPrefix}:state_${state}`
// Store context needed by the callback handler, associated with the state param
const stateData: StoredState = {
serverUrlHash: this.serverUrlHash,
expiry: Date.now() + 1000 * 60 * 10, // State expires in 10 minutes
// Store provider options needed to reconstruct on callback
providerOptions: {
serverUrl: this.serverUrl,
storageKeyPrefix: this.storageKeyPrefix,
clientName: this.clientName,
clientUri: this.clientUri,
callbackUrl: this.callbackUrl,
},
}
localStorage.setItem(stateKey, JSON.stringify(stateData))
// Add the state parameter to the URL
authorizationUrl.searchParams.set('state', state)
const authUrlString = authorizationUrl.toString()
// Persist the exact auth URL in case the popup fails and manual navigation is needed
localStorage.setItem(this.getKey('last_auth_url'), authUrlString)
// Attempt to open the popup
const popupFeatures = 'width=600,height=700,resizable=yes,scrollbars=yes,status=yes' // Make configurable if needed
try {
const popup = window.open(authUrlString, `mcp_auth_${this.serverUrlHash}`, popupFeatures)
if (!popup || popup.closed || typeof popup.closed === 'undefined') {
console.warn(
`[${this.storageKeyPrefix}] Popup likely blocked by browser. Manual navigation might be required using the stored URL.`,
)
// Cannot signal failure back via SDK auth() directly.
// useMcp will need to rely on timeout or manual trigger if stuck.
} else {
popup.focus()
console.info(`[${this.storageKeyPrefix}] Redirecting to authorization URL in popup.`)
}
} catch (e) {
console.error(`[${this.storageKeyPrefix}] Error opening popup window:`, e)
// Cannot signal failure back via SDK auth() directly.
}
// Regardless of popup success, the interface expects this method to initiate the redirect.
// If the popup failed, the user journey stops here until manual action or timeout.
}
// --- Helper Methods ---
/**
* Retrieves the last URL passed to `redirectToAuthorization`. Useful for manual fallback.
*/
getLastAttemptedAuthUrl(): string | null {
return localStorage.getItem(this.getKey('last_auth_url'))
}
clearStorage(): number {
const prefixPattern = `${this.storageKeyPrefix}_${this.serverUrlHash}_`
const statePattern = `${this.storageKeyPrefix}:state_`
const keysToRemove: string[] = []
let count = 0
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (!key) continue
if (key.startsWith(prefixPattern)) {
keysToRemove.push(key)
} else if (key.startsWith(statePattern)) {
try {
const item = localStorage.getItem(key)
if (item) {
// Check if state belongs to this provider instance based on serverUrlHash
// We need to parse cautiously as the structure isn't guaranteed.
const state = JSON.parse(item) as Partial<StoredState>
if (state.serverUrlHash === this.serverUrlHash) {
keysToRemove.push(key)
}
}
} catch (e) {
console.warn(`[${this.storageKeyPrefix}] Error parsing state key ${key} during clearStorage:`, e)
// Optionally remove malformed keys
// keysToRemove.push(key);
}
}
}
const uniqueKeysToRemove = [...new Set(keysToRemove)]
uniqueKeysToRemove.forEach((key) => {
localStorage.removeItem(key)
count++
})
return count
}
private hashString(str: string): string {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash
}
return Math.abs(hash).toString(16)
}
getKey(keySuffix: string): string {
return `${this.storageKeyPrefix}_${this.serverUrlHash}_${keySuffix}`
}
}

128
src/auth/callback.ts Normal file
View file

@ -0,0 +1,128 @@
// callback.ts
import { auth } from '@modelcontextprotocol/sdk/client/auth.js'
import { BrowserOAuthClientProvider } from './browser-provider.js' // Adjust path
import { StoredState } from './types.js' // Adjust path, ensure definition includes providerOptions
/**
* Handles the OAuth callback using the SDK's auth() function.
* Assumes it's running on the page specified as the callbackUrl.
*/
export async function onMcpAuthorization() {
const queryParams = new URLSearchParams(window.location.search)
const code = queryParams.get('code')
const state = queryParams.get('state')
const error = queryParams.get('error')
const errorDescription = queryParams.get('error_description')
const logPrefix = '[mcp-callback]' // Generic prefix, or derive from stored state later
console.log(`${logPrefix} Handling callback...`, { code, state, error, errorDescription })
let provider: BrowserOAuthClientProvider | null = null
let storedStateData: StoredState | null = null
const stateKey = state ? `mcp:auth:state_${state}` : null // Reconstruct state key prefix assumption
try {
// --- Basic Error Handling ---
if (error) {
throw new Error(`OAuth error: ${error} - ${errorDescription || 'No description provided.'}`)
}
if (!code) {
throw new Error('Authorization code not found in callback query parameters.')
}
if (!state || !stateKey) {
throw new Error('State parameter not found or invalid in callback query parameters.')
}
// --- Retrieve Stored State & Provider Options ---
const storedStateJSON = localStorage.getItem(stateKey)
if (!storedStateJSON) {
throw new Error(`Invalid or expired state parameter "${state}". No matching state found in storage.`)
}
try {
storedStateData = JSON.parse(storedStateJSON) as StoredState
} catch (e) {
throw new Error('Failed to parse stored OAuth state.')
}
// Validate expiry
if (!storedStateData.expiry || storedStateData.expiry < Date.now()) {
localStorage.removeItem(stateKey) // Clean up expired state
throw new Error('OAuth state has expired. Please try initiating authentication again.')
}
// Ensure provider options are present
if (!storedStateData.providerOptions) {
throw new Error('Stored state is missing required provider options.')
}
const { serverUrl, ...providerOptions } = storedStateData.providerOptions
// --- Instantiate Provider ---
console.log(`${logPrefix} Re-instantiating provider for server: ${serverUrl}`)
provider = new BrowserOAuthClientProvider(serverUrl, providerOptions)
// --- Call SDK Auth Function ---
console.log(`${logPrefix} Calling SDK auth() to exchange code...`)
// The SDK auth() function will internally:
// 1. Use provider.clientInformation()
// 2. Use provider.codeVerifier()
// 3. Call exchangeAuthorization()
// 4. Use provider.saveTokens() on success
const authResult = await auth(provider, { serverUrl, authorizationCode: code })
if (authResult === 'AUTHORIZED') {
console.log(`${logPrefix} Authorization successful via SDK auth(). Notifying opener...`)
// --- Notify Opener and Close (Success) ---
if (window.opener && !window.opener.closed) {
window.opener.postMessage({ type: 'mcp_auth_callback', success: true }, window.location.origin)
window.close()
} else {
console.warn(`${logPrefix} No opener window detected. Redirecting to root.`)
window.location.href = '/' // Or a configured post-auth destination
}
// Clean up state ONLY on success and after notifying opener
localStorage.removeItem(stateKey)
} else {
// This case shouldn't happen if `authorizationCode` is provided to `auth()`
console.warn(`${logPrefix} SDK auth() returned unexpected status: ${authResult}`)
throw new Error(`Unexpected result from authentication library: ${authResult}`)
}
} catch (err) {
console.error(`${logPrefix} Error during OAuth callback handling:`, err)
const errorMessage = err instanceof Error ? err.message : String(err)
// --- Notify Opener and Display Error (Failure) ---
if (window.opener && !window.opener.closed) {
window.opener.postMessage({ type: 'mcp_auth_callback', success: false, error: errorMessage }, window.location.origin)
// Optionally close even on error, depending on UX preference
// window.close();
}
// Display error in the callback window
try {
document.body.innerHTML = `
<div style="font-family: sans-serif; padding: 20px;">
<h1>Authentication Error</h1>
<p style="color: red; background-color: #ffebeb; border: 1px solid red; padding: 10px; border-radius: 4px;">
${errorMessage}
</p>
<p>You can close this window or <a href="#" onclick="window.close(); return false;">click here to close</a>.</p>
<pre style="font-size: 0.8em; color: #555; margin-top: 20px; white-space: pre-wrap;">${
err instanceof Error ? err.stack : ''
}</pre>
</div>
`
} catch (displayError) {
console.error(`${logPrefix} Could not display error in callback window:`, displayError)
}
// Clean up potentially invalid state on error
if (stateKey) {
localStorage.removeItem(stateKey)
}
// Clean up potentially dangling verifier or last_auth_url if auth failed badly
// Note: saveTokens should clean these on success
if (provider) {
localStorage.removeItem(provider.getKey('code_verifier'))
localStorage.removeItem(provider.getKey('last_auth_url'))
}
}
}

19
src/auth/types.ts Normal file
View file

@ -0,0 +1,19 @@
import { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth.js'
/**
* Internal type for storing OAuth state in localStorage during the popup flow.
* @internal
*/
export interface StoredState {
expiry: number
metadata?: OAuthMetadata // Optional: might not be needed if auth() rediscovers
serverUrlHash: string
// Add provider options needed on callback:
providerOptions: {
serverUrl: string
storageKeyPrefix: string
clientName: string
clientUri: string
callbackUrl: string
}
}