diff --git a/app/(oauth)/.well-known/oauth-authorization-server/route.ts b/app/(oauth)/.well-known/oauth-authorization-server/route.ts
new file mode 100644
index 0000000..d2772cc
--- /dev/null
+++ b/app/(oauth)/.well-known/oauth-authorization-server/route.ts
@@ -0,0 +1,25 @@
+import { NextResponse } from "next/server";
+
+export async function GET(request: Request) {
+ const metadata = {
+ issuer: "http://localhost:3000",
+ authorization_endpoint: "http://localhost:3000/authorize",
+ token_endpoint: "http://localhost:3000/token",
+ registration_endpoint: "http://localhost:3000/oauth/register",
+ scopes_supported: ["read_write"],
+ response_types_supported: ["code"],
+ grant_types_supported: ["authorization_code"],
+ token_endpoint_auth_methods_supported: ["client_secret_basic"],
+ code_challenge_methods_supported: ["S256"],
+ service_documentation:
+ "https://docs.stripe.com/stripe-apps/api-authentication/oauth",
+ };
+
+ return new NextResponse(JSON.stringify(metadata), {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*",
+ },
+ });
+}
diff --git a/app/(oauth)/api/auth/login/route.ts b/app/(oauth)/api/auth/login/route.ts
new file mode 100644
index 0000000..44b887f
--- /dev/null
+++ b/app/(oauth)/api/auth/login/route.ts
@@ -0,0 +1,51 @@
+import { NextResponse } from "next/server";
+import { cookies } from "next/headers";
+import { SignJWT } from "jose";
+
+// In a real app, you would validate against a database
+const VALID_CREDENTIALS = {
+ email: "test@example.com",
+ password: "password123",
+};
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const { email, password } = body;
+
+ // Validate credentials
+ if (
+ email !== VALID_CREDENTIALS.email ||
+ password !== VALID_CREDENTIALS.password
+ ) {
+ return new NextResponse(
+ JSON.stringify({ error: "Invalid credentials" }),
+ { status: 401 }
+ );
+ }
+
+ // Generate a JWT token
+ const secret = new TextEncoder().encode(
+ process.env.JWT_SECRET || "your-secret-key"
+ );
+
+ const token = await new SignJWT({ email })
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuedAt()
+ .setExpirationTime("24h")
+ .sign(secret);
+
+ // Return the token
+ return new NextResponse(JSON.stringify({ token }), {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ } catch (error) {
+ return new NextResponse(
+ JSON.stringify({ error: "Internal server error" }),
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/(oauth)/api/auth/verify/route.ts b/app/(oauth)/api/auth/verify/route.ts
new file mode 100644
index 0000000..c904502
--- /dev/null
+++ b/app/(oauth)/api/auth/verify/route.ts
@@ -0,0 +1,33 @@
+import { NextResponse } from "next/server";
+import { cookies } from "next/headers";
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const { email, password } = body;
+
+ // Accept any non-empty email and password
+ if (!email || !password) {
+ return new NextResponse(
+ JSON.stringify({ error: "Email and password are required" }),
+ { status: 400 }
+ );
+ }
+
+ // Set the session cookie with the email
+ const cookieStore = await cookies();
+ cookieStore.set("auth_session", email, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ maxAge: 60 * 60 * 24, // 24 hours
+ });
+
+ return new NextResponse(JSON.stringify({ success: true }), { status: 200 });
+ } catch (error) {
+ return new NextResponse(
+ JSON.stringify({ error: "Internal server error" }),
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/(oauth)/authorize/login/page.tsx b/app/(oauth)/authorize/login/page.tsx
new file mode 100644
index 0000000..d4ff619
--- /dev/null
+++ b/app/(oauth)/authorize/login/page.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import { useState } from "react";
+
+export default function AuthorizeLoginPage() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setIsLoading(true);
+
+ try {
+ const response = await fetch("/api/auth/verify", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ email, password }),
+ });
+
+ if (!response.ok) {
+ throw new Error("Invalid credentials");
+ }
+
+ // Get all the original OAuth parameters
+ const client_id = searchParams.get("client_id");
+ const redirect_uri = searchParams.get("redirect_uri");
+ const response_type = searchParams.get("response_type");
+ const code_challenge = searchParams.get("code_challenge");
+ const code_challenge_method = searchParams.get("code_challenge_method");
+ const state = searchParams.get("state");
+
+ // Redirect back to authorize with all parameters
+ const authorizeUrl = new URL("/authorize", window.location.origin);
+ authorizeUrl.searchParams.set("client_id", client_id || "");
+ authorizeUrl.searchParams.set("redirect_uri", redirect_uri || "");
+ authorizeUrl.searchParams.set("response_type", response_type || "");
+ if (code_challenge)
+ authorizeUrl.searchParams.set("code_challenge", code_challenge);
+ if (code_challenge_method)
+ authorizeUrl.searchParams.set(
+ "code_challenge_method",
+ code_challenge_method
+ );
+ if (state) authorizeUrl.searchParams.set("state", state);
+ authorizeUrl.searchParams.set("authenticated", "true");
+
+ // Use window.location.href for a full page navigation
+ window.location.href = authorizeUrl.toString();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "An error occurred");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ Welcome Back
+
+
+ Please sign in to continue
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/(oauth)/authorize/route.ts b/app/(oauth)/authorize/route.ts
new file mode 100644
index 0000000..35977ca
--- /dev/null
+++ b/app/(oauth)/authorize/route.ts
@@ -0,0 +1,138 @@
+import { NextResponse } from "next/server";
+import { cookies } from "next/headers";
+import { SignJWT } from "jose";
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url);
+
+ // Required OAuth parameters
+ const client_id = searchParams.get("client_id");
+ const redirect_uri = searchParams.get("redirect_uri");
+ const response_type = searchParams.get("response_type");
+ const code_challenge = searchParams.get("code_challenge");
+ const code_challenge_method = searchParams.get("code_challenge_method");
+ const state = "123";
+ const authenticated = searchParams.get("authenticated");
+
+ // Check which parameters are missing
+ const missingParams = [];
+ if (!client_id) missingParams.push("client_id");
+ if (!redirect_uri) missingParams.push("redirect_uri");
+ if (!response_type) missingParams.push("response_type");
+
+ // Validate required parameters
+ if (missingParams.length > 0) {
+ return new NextResponse(
+ JSON.stringify({
+ error: "invalid_request",
+ error_description: `Missing required parameters: ${missingParams.join(
+ ", "
+ )}`,
+ }),
+ { status: 400, headers: { "Content-Type": "application/json" } }
+ );
+ }
+
+ // Validate response_type
+ if (response_type !== "code") {
+ return new NextResponse(
+ JSON.stringify({
+ error: "unsupported_response_type",
+ error_description: "Only code response type is supported",
+ }),
+ { status: 400, headers: { "Content-Type": "application/json" } }
+ );
+ }
+
+ // Validate PKCE if provided
+ if (code_challenge && code_challenge_method !== "S256") {
+ return new NextResponse(
+ JSON.stringify({
+ error: "invalid_request",
+ error_description: "Only S256 code challenge method is supported",
+ }),
+ { status: 400, headers: { "Content-Type": "application/json" } }
+ );
+ }
+
+ // Check for authentication
+ if (authenticated !== "true") {
+ // Redirect to login page with all parameters
+ const loginUrl = new URL("/authorize/login", request.url);
+ searchParams.forEach((value, key) => {
+ if (value) {
+ loginUrl.searchParams.set(key, value);
+ }
+ });
+ return NextResponse.redirect(loginUrl.toString());
+ }
+
+ // At this point, we know the user is authenticated
+ // Get the email from the session cookie
+ const cookieStore = await cookies();
+ const authSession = cookieStore.get("auth_session");
+ if (!authSession?.value) {
+ return new NextResponse(
+ JSON.stringify({
+ error: "unauthorized",
+ error_description: "No authenticated session found",
+ }),
+ { status: 401, headers: { "Content-Type": "application/json" } }
+ );
+ }
+
+ // Generate authorization code
+ const code = await generateAuthorizationCode(
+ client_id,
+ "read_write", // Default scope
+ code_challenge,
+ authSession.value
+ );
+
+ // Store the code and its details in a secure way (e.g., database)
+ // For this example, we'll use cookies
+ cookieStore.set("auth_code", code, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ maxAge: 60 * 10, // 10 minutes
+ });
+
+ // Redirect to the client's redirect URI with the authorization code
+ if (!redirect_uri) {
+ throw new Error("Redirect URI is required");
+ }
+
+ const redirectUrl = new URL(redirect_uri);
+ redirectUrl.searchParams.set("code", code);
+ redirectUrl.searchParams.set("state", state);
+
+ return NextResponse.redirect(redirectUrl.toString());
+}
+
+async function generateAuthorizationCode(
+ client_id: string | null,
+ scope: string,
+ code_challenge?: string | null,
+ email?: string
+) {
+ if (!client_id) {
+ throw new Error("Client ID is required");
+ }
+
+ const secret = new TextEncoder().encode(
+ process.env.JWT_SECRET || "your-secret-key"
+ );
+
+ return new SignJWT({
+ client_id,
+ scope,
+ code_challenge,
+ email,
+ timestamp: Date.now(),
+ })
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuedAt()
+ .setExpirationTime("10m")
+ .sign(secret);
+}
diff --git a/app/(oauth)/oauth/register/route.ts b/app/(oauth)/oauth/register/route.ts
new file mode 100644
index 0000000..34b864b
--- /dev/null
+++ b/app/(oauth)/oauth/register/route.ts
@@ -0,0 +1,140 @@
+import { NextResponse } from "next/server";
+import { SignJWT } from "jose";
+
+interface OAuthClient {
+ client_id: string;
+ client_secret: string;
+ client_name: string;
+ redirect_uris: string[];
+ grant_types: string[];
+ response_types: string[];
+ token_endpoint_auth_method: string;
+ created_at: number;
+}
+
+// In a real application, you would store this in a database
+const clients = new Map();
+
+// Add OPTIONS handler for CORS preflight requests
+export async function OPTIONS() {
+ return new NextResponse(null, {
+ status: 204,
+ headers: {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ "Access-Control-Max-Age": "86400",
+ },
+ });
+}
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+
+ // Required registration parameters
+ const {
+ client_name,
+ redirect_uris,
+ grant_types,
+ response_types,
+ token_endpoint_auth_method,
+ } = body;
+
+ // Validate required parameters
+ if (!client_name || !redirect_uris || !grant_types || !response_types) {
+ return new NextResponse(
+ JSON.stringify({
+ error: "invalid_request",
+ error_description: "Missing required parameters",
+ }),
+ {
+ status: 400,
+ headers: {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ },
+ }
+ );
+ }
+
+ // Generate client credentials
+ const client_id = await generateClientId();
+ const client_secret = await generateClientSecret();
+
+ // Store client information
+ clients.set(client_id, {
+ client_id,
+ client_secret,
+ client_name,
+ redirect_uris,
+ grant_types,
+ response_types,
+ token_endpoint_auth_method:
+ token_endpoint_auth_method || "client_secret_basic",
+ created_at: Date.now(),
+ });
+
+ // Return client credentials
+ return new NextResponse(
+ JSON.stringify({
+ client_id,
+ client_secret,
+ client_id_issued_at: Math.floor(Date.now() / 1000),
+ client_secret_expires_at: 0, // 0 means it never expires
+ redirect_uris,
+ grant_types,
+ response_types,
+ token_endpoint_auth_method:
+ token_endpoint_auth_method || "client_secret_basic",
+ }),
+ {
+ status: 201,
+ headers: {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ },
+ }
+ );
+ } catch (error) {
+ return new NextResponse(
+ JSON.stringify({
+ error: "server_error",
+ error_description: "Internal server error",
+ }),
+ {
+ status: 500,
+ headers: {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ },
+ }
+ );
+ }
+}
+
+async function generateClientId() {
+ const secret = new TextEncoder().encode(
+ process.env.JWT_SECRET || "your-secret-key"
+ );
+ return new SignJWT({ type: "client_id" })
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuedAt()
+ .sign(secret);
+}
+
+async function generateClientSecret() {
+ const secret = new TextEncoder().encode(
+ process.env.JWT_SECRET || "your-secret-key"
+ );
+ return new SignJWT({ type: "client_secret" })
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuedAt()
+ .sign(secret);
+}
diff --git a/app/(oauth)/token/route.ts b/app/(oauth)/token/route.ts
new file mode 100644
index 0000000..7e869b5
--- /dev/null
+++ b/app/(oauth)/token/route.ts
@@ -0,0 +1,179 @@
+import { NextResponse } from "next/server";
+import { SignJWT, jwtVerify } from "jose";
+import { cookies } from "next/headers";
+
+// Add OPTIONS handler for CORS preflight requests
+export async function OPTIONS() {
+ return new NextResponse(null, {
+ status: 204,
+ headers: {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ "Access-Control-Max-Age": "86400",
+ },
+ });
+}
+
+export async function POST(request: Request) {
+ try {
+ const formData = await request.formData();
+
+ // Required OAuth parameters
+ const grant_type = formData.get("grant_type");
+ const code = formData.get("code");
+ const redirect_uri = formData.get("redirect_uri");
+ const client_id = formData.get("client_id");
+ const code_verifier = formData.get("code_verifier");
+
+ // Validate required parameters
+ if (!grant_type || !code || !redirect_uri || !client_id) {
+ return new NextResponse(
+ JSON.stringify({
+ error: "invalid_request",
+ error_description: "Missing required parameters",
+ }),
+ {
+ status: 400,
+ headers: {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ },
+ }
+ );
+ }
+
+ // Validate grant type
+ if (grant_type !== "authorization_code") {
+ return new NextResponse(
+ JSON.stringify({
+ error: "unsupported_grant_type",
+ error_description: "Only authorization_code grant type is supported",
+ }),
+ {
+ status: 400,
+ headers: {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ },
+ }
+ );
+ }
+
+ // Verify the authorization code
+ const secret = new TextEncoder().encode(
+ process.env.JWT_SECRET || "your-secret-key"
+ );
+
+ // biome-ignore lint/suspicious/noExplicitAny: decrypt
+ let codePayload: any;
+ try {
+ const { payload } = await jwtVerify(code as string, secret);
+ codePayload = payload;
+ } catch (error) {
+ return new NextResponse(
+ JSON.stringify({
+ error: "invalid_grant",
+ error_description: "Invalid authorization code",
+ }),
+ {
+ status: 400,
+ headers: {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ },
+ }
+ );
+ }
+
+ // Verify PKCE if code challenge was provided
+ if (codePayload.code_challenge && !code_verifier) {
+ return new NextResponse(
+ JSON.stringify({
+ error: "invalid_request",
+ error_description: "Code verifier required",
+ }),
+ {
+ status: 400,
+ headers: {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ },
+ }
+ );
+ }
+
+ // Generate access token
+ const accessToken = await generateAccessToken(
+ client_id as string,
+ codePayload.scope as string,
+ codePayload.email as string
+ );
+
+ // Return the access token
+ return new NextResponse(
+ JSON.stringify({
+ access_token: accessToken,
+ token_type: "Bearer",
+ expires_in: 3600, // 1 hour
+ scope: codePayload.scope,
+ }),
+ {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json",
+ "Cache-Control": "no-store",
+ Pragma: "no-cache",
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ },
+ }
+ );
+ } catch (error) {
+ return new NextResponse(
+ JSON.stringify({
+ error: "server_error",
+ error_description: "Internal server error",
+ }),
+ {
+ status: 500,
+ headers: {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ },
+ }
+ );
+ }
+}
+
+async function generateAccessToken(
+ client_id: string,
+ scope: string,
+ email: string
+) {
+ const secret = new TextEncoder().encode(
+ process.env.JWT_SECRET || "your-secret-key"
+ );
+
+ return new SignJWT({
+ client_id,
+ scope,
+ email,
+ timestamp: Date.now(),
+ })
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuedAt()
+ .setExpirationTime("1h")
+ .sign(secret);
+}
diff --git a/app/[transport]/route.ts b/app/[transport]/route.ts
index c0d3bc4..afe7b20 100644
--- a/app/[transport]/route.ts
+++ b/app/[transport]/route.ts
@@ -1,33 +1,48 @@
import { createMcpHandler } from "@vercel/mcp-adapter";
import { z } from "zod";
+import { withMcpAuth } from "@/lib/auth";
-const handler = createMcpHandler(
- (server) => {
- server.tool(
- "echo",
- "Echo a message",
- { message: z.string() },
- async ({ message }) => ({
- content: [{ type: "text", text: `Tool echo: ${message}` }],
- })
- );
- },
- {
- capabilities: {
- tools: {
- echo: {
- description: "Echo a message",
+const createHandler = (req: Request) => {
+ console.log("auth", req.headers.get("x-user-email"));
+
+ return createMcpHandler(
+ (server) => {
+ server.tool(
+ "echo",
+ "Echo a message",
+ { message: z.string() },
+ async ({ message }) => {
+ return {
+ content: [
+ {
+ type: "text",
+ text: `Tool echo: ${message}`,
+ },
+ ],
+ };
+ }
+ );
+ },
+ {
+ capabilities: {
+ tools: {
+ echo: {
+ description: "Echo a message",
+ },
},
},
},
- },
- {
- redisUrl: process.env.REDIS_URL,
- sseEndpoint: "/sse",
- streamableHttpEndpoint: "/mcp",
- verboseLogs: true,
- maxDuration: 60,
- }
-);
+ {
+ redisUrl: process.env.REDIS_URL,
+ sseEndpoint: "/sse",
+ streamableHttpEndpoint: "/mcp",
+ verboseLogs: false,
+ maxDuration: 60,
+ }
+ )(req);
+};
+
+// Create and wrap the handler with auth
+const handler = withMcpAuth(createHandler);
export { handler as GET, handler as POST, handler as DELETE };
diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000..a14e64f
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,16 @@
+export const metadata = {
+ title: 'Next.js',
+ description: 'Generated by Next.js',
+}
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/lib/auth.ts b/lib/auth.ts
new file mode 100644
index 0000000..686cabf
--- /dev/null
+++ b/lib/auth.ts
@@ -0,0 +1,48 @@
+import { createMcpHandler } from "@vercel/mcp-adapter";
+import { cookies } from "next/headers";
+import { jwtVerify } from "jose";
+
+// Wrapper function for MCP handler with auth
+export const withMcpAuth = (handler: (req: Request) => Promise) => {
+ return async (req: Request) => {
+ const authHeader = req.headers.get("authorization");
+ const cookieStore = await cookies();
+ const authSession = cookieStore.get("auth_session");
+
+ let email: string | undefined;
+
+ // Check for bearer token
+ if (authHeader?.startsWith("Bearer ")) {
+ try {
+ const token = authHeader.split(" ")[1];
+ const secret = new TextEncoder().encode(
+ process.env.JWT_SECRET || "your-secret-key"
+ );
+ const { payload } = await jwtVerify(token, secret);
+ email = payload.email as string;
+ } catch (error) {
+ // Token verification failed, try session cookie
+ email = authSession?.value;
+ }
+ } else {
+ // No bearer token, try session cookie
+ email = authSession?.value;
+ }
+
+ if (!email) {
+ // Redirect to login page if no valid token or session
+ return new Response(null, {
+ status: 401,
+ headers: {
+ Location: "/authorize/login",
+ },
+ });
+ }
+
+ // Add email to request headers
+ req.headers.set("x-user-email", email);
+
+ // If authenticated, proceed with the MCP handler
+ return handler(req);
+ };
+};
diff --git a/package.json b/package.json
index 1aca2a7..8c2ee66 100644
--- a/package.json
+++ b/package.json
@@ -8,15 +8,21 @@
"start": "next start"
},
"dependencies": {
+ "@auth/core": "^0.39.0",
"@modelcontextprotocol/sdk": "^1.10.2",
"@vercel/mcp-adapter": "^0.2.1",
+ "jose": "^6.0.11",
"next": "15.2.4",
+ "next-auth": "^4.24.11",
"redis": "^4.7.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
+ "autoprefixer": "^10.4.21",
+ "postcss": "^8.5.3",
+ "tailwindcss": "^4.1.5",
"typescript": "^5"
},
"packageManager": "pnpm@8.15.7+sha512.c85cd21b6da10332156b1ca2aa79c0a61ee7ad2eb0453b88ab299289e9e8ca93e6091232b25c07cbf61f6df77128d9c849e5c9ac6e44854dbd211c49f3a67adc"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4429383..9fc7cde 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -5,15 +5,24 @@ settings:
excludeLinksFromLockfile: false
dependencies:
+ '@auth/core':
+ specifier: ^0.39.0
+ version: 0.39.0
'@modelcontextprotocol/sdk':
specifier: ^1.10.2
version: 1.10.2
'@vercel/mcp-adapter':
specifier: ^0.2.1
version: 0.2.1(@modelcontextprotocol/sdk@1.10.2)(next@15.2.4)(redis@4.7.0)
+ jose:
+ specifier: ^6.0.11
+ version: 6.0.11
next:
specifier: 15.2.4
version: 15.2.4(react-dom@19.1.0)(react@19.1.0)
+ next-auth:
+ specifier: ^4.24.11
+ version: 4.24.11(@auth/core@0.39.0)(next@15.2.4)(react-dom@19.1.0)(react@19.1.0)
redis:
specifier: ^4.7.0
version: 4.7.0
@@ -28,12 +37,47 @@ devDependencies:
'@types/react':
specifier: ^19
version: 19.1.0
+ autoprefixer:
+ specifier: ^10.4.21
+ version: 10.4.21(postcss@8.5.3)
+ postcss:
+ specifier: ^8.5.3
+ version: 8.5.3
+ tailwindcss:
+ specifier: ^4.1.5
+ version: 4.1.5
typescript:
specifier: ^5
version: 5.8.3
packages:
+ /@auth/core@0.39.0:
+ resolution: {integrity: sha512-jusviw/sUSfAh6S/wjY5tRmJOq0Itd3ImF+c/b4HB9DfmfChtcfVJTNJeqCeExeCG8oh4PBKRsMQJsn2W6NhFQ==}
+ peerDependencies:
+ '@simplewebauthn/browser': ^9.0.1
+ '@simplewebauthn/server': ^9.0.2
+ nodemailer: ^6.8.0
+ peerDependenciesMeta:
+ '@simplewebauthn/browser':
+ optional: true
+ '@simplewebauthn/server':
+ optional: true
+ nodemailer:
+ optional: true
+ dependencies:
+ '@panva/hkdf': 1.2.1
+ jose: 6.0.11
+ oauth4webapi: 3.5.1
+ preact: 10.24.3
+ preact-render-to-string: 6.5.11(preact@10.24.3)
+ dev: false
+
+ /@babel/runtime@7.27.1:
+ resolution: {integrity: sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==}
+ engines: {node: '>=6.9.0'}
+ dev: false
+
/@emnapi/runtime@1.4.0:
resolution: {integrity: sha512-64WYIf4UYcdLnbKn/umDlNjQDSS8AgZrI/R9+x5ilkUVFxXcA1Ebl+gQLc/6mERA4407Xof0R7wEyEuj091CVw==}
requiresBuild: true
@@ -316,6 +360,10 @@ packages:
dev: false
optional: true
+ /@panva/hkdf@1.2.1:
+ resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
+ dev: false
+
/@redis/bloom@1.2.0(@redis/client@1.6.0):
resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==}
peerDependencies:
@@ -407,6 +455,22 @@ packages:
negotiator: 1.0.0
dev: false
+ /autoprefixer@10.4.21(postcss@8.5.3):
+ resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==}
+ engines: {node: ^10 || ^12 || >=14}
+ hasBin: true
+ peerDependencies:
+ postcss: ^8.1.0
+ dependencies:
+ browserslist: 4.24.5
+ caniuse-lite: 1.0.30001712
+ fraction.js: 4.3.7
+ normalize-range: 0.1.2
+ picocolors: 1.1.1
+ postcss: 8.5.3
+ postcss-value-parser: 4.2.0
+ dev: true
+
/body-parser@2.2.0:
resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==}
engines: {node: '>=18'}
@@ -424,6 +488,17 @@ packages:
- supports-color
dev: false
+ /browserslist@4.24.5:
+ resolution: {integrity: sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+ dependencies:
+ caniuse-lite: 1.0.30001717
+ electron-to-chromium: 1.5.150
+ node-releases: 2.0.19
+ update-browserslist-db: 1.1.3(browserslist@4.24.5)
+ dev: true
+
/busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
@@ -454,7 +529,10 @@ packages:
/caniuse-lite@1.0.30001712:
resolution: {integrity: sha512-MBqPpGYYdQ7/hfKiet9SCI+nmN5/hp4ZzveOJubl5DTAMa5oggjAuoi0Z4onBpKPFI2ePGnQuQIzF3VxDjDJig==}
- dev: false
+
+ /caniuse-lite@1.0.30001717:
+ resolution: {integrity: sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==}
+ dev: true
/client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
@@ -579,6 +657,10 @@ packages:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
dev: false
+ /electron-to-chromium@1.5.150:
+ resolution: {integrity: sha512-rOOkP2ZUMx1yL4fCxXQKDHQ8ZXwisb2OycOQVKHgvB3ZI4CvehOd4y2tfnnLDieJ3Zs1RL1Dlp3cMkyIn7nnXA==}
+ dev: true
+
/encodeurl@2.0.0:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
@@ -601,6 +683,11 @@ packages:
es-errors: 1.3.0
dev: false
+ /escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+ dev: true
+
/escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
dev: false
@@ -685,6 +772,10 @@ packages:
engines: {node: '>= 0.6'}
dev: false
+ /fraction.js@4.3.7:
+ resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
+ dev: true
+
/fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
@@ -781,6 +872,21 @@ packages:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
dev: false
+ /jose@4.15.9:
+ resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
+ dev: false
+
+ /jose@6.0.11:
+ resolution: {integrity: sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==}
+ dev: false
+
+ /lru-cache@6.0.0:
+ resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
+ engines: {node: '>=10'}
+ dependencies:
+ yallist: 4.0.0
+ dev: false
+
/math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -816,13 +922,41 @@ packages:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
- dev: false
/negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
dev: false
+ /next-auth@4.24.11(@auth/core@0.39.0)(next@15.2.4)(react-dom@19.1.0)(react@19.1.0):
+ resolution: {integrity: sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==}
+ peerDependencies:
+ '@auth/core': 0.34.2
+ next: ^12.2.5 || ^13 || ^14 || ^15
+ nodemailer: ^6.6.5
+ react: ^17.0.2 || ^18 || ^19
+ react-dom: ^17.0.2 || ^18 || ^19
+ peerDependenciesMeta:
+ '@auth/core':
+ optional: true
+ nodemailer:
+ optional: true
+ dependencies:
+ '@auth/core': 0.39.0
+ '@babel/runtime': 7.27.1
+ '@panva/hkdf': 1.2.1
+ cookie: 0.7.2
+ jose: 4.15.9
+ next: 15.2.4(react-dom@19.1.0)(react@19.1.0)
+ oauth: 0.9.15
+ openid-client: 5.7.1
+ preact: 10.26.6
+ preact-render-to-string: 5.2.6(preact@10.26.6)
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ uuid: 8.3.2
+ dev: false
+
/next@15.2.4(react-dom@19.1.0)(react@19.1.0):
resolution: {integrity: sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@@ -868,16 +1002,43 @@ packages:
- babel-plugin-macros
dev: false
+ /node-releases@2.0.19:
+ resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
+ dev: true
+
+ /normalize-range@0.1.2:
+ resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /oauth4webapi@3.5.1:
+ resolution: {integrity: sha512-txg/jZQwcbaF7PMJgY7aoxc9QuCxHVFMiEkDIJ60DwDz3PbtXPQnrzo+3X4IRYGChIwWLabRBRpf1k9hO9+xrQ==}
+ dev: false
+
+ /oauth@0.9.15:
+ resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==}
+ dev: false
+
/object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
dev: false
+ /object-hash@2.2.0:
+ resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==}
+ engines: {node: '>= 6'}
+ dev: false
+
/object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
dev: false
+ /oidc-token-hash@5.1.0:
+ resolution: {integrity: sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==}
+ engines: {node: ^10.13.0 || >=12.0.0}
+ dev: false
+
/on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
@@ -891,6 +1052,15 @@ packages:
wrappy: 1.0.2
dev: false
+ /openid-client@5.7.1:
+ resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
+ dependencies:
+ jose: 4.15.9
+ lru-cache: 6.0.0
+ object-hash: 2.2.0
+ oidc-token-hash: 5.1.0
+ dev: false
+
/parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@@ -908,13 +1078,16 @@ packages:
/picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
- dev: false
/pkce-challenge@5.0.0:
resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==}
engines: {node: '>=16.20.0'}
dev: false
+ /postcss-value-parser@4.2.0:
+ resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
+ dev: true
+
/postcss@8.4.31:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
@@ -924,6 +1097,44 @@ packages:
source-map-js: 1.2.1
dev: false
+ /postcss@8.5.3:
+ resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
+ engines: {node: ^10 || ^12 || >=14}
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+ dev: true
+
+ /preact-render-to-string@5.2.6(preact@10.26.6):
+ resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==}
+ peerDependencies:
+ preact: '>=10'
+ dependencies:
+ preact: 10.26.6
+ pretty-format: 3.8.0
+ dev: false
+
+ /preact-render-to-string@6.5.11(preact@10.24.3):
+ resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
+ peerDependencies:
+ preact: '>=10'
+ dependencies:
+ preact: 10.24.3
+ dev: false
+
+ /preact@10.24.3:
+ resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
+ dev: false
+
+ /preact@10.26.6:
+ resolution: {integrity: sha512-5SRRBinwpwkaD+OqlBDeITlRgvd8I8QlxHJw9AxSdMNV6O+LodN9nUyYGpSF7sadHjs6RzeFShMexC6DbtWr9g==}
+ dev: false
+
+ /pretty-format@3.8.0:
+ resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==}
+ dev: false
+
/proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -1141,7 +1352,6 @@ packages:
/source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
- dev: false
/statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
@@ -1170,6 +1380,10 @@ packages:
react: 19.1.0
dev: false
+ /tailwindcss@4.1.5:
+ resolution: {integrity: sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA==}
+ dev: true
+
/toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
@@ -1203,6 +1417,22 @@ packages:
engines: {node: '>= 0.8'}
dev: false
+ /update-browserslist-db@1.1.3(browserslist@4.24.5):
+ resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+ dependencies:
+ browserslist: 4.24.5
+ escalade: 3.2.0
+ picocolors: 1.1.1
+ dev: true
+
+ /uuid@8.3.2:
+ resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
+ hasBin: true
+ dev: false
+
/vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..12a703d
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..3de3845
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,12 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: [
+ "./app/**/*.{js,ts,jsx,tsx,mdx}",
+ "./pages/**/*.{js,ts,jsx,tsx,mdx}",
+ "./components/**/*.{js,ts,jsx,tsx,mdx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};