What Is CopilotKit?
CopilotKit is an open-source framework for building AI copilots and agents directly inside React applications. Unlike standalone chatbot SDKs, CopilotKit deeply integrates with your app's state, UI, and backend — letting AI agents read your application context, trigger UI actions, and call server-side functions as part of a single coherent workflow.
At its core, CopilotKit provides:
useCopilotReadable— expose app state as context the AI can readuseCopilotAction— define frontend actions the AI can invoke (open modals, update state, submit forms)CopilotRuntime— a server-side runtime that orchestrates LLM calls, tool execution, and multi-step agent workflowsCopilotChat/CopilotSidebar— prebuilt React UI components for the chat interface
The framework was purpose-built for production React apps, not demos. It handles streaming, multi-turn conversation state, tool call rendering, and agent loop orchestration out of the box.
What Is MCP and Why Does It Matter Here?
The Model Context Protocol (MCP), developed by Anthropic, is an open standard that defines how AI models connect to external tools, data sources, and services. Think of it as a USB-C standard for AI integrations — one protocol, any tool.
MCP servers expose three primitives:
| Primitive | Purpose | Example |
|---|---|---|
| Tools | Executable functions the AI can call | Run SQL query, send email, create GitHub issue |
| Resources | Read-only data the AI can access | File contents, database records, API responses |
| Prompts | Reusable prompt templates | Structured task prompts with arguments |
MCP uses JSON-RPC 2.0 over either stdio (local process) or HTTP with SSE or Streamable HTTP (networked). Tool discovery happens automatically — clients call tools/list and get back every available tool with its JSON Schema definition.
Why Combine CopilotKit with MCP?
Want to analyze your API security?
Import your OpenAPI spec and generate a Security Report automatically.
Before MCP, adding a new tool to a CopilotKit agent meant writing custom backend code, updating your runtime configuration, and redeploying. Every integration was bespoke.
With MCP, your CopilotKit runtime connects to any MCP-compliant server and automatically inherits all of its tools. You deploy a Postgres MCP server once — every CopilotKit agent in your organization can query your database without any additional code.
The practical advantages:
- Zero-code tool additions — point CopilotKit at a new MCP server, tools appear automatically
- Reusability — the same MCP server works with Claude Desktop, Cursor, CopilotKit, and any other MCP client
- Separation of concerns — tool logic lives in the MCP server, agent orchestration lives in CopilotKit
- Ecosystem leverage — thousands of production-ready MCP servers already exist for GitHub, Slack, Notion, Postgres, Stripe, and more
- Standardized schema — JSON Schema tool definitions mean consistent validation and type safety
When MCP specifically outperforms custom integrations:
Custom integration: 1 tool = 1 custom implementation
MCP integration: 1 server = N tools, any client
If you are building a multi-agent system, a platform product, or an AI copilot that needs to grow its tool surface over time, MCP is the architectural decision that keeps your system maintainable.
Architecture: How CopilotKit + MCP Works
┌─────────────────────────────────────────────────────┐
│ Browser / React App │
│ ┌──────────────┐ ┌──────────────────────────────┐ │
│ │ CopilotChat │ │ useCopilotReadable │ │
│ │ UI Component │ │ useCopilotAction (frontend) │ │
│ └──────┬───────┘ └──────────────────────────────┘ │
└─────────┼───────────────────────────────────────────┘
│ HTTP (streaming)
▼
┌─────────────────────────────────────────────────────┐
│ CopilotKit Runtime (Server) │
│ ┌──────────────────────────────────────────────┐ │
│ │ LLM Adapter (OpenAI / Anthropic / Gemini) │ │
│ └──────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────┐ │
│ │ MCP Client Layer │ │
│ │ - Discovers tools via tools/list │ │
│ │ - Injects schemas into LLM context │ │
│ │ - Executes tool calls via tools/call │ │
│ └──────────────────────┬───────────────────────┘ │
└─────────────────────────┼───────────────────────────┘
│ JSON-RPC (SSE / HTTP)
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ GitHub MCP │ │ Postgres MCP│ │ Slack MCP │
│ Server │ │ Server │ │ Server │
└─────────────┘ └─────────────┘ └─────────────┘
The CopilotKit runtime sits between your React frontend and your MCP servers. It discovers tools, injects their schemas into the LLM's system context, routes tool call responses back to the appropriate MCP server, and streams results back to the browser.
Prerequisites
Before starting, ensure you have:
- Node.js 18+ (LTS recommended)
- A React 18+ project (Next.js 14+ with App Router recommended)
- An LLM API key (OpenAI, Anthropic, or compatible)
- At least one MCP server running locally or remotely
- Basic familiarity with TypeScript
Installation
1. Install CopilotKit packages
npm install @copilotkit/react-core @copilotkit/react-ui @copilotkit/runtime
For Anthropic's Claude as your LLM:
npm install @ai-sdk/anthropic
For OpenAI:
npm install @ai-sdk/openai
2. Install MCP SDK (if building your own MCP server)
npm install @modelcontextprotocol/sdk
Project Structure
For a Next.js App Router project:
my-app/
├── app/
│ ├── api/
│ │ └── copilotkit/
│ │ └── route.ts ← CopilotKit runtime endpoint
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ └── CopilotWrapper.tsx ← CopilotKit provider setup
├── mcp-servers/
│ └── my-tools-server.ts ← Optional: your own MCP server
└── .env.local
Step 1: Configure the CopilotKit Runtime with MCP
This is where CopilotKit connects to your MCP servers. Create your route handler:
// app/api/copilotkit/route.ts
import {
CopilotRuntime,
AnthropicAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from '@copilotkit/runtime';
import { experimental_createMCPClient } from 'ai';
import { NextRequest } from 'next/server';
export const POST = async (req: NextRequest) => {
// Initialize MCP clients for each server
const githubMCPClient = await experimental_createMCPClient({
transport: {
type: 'sse',
url: process.env.GITHUB_MCP_SERVER_URL!, // e.g., http://localhost:3001/sse
headers: {
Authorization: `Bearer ${process.env.GITHUB_MCP_TOKEN}`,
},
},
});
const postgresMCPClient = await experimental_createMCPClient({
transport: {
type: 'sse',
url: process.env.POSTGRES_MCP_SERVER_URL!,
},
});
// Discover tools from all MCP servers
const githubTools = await githubMCPClient.tools();
const postgresTools = await postgresMCPClient.tools();
const runtime = new CopilotRuntime({
tools: {
...githubTools,
...postgresTools,
},
});
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter: new AnthropicAdapter({
model: 'claude-3-5-sonnet-20241022',
}),
endpoint: '/api/copilotkit',
});
return handleRequest(req);
};
Important: MCP client initialization happens per-request in this example for simplicity. In production, consider caching the MCP client connections and refreshing tools periodically rather than reconnecting on every request. See the production deployment section below.
Environment variables
# .env.local
ANTHROPIC_API_KEY=sk-ant-...
GITHUB_MCP_SERVER_URL=http://localhost:3001/sse
GITHUB_MCP_TOKEN=ghp_...
POSTGRES_MCP_SERVER_URL=http://localhost:3002/sse
Step 2: Wrap Your React App with CopilotKit
// components/CopilotWrapper.tsx
'use client';
import { CopilotKit } from '@copilotkit/react-core';
import { ReactNode } from 'react';
export function CopilotWrapper({ children }: { children: ReactNode }) {
return (
<CopilotKit runtimeUrl="/api/copilotkit">
{children}
</CopilotKit>
);
}
// app/layout.tsx
import { CopilotWrapper } from '@/components/CopilotWrapper';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<CopilotWrapper>
{children}
</CopilotWrapper>
</body>
</html>
);
}
Step 3: Add the Chat Interface
// app/page.tsx
'use client';
import { CopilotSidebar } from '@copilotkit/react-ui';
import '@copilotkit/react-ui/styles.css';
export default function Home() {
return (
<main>
<h1>My AI Copilot</h1>
{/* Your existing app content */}
<CopilotSidebar
defaultOpen={false}
instructions="You are a helpful assistant with access to GitHub and database tools. Use tools proactively to help the user."
labels={{
title: 'AI Assistant',
initial: 'Hi! I can help you manage GitHub issues and query your database.',
}}
/>
</main>
);
}
Step 4: Build an MCP Server (If You Need a Custom One)
If existing MCP servers don't cover your use case, here is a production-ready custom MCP server using SSE transport:
// mcp-servers/crm-server.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import express from 'express';
import { z } from 'zod';
const app = express();
const server = new McpServer({
name: 'crm-mcp-server',
version: '1.0.0',
});
// Define a tool: get customer by ID
server.tool(
'get_customer',
'Retrieve a customer record from the CRM by their ID',
{
customer_id: z.string().describe('The unique customer identifier'),
},
async ({ customer_id }) => {
// In production: query your actual CRM API
const customer = await fetchCustomerFromCRM(customer_id);
if (!customer) {
return {
content: [{
type: 'text',
text: `No customer found with ID: ${customer_id}`,
}],
isError: true,
};
}
return {
content: [{
type: 'text',
text: JSON.stringify(customer, null, 2),
}],
};
}
);
// Define a tool: create support ticket
server.tool(
'create_ticket',
'Create a new support ticket for a customer',
{
customer_id: z.string(),
subject: z.string().max(200),
priority: z.enum(['low', 'medium', 'high', 'critical']),
description: z.string().max(2000),
},
async ({ customer_id, subject, priority, description }) => {
const ticket = await createSupportTicket({ customer_id, subject, priority, description });
return {
content: [{
type: 'text',
text: `Ticket created successfully. Ticket ID: ${ticket.id}`,
}],
};
}
);
// SSE transport setup
const transports: Map<string, SSEServerTransport> = new Map();
app.get('/sse', async (req, res) => {
const transport = new SSEServerTransport('/messages', res);
transports.set(transport.sessionId, transport);
res.on('close', () => {
transports.delete(transport.sessionId);
});
await server.connect(transport);
});
app.post('/messages', async (req, res) => {
const { sessionId } = req.query as { sessionId: string };
const transport = transports.get(sessionId);
if (!transport) {
res.status(404).json({ error: 'Session not found' });
return;
}
await transport.handlePostMessage(req, res);
});
app.listen(3001, () => {
console.log('CRM MCP Server running on http://localhost:3001');
});
Combining MCP Tools with Frontend CopilotKit Actions
One of CopilotKit's most powerful patterns is mixing MCP server tools (which run server-side) with frontend actions (which run in the browser). The AI decides which tool to call:
// In your React component
'use client';
import { useCopilotAction, useCopilotReadable } from '@copilotkit/react-core';
import { useState } from 'react';
export function CustomerDashboard() {
const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(null);
const [ticketModalOpen, setTicketModalOpen] = useState(false);
// Expose current UI state as readable context
useCopilotReadable({
description: 'Currently selected customer ID in the dashboard',
value: selectedCustomerId,
});
// Frontend action: open the ticket creation modal
// This runs in the browser — no server round trip
useCopilotAction({
name: 'open_ticket_modal',
description: 'Open the ticket creation modal for the current customer',
parameters: [],
handler: async () => {
setTicketModalOpen(true);
},
});
// Frontend action: navigate to a customer profile
useCopilotAction({
name: 'select_customer',
description: 'Select a customer and navigate to their profile',
parameters: [
{
name: 'customer_id',
type: 'string',
description: 'The customer ID to navigate to',
required: true,
},
],
handler: async ({ customer_id }) => {
setSelectedCustomerId(customer_id);
},
});
return (
<div>
{/* Your UI */}
{ticketModalOpen && <TicketModal onClose={() => setTicketModalOpen(false)} />}
</div>
);
}
Now the AI has access to:
get_customer— MCP tool, queries your CRM API server-sidecreate_ticket— MCP tool, creates a real ticketopen_ticket_modal— frontend action, opens the React modalselect_customer— frontend action, updates React state
A user can say "Look up customer 12345 and open a high priority ticket for their billing issue" — the AI will call get_customer, then open_ticket_modal, all in sequence.
AI Agent Workflows with CopilotKit + MCP
For more complex, multi-step agentic workflows, CopilotKit integrates with LangGraph and other agent frameworks. MCP tools slot directly into these agent graphs:
// Using CopilotKit with a LangGraph agent that has MCP tools
import { CopilotRuntime } from '@copilotkit/runtime';
import { LangGraphAdapter } from '@copilotkit/runtime';
const runtime = new CopilotRuntime({
remoteActions: [
{
url: process.env.LANGGRAPH_AGENT_URL!, // Your deployed LangGraph agent
},
],
});
In your LangGraph agent, MCP tools are bound directly to the graph nodes:
# agent/graph.py (Python LangGraph example)
from langchain_mcp_adapters.client import MultiServerMCPClient
from langgraph.prebuilt import create_react_agent
from langchain_anthropic import ChatAnthropic
async def create_agent():
client = MultiServerMCPClient(
{
"github": {
"url": "http://github-mcp-server:3001/sse",
"transport": "sse",
},
"postgres": {
"url": "http://postgres-mcp-server:3002/sse",
"transport": "sse",
},
}
)
tools = await client.get_tools()
model = ChatAnthropic(model="claude-3-5-sonnet-20241022")
agent = create_react_agent(model, tools)
return agent
This pattern is powerful for research agents, code review pipelines, customer support bots, and any workflow requiring multiple sequential tool calls with reasoning between steps.
Claude Desktop Integration
If your team uses Claude Desktop alongside CopilotKit, you can point it at the same MCP servers your CopilotKit runtime uses. This means your AI copilot and Claude Desktop share the same tool ecosystem.
// ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)
{
"mcpServers": {
"crm-server": {
"command": "node",
"args": ["/path/to/mcp-servers/crm-server.js"],
"env": {
"CRM_API_KEY": "your-api-key"
}
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_..."
}
}
}
}
For SSE-based servers you've already deployed for CopilotKit:
{
"mcpServers": {
"crm-server": {
"command": "npx",
"args": ["mcp-remote", "http://your-crm-mcp-server.com/sse"],
"env": {
"MCP_BEARER_TOKEN": "your-bearer-token"
}
}
}
}
Restart Claude Desktop after saving. Tools appear immediately in the Claude Desktop interface.
Cursor Integration
Cursor IDE supports MCP for AI-assisted development. Connect the same MCP servers:
// .cursor/mcp.json (project-level) or ~/.cursor/mcp.json (global)
{
"mcpServers": {
"crm-server": {
"command": "node",
"args": ["./mcp-servers/crm-server.js"]
},
"postgres": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres"],
"env": {
"POSTGRES_CONNECTION_STRING": "postgresql://user:pass@localhost:5432/mydb"
}
}
}
}
With this in place, Cursor's AI composer can call your actual database or CRM while helping you write code — the same server your CopilotKit runtime calls in production.
Authentication and Security
This is where most production CopilotKit + MCP setups get it wrong. Here is what actually matters:
Authenticating MCP Server Connections
// Passing credentials to MCP servers in your CopilotKit runtime
const mcpClient = await experimental_createMCPClient({
transport: {
type: 'sse',
url: process.env.PRIVATE_MCP_SERVER_URL!,
headers: {
Authorization: `Bearer ${process.env.MCP_SERVER_TOKEN}`,
'X-Service-Name': 'copilotkit-runtime',
'X-Request-ID': crypto.randomUUID(), // For tracing
},
},
});
Validating User Identity Before Tool Calls
Your CopilotKit route handler receives the original HTTP request. Use this to validate the user before initializing MCP clients:
// app/api/copilotkit/route.ts
import { getServerSession } from 'next-auth';
export const POST = async (req: NextRequest) => {
// Validate the user session first
const session = await getServerSession();
if (!session?.user) {
return new Response('Unauthorized', { status: 401 });
}
// Scope MCP tool access based on user role
const userRole = session.user.role;
const mcpClient = await experimental_createMCPClient({
transport: {
type: 'sse',
url: process.env.MCP_SERVER_URL!,
headers: {
Authorization: `Bearer ${process.env.MCP_SERVICE_TOKEN}`,
'X-User-ID': session.user.id,
'X-User-Role': userRole,
},
},
});
let tools = await mcpClient.tools();
// Allowlist tools based on user role
if (userRole !== 'admin') {
const allowedTools = ['get_customer', 'create_ticket'];
tools = Object.fromEntries(
Object.entries(tools).filter(([name]) => allowedTools.includes(name))
);
}
const runtime = new CopilotRuntime({ tools });
// ...
};
MCP Server-Side Security
Your MCP server should never trust the client blindly:
// In your MCP server tool handler
server.tool(
'delete_customer',
'Delete a customer record',
{ customer_id: z.string() },
async ({ customer_id }, { requestContext }) => {
const userRole = requestContext?.headers?.['x-user-role'];
if (userRole !== 'admin') {
return {
content: [{ type: 'text', text: 'Permission denied: admin role required' }],
isError: true,
};
}
// Validate the customer_id format to prevent injection
if (!/^[a-zA-Z0-9-]{8,36}$/.test(customer_id)) {
return {
content: [{ type: 'text', text: 'Invalid customer ID format' }],
isError: true,
};
}
await deleteCustomer(customer_id);
return {
content: [{ type: 'text', text: `Customer ${customer_id} deleted` }],
};
}
);
Security Checklist
- All MCP server URLs use HTTPS in production
- Bearer tokens for MCP server connections stored in environment variables, never hardcoded
- CopilotKit runtime route validates user session before connecting to MCP servers
- User role information passed to MCP server in request headers
- MCP server validates all tool arguments before execution
- Rate limiting applied at the MCP server level (per user/session)
- Tool allowlists enforced based on user permissions
- Audit logging enabled for all tool calls with user ID and timestamp
- MCP servers not directly accessible from public internet (behind API gateway)
Validating Your MCP Server Before Integration
Before wiring an MCP server into CopilotKit production, validate it properly. Protocol compliance issues — missing capability declarations, malformed JSON Schema, incorrect JSON-RPC handshake — cause silent failures that are painful to debug.
MCPForge Verify runs your MCP server through a full protocol compliance check: it validates the initialize handshake, tools/list response schema, tool argument schemas, error handling, and transport configuration. Use it before any production deployment.
For discovering battle-tested, pre-validated MCP servers compatible with CopilotKit, browse the MCPForge Verified Directory — servers listed there have already passed protocol compliance checks.
Production Deployment
Connection Pooling for MCP Clients
Do not create a new MCP client connection on every request in production. Instead, cache connections and refresh the tool list on a schedule:
// lib/mcp-client-pool.ts
import { experimental_createMCPClient } from 'ai';
interface MCPClientPool {
tools: Record<string, unknown>;
lastRefreshed: number;
client: Awaited<ReturnType<typeof experimental_createMCPClient>>;
}
const pools = new Map<string, MCPClientPool>();
const REFRESH_INTERVAL_MS = 5 * 60 * 1000; // Refresh tool list every 5 minutes
export async function getMCPTools(
serverName: string,
serverUrl: string,
headers?: Record<string, string>
): Promise<Record<string, unknown>> {
const existing = pools.get(serverName);
const now = Date.now();
if (existing && now - existing.lastRefreshed < REFRESH_INTERVAL_MS) {
return existing.tools;
}
const client = await experimental_createMCPClient({
transport: { type: 'sse', url: serverUrl, headers },
});
const tools = await client.tools();
pools.set(serverName, {
tools,
lastRefreshed: now,
client,
});
return tools;
}
Health Checks
Implement health check endpoints on your MCP servers and monitor them:
// In your Express MCP server
app.get('/health', async (req, res) => {
try {
// Check that the server can still reach its dependencies
await db.query('SELECT 1');
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
} catch (error) {
res.status(503).json({ status: 'unhealthy', error: String(error) });
}
});
Docker Deployment
# Dockerfile for your MCP server
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY dist/ ./dist/
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD wget -q -O- http://localhost:3001/health || exit 1
CMD ["node", "dist/crm-server.js"]
# docker-compose.yml
version: '3.8'
services:
crm-mcp-server:
build: ./mcp-servers/crm
ports:
- '3001:3001'
environment:
CRM_API_URL: ${CRM_API_URL}
CRM_API_KEY: ${CRM_API_KEY}
restart: unless-stopped
healthcheck:
test: ['CMD', 'wget', '-q', '-O-', 'http://localhost:3001/health']
interval: 30s
timeout: 10s
retries: 3
nextjs-app:
build: .
ports:
- '3000:3000'
environment:
CRM_MCP_SERVER_URL: http://crm-mcp-server:3001/sse
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
depends_on:
crm-mcp-server:
condition: service_healthy
For a deeper look at scaling and operating MCP servers in production environments, see Running MCP in Production.
Performance Optimization
Tool Schema Caching
Tool schemas rarely change. Cache them aggressively:
import { unstable_cache } from 'next/cache';
const getCachedMCPTools = unstable_cache(
async (serverUrl: string) => {
const client = await experimental_createMCPClient({
transport: { type: 'sse', url: serverUrl },
});
return client.tools();
},
['mcp-tools'],
{ revalidate: 300 } // Cache for 5 minutes
);
Limit Tool Context Size
If you connect many MCP servers, the aggregated tool schemas can consume significant LLM context tokens. Be selective:
// Only include tools relevant to the current page or user role
const allTools = await getMCPTools('crm-server', process.env.CRM_MCP_URL!);
// Filter to only the tools this user needs
const relevantTools = Object.fromEntries(
Object.entries(allTools).filter(([name]) =>
userPermissions.allowedTools.includes(name)
)
);
Parallel Tool Discovery
When connecting to multiple MCP servers, discover tools in parallel:
const [githubTools, postgresTools, slackTools] = await Promise.all([
getMCPTools('github', process.env.GITHUB_MCP_URL!),
getMCPTools('postgres', process.env.POSTGRES_MCP_URL!),
getMCPTools('slack', process.env.SLACK_MCP_URL!),
]);
const allTools = { ...githubTools, ...postgresTools, ...slackTools };
Common Mistakes
1. Using stdio transport for deployed applications
Stdio only works when the MCP server runs in the same process or on the same machine. The moment your CopilotKit runtime is deployed (Vercel, Railway, AWS), stdio breaks. Always use SSE or Streamable HTTP for deployed setups.
2. Exposing MCP servers directly to the internet
Your MCP server URLs should never be publicly accessible without authentication. Run them behind an API gateway or load balancer, require Bearer tokens, and never expose them from a public Docker port without authentication middleware.
3. Initializing MCP clients on every request without caching
Each MCP client initialization involves a tools/list round-trip. At scale, this adds hundreds of milliseconds to every request. Use the connection pooling pattern shown above.
4. Not validating tool arguments in the MCP server
The LLM can hallucinate tool arguments. Your MCP server is the last line of defense. Always validate with Zod or equivalent before executing any database query, API call, or system operation.
5. Naming conflicts between MCP tool sources
If two MCP servers expose a tool with the same name, the second will silently overwrite the first when you spread them into a single object. Use namespacing:
const githubTools = await githubClient.tools();
const gitlabTools = await gitlabClient.tools();
// Namespace to avoid collisions
const namespacedGithubTools = Object.fromEntries(
Object.entries(githubTools).map(([k, v]) => [`github__${k}`, v])
);
const namespacedGitlabTools = Object.fromEntries(
Object.entries(gitlabTools).map(([k, v]) => [`gitlab__${k}`, v])
);
6. Missing error handling in tool handlers
An unhandled exception in an MCP tool handler will crash the tool call and may crash the server. Always wrap handlers:
server.tool('risky_operation', '...', { id: z.string() }, async ({ id }) => {
try {
const result = await riskyOperation(id);
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
} catch (error) {
console.error('risky_operation failed:', error);
return {
content: [{ type: 'text', text: `Operation failed: ${error instanceof Error ? error.message : 'Unknown error'}` }],
isError: true,
};
}
});
Troubleshooting
Tools not appearing in CopilotKit
- Verify your MCP server is running:
curl http://localhost:3001/health - Test the SSE endpoint directly:
curl -N http://localhost:3001/sse - Check the
tools/listresponse manually using MCP Inspector (npx @modelcontextprotocol/inspector) - Run your server URL through MCPForge Verify to catch protocol issues
- Check your CopilotKit runtime logs for MCP connection errors
SSE connection drops immediately
- Ensure you are not behind a reverse proxy that buffers SSE responses (nginx needs
proxy_buffering off) - Add appropriate keep-alive headers
- Check that the MCP server does not send
Connection: close
# nginx config for MCP SSE endpoint
location /sse {
proxy_pass http://mcp-server:3001;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
chunked_transfer_encoding on;
}
Tool calls return unexpected errors
- Check MCP server logs for the actual error
- Verify Zod schema matches what the LLM is sending
- Add argument logging at the top of each tool handler during debugging
- Test tool calls manually using MCP Inspector
CopilotKit runtime timeout on tool execution
Long-running tools (>30s) can timeout. Solutions:
- Return a ticket/job ID immediately and poll for results
- Increase the CopilotKit runtime timeout in your route handler
- Use streaming responses for incremental results
CopilotKit vs Traditional AI SDK Integration
| Dimension | CopilotKit + MCP | Direct LLM SDK + Custom Tools |
|---|---|---|
| Tool addition | Add MCP server, tools auto-discovered | Write new function, redeploy |
| Frontend integration | Native React hooks, state binding | Manual implementation |
| Multi-agent | LangGraph/CoAgents support built-in | DIY orchestration |
| Tool reusability | Any MCP client (Claude, Cursor, etc.) | Only your app |
| Streaming | Built-in | Manual implementation |
| Schema validation | Zod via MCP SDK | Manual |
| Auth handling | Per-request context available | Varies by implementation |
| Setup complexity | Medium (more moving parts) | Low initially, high at scale |
| Best for | Production apps, growing tool surface | Simple, fixed-tool chatbots |
Production Readiness Checklist
Infrastructure
☐ MCP servers deployed with Docker / Kubernetes
☐ Health check endpoints implemented and monitored
☐ MCP servers behind API gateway (not public-facing)
☐ HTTPS enforced on all MCP server URLs
☐ Restart policies configured (restart: unless-stopped)
Security
☐ Bearer tokens stored in environment variables
☐ User session validated before MCP client initialization
☐ Tool allowlists enforced per user role
☐ All tool arguments validated server-side with Zod
☐ Rate limiting enabled per user/session
☐ Audit logging for all tool calls
Performance
☐ MCP client connection pooling implemented
☐ Tool schemas cached (5-minute TTL minimum)
☐ Parallel tool discovery for multiple servers
☐ Tool namespace filtered to relevant subset per user
Reliability
☐ Tool handler errors caught and returned as isError: true
☐ MCP server timeouts configured appropriately
☐ Graceful degradation when MCP servers are unavailable
☐ Alerting on MCP server health check failures
Validation
☐ MCP server validated with MCPForge Verify before go-live
☐ Tool schemas tested with MCP Inspector
☐ Load tested with expected concurrent user count
Key Takeaways
- CopilotKit + MCP is the production-grade pattern for React AI copilots that need to grow their tool surface without constant redeployment.
- The CopilotKit runtime lives server-side and connects to MCP servers via SSE — never expose MCP servers directly to the browser.
- Validate user sessions in the CopilotKit route handler before initializing MCP clients, and enforce role-based tool allowlists.
- Cache MCP tool schemas aggressively — tool lists rarely change and re-fetching on every request kills performance at scale.
- Validate your MCP server with MCPForge Verify before any production integration to catch protocol compliance issues early.
- Mix MCP server tools (server-side execution) with
useCopilotAction(browser-side execution) for the most capable copilot experience. - Namespace tool names when aggregating from multiple MCP servers to avoid silent collisions.