From fc6d7a5152d81f503c6f3b52faca5923b18d147b Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Fri, 1 Aug 2025 23:43:04 +0200 Subject: [PATCH 1/2] wip --- .../web/src/app/api/ai/auth/callback/route.ts | 20 ++ apps/web/src/app/api/ai/auth/connect/route.ts | 62 ++++++ .../src/app/api/ai/auth/disconnect/route.ts | 30 +++ apps/web/src/app/api/ai/auth/finish/route.ts | 43 ++++ apps/web/src/hooks/use-mcp-auth.tsx | 140 ++++++++++++ bun.lock | 22 ++ packages/ai/eslint.config.mjs | 4 + packages/ai/package.json | 29 +++ packages/ai/src/auth/oauth-client.ts | 170 +++++++++++++++ packages/ai/src/auth/session-store.ts | 202 ++++++++++++++++++ packages/ai/tsconfig.json | 5 + 11 files changed, 727 insertions(+) create mode 100644 apps/web/src/app/api/ai/auth/callback/route.ts create mode 100644 apps/web/src/app/api/ai/auth/connect/route.ts create mode 100644 apps/web/src/app/api/ai/auth/disconnect/route.ts create mode 100644 apps/web/src/app/api/ai/auth/finish/route.ts create mode 100644 apps/web/src/hooks/use-mcp-auth.tsx create mode 100644 packages/ai/eslint.config.mjs create mode 100644 packages/ai/package.json create mode 100644 packages/ai/src/auth/oauth-client.ts create mode 100644 packages/ai/src/auth/session-store.ts create mode 100644 packages/ai/tsconfig.json diff --git a/apps/web/src/app/api/ai/auth/callback/route.ts b/apps/web/src/app/api/ai/auth/callback/route.ts new file mode 100644 index 00000000..bd90f222 --- /dev/null +++ b/apps/web/src/app/api/ai/auth/callback/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const code = searchParams.get("code"); + const error = searchParams.get("error"); + + // Get the base URL for redirects + const baseUrl = new URL(request.url).origin; + + if (code) { + // Redirect to main app with auth code + return NextResponse.redirect(`${baseUrl}/?code=${code}`); + } else if (error) { + // Redirect to main app with error + return NextResponse.redirect(`${baseUrl}/?error=${error}`); + } + + return new NextResponse("Bad request", { status: 400 }); +} diff --git a/apps/web/src/app/api/ai/auth/connect/route.ts b/apps/web/src/app/api/ai/auth/connect/route.ts new file mode 100644 index 00000000..4118e9cc --- /dev/null +++ b/apps/web/src/app/api/ai/auth/connect/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { MCPOAuthClient } from "@repo/ai/auth/oauth-client"; +import { sessionStore } from "@repo/ai/auth/session-store"; + +interface ConnectRequestBody { + serverUrl: string; + callbackUrl: string; +} + +export async function POST(request: NextRequest) { + try { + const body: ConnectRequestBody = await request.json(); + const { serverUrl, callbackUrl } = body; + + if (!serverUrl || !callbackUrl) { + return NextResponse.json( + { error: "Server URL and callback URL are required" }, + { status: 400 }, + ); + } + + const sessionId = sessionStore.generateSessionId(); + let authUrl: string | null = null; + + const client = new MCPOAuthClient( + serverUrl, + callbackUrl, + (redirectUrl: string) => { + authUrl = redirectUrl; + }, + ); + + try { + await client.connect(); + // If we get here, connection succeeded without OAuth + await sessionStore.setClient(sessionId, client); + return NextResponse.json({ success: true, sessionId }); + } catch (error: unknown) { + if (error instanceof Error) { + if (error.message === "OAuth authorization required" && authUrl) { + // Always require OAuth - store client for later use + await sessionStore.setClient(sessionId, client); + return NextResponse.json( + { requiresAuth: true, authUrl, sessionId }, + { status: 200 }, // Return 200 since OAuth is expected + ); + } else { + return NextResponse.json( + { error: error.message || "Unknown error" }, + { status: 500 }, + ); + } + } + } + } catch (error: unknown) { + if (error instanceof Error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/ai/auth/disconnect/route.ts b/apps/web/src/app/api/ai/auth/disconnect/route.ts new file mode 100644 index 00000000..1fbfa532 --- /dev/null +++ b/apps/web/src/app/api/ai/auth/disconnect/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { sessionStore } from "@repo/ai/auth/session-store"; + +interface DisconnectRequestBody { + sessionId: string; +} + +export async function POST(request: NextRequest) { + try { + const body: DisconnectRequestBody = await request.json(); + const { sessionId } = body; + + if (!sessionId) { + return NextResponse.json( + { error: "Session ID is required" }, + { status: 400 }, + ); + } + + await sessionStore.removeClient(sessionId); + + return NextResponse.json({ success: true }); + } catch (error: unknown) { + if (error instanceof Error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/ai/auth/finish/route.ts b/apps/web/src/app/api/ai/auth/finish/route.ts new file mode 100644 index 00000000..afdf29dc --- /dev/null +++ b/apps/web/src/app/api/ai/auth/finish/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { sessionStore } from "@repo/ai/auth/session-store"; + +interface FinishAuthRequestBody { + authCode: string; + sessionId: string; +} + +export async function POST(request: NextRequest) { + try { + const body: FinishAuthRequestBody = await request.json(); + const { authCode, sessionId } = body; + + if (!authCode || !sessionId) { + return NextResponse.json( + { error: "Authorization code and session ID are required" }, + { status: 400 }, + ); + } + + const client = await sessionStore.getClient(sessionId); + + if (!client) { + return NextResponse.json( + { error: "No active OAuth session found" }, + { status: 400 }, + ); + } + + await client.finishAuth(authCode); + + // Update the stored client state with new OAuth tokens + await sessionStore.setClient(sessionId, client); + + return NextResponse.json({ success: true }); + } catch (error: unknown) { + if (error instanceof Error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/apps/web/src/hooks/use-mcp-auth.tsx b/apps/web/src/hooks/use-mcp-auth.tsx new file mode 100644 index 00000000..db2bb34e --- /dev/null +++ b/apps/web/src/hooks/use-mcp-auth.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; + +interface ConnectParams { + serverUrl: string; + callbackUrl: string; +} + +interface FinishAuthParams { + authCode: string; + sessionId: string; +} + +interface DisconnectParams { + sessionId: string; +} + +interface UseMcpAuthOptions { + serverUrl: string; +} + +export function useMcpAuth({ serverUrl }: UseMcpAuthOptions) { + const [sessionId, setSessionId] = useState(null); + + const connectMutation = useMutation({ + mutationFn: async ({ serverUrl, callbackUrl }: ConnectParams) => { + const response = await fetch("/api/ai/auth/connect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ serverUrl, callbackUrl }), + }); + + const data = await response.json(); + + if (!response.ok) { + if (data.requiresAuth && data.authUrl && data.sessionId) { + // Return data for OAuth flow handling + return { + requiresAuth: true, + authUrl: data.authUrl, + sessionId: data.sessionId, + }; + } else { + throw new Error(data.error || "Connection failed"); + } + } + + return { requiresAuth: false, sessionId: data.sessionId }; + }, + onSuccess: async (data) => { + if (data.requiresAuth) { + // Handle OAuth flow + setSessionId(data.sessionId); + + // Open authorization URL in a popup + const popup = window.open( + data.authUrl, + "oauth-popup", + "width=600,height=700,scrollbars=yes,resizable=yes", + ); + + // Listen for messages from the popup + const messageHandler = async (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + + if (event.data.type === "oauth-success") { + popup?.close(); + + try { + await finishAuthMutation.mutateAsync({ + authCode: event.data.code, + sessionId: data.sessionId, + }); + } catch (err) { + console.error("Failed to complete authentication:", err); + } + + window.removeEventListener("message", messageHandler); + } else if (event.data.type === "oauth-error") { + popup?.close(); + window.removeEventListener("message", messageHandler); + throw new Error(`OAuth failed: ${event.data.error}`); + } + }; + + window.addEventListener("message", messageHandler); + } else { + // Direct connection success + setSessionId(data.sessionId); + } + }, + }); + + const finishAuthMutation = useMutation({ + mutationFn: async ({ authCode, sessionId }: FinishAuthParams) => { + const response = await fetch("/api/ai/auth/finish", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ authCode, sessionId }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + `Failed to complete authentication: ${errorData.error}`, + ); + } + + return response.json(); + }, + }); + + const disconnectMutation = useMutation({ + mutationFn: async ({ sessionId }: DisconnectParams) => { + await fetch("/api/ai/auth/disconnect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId }), + }); + }, + onSuccess: () => { + setSessionId(null); + }, + onError: () => { + // Always reset state even if disconnect fails + setSessionId(null); + }, + }); + + const isConnected = sessionId !== null; + + return { + sessionId, + isConnected, + connectMutation, + disconnectMutation, + }; +} diff --git a/bun.lock b/bun.lock index 6a33806e..1b6675f5 100644 --- a/bun.lock +++ b/bun.lock @@ -103,6 +103,26 @@ "typescript": "^5.8.3", }, }, + "packages/ai": { + "name": "@repo/ai", + "version": "0.1.0", + "dependencies": { + "@repo/env": "workspace:*", + "@upstash/redis": "^1.35.1", + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@types/node": "^22.9.0", + "eslint": "^9.31.0", + "typescript": "^5.8.3", + }, + "peerDependencies": { + "next": "^15.4.2", + "server-only": "^0.0.1", + "zod": "^4.0.5", + }, + }, "packages/api": { "name": "@repo/api", "version": "0.1.0", @@ -1072,6 +1092,8 @@ "@remixicon/react": ["@remixicon/react@4.6.0", "", { "peerDependencies": { "react": ">=18.2.0" } }, "sha512-bY56maEgT5IYUSRotqy9h03IAKJC85vlKtWFg2FKzfs8JPrkdBAYSa9dxoUSKFwGzup8Ux6vjShs9Aec3jvr2w=="], + "@repo/ai": ["@repo/ai@workspace:packages/ai"], + "@repo/api": ["@repo/api@workspace:packages/api"], "@repo/auth": ["@repo/auth@workspace:packages/auth"], diff --git a/packages/ai/eslint.config.mjs b/packages/ai/eslint.config.mjs new file mode 100644 index 00000000..31bf7507 --- /dev/null +++ b/packages/ai/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@repo/eslint-config/base"; + +/** @type {import("eslint").Linter.Config} */ +export default config; \ No newline at end of file diff --git a/packages/ai/package.json b/packages/ai/package.json new file mode 100644 index 00000000..4ff30724 --- /dev/null +++ b/packages/ai/package.json @@ -0,0 +1,29 @@ +{ + "name": "@repo/ai", + "version": "0.1.0", + "private": true, + "exports": { + "./auth/session-store": "./src/auth/session-store.ts", + "./auth/oauth-client": "./src/auth/oauth-client.ts" + }, + "scripts": { + "lint": "eslint .", + "type-check": "tsc --noEmit" + }, + "peerDependencies": { + "next": "^15.4.2", + "server-only": "^0.0.1", + "zod": "^4.0.5" + }, + "dependencies": { + "@repo/env": "workspace:*", + "@upstash/redis": "^1.35.1" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@types/node": "^22.9.0", + "eslint": "^9.31.0", + "typescript": "^5.8.3" + } +} diff --git a/packages/ai/src/auth/oauth-client.ts b/packages/ai/src/auth/oauth-client.ts new file mode 100644 index 00000000..d863401e --- /dev/null +++ b/packages/ai/src/auth/oauth-client.ts @@ -0,0 +1,170 @@ +import { URL } from "node:url"; +import { + OAuthClientProvider, + UnauthorizedError, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { + OAuthClientInformation, + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { + ListToolsRequest, + ListToolsResult, + ListToolsResultSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +class InMemoryOAuthClientProvider implements OAuthClientProvider { + private _clientInformation?: OAuthClientInformationFull; + private _tokens?: OAuthTokens; + private _codeVerifier?: string; + + constructor( + private readonly _redirectUrl: string | URL, + private readonly _clientMetadata: OAuthClientMetadata, + onRedirect?: (url: URL) => void, + ) { + this._onRedirect = + onRedirect || + ((url) => { + console.log(`Redirect to: ${url.toString()}`); + }); + } + + private _onRedirect: (url: URL) => void; + + get redirectUrl(): string | URL { + return this._redirectUrl; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation | undefined { + return this._clientInformation; + } + + saveClientInformation(clientInformation: OAuthClientInformationFull): void { + this._clientInformation = clientInformation; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + redirectToAuthorization(authorizationUrl: URL): void { + this._onRedirect(authorizationUrl); + } + + saveCodeVerifier(codeVerifier: string): void { + this._codeVerifier = codeVerifier; + } + + codeVerifier(): string { + if (!this._codeVerifier) { + throw new Error("No code verifier saved"); + } + return this._codeVerifier; + } +} + +export class MCPOAuthClient { + private client: Client | null = null; + private oauthProvider: InMemoryOAuthClientProvider | null = null; + + constructor( + private serverUrl: string, + private callbackUrl: string, + private onRedirect: (url: string) => void, + ) {} + + async connect(): Promise { + const clientMetadata: OAuthClientMetadata = { + client_name: "Next.js MCP OAuth Client", + redirect_uris: [this.callbackUrl], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "client_secret_post", + scope: "mcp:tools", + }; + + this.oauthProvider = new InMemoryOAuthClientProvider( + this.callbackUrl, + clientMetadata, + (redirectUrl: URL) => { + this.onRedirect(redirectUrl.toString()); + }, + ); + + this.client = new Client( + { + name: "nextjs-oauth-client", + version: "1.0.0", + }, + { capabilities: {} }, + ); + + await this.attemptConnection(); + } + + private async attemptConnection(): Promise { + if (!this.client || !this.oauthProvider) { + throw new Error("Client not initialized"); + } + + const baseUrl = new URL(this.serverUrl); + const transport = new StreamableHTTPClientTransport(baseUrl, { + authProvider: this.oauthProvider, + }); + + try { + await this.client.connect(transport); + } catch (error) { + if (error instanceof UnauthorizedError) { + throw new Error("OAuth authorization required"); + } else { + throw error; + } + } + } + + async finishAuth(authCode: string): Promise { + if (!this.client || !this.oauthProvider) { + throw new Error("Client not initialized"); + } + + const baseUrl = new URL(this.serverUrl); + const transport = new StreamableHTTPClientTransport(baseUrl, { + authProvider: this.oauthProvider, + }); + + await transport.finishAuth(authCode); + await this.client.connect(transport); + } + + async listTools(): Promise { + if (!this.client) { + throw new Error("Not connected to server"); + } + + const request: ListToolsRequest = { + method: "tools/list", + params: {}, + }; + + return await this.client.request(request, ListToolsResultSchema); + } + + disconnect(): void { + this.client = null; + this.oauthProvider = null; + } +} diff --git a/packages/ai/src/auth/session-store.ts b/packages/ai/src/auth/session-store.ts new file mode 100644 index 00000000..77914246 --- /dev/null +++ b/packages/ai/src/auth/session-store.ts @@ -0,0 +1,202 @@ +import { + OAuthClientInformationFull, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { Redis } from "@upstash/redis"; + +import { env } from "@repo/env/server"; + +import { MCPOAuthClient } from "./oauth-client"; + +// Serializable representation of MCPOAuthClient state +interface SerializableClientState { + serverUrl: string; + callbackUrl: string; + // We can't serialize the onRedirect function, so we'll need to reconstruct it + oauthProviderState?: { + clientInformation?: OAuthClientInformationFull; + tokens?: OAuthTokens; + codeVerifier?: string; + }; + isConnected?: boolean; +} + +// Type-safe access to private properties (since we need to serialize them) +interface MCPOAuthClientInternal { + serverUrl: string; + callbackUrl: string; + client: unknown | null; + oauthProvider?: { + _clientInformation?: OAuthClientInformationFull; + _tokens?: OAuthTokens; + _codeVerifier?: string; + } | null; +} + +// Redis-based session store for production use +class SessionStore { + private redis: Redis; + + constructor() { + this.redis = new Redis({ + url: env.UPSTASH_REDIS_REST_URL, + token: env.UPSTASH_REDIS_REST_TOKEN, + }); + } + + private getSessionKey(sessionId: string): string { + return `session:${sessionId}`; + } + + async setClient(sessionId: string, client: MCPOAuthClient): Promise { + try { + // Extract serializable state from the client using type assertion + const internalClient = client as unknown as MCPOAuthClientInternal; + + const clientState: SerializableClientState = { + serverUrl: internalClient.serverUrl, + callbackUrl: internalClient.callbackUrl, + oauthProviderState: { + clientInformation: internalClient.oauthProvider?._clientInformation, + tokens: internalClient.oauthProvider?._tokens, + codeVerifier: internalClient.oauthProvider?._codeVerifier, + }, + isConnected: internalClient.client !== null, + }; + + const key = this.getSessionKey(sessionId); + // Set with 24 hour expiration + await this.redis.set(key, JSON.stringify(clientState), { + ex: 24 * 60 * 60, + }); + } catch (error) { + console.error("Failed to store client state:", error); + throw new Error("Failed to store session"); + } + } + + async getClient(sessionId: string): Promise { + try { + const key = this.getSessionKey(sessionId); + const data = await this.redis.get(key); + + if (!data) { + return null; + } + + const clientState: SerializableClientState = JSON.parse(data); + + const client = new MCPOAuthClient( + clientState.serverUrl, + clientState.callbackUrl, + (url: string) => { + // Default onRedirect handler - this will need to be handled at the application level + console.log(`OAuth redirect required: ${url}`); + }, + ); + + // If we had OAuth state, restore it to the client + if (clientState.oauthProviderState) { + const { clientInformation, tokens, codeVerifier } = + clientState.oauthProviderState; + + // Access the private oauthProvider through type assertion + // This is a workaround since the properties are private + const internalClient = client as unknown as MCPOAuthClientInternal; + const oauthProvider = internalClient.oauthProvider; + + if (oauthProvider && clientInformation) { + oauthProvider._clientInformation = clientInformation; + } + + if (oauthProvider && tokens) { + oauthProvider._tokens = tokens; + } + + if (oauthProvider && codeVerifier) { + oauthProvider._codeVerifier = codeVerifier; + } + } + + return client; + } catch (error) { + console.error("Failed to deserialize client state:", error); + return null; + } + } + + async removeClient(sessionId: string): Promise { + try { + const key = this.getSessionKey(sessionId); + + // Get the client first to disconnect it + const client = await this.getClient(sessionId); + if (client) { + client.disconnect(); + } + + await this.redis.del(key); + } catch (error) { + console.error("Failed to remove client:", error); + throw new Error("Failed to remove session"); + } + } + + generateSessionId(): string { + return Math.random().toString(36).substring(2) + Date.now().toString(36); + } + + // Helper method to check if a session exists + async hasSession(sessionId: string): Promise { + try { + const key = this.getSessionKey(sessionId); + const exists = await this.redis.exists(key); + return exists === 1; + } catch (error) { + console.error("Failed to check session existence:", error); + return false; + } + } + + // Helper method to extend session expiration + async extendSession( + sessionId: string, + ttlSeconds: number = 24 * 60 * 60, + ): Promise { + try { + const key = this.getSessionKey(sessionId); + const result = await this.redis.expire(key, ttlSeconds); + return result === 1; + } catch (error) { + console.error("Failed to extend session:", error); + return false; + } + } + + // Helper method to update OAuth state without recreating the entire client + async updateClientOAuthState( + sessionId: string, + oauthState: SerializableClientState["oauthProviderState"], + ): Promise { + try { + const key = this.getSessionKey(sessionId); + const data = await this.redis.get(key); + + if (!data) { + throw new Error("Session not found"); + } + + const clientState: SerializableClientState = JSON.parse(data); + clientState.oauthProviderState = oauthState; + + await this.redis.set(key, JSON.stringify(clientState), { + ex: 24 * 60 * 60, + }); + } catch (error) { + console.error("Failed to update OAuth state:", error); + throw new Error("Failed to update session OAuth state"); + } + } +} + +export const sessionStore = new SessionStore(); diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json new file mode 100644 index 00000000..70c407ed --- /dev/null +++ b/packages/ai/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@repo/typescript-config/base.json", + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} \ No newline at end of file From 2a4d71ddc4215d95ea16256035848237d17d07f1 Mon Sep 17 00:00:00 2001 From: "Jean P.D. Meijer" Date: Sat, 2 Aug 2025 10:03:42 +0200 Subject: [PATCH 2/2] wip --- apps/web/src/hooks/use-mcp-auth.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/web/src/hooks/use-mcp-auth.tsx b/apps/web/src/hooks/use-mcp-auth.tsx index db2bb34e..a99d1ff8 100644 --- a/apps/web/src/hooks/use-mcp-auth.tsx +++ b/apps/web/src/hooks/use-mcp-auth.tsx @@ -17,11 +17,7 @@ interface DisconnectParams { sessionId: string; } -interface UseMcpAuthOptions { - serverUrl: string; -} - -export function useMcpAuth({ serverUrl }: UseMcpAuthOptions) { +export function useMcpAuth() { const [sessionId, setSessionId] = useState(null); const connectMutation = useMutation({