SpainMCP
Recetas

Construir un cliente compatible con OAuth

Cómo construir un cliente OAuth en TypeScript usando Next.js

En esta guia vamos a recorrer paso a paso la construccion de un cliente MCP con soporte OAuth utilizando TypeScript. El stack elegido es Next.js junto con el Model Context Protocol SDK. Todo el contenido esta basado en el ejemplo oficial del MCP TypeScript SDK.

1. Instalar dependencias

Lo primero es inicializar un proyecto Next.js nuevo e incorporar el MCP TypeScript SDK.

npx create-next-app@latest my-mcp-client
cd my-mcp-client
npm install @modelcontextprotocol/sdk

2. Crear archivos de libreria

El siguiente paso es crear un directorio /lib donde alojar el codigo compartido de la aplicacion. Aqui residira la logica central del cliente MCP y todo el flujo de autenticacion.

Dentro de /lib, genera los siguientes archivos:

Session Store

Este archivo contiene un almacén de sesiones en memoria simple. Para aplicaciones en producción, deberías usar una solución más robusta como Redis o una base de datos.

/lib/session-store.ts
import { MCPOAuthClient } from "./oauth-client";

// Almacén de sesiones en memoria simple para demostración
// En producción, usa Redis, base de datos, o gestión de sesiones adecuada
class SessionStore {
  private clients = new Map<string, MCPOAuthClient>();

  setClient(sessionId: string, client: MCPOAuthClient) {
    this.clients.set(sessionId, client);
  }

  getClient(sessionId: string): MCPOAuthClient | null {
    return this.clients.get(sessionId) || null;
  }

  removeClient(sessionId: string) {
    const client = this.clients.get(sessionId);
    if (client) {
      client.disconnect();
      this.clients.delete(sessionId);
    }
  }

  generateSessionId(): string {
    return Math.random().toString(36).substring(2) + Date.now().toString(36);
  }
}

export const sessionStore = new SessionStore();

OAuth Client

En este archivo se encuentra toda la logica del cliente MCP con autenticacion OAuth.

/lib/oauth-client.ts
import { URL } from "node:url";
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 {
  CallToolRequest,
  ListToolsRequest,
  CallToolResultSchema,
  ListToolsResultSchema,
  ListToolsResult,
  CallToolResult,
} from "@modelcontextprotocol/sdk/types.js";
import {
  OAuthClientProvider,
  UnauthorizedError,
} from "@modelcontextprotocol/sdk/client/auth.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<void> {
    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<void> {
    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<void> {
    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<ListToolsResult> {
    if (!this.client) {
      throw new Error("Not connected to server");
    }

    const request: ListToolsRequest = {
      method: "tools/list",
      params: {},
    };

    return await this.client.request(request, ListToolsResultSchema);
  }

  async callTool(
    toolName: string,
    toolArgs: Record<string, unknown>
  ): Promise<CallToolResult> {
    if (!this.client) {
      throw new Error("Not connected to server");
    }

    const request: CallToolRequest = {
      method: "tools/call",
      params: {
        name: toolName,
        arguments: toolArgs,
      },
    };

    return await this.client.request(request, CallToolResultSchema);
  }

  disconnect(): void {
    this.client = null;
    this.oauthProvider = null;
  }
}

3. Definir las Rutas API para OAuth

Ahora toca implementar los endpoints que gestionaran el ciclo completo de autenticacion OAuth.

Crea las siguientes rutas dentro de un directorio /app/api/mcp:

  • /app/api/mcp/auth/connect - Arranca la conexion con el servidor MCP.
  • /app/api/mcp/auth/callback - Procesa la respuesta OAuth que devuelve el servidor MCP.
  • /app/api/mcp/auth/finish - Completa el proceso OAuth y persiste los tokens.
  • /app/api/mcp/auth/disconnect - Corta la conexion con el servidor MCP.

Arrancar el flujo OAuth

Este endpoint pone en marcha la conexion inicial con el servidor MCP.

/app/api/mcp/auth/connect/route.ts
import { NextRequest, NextResponse } from "next/server";
import { MCPOAuthClient } from "@/lib/oauth-client";
import { sessionStore } from "@/lib/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();
      sessionStore.setClient(sessionId, client);
      return NextResponse.json({ success: true, sessionId });
    } catch (error: unknown) {
      if (error instanceof Error) {
        if (error.message === "OAuth authorization required" && authUrl) {
          sessionStore.setClient(sessionId, client);
          return NextResponse.json(
            { requiresAuth: true, authUrl, sessionId },
            { status: 401 }
          );
        } 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 });
  }
}

