Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/web/src/app/api/ai/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
62 changes: 62 additions & 0 deletions apps/web/src/app/api/ai/auth/connect/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
30 changes: 30 additions & 0 deletions apps/web/src/app/api/ai/auth/disconnect/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
43 changes: 43 additions & 0 deletions apps/web/src/app/api/ai/auth/finish/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
136 changes: 136 additions & 0 deletions apps/web/src/hooks/use-mcp-auth.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"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;
}

export function useMcpAuth() {
const [sessionId, setSessionId] = useState<string | null>(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,
};
}
22 changes: 22 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/ai/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { config } from "@repo/eslint-config/base";

/** @type {import("eslint").Linter.Config} */
export default config;
29 changes: 29 additions & 0 deletions packages/ai/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading