Skip to main content

Overview

This example demonstrates how to connect LLMs to MCP tools using the Vercel AI SDK. We’ll create a simple chat component that connects to MCP servers and executes tools dynamically. We’ll use a simple effect on mount to connect to servers. However, you can easily let users add their own servers by implementing a server management UI or allowing server URLs to be configured through environment variables or user settings.

Installation

Install the required dependencies:
npm install ai @ai-sdk/react @ai-sdk/openai @modelcontextprotocol/sdk @smithery/sdk

Example

Client-side Component

Create a chat component that connects to MCP servers and handles tool execution:
'use client';

import { useState, useCallback, useRef, useEffect } from 'react';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
import { createMCPClient } from '@/lib/mcp/create-client';
import { createSmitheryUrl } from "@smithery/sdk/shared/config.js";
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type { Tool as MCPTool } from "@modelcontextprotocol/sdk/types.js";

export default function Home() {
  const [text, setText] = useState<string>('');
  const [client, setClient] = useState<Client | null>(null);
  const [allTools, setAllTools] = useState<MCPTool[]>([]);
  
  // Connect to MCP server on mount
  // This is for demo - in your application you might want to let users dynamically add their servers
  useEffect(() => {
    const connectToMCP = async () => {
      try {
        // Replace with your server details
        const serverUrl = 'https://server.smithery.ai/{server_id}'; 
        const apiKey = '{your_api_key}'; 
        const profile = '{your_profile}'; 
        
        const connectionUrl = createSmitheryUrl(serverUrl, { 
          apiKey, 
          profile, 
        }); 
        
        const result = await createMCPClient(new URL(connectionUrl));
        
        if (!result.ok) {
          throw result.error;
        }
        
        const { client } = result.value;
        setClient(client);
        
        // Get available tools
        try {
          const toolsResult = await client.listTools();
          setAllTools(toolsResult.tools || []);
        } catch (toolsError) {
          console.warn('Failed to list tools:', toolsError);
          setAllTools([]);
        }
        
      } catch (error) {
        console.error('Failed to connect to MCP server:', error);
      }
    };
    
    connectToMCP();
    
    // Cleanup on unmount
    return () => {
      if (client) {
        client.close().catch(console.error);
      }
    };
  }, []);
  
  // Use a ref to store the latest handler to avoid stale closures
  const handleToolCallRef = useRef<((params: any) => Promise<void>) | null>(null);
  
  // Stable wrapper that always calls the latest version
  const handleToolCall = useCallback(async (params: any) => {
    return handleToolCallRef.current?.(params);
  }, []);

  // Configure useChat with MCP tool handling
  const { messages, sendMessage, status, addToolResult } = useChat({ 
    transport: new DefaultChatTransport({ 
      api: '/api/chat', 
    }), 
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, 
    onToolCall: handleToolCall, 
  }); 

  // Update the ref with the latest implementation
  handleToolCallRef.current = async ({ toolCall }: any) => {
    // Check if it's a dynamic tool first for proper type narrowing
    if (toolCall.dynamic) {
      return;
    }
    
    try {
      if (!client) {
        throw new Error('MCP client not connected');
      }
      
      // Execute the tool using the MCP client
      const result = await client.callTool({ 
        name: toolCall.toolName, 
        arguments: toolCall.input || {} 
      }); 
      
      // Extract text content from the result
      let output = 'Tool executed successfully';
      if (result.content && Array.isArray(result.content)) {
        const textContent = result.content
          .filter(item => item.type === 'text')
          .map(item => item.text)
          .join('\n');
        output = textContent || output;
      }
      
      // Add the MCP result back to the conversation
      addToolResult({
        toolCallId: toolCall.toolCallId,
        tool: toolCall.toolName,
        output,
      });
    } catch (error) {
      // Add error result
      addToolResult({
        toolCallId: toolCall.toolCallId,
        tool: toolCall.toolName,
        output: { 
          error: 'Tool execution failed', 
          message: error instanceof Error ? error.message : 'Unknown error' 
        },
      });
    }
  };

  const handleSendMessage = useCallback(async () => {
    if (!text.trim() || status === 'streaming') return;
    
    // Pass the current tools state to the API
    sendMessage({ text }, { 
      body: {
        tools: allTools,
      }
    });
    
    setText('');
  }, [text, status, sendMessage, allTools]);

  return (
    <div className="flex flex-col h-screen max-w-4xl mx-auto">
      {/* Messages */}
      <div className="flex-1 overflow-y-auto p-4">
        {messages.map((message) => (
          <div key={message.id} className="mb-4">
            <div className="font-semibold mb-2">
              {message.role === 'user' ? 'You' : 'Assistant'}:
            </div>
            
            {message.parts.map((part, i) => {
              switch (part.type) {
                case 'text':
                  return (
                    <div key={`${message.id}-${i}`} className="prose">
                      {part.text}
                    </div>
                  );
                
                default:
                  // Handle tool call parts generically
                  if (part.type.startsWith('tool-') && 'toolCallId' in part) {
                    const toolPart = part as any;
                    const callId = toolPart.toolCallId;
                    const toolName = part.type.replace('tool-', '');
                    
                    if ('state' in toolPart) {
                      switch (toolPart.state) {
                        case 'input-streaming':
                          return (
                            <div key={callId} className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
                              Preparing {toolName}...
                            </div>
                          );
                        case 'input-available':
                          return (
                            <div key={callId} className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
                              Executing {toolName}...
                            </div>
                          );
                        case 'output-available':
                          return (
                            <div key={callId} className="p-3 bg-green-50 border border-green-200 rounded-lg">
                              <strong>{toolName} result:</strong> {toolPart.output}
                            </div>
                          );
                        case 'output-error':
                          return (
                            <div key={callId} className="p-3 bg-red-50 border border-red-200 rounded-lg">
                              Error in {toolName}: {toolPart.errorText}
                            </div>
                          );
                      }
                    }
                  }
                  return null;
              }
            })}
          </div>
        ))}
        
        {status === 'streaming' && (
          <div className="mb-4">
            <div className="font-semibold mb-2">Assistant:</div>
            <div className="animate-pulse">Thinking...</div>
          </div>
        )}
      </div>

      {/* Input */}
      <div className="p-4 border-t">
        <div className="flex gap-2">
          <input
            value={text}
            onChange={(e) => setText(e.target.value)}
            placeholder="Ask anything..."
            className="flex-1 p-3 border border-gray-300 rounded-lg"
            disabled={status === 'streaming'}
            onKeyDown={(e) => {
              if (e.key === 'Enter' && !e.shiftKey) {
                e.preventDefault();
                handleSendMessage();
              }
            }}
          />
          <button
            onClick={handleSendMessage}
            disabled={!text.trim() || status === 'streaming'}
            className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
          >
            Send
          </button>
        </div>
      </div>
    </div>
  );
}

API Route

Create an API route that handles MCP tool integration:
import { streamText, UIMessage, convertToModelMessages, jsonSchema } from 'ai';
import { openai } from '@ai-sdk/openai';
import type { Tool as MCPTool } from "@modelcontextprotocol/sdk/types.js";
import type { JSONSchema7 } from "json-schema";

// Allow streaming responses up to 30 seconds
export const maxDuration = 30;

export async function POST(req: Request) {
  const { 
    model, 
    messages,
    tools,
  }: { 
    messages: UIMessage[]; 
    model?: string;
    tools?: MCPTool[];
  } = await req.json();

  // Convert MCP tools to AI SDK format
  const aiTools = Object.fromEntries( 
    (tools || []).map(tool => [ 
      tool.name, 
      { 
        description: tool.description || tool.name, 
        inputSchema: jsonSchema(tool.inputSchema as JSONSchema7), 
      }, 
    ]), 
  ); 

  const result = streamText({
    model: openai(model || 'gpt-4o-mini'),
    messages: convertToModelMessages(messages),
    system: 'You are a helpful assistant with access to various tools through MCP (Model Context Protocol). Use the available tools to help users with their requests.',
    tools: aiTools,
  });

  return result.toUIMessageStreamResponse();
}

Advanced Configuration

Multiple MCP Servers

You can connect to multiple MCP servers and aggregate their tools:
const [clients, setClients] = useState<Client[]>([]);
const [allTools, setAllTools] = useState<MCPTool[]>([]);

useEffect(() => {
  const connectToMultipleMCP = async () => {
    const urls = [
      'https://server1.example.com/mcp',
      'https://server2.example.com/mcp',
    ];
    
    const connectedClients: Client[] = [];
    const allTools: MCPTool[] = [];
    
    for (const url of urls) {
      try {
        const result = await createMCPClient(new URL(url));
        if (result.ok) {
          const { client } = result.value;
          connectedClients.push(client);
          
          const toolsResult = await client.listTools();
          allTools.push(...(toolsResult.tools || []));
        }
      } catch (error) {
        console.error(`Failed to connect to ${url}:`, error);
      }
    }
    
    setClients(connectedClients);
    setAllTools(allTools);
  };
  
  connectToMultipleMCP();
}, []);
I