Procesar el callback OAuth

Este es el endpoint que recibe la respuesta de autorizacion.

/app/api/mcp/auth/callback/route.ts
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");

  if (code) {
    const html = `
      <html>
        <body>
          <h1>¡Autorización exitosa!</h1>
          <p>Puedes cerrar esta ventana y volver a la app.</p>
          <script>
            if (window.opener) {
              window.opener.postMessage({ type: 'oauth-success', code: '${code}' }, '*');
              window.close();
            } else {
              window.location.href = '/?code=${code}';
            }
          </script>
        </body>
      </html>
    `;
    return new NextResponse(html, {
      headers: { "Content-Type": "text/html" },
    });
  } else if (error) {
    const html = `
      <html>
        <body>
          <h1>Autorización fallida</h1>
          <p>Error: ${error}</p>
          <script>
            if (window.opener) {
              window.opener.postMessage({ type: 'oauth-error', error: '${error}' }, '*');
              window.close();
            } else {
              window.location.href = '/?error=${error}';
            }
          </script>
        </body>
      </html>
    `;
    return new NextResponse(html, {
      headers: { "Content-Type": "text/html" },
    });
  }

  return new NextResponse("Bad request", { status: 400 });
}

Completar el flujo OAuth

Este endpoint cierra el ciclo de autenticacion OAuth.

/app/api/mcp/auth/finish/route.ts
import { NextRequest, NextResponse } from "next/server";
import { sessionStore } from "@/lib/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 = sessionStore.getClient(sessionId);

    if (!client) {
      return NextResponse.json(
        { error: "No active OAuth session found" },
        { status: 400 }
      );
    }

    await client.finishAuth(authCode);

    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 });
  }
}

Cerrar la conexion con el servidor MCP

Este endpoint se encarga de terminar la sesion activa contra el servidor MCP.

/app/api/mcp/auth/disconnect/route.ts
import { NextRequest, NextResponse } from "next/server";
import { sessionStore } from "@/lib/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 }
      );
    }

    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 });
  }
}

4. Listar y ejecutar herramientas

El paso siguiente es crear los endpoints que permitan interactuar con las herramientas que expone el servidor MCP. Agrega estas rutas dentro de /app/api/mcp:

  • /app/api/mcp/tool/list - Obtiene el catalogo de herramientas del servidor MCP.
  • /app/api/mcp/tool/call - Ejecuta una herramienta concreta en el servidor MCP.

Obtener las herramientas disponibles

Este endpoint devuelve la lista de herramientas que ofrece el servidor MCP.

/app/api/mcp/tool/list/route.ts
import { NextRequest, NextResponse } from "next/server";
import { sessionStore } from "@/lib/session-store";

export async function GET(request: NextRequest) {
  try {
    const sessionId = request.nextUrl.searchParams.get("sessionId");

    if (!sessionId) {
      return NextResponse.json(
        { error: "Session ID is required" },
        { status: 400 }
      );
    }

    const client = sessionStore.getClient(sessionId);

    if (!client) {
      return NextResponse.json(
        { error: "Not connected to server" },
        { status: 400 }
      );
    }

    const result = await client.listTools();

    return NextResponse.json({ tools: result.tools || [] });
  } catch (error: unknown) {
    if (error instanceof Error) {
      return NextResponse.json({ error: error.message }, { status: 500 });
    }
    return NextResponse.json({ error: String(error) }, { status: 500 });
  }
}

Ejecutar una herramienta

Este endpoint invoca una herramienta especifica en el servidor MCP.

/app/api/mcp/tool/call/route.ts
import { NextRequest, NextResponse } from "next/server";
import { sessionStore } from "@/lib/session-store";

interface CallToolRequestBody {
  toolName: string;
  toolArgs?: Record<string, unknown>;
  sessionId: string;
}

export async function POST(request: NextRequest) {
  try {
    const body: CallToolRequestBody = await request.json();
    const { toolName, toolArgs, sessionId } = body;

    if (!toolName || !sessionId) {
      return NextResponse.json(
        { error: "Tool name and session ID are required" },
        { status: 400 }
      );
    }

    const client = sessionStore.getClient(sessionId);

    if (!client) {
      return NextResponse.json(
        { error: "Not connected to server" },
        { status: 400 }
      );
    }

    const result = await client.callTool(toolName, toolArgs || {});

    return NextResponse.json({ result });
  } catch (error: unknown) {
    if (error instanceof Error) {
      return NextResponse.json({ error: error.message }, { status: 500 });
    }
    return NextResponse.json({ error: String(error) }, { status: 500 });
  }
}

5. Montar la interfaz de usuario

Por ultimo, necesitas construir la interfaz que se comunique con estos endpoints. Aqui tienes una base para arrancar:

/app/page.tsx
"use client";

import { useState } from "react";

interface SchemaProperty {
  type?: string;
  description?: string;
  default?: unknown;
}

interface Tool {
  name: string;
  description?: string;
  inputSchema?: {
    type: "object";
    properties?: Record<string, SchemaProperty>;
    required?: string[];
  };
}

export default function Home() {
  const [serverUrl, setServerUrl] = useState("https://exa.run.tools");
  const [sessionId, setSessionId] = useState<string | null>(null);
  const [isConnected, setIsConnected] = useState(false);
  const [tools, setTools] = useState<Tool[]>([]);
  const [selectedTool, setSelectedTool] = useState("");
  const [toolArgs, setToolArgs] = useState("{}");
  const [toolResult, setToolResult] = useState<object | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // ... (ver repositorio completo para la implementación de UI)
}

Para el código completo de la UI (~445 líneas), consulta el repositorio de ejemplo en GitHub.

Vision de conjunto

Una vez que tengas todos estos archivos montados, tu aplicacion Next.js estara preparada para comunicarse con cualquier servidor MCP.

Endpoints de Autenticacion

  • Connect: POST /api/mcp/auth/connect con serverUrl y callbackUrl en el body. El callbackUrl debe apuntar a /api/mcp/auth/callback.
  • List Tools: GET /api/mcp/tool/list?sessionId=<sessionId>
  • Call Tool: POST /api/mcp/tool/call con toolName, toolArgs y sessionId en el body.
  • Disconnect: POST /api/mcp/auth/disconnect con sessionId en el body.

Endpoints de Herramientas

  • List Tools: GET /api/mcp/tool/list?sessionId=<sessionId>
  • Call Tool: POST /api/mcp/tool/call con toolName, toolArgs y sessionId en el body.

La estructura de directorios resultante deberia verse asi:

mcp-oauth-client/
  |-- next-env.d.ts
  |-- next.config.ts
  |-- package-lock.json
  |-- package.json
  |-- README.md
  |-- src/
  |   |-- app/
  |   |   |-- api/
  |   |   |   |-- mcp/
  |   |   |   |   |-- auth/
  |   |   |   |   |   |-- callback/
  |   |   |   |   |   |   |-- route.ts
  |   |   |   |   |   |-- connect/
  |   |   |   |   |   |   |-- route.ts
  |   |   |   |   |   |-- disconnect/
  |   |   |   |   |   |   |-- route.ts
  |   |   |   |   |   |-- finish/
  |   |   |   |   |       |-- route.ts
  |   |   |   |   |-- tool/
  |   |   |   |   |   |-- call/
  |   |   |   |   |   |   |-- route.ts
  |   |   |   |   |   |-- list/
  |   |   |   |   |       |-- route.ts
  |   |   |   |-- page.tsx
  |   |-- lib/
  |       |-- oauth-client.ts
  |       |-- session-store.ts
  |-- tsconfig.json
¿Te ha sido útil esta página?

En esta página