Implement MCP OAuth authentication system
- Add MCPOAuthProvider implementing OAuthClientProvider interface - Implement popup-based OAuth flow with message passing - Add automatic connection retry after OAuth completion - Support client registration persistence per server URL - Add proper error suppression for expected OAuth UnauthorizedErrors - Create comprehensive OAuth implementation documentation - Clean up debug logging with emoji status indicators 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
438ceb333c
commit
12cb59e765
8 changed files with 2076 additions and 16 deletions
135
docs/mcp_oauth_implementation.md
Normal file
135
docs/mcp_oauth_implementation.md
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
# MCP OAuth Implementation Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document explains how OAuth authentication is implemented for MCP servers in our client, including lessons learned from early implementation attempts.
|
||||||
|
|
||||||
|
## Final Working Implementation
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
The OAuth implementation uses the MCP TypeScript SDK's built-in auth system with a custom `OAuthClientProvider` that handles browser-based popup flows:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class MCPOAuthProvider implements OAuthClientProvider {
|
||||||
|
// Implements all required OAuth provider methods
|
||||||
|
// Handles popup-based authorization flow
|
||||||
|
// Manages client registration and token storage
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
1. **MCPOAuthProvider**: Implements the SDK's `OAuthClientProvider` interface
|
||||||
|
2. **Popup-based authorization**: Opens OAuth server in popup window
|
||||||
|
3. **Message passing**: Popup communicates auth code back to parent window
|
||||||
|
4. **Automatic retry**: Connection automatically retries after OAuth completion
|
||||||
|
|
||||||
|
### OAuth Flow
|
||||||
|
|
||||||
|
1. **Initial connection attempt**: Fails with `UnauthorizedError` (expected)
|
||||||
|
2. **SDK triggers OAuth**: Calls `redirectToAuthorization()` which opens popup
|
||||||
|
3. **User authorizes**: Completes OAuth flow in popup window
|
||||||
|
4. **Authorization code received**: Popup sends auth code to parent via `postMessage`
|
||||||
|
5. **Token exchange**: OAuth provider immediately calls SDK's `auth()` with the code
|
||||||
|
6. **Tokens stored**: SDK calls `saveTokens()` with access/refresh tokens
|
||||||
|
7. **Connection retry**: OAuth completion triggers automatic connection retry
|
||||||
|
8. **Success**: Connection succeeds with valid tokens
|
||||||
|
|
||||||
|
## What We Got Wrong Initially
|
||||||
|
|
||||||
|
### Mistake 1: Manual OAuth Flow Management
|
||||||
|
|
||||||
|
**Wrong approach**: We initially tried to manually manage the entire OAuth flow outside of the SDK:
|
||||||
|
- Manually opening authorization URLs
|
||||||
|
- Manually exchanging authorization codes for tokens
|
||||||
|
- Trying to inject tokens into the transport after the fact
|
||||||
|
|
||||||
|
**Why it was wrong**: The MCP SDK already has a complete OAuth implementation. Fighting against it created timing issues and complexity.
|
||||||
|
|
||||||
|
**Correct approach**: Implement the `OAuthClientProvider` interface and let the SDK handle the OAuth flow orchestration.
|
||||||
|
|
||||||
|
### Mistake 2: Waiting for Authorization in `redirectToAuthorization`
|
||||||
|
|
||||||
|
**Wrong approach**: Making `redirectToAuthorization()` wait for the popup to complete:
|
||||||
|
```typescript
|
||||||
|
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
||||||
|
const popup = window.open(authorizationUrl);
|
||||||
|
// Wait for popup to complete and return auth code
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Listen for popup messages...
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why it was wrong**: The SDK's `auth()` function expects `redirectToAuthorization()` to return immediately after opening the authorization URL. The authorization code processing happens in a separate call.
|
||||||
|
|
||||||
|
**Correct approach**: Return immediately from `redirectToAuthorization()` and process the authorization code asynchronously when received from the popup.
|
||||||
|
|
||||||
|
### Mistake 3: Not Understanding the Two-Step OAuth Flow
|
||||||
|
|
||||||
|
**Wrong approach**: Thinking the SDK's `auth()` function is called once and handles everything.
|
||||||
|
|
||||||
|
**Why it was wrong**: The OAuth flow actually involves multiple interactions:
|
||||||
|
1. First call to `auth()`: Starts authorization flow, calls `redirectToAuthorization()`
|
||||||
|
2. User completes authorization in popup
|
||||||
|
3. Authorization code received via popup message
|
||||||
|
4. Second call to `auth()` with the authorization code: Exchanges code for tokens
|
||||||
|
|
||||||
|
**Correct approach**: Handle authorization code reception asynchronously and immediately call `auth()` again with the code to complete token exchange.
|
||||||
|
|
||||||
|
### Mistake 4: Wrong Error Handling Strategy
|
||||||
|
|
||||||
|
**Wrong approach**: Treating initial `UnauthorizedError` as a real failure and showing error alerts.
|
||||||
|
|
||||||
|
**Why it was wrong**: For OAuth servers, the initial connection attempt is *expected* to fail with `UnauthorizedError`. This triggers the OAuth flow.
|
||||||
|
|
||||||
|
**Correct approach**: Suppress `UnauthorizedError` for OAuth servers and let the OAuth flow handle authentication automatically.
|
||||||
|
|
||||||
|
## Key Implementation Details
|
||||||
|
|
||||||
|
### Client Registration Per Server
|
||||||
|
|
||||||
|
Client information is stored per server URL (not per connection) to avoid unnecessary re-registrations:
|
||||||
|
```typescript
|
||||||
|
// Generate consistent key for server
|
||||||
|
private getServerKey(): string {
|
||||||
|
const url = new URL(this.serverUrl);
|
||||||
|
return btoa(`${url.hostname}${url.pathname}`).replace(/[+/=]/g, '');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Predictable Redirect URI
|
||||||
|
|
||||||
|
Uses a single redirect URI with state-based session identification:
|
||||||
|
- Redirect URI: `${origin}/oauth/mcp/callback`
|
||||||
|
- State parameter: `${connectionId}.${randomString}`
|
||||||
|
|
||||||
|
This avoids the "unregistered redirect_uri" error that occurs with dynamic URIs.
|
||||||
|
|
||||||
|
### Automatic Connection Retry
|
||||||
|
|
||||||
|
After OAuth completion, the connection automatically retries:
|
||||||
|
```typescript
|
||||||
|
private async handleOAuthSuccess(): Promise<void> {
|
||||||
|
// Reset connection state and retry with new tokens
|
||||||
|
this.connection.status = 'connecting';
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices Learned
|
||||||
|
|
||||||
|
1. **Follow the SDK patterns**: Don't fight against the SDK's built-in OAuth system
|
||||||
|
2. **Handle expected failures gracefully**: OAuth servers will return 401 on first connection
|
||||||
|
3. **Use consistent redirect URIs**: Avoid dynamic URIs that require pre-registration
|
||||||
|
4. **Store client info per server**: Minimize unnecessary client registrations
|
||||||
|
5. **Process auth codes immediately**: Don't store them for later processing
|
||||||
|
6. **Implement proper error suppression**: Distinguish between expected OAuth errors and real failures
|
||||||
|
|
||||||
|
## Future Improvements
|
||||||
|
|
||||||
|
- Add token refresh handling for long-lived connections
|
||||||
|
- Implement proper error recovery for failed OAuth flows
|
||||||
|
- Add support for different OAuth grant types (currently only authorization code)
|
||||||
|
- Consider adding OAuth scope configuration options
|
||||||
305
docs/mcp_provider_interface.md
Normal file
305
docs/mcp_provider_interface.md
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
# MCP Provider Interface Design
|
||||||
|
|
||||||
|
> **⚠️ DRAFT/TENTATIVE DESIGN**
|
||||||
|
> This document outlines a proposed design for the MCP provider system. The design is subject to iteration and refinement during implementation.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The MCP Provider system manages connections to multiple MCP servers, providing a unified interface for tool calling, resource access, and connection management. It follows similar architectural patterns to the InferenceProvider system.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Multi-Server Support**: Connect to multiple MCP servers simultaneously
|
||||||
|
2. **Transport Abstraction**: Support SSE and Streamable HTTP transports with automatic fallback
|
||||||
|
3. **Tool Aggregation**: Combine tools from all servers with conflict resolution
|
||||||
|
4. **Connection Management**: Handle connection lifecycle, reconnection, and error recovery
|
||||||
|
5. **OAuth Integration**: Support MCP server OAuth flows with proper callback routing
|
||||||
|
6. **Debug Visibility**: Comprehensive debugging interface for development and troubleshooting
|
||||||
|
|
||||||
|
## Core Interfaces
|
||||||
|
|
||||||
|
### MCPConnection
|
||||||
|
```typescript
|
||||||
|
interface MCPConnection {
|
||||||
|
id: string; // Unique connection identifier
|
||||||
|
name: string; // User-provided server name
|
||||||
|
url: string; // Server URL
|
||||||
|
status: 'connecting' | 'connected' | 'failed' | 'disconnected';
|
||||||
|
client?: Client; // MCP SDK client instance
|
||||||
|
transport: 'sse' | 'streamable-http';
|
||||||
|
authType: 'none' | 'oauth';
|
||||||
|
|
||||||
|
// Available capabilities
|
||||||
|
tools: Tool[]; // Tools with name-prefixed identifiers
|
||||||
|
resources?: Resource[]; // Available resources
|
||||||
|
prompts?: Prompt[]; // Available prompts
|
||||||
|
|
||||||
|
// Connection metadata
|
||||||
|
error?: string; // Last error message
|
||||||
|
lastConnected?: Date; // Last successful connection
|
||||||
|
capabilities?: ServerCapabilities; // Server-advertised capabilities
|
||||||
|
|
||||||
|
// Debug information
|
||||||
|
messageTrace?: MCPMessage[]; // Recent message exchange history
|
||||||
|
connectionAttempts: number; // Number of reconnection attempts
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MCPServerConfig
|
||||||
|
```typescript
|
||||||
|
interface MCPServerConfig {
|
||||||
|
name: string; // User-provided display name
|
||||||
|
url: string; // Server endpoint URL
|
||||||
|
transport?: 'sse' | 'streamable-http' | 'auto'; // Default: auto-detect
|
||||||
|
authType?: 'none' | 'oauth'; // Default: none
|
||||||
|
oauthConfig?: {
|
||||||
|
// OAuth configuration if needed
|
||||||
|
clientId?: string;
|
||||||
|
};
|
||||||
|
autoReconnect?: boolean; // Default: true
|
||||||
|
maxReconnectAttempts?: number; // Default: 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MCPContextValue
|
||||||
|
```typescript
|
||||||
|
interface MCPContextValue {
|
||||||
|
// Connection state
|
||||||
|
connections: MCPConnection[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Connection management
|
||||||
|
addMcpServer: (config: MCPServerConfig) => Promise<string>; // Returns connection ID
|
||||||
|
removeMcpServer: (connectionId: string) => void;
|
||||||
|
reconnectServer: (connectionId: string) => Promise<void>;
|
||||||
|
|
||||||
|
// Tool access (explicit server routing)
|
||||||
|
getAllTools: () => Tool[]; // All tools with prefixed names
|
||||||
|
getToolsForServer: (connectionId: string) => Tool[];
|
||||||
|
callTool: (connectionId: string, toolName: string, args: any) => Promise<any>;
|
||||||
|
|
||||||
|
// Resource access
|
||||||
|
getAllResources: () => Resource[];
|
||||||
|
getResourcesForServer: (connectionId: string) => Resource[];
|
||||||
|
|
||||||
|
// Status and debugging
|
||||||
|
getConnectedServers: () => MCPConnection[];
|
||||||
|
getServerStatus: (connectionId: string) => MCPConnection['status'];
|
||||||
|
getConnectionById: (connectionId: string) => MCPConnection | undefined;
|
||||||
|
getMessageTrace: (connectionId: string) => MCPMessage[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool Name Resolution
|
||||||
|
|
||||||
|
### Prefixing Strategy
|
||||||
|
Tools from each server are prefixed with the user-provided server name to avoid conflicts:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Original tool from weather server: "get_weather"
|
||||||
|
// Prefixed tool: "weather_server.get_weather"
|
||||||
|
|
||||||
|
// Original tool from calendar server: "get_events"
|
||||||
|
// Prefixed tool: "calendar.get_events"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Discovery and Registration
|
||||||
|
```typescript
|
||||||
|
class MCPConnection {
|
||||||
|
private async discoverTools(): Promise<Tool[]> {
|
||||||
|
const tools = await this.client.listTools();
|
||||||
|
return tools.map(tool => ({
|
||||||
|
...tool,
|
||||||
|
function: {
|
||||||
|
...tool.function,
|
||||||
|
name: `${this.name}.${tool.function.name}`, // Add prefix
|
||||||
|
description: `[${this.name}] ${tool.function.description}`, // Add server context
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connection Management
|
||||||
|
|
||||||
|
### Connection Lifecycle
|
||||||
|
```typescript
|
||||||
|
class MCPConnection {
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
this.status = 'connecting';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Try StreamableHTTP first
|
||||||
|
if (this.transport === 'auto' || this.transport === 'streamable-http') {
|
||||||
|
await this.tryStreamableHttp();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback to SSE if needed
|
||||||
|
if (this.status !== 'connected' && (this.transport === 'auto' || this.transport === 'sse')) {
|
||||||
|
await this.trySSE();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Initialize capabilities
|
||||||
|
await this.initializeCapabilities();
|
||||||
|
|
||||||
|
this.status = 'connected';
|
||||||
|
this.lastConnected = new Date();
|
||||||
|
this.connectionAttempts = 0;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.status = 'failed';
|
||||||
|
this.error = error.message;
|
||||||
|
this.connectionAttempts++;
|
||||||
|
|
||||||
|
if (this.autoReconnect && this.connectionAttempts < this.maxReconnectAttempts) {
|
||||||
|
setTimeout(() => this.connect(), this.getBackoffDelay());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-Reconnection Strategy
|
||||||
|
- Exponential backoff: 1s, 2s, 4s, 8s, 16s
|
||||||
|
- Maximum 5 attempts by default
|
||||||
|
- Reset attempt counter on successful connection
|
||||||
|
- User can manually retry after max attempts reached
|
||||||
|
|
||||||
|
## React Integration
|
||||||
|
|
||||||
|
### MCPProvider Context
|
||||||
|
```typescript
|
||||||
|
export function MCPProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [connections, setConnections] = useState<MCPConnection[]>([]);
|
||||||
|
const [connectionStates, setConnectionStates] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
// Load persisted connections from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadPersistedConnections();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-save connections to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
persistConnections(connections);
|
||||||
|
}, [connections]);
|
||||||
|
|
||||||
|
// ... implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hook Usage
|
||||||
|
```typescript
|
||||||
|
const {
|
||||||
|
connections,
|
||||||
|
addMcpServer,
|
||||||
|
callTool,
|
||||||
|
getAllTools
|
||||||
|
} = useMCP();
|
||||||
|
|
||||||
|
// Add a server
|
||||||
|
const connectionId = await addMcpServer({
|
||||||
|
name: 'Weather Service',
|
||||||
|
url: 'https://weather.mcp.example.com',
|
||||||
|
transport: 'auto'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call a tool
|
||||||
|
const result = await callTool(connectionId, 'get_weather', {
|
||||||
|
location: 'San Francisco'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication & OAuth
|
||||||
|
|
||||||
|
### OAuth Flow for MCP Servers
|
||||||
|
Following the established callback routing pattern:
|
||||||
|
- OAuth callbacks: `/oauth/mcp/{connectionId}/callback`
|
||||||
|
- Server-specific state management
|
||||||
|
- Automatic token refresh handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface MCPOAuthProvider {
|
||||||
|
authenticate(connectionId: string): Promise<void>;
|
||||||
|
refreshToken(connectionId: string): Promise<void>;
|
||||||
|
getAuthStatus(connectionId: string): 'authenticated' | 'pending' | 'failed';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling & Debugging
|
||||||
|
|
||||||
|
### Message Tracing
|
||||||
|
```typescript
|
||||||
|
interface MCPMessage {
|
||||||
|
timestamp: Date;
|
||||||
|
direction: 'sent' | 'received';
|
||||||
|
type: 'request' | 'response' | 'notification';
|
||||||
|
method?: string;
|
||||||
|
id?: string | number;
|
||||||
|
content: any;
|
||||||
|
error?: any;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connection Debugging
|
||||||
|
- Real-time connection status monitoring
|
||||||
|
- Message trace viewer with filtering
|
||||||
|
- Transport fallback information
|
||||||
|
- OAuth flow status tracking
|
||||||
|
- Tool discovery and registration logs
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase 1: Core Connection Management ⏳
|
||||||
|
1. Basic MCPConnection class with transport support
|
||||||
|
2. MCPProvider React context with connection lifecycle
|
||||||
|
3. Tool discovery and name prefixing
|
||||||
|
4. Basic error handling and reconnection
|
||||||
|
|
||||||
|
### Phase 2: Debug Interface ⏳
|
||||||
|
1. Connection status dashboard
|
||||||
|
2. Tool listing and testing interface
|
||||||
|
3. Message trace viewer
|
||||||
|
4. Manual connection controls
|
||||||
|
|
||||||
|
### Phase 3: OAuth & Advanced Features
|
||||||
|
1. OAuth integration for authenticated servers
|
||||||
|
2. Resource and prompt support
|
||||||
|
3. Enhanced debugging and monitoring
|
||||||
|
4. Performance optimizations
|
||||||
|
|
||||||
|
### Phase 4: Integration
|
||||||
|
1. Integration with agent loop and inference provider
|
||||||
|
2. Unified tool calling across test tools and MCP tools
|
||||||
|
3. Advanced error recovery and resilience
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **URL Validation**: Validate MCP server URLs to prevent malicious redirects
|
||||||
|
2. **OAuth Security**: Proper PKCE implementation for OAuth flows
|
||||||
|
3. **Message Validation**: Validate all MCP messages against protocol schema
|
||||||
|
4. **Storage Security**: Secure storage of OAuth tokens and sensitive config
|
||||||
|
5. **CORS Handling**: Proper CORS error detection and user guidance
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- MCPConnection class functionality
|
||||||
|
- Tool name prefixing and conflict resolution
|
||||||
|
- Connection lifecycle and error handling
|
||||||
|
- OAuth flow simulation
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- Real MCP server connections (with test servers)
|
||||||
|
- Transport fallback scenarios
|
||||||
|
- Multi-server tool calling
|
||||||
|
- OAuth authentication flows
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Advanced Tool Routing**: Smart tool routing based on capability matching
|
||||||
|
2. **Connection Pooling**: Efficient connection reuse and management
|
||||||
|
3. **Caching**: Tool schema and response caching
|
||||||
|
4. **Monitoring**: Connection health monitoring and alerts
|
||||||
|
5. **Batch Operations**: Bulk tool calling and resource access
|
||||||
|
6. **Custom Transports**: Support for WebSocket and custom transport protocols
|
||||||
51
src/App.tsx
51
src/App.tsx
|
|
@ -1,30 +1,63 @@
|
||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import { InferenceProvider } from '@/contexts/InferenceContext'
|
import { InferenceProvider } from '@/contexts/InferenceContext'
|
||||||
|
import { MCPProvider } from '@/contexts/MCPContext'
|
||||||
import { InferenceTest } from '@/components/InferenceTest'
|
import { InferenceTest } from '@/components/InferenceTest'
|
||||||
|
import { MCPTest } from '@/components/MCPTest'
|
||||||
import { OAuthCallback } from '@/components/OAuthCallback'
|
import { OAuthCallback } from '@/components/OAuthCallback'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// Simple routing based on pathname
|
// Simple routing based on pathname
|
||||||
const pathname = window.location.pathname;
|
const pathname = window.location.pathname;
|
||||||
const isInferenceOAuthCallback = pathname === '/oauth/inference/callback';
|
const isInferenceOAuthCallback = pathname === '/oauth/inference/callback';
|
||||||
const isMcpOAuthCallback = pathname.startsWith('/oauth/mcp/');
|
const isMcpOAuthCallback = pathname === '/oauth/mcp/callback';
|
||||||
|
|
||||||
if (isInferenceOAuthCallback) {
|
if (isInferenceOAuthCallback) {
|
||||||
return <OAuthCallback type="inference" />;
|
return <OAuthCallback type="inference" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMcpOAuthCallback) {
|
if (isMcpOAuthCallback) {
|
||||||
// Extract server identifier from path like /oauth/mcp/server123/callback
|
return <OAuthCallback type="mcp" />;
|
||||||
const serverMatch = pathname.match(/^\/oauth\/mcp\/([^\/]+)\/callback$/);
|
|
||||||
const serverId = serverMatch?.[1];
|
|
||||||
return <OAuthCallback type="mcp" serverId={serverId} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'inference' | 'mcp'>('inference');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InferenceProvider>
|
<InferenceProvider>
|
||||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900">
|
<MCPProvider>
|
||||||
<InferenceTest />
|
<div className="min-h-screen bg-gray-100 dark:bg-gray-900">
|
||||||
</div>
|
{/* Tab Navigation */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
|
<div className="flex space-x-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('inference')}
|
||||||
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === 'inference'
|
||||||
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Inference Provider Test
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('mcp')}
|
||||||
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === 'mcp'
|
||||||
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
MCP Provider Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
{activeTab === 'inference' && <InferenceTest />}
|
||||||
|
{activeTab === 'mcp' && <MCPTest />}
|
||||||
|
</div>
|
||||||
|
</MCPProvider>
|
||||||
</InferenceProvider>
|
</InferenceProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
430
src/components/MCPTest.tsx
Normal file
430
src/components/MCPTest.tsx
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
// Test UI for MCP provider functionality
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { useMCP } from '@/contexts/MCPContext';
|
||||||
|
import type { MCPServerConfig } from '@/types/mcp';
|
||||||
|
|
||||||
|
export function MCPTest() {
|
||||||
|
const {
|
||||||
|
connections,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
addMcpServer,
|
||||||
|
removeMcpServer,
|
||||||
|
reconnectServer,
|
||||||
|
getAllTools,
|
||||||
|
getToolsForServer,
|
||||||
|
callTool,
|
||||||
|
getAllResources,
|
||||||
|
getConnectedServers,
|
||||||
|
} = useMCP();
|
||||||
|
|
||||||
|
const [newServerName, setNewServerName] = useState('');
|
||||||
|
const [newServerUrl, setNewServerUrl] = useState('');
|
||||||
|
const [selectedConnectionId, setSelectedConnectionId] = useState<string>('');
|
||||||
|
const [selectedToolName, setSelectedToolName] = useState('');
|
||||||
|
const [toolArgs, setToolArgs] = useState('{}');
|
||||||
|
const [toolResult, setToolResult] = useState<string>('');
|
||||||
|
|
||||||
|
const handleAddServer = useCallback(async () => {
|
||||||
|
if (!newServerName.trim() || !newServerUrl.trim()) {
|
||||||
|
alert('Please enter both server name and URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: MCPServerConfig = {
|
||||||
|
name: newServerName.trim(),
|
||||||
|
url: newServerUrl.trim(),
|
||||||
|
transport: 'auto',
|
||||||
|
authType: 'none',
|
||||||
|
autoReconnect: false, // Disable auto-reconnect for now to prevent loops
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addMcpServer(config);
|
||||||
|
setNewServerName('');
|
||||||
|
setNewServerUrl('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add server:', error);
|
||||||
|
alert(`Failed to add server: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}, [newServerName, newServerUrl, addMcpServer]);
|
||||||
|
|
||||||
|
const handleAddServerWithOAuth = useCallback(async () => {
|
||||||
|
if (!newServerName.trim() || !newServerUrl.trim()) {
|
||||||
|
alert('Please enter both server name and URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: MCPServerConfig = {
|
||||||
|
name: newServerName.trim(),
|
||||||
|
url: newServerUrl.trim(),
|
||||||
|
transport: 'auto',
|
||||||
|
authType: 'oauth',
|
||||||
|
autoReconnect: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addMcpServer(config);
|
||||||
|
setNewServerName('');
|
||||||
|
setNewServerUrl('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add server with OAuth:', error);
|
||||||
|
alert(`Failed to add server with OAuth: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}, [newServerName, newServerUrl, addMcpServer]);
|
||||||
|
|
||||||
|
const handleRemoveServer = useCallback((connectionId: string) => {
|
||||||
|
if (confirm('Are you sure you want to remove this server?')) {
|
||||||
|
removeMcpServer(connectionId);
|
||||||
|
if (selectedConnectionId === connectionId) {
|
||||||
|
setSelectedConnectionId('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [removeMcpServer, selectedConnectionId]);
|
||||||
|
|
||||||
|
const handleReconnectServer = useCallback(async (connectionId: string) => {
|
||||||
|
try {
|
||||||
|
await reconnectServer(connectionId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reconnect:', error);
|
||||||
|
alert(`Failed to reconnect: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}, [reconnectServer]);
|
||||||
|
|
||||||
|
const handleCallTool = useCallback(async () => {
|
||||||
|
if (!selectedConnectionId || !selectedToolName) {
|
||||||
|
alert('Please select a connection and tool');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = JSON.parse(toolArgs);
|
||||||
|
const result = await callTool(selectedConnectionId, selectedToolName, args);
|
||||||
|
setToolResult(JSON.stringify(result, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Tool call failed:', error);
|
||||||
|
const errorResult = {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
details: error,
|
||||||
|
};
|
||||||
|
setToolResult(JSON.stringify(errorResult, null, 2));
|
||||||
|
}
|
||||||
|
}, [selectedConnectionId, selectedToolName, toolArgs, callTool]);
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'connected': return 'text-green-600 dark:text-green-400';
|
||||||
|
case 'connecting': return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
case 'failed': return 'text-red-600 dark:text-red-400';
|
||||||
|
case 'disconnected': return 'text-gray-600 dark:text-gray-400';
|
||||||
|
default: return 'text-gray-600 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'connected': return '✅';
|
||||||
|
case 'connecting': return '🔄';
|
||||||
|
case 'failed': return '❌';
|
||||||
|
case 'disconnected': return '⚫';
|
||||||
|
default: return '❓';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedConnection = connections.find(conn => conn.id === selectedConnectionId);
|
||||||
|
const selectedTools = selectedConnection ? getToolsForServer(selectedConnectionId) : [];
|
||||||
|
const allTools = getAllTools();
|
||||||
|
const allResources = getAllResources();
|
||||||
|
const connectedServers = getConnectedServers();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">
|
||||||
|
MCP Provider Test
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Add Server Section */}
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Add MCP Server
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Server name (e.g., 'Weather Service')"
|
||||||
|
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
|
value={newServerName}
|
||||||
|
onChange={(e) => setNewServerName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="Server URL (e.g., 'https://api.example.com/mcp')"
|
||||||
|
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
|
value={newServerUrl}
|
||||||
|
onChange={(e) => setNewServerUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={handleAddServer}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Adding...' : 'Add Server (No Auth)'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAddServerWithOAuth}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Adding...' : 'Add Server (OAuth)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server List */}
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Connected Servers ({connections.length})
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{connections.length === 0 ? (
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">No servers added yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{connections.map((connection) => (
|
||||||
|
<div key={connection.id} className="border border-gray-200 dark:border-gray-700 rounded-md p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-lg">{getStatusIcon(connection.status)}</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{connection.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{connection.url}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center gap-4 text-sm">
|
||||||
|
<span className={getStatusColor(connection.status)}>
|
||||||
|
Status: {connection.status}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">
|
||||||
|
Transport: {connection.transport}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">
|
||||||
|
Tools: {connection.tools.length}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">
|
||||||
|
Resources: {connection.resources.length}
|
||||||
|
</span>
|
||||||
|
{connection.connectionAttempts > 0 && (
|
||||||
|
<span className="text-orange-600 dark:text-orange-400">
|
||||||
|
Attempts: {connection.connectionAttempts}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connection.error && (
|
||||||
|
<div className="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||||
|
Error: {connection.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{connection.lastConnected && (
|
||||||
|
<div className="mt-2 text-sm text-gray-500 dark:text-gray-500">
|
||||||
|
Last connected: {connection.lastConnected.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{connection.status === 'failed' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleReconnectServer(connection.id)}
|
||||||
|
className="px-3 py-1 bg-yellow-600 text-white rounded-md hover:bg-yellow-700 text-sm"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveServer(connection.id)}
|
||||||
|
className="px-3 py-1 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tool Testing */}
|
||||||
|
{connectedServers.length > 0 && (
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Tool Testing
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Server Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Select Server:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedConnectionId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedConnectionId(e.target.value);
|
||||||
|
setSelectedToolName('');
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">Select a server</option>
|
||||||
|
{connectedServers.map((connection) => (
|
||||||
|
<option key={connection.id} value={connection.id}>
|
||||||
|
{connection.name} ({connection.tools.length} tools)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tool Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Select Tool:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedToolName}
|
||||||
|
onChange={(e) => setSelectedToolName(e.target.value)}
|
||||||
|
disabled={!selectedConnectionId}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">Select a tool</option>
|
||||||
|
{selectedTools.map((tool) => (
|
||||||
|
<option key={tool.function.name} value={tool.function.name}>
|
||||||
|
{tool.function.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tool Arguments */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Tool Arguments (JSON):
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={toolArgs}
|
||||||
|
onChange={(e) => setToolArgs(e.target.value)}
|
||||||
|
placeholder='{"param1": "value1", "param2": "value2"}'
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 font-mono text-sm"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Call Tool Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleCallTool}
|
||||||
|
disabled={!selectedConnectionId || !selectedToolName || isLoading}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Calling...' : 'Call Tool'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Tool Result */}
|
||||||
|
{toolResult && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Tool Result:
|
||||||
|
</label>
|
||||||
|
<pre className="bg-gray-100 dark:bg-gray-700 p-4 rounded-md text-xs overflow-x-auto text-gray-900 dark:text-gray-100">
|
||||||
|
{toolResult}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{connections.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
Total Servers
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||||
|
{connectedServers.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-green-800 dark:text-green-200">
|
||||||
|
Connected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-md p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||||
|
{allTools.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-purple-800 dark:text-purple-200">
|
||||||
|
Total Tools
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-md p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||||
|
{allResources.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-orange-800 dark:text-orange-200">
|
||||||
|
Total Resources
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-4">
|
||||||
|
<p className="text-red-700 dark:text-red-400">Error: {error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* All Tools List */}
|
||||||
|
{allTools.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
All Available Tools ({allTools.length})
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{allTools.map((tool, index) => (
|
||||||
|
<div key={index} className="bg-gray-50 dark:bg-gray-700 rounded-md p-3">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{tool.function.name}
|
||||||
|
</div>
|
||||||
|
{tool.function.description && (
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{tool.function.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,10 +4,9 @@ import React, { useEffect } from 'react';
|
||||||
|
|
||||||
interface OAuthCallbackProps {
|
interface OAuthCallbackProps {
|
||||||
type: 'inference' | 'mcp';
|
type: 'inference' | 'mcp';
|
||||||
serverId?: string; // Required when type is 'mcp'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OAuthCallback({ type, serverId }: OAuthCallbackProps) {
|
export function OAuthCallback({ type }: OAuthCallbackProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Extract OAuth parameters from URL
|
// Extract OAuth parameters from URL
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
|
@ -16,7 +15,8 @@ export function OAuthCallback({ type, serverId }: OAuthCallbackProps) {
|
||||||
const error = urlParams.get('error');
|
const error = urlParams.get('error');
|
||||||
const errorDescription = urlParams.get('error_description');
|
const errorDescription = urlParams.get('error_description');
|
||||||
|
|
||||||
// Send result to parent window
|
// For MCP OAuth, we just pass the callback data to the parent window
|
||||||
|
// The parent window (which has the MCPProvider context) will handle the actual callback processing
|
||||||
if (window.opener) {
|
if (window.opener) {
|
||||||
const messageType = type === 'inference' ? 'oauth_callback' : 'mcp_oauth_callback';
|
const messageType = type === 'inference' ? 'oauth_callback' : 'mcp_oauth_callback';
|
||||||
|
|
||||||
|
|
@ -24,14 +24,13 @@ export function OAuthCallback({ type, serverId }: OAuthCallbackProps) {
|
||||||
window.opener.postMessage({
|
window.opener.postMessage({
|
||||||
type: messageType,
|
type: messageType,
|
||||||
callbackType: type,
|
callbackType: type,
|
||||||
serverId,
|
|
||||||
error: errorDescription || error,
|
error: errorDescription || error,
|
||||||
|
state,
|
||||||
}, window.location.origin);
|
}, window.location.origin);
|
||||||
} else if (code && state) {
|
} else if (code && state) {
|
||||||
window.opener.postMessage({
|
window.opener.postMessage({
|
||||||
type: messageType,
|
type: messageType,
|
||||||
callbackType: type,
|
callbackType: type,
|
||||||
serverId,
|
|
||||||
code,
|
code,
|
||||||
state,
|
state,
|
||||||
}, window.location.origin);
|
}, window.location.origin);
|
||||||
|
|
@ -39,8 +38,8 @@ export function OAuthCallback({ type, serverId }: OAuthCallbackProps) {
|
||||||
window.opener.postMessage({
|
window.opener.postMessage({
|
||||||
type: messageType,
|
type: messageType,
|
||||||
callbackType: type,
|
callbackType: type,
|
||||||
serverId,
|
|
||||||
error: 'Invalid OAuth callback - missing code or state',
|
error: 'Invalid OAuth callback - missing code or state',
|
||||||
|
state,
|
||||||
}, window.location.origin);
|
}, window.location.origin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,7 +51,7 @@ export function OAuthCallback({ type, serverId }: OAuthCallbackProps) {
|
||||||
// Fallback if not in a popup - redirect to main app
|
// Fallback if not in a popup - redirect to main app
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
}, []);
|
}, [type]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 flex items-center justify-center">
|
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 flex items-center justify-center">
|
||||||
|
|
|
||||||
352
src/contexts/MCPContext.tsx
Normal file
352
src/contexts/MCPContext.tsx
Normal file
|
|
@ -0,0 +1,352 @@
|
||||||
|
// React context for MCP server connection management
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode, useRef } from 'react';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MCPConnection,
|
||||||
|
MCPServerConfig,
|
||||||
|
MCPResource,
|
||||||
|
MCPContextValue,
|
||||||
|
MCPError,
|
||||||
|
} from '@/types/mcp';
|
||||||
|
import type { Tool } from '@/types/inference';
|
||||||
|
import { MCPConnectionManager } from '@/mcp/connection';
|
||||||
|
|
||||||
|
const MCPContext = createContext<MCPContextValue | null>(null);
|
||||||
|
|
||||||
|
interface MCPProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MCPProvider({ children }: MCPProviderProps) {
|
||||||
|
const [connections, setConnections] = useState<MCPConnection[]>([]);
|
||||||
|
const [managers, setManagers] = useState<Map<string, MCPConnectionManager>>(new Map());
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const hasLoadedPersisted = useRef(false);
|
||||||
|
|
||||||
|
// Load persisted connections from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
// Prevent loading if we've already loaded
|
||||||
|
if (hasLoadedPersisted.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasLoadedPersisted.current = true;
|
||||||
|
|
||||||
|
const loadPersistedConnections = async () => {
|
||||||
|
try {
|
||||||
|
const persistedData = localStorage.getItem('mcp_connections');
|
||||||
|
if (persistedData) {
|
||||||
|
const persistedConfigs: MCPServerConfig[] = JSON.parse(persistedData);
|
||||||
|
|
||||||
|
// Restore connections directly without using addMcpServer to avoid loops
|
||||||
|
for (const config of persistedConfigs) {
|
||||||
|
try {
|
||||||
|
const connectionId = uuidv4();
|
||||||
|
const manager = new MCPConnectionManager(connectionId, config);
|
||||||
|
|
||||||
|
// Add to managers map
|
||||||
|
setManagers(prev => new Map(prev).set(connectionId, manager));
|
||||||
|
|
||||||
|
// Add initial connection state
|
||||||
|
setConnections(prev => [...prev, manager.getConnection()]);
|
||||||
|
|
||||||
|
// Don't auto-connect during restoration - let user manually connect
|
||||||
|
console.log(`Restored connection config for ${config.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to restore connection to ${config.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load persisted MCP connections:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPersistedConnections();
|
||||||
|
}, []); // Empty dependency array - only run once on mount
|
||||||
|
|
||||||
|
const persistConnections = useCallback(() => {
|
||||||
|
try {
|
||||||
|
const configs = connections.map(conn => conn.config);
|
||||||
|
localStorage.setItem('mcp_connections', JSON.stringify(configs));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to persist MCP connections:', error);
|
||||||
|
}
|
||||||
|
}, [connections]);
|
||||||
|
|
||||||
|
// Auto-save connections to localStorage when they change
|
||||||
|
useEffect(() => {
|
||||||
|
// Only save if we've finished initial loading
|
||||||
|
if (hasLoadedPersisted.current && connections.length > 0) {
|
||||||
|
persistConnections();
|
||||||
|
}
|
||||||
|
}, [persistConnections]);
|
||||||
|
|
||||||
|
const addMcpServer = useCallback(async (config: MCPServerConfig): Promise<string> => {
|
||||||
|
const connectionId = uuidv4();
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create connection manager
|
||||||
|
const manager = new MCPConnectionManager(connectionId, config);
|
||||||
|
|
||||||
|
// Set up callback for connection state updates
|
||||||
|
manager.setConnectionUpdateCallback(() => {
|
||||||
|
setConnections(prev =>
|
||||||
|
prev.map(conn =>
|
||||||
|
conn.id === connectionId ? manager.getConnection() : conn
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to managers map
|
||||||
|
setManagers(prev => new Map(prev).set(connectionId, manager));
|
||||||
|
|
||||||
|
// Add initial connection state
|
||||||
|
setConnections(prev => [...prev, manager.getConnection()]);
|
||||||
|
|
||||||
|
// Attempt to connect
|
||||||
|
try {
|
||||||
|
await manager.connect();
|
||||||
|
|
||||||
|
// Update connection state after successful connection
|
||||||
|
setConnections(prev =>
|
||||||
|
prev.map(conn =>
|
||||||
|
conn.id === connectionId ? manager.getConnection() : conn
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Update connection state with error
|
||||||
|
setConnections(prev =>
|
||||||
|
prev.map(conn =>
|
||||||
|
conn.id === connectionId ? managers.get(connectionId)?.getConnection() || conn : conn
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// For OAuth servers, initial connection failure is expected
|
||||||
|
// The OAuth flow will handle the authentication and retry automatically
|
||||||
|
console.log('Caught error during connection:', {
|
||||||
|
authType: config.authType,
|
||||||
|
errorType: typeof error,
|
||||||
|
errorConstructor: error?.constructor?.name,
|
||||||
|
errorMessage: error?.message,
|
||||||
|
errorDetails: error?.details,
|
||||||
|
detailsConstructor: error?.details?.constructor?.name,
|
||||||
|
fullError: error
|
||||||
|
});
|
||||||
|
|
||||||
|
const isUnauthorizedError =
|
||||||
|
config.authType === 'oauth' && (
|
||||||
|
(error instanceof Error && error.message === 'Unauthorized') ||
|
||||||
|
(error instanceof Error && error.constructor.name === 'UnauthorizedError') ||
|
||||||
|
(error && typeof error === 'object' && error.message === 'Unauthorized' &&
|
||||||
|
error.details && error.details.constructor && error.details.constructor.name === 'UnauthorizedError')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isUnauthorizedError) {
|
||||||
|
console.log('Initial OAuth connection failed as expected, OAuth flow will handle authentication...');
|
||||||
|
} else {
|
||||||
|
console.log('Non-OAuth error, propagating:', error);
|
||||||
|
// For non-OAuth errors, propagate the error
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to add MCP server';
|
||||||
|
setError(errorMessage);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connectionId;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [managers]);
|
||||||
|
|
||||||
|
const removeMcpServer = useCallback((connectionId: string) => {
|
||||||
|
const manager = managers.get(connectionId);
|
||||||
|
if (manager) {
|
||||||
|
// Disconnect the server
|
||||||
|
manager.disconnect();
|
||||||
|
|
||||||
|
// Remove from managers
|
||||||
|
setManagers(prev => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
newMap.delete(connectionId);
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove from connections
|
||||||
|
setConnections(prev => prev.filter(conn => conn.id !== connectionId));
|
||||||
|
}
|
||||||
|
}, [managers]);
|
||||||
|
|
||||||
|
const reconnectServer = useCallback(async (connectionId: string): Promise<void> => {
|
||||||
|
const manager = managers.get(connectionId);
|
||||||
|
if (!manager) {
|
||||||
|
throw new Error(`Connection ${connectionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.reconnect();
|
||||||
|
|
||||||
|
// Update connection state
|
||||||
|
setConnections(prev =>
|
||||||
|
prev.map(conn =>
|
||||||
|
conn.id === connectionId ? manager.getConnection() : conn
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Update connection state with error
|
||||||
|
setConnections(prev =>
|
||||||
|
prev.map(conn =>
|
||||||
|
conn.id === connectionId ? manager.getConnection() : conn
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to reconnect server';
|
||||||
|
setError(errorMessage);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [managers]);
|
||||||
|
|
||||||
|
const getAllTools = useCallback((): Tool[] => {
|
||||||
|
return connections.flatMap(conn => conn.tools);
|
||||||
|
}, [connections]);
|
||||||
|
|
||||||
|
const getToolsForServer = useCallback((connectionId: string): Tool[] => {
|
||||||
|
const connection = connections.find(conn => conn.id === connectionId);
|
||||||
|
return connection?.tools || [];
|
||||||
|
}, [connections]);
|
||||||
|
|
||||||
|
const callTool = useCallback(async (connectionId: string, toolName: string, args: any): Promise<any> => {
|
||||||
|
const manager = managers.get(connectionId);
|
||||||
|
if (!manager) {
|
||||||
|
throw new Error(`Connection ${connectionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await manager.callTool(toolName, args);
|
||||||
|
} catch (error) {
|
||||||
|
// Update connection state in case status changed
|
||||||
|
setConnections(prev =>
|
||||||
|
prev.map(conn =>
|
||||||
|
conn.id === connectionId ? manager.getConnection() : conn
|
||||||
|
)
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [managers]);
|
||||||
|
|
||||||
|
const getAllResources = useCallback((): MCPResource[] => {
|
||||||
|
return connections.flatMap(conn => conn.resources);
|
||||||
|
}, [connections]);
|
||||||
|
|
||||||
|
const getResourcesForServer = useCallback((connectionId: string): MCPResource[] => {
|
||||||
|
const connection = connections.find(conn => conn.id === connectionId);
|
||||||
|
return connection?.resources || [];
|
||||||
|
}, [connections]);
|
||||||
|
|
||||||
|
const getConnectedServers = useCallback((): MCPConnection[] => {
|
||||||
|
return connections.filter(conn => conn.status === 'connected');
|
||||||
|
}, [connections]);
|
||||||
|
|
||||||
|
const getServerStatus = useCallback((connectionId: string): MCPConnection['status'] => {
|
||||||
|
const connection = connections.find(conn => conn.id === connectionId);
|
||||||
|
return connection?.status || 'disconnected';
|
||||||
|
}, [connections]);
|
||||||
|
|
||||||
|
const getConnectionById = useCallback((connectionId: string): MCPConnection | undefined => {
|
||||||
|
return connections.find(conn => conn.id === connectionId);
|
||||||
|
}, [connections]);
|
||||||
|
|
||||||
|
const updateServerConfig = useCallback((connectionId: string, configUpdate: Partial<MCPServerConfig>) => {
|
||||||
|
setConnections(prev =>
|
||||||
|
prev.map(conn =>
|
||||||
|
conn.id === connectionId
|
||||||
|
? { ...conn, config: { ...conn.config, ...configUpdate } }
|
||||||
|
: conn
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOAuthCallback = useCallback(async (connectionId: string, authorizationCode: string): Promise<void> => {
|
||||||
|
const manager = managers.get(connectionId);
|
||||||
|
if (!manager) {
|
||||||
|
throw new Error(`Connection ${connectionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Type assertion to access the OAuth callback method
|
||||||
|
const connectionManager = manager as any;
|
||||||
|
if (typeof connectionManager.handleOAuthCallback === 'function') {
|
||||||
|
await connectionManager.handleOAuthCallback(authorizationCode);
|
||||||
|
|
||||||
|
// Update connection state
|
||||||
|
setConnections(prev =>
|
||||||
|
prev.map(conn =>
|
||||||
|
conn.id === connectionId ? manager.getConnection() : conn
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error('OAuth callback not supported by this connection');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Update connection state with error
|
||||||
|
setConnections(prev =>
|
||||||
|
prev.map(conn =>
|
||||||
|
conn.id === connectionId ? manager.getConnection() : conn
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to handle OAuth callback';
|
||||||
|
setError(errorMessage);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [managers]);
|
||||||
|
|
||||||
|
const contextValue: MCPContextValue = {
|
||||||
|
connections,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
addMcpServer,
|
||||||
|
removeMcpServer,
|
||||||
|
reconnectServer,
|
||||||
|
getAllTools,
|
||||||
|
getToolsForServer,
|
||||||
|
callTool,
|
||||||
|
getAllResources,
|
||||||
|
getResourcesForServer,
|
||||||
|
getConnectedServers,
|
||||||
|
getServerStatus,
|
||||||
|
getConnectionById,
|
||||||
|
updateServerConfig,
|
||||||
|
handleOAuthCallback,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MCPContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</MCPContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMCP(): MCPContextValue {
|
||||||
|
const context = useContext(MCPContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useMCP must be used within an MCPProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
674
src/mcp/connection.ts
Normal file
674
src/mcp/connection.ts
Normal file
|
|
@ -0,0 +1,674 @@
|
||||||
|
// Individual MCP server connection management
|
||||||
|
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
import {
|
||||||
|
auth,
|
||||||
|
discoverOAuthMetadata,
|
||||||
|
extractResourceMetadataUrl,
|
||||||
|
type OAuthClientProvider,
|
||||||
|
type OAuthClientMetadata,
|
||||||
|
type OAuthClientInformation,
|
||||||
|
type OAuthTokens,
|
||||||
|
type OAuthClientInformationFull
|
||||||
|
} from '@modelcontextprotocol/sdk/client/auth.js';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MCPConnection,
|
||||||
|
MCPServerConfig,
|
||||||
|
MCPResource,
|
||||||
|
MCPPrompt,
|
||||||
|
MCPConnectionManager,
|
||||||
|
MCPError,
|
||||||
|
} from '@/types/mcp';
|
||||||
|
import type { Tool } from '@/types/inference';
|
||||||
|
|
||||||
|
interface MCPOAuthState {
|
||||||
|
codeVerifier: string;
|
||||||
|
state: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MCPOAuthProvider implements OAuthClientProvider {
|
||||||
|
private connectionId: string;
|
||||||
|
private serverName: string;
|
||||||
|
private serverUrl: string;
|
||||||
|
pendingAuthorizationCode?: string;
|
||||||
|
authError?: string;
|
||||||
|
private onOAuthComplete?: () => void;
|
||||||
|
|
||||||
|
constructor(connectionId: string, serverName: string, serverUrl: string, onOAuthComplete?: () => void) {
|
||||||
|
this.connectionId = connectionId;
|
||||||
|
this.serverName = serverName;
|
||||||
|
this.serverUrl = serverUrl;
|
||||||
|
this.onOAuthComplete = onOAuthComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
get redirectUrl(): string {
|
||||||
|
return `${window.location.origin}/oauth/mcp/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get clientMetadata(): OAuthClientMetadata {
|
||||||
|
return {
|
||||||
|
redirect_uris: [this.redirectUrl],
|
||||||
|
grant_types: ['authorization_code'],
|
||||||
|
response_types: ['code'],
|
||||||
|
client_name: `MCP Client - ${this.serverName}`,
|
||||||
|
token_endpoint_auth_method: 'none', // Public client
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
state(): string {
|
||||||
|
// Encode connection ID in the state parameter for callback identification
|
||||||
|
const randomPart = this.generateRandomString(8);
|
||||||
|
return `${this.connectionId}.${randomPart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a consistent key for the server based on URL
|
||||||
|
getServerKey(): string {
|
||||||
|
try {
|
||||||
|
const url = new URL(this.serverUrl);
|
||||||
|
// Use hostname + pathname to create a unique but consistent key
|
||||||
|
// This allows the same server to reuse client registration even if added multiple times
|
||||||
|
return btoa(`${url.hostname}${url.pathname}`).replace(/[+/=]/g, '');
|
||||||
|
} catch {
|
||||||
|
// Fallback to connection ID if URL parsing fails
|
||||||
|
return this.connectionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientInformation(): OAuthClientInformation | undefined {
|
||||||
|
const serverKey = this.getServerKey();
|
||||||
|
const stored = localStorage.getItem(`mcp_oauth_client_${serverKey}`);
|
||||||
|
return stored ? JSON.parse(stored) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise<void> {
|
||||||
|
console.log('Registered OAuth client for MCP server');
|
||||||
|
const serverKey = this.getServerKey();
|
||||||
|
localStorage.setItem(`mcp_oauth_client_${serverKey}`, JSON.stringify(clientInformation));
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens(): OAuthTokens | undefined {
|
||||||
|
const stored = localStorage.getItem(`mcp_oauth_tokens_${this.connectionId}`);
|
||||||
|
return stored ? JSON.parse(stored) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||||
|
console.log('OAuth tokens saved successfully');
|
||||||
|
localStorage.setItem(`mcp_oauth_tokens_${this.connectionId}`, JSON.stringify(tokens));
|
||||||
|
}
|
||||||
|
|
||||||
|
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
||||||
|
console.log('🔐 Starting OAuth flow in popup window...');
|
||||||
|
|
||||||
|
// Open popup for OAuth flow
|
||||||
|
const popup = window.open(
|
||||||
|
authorizationUrl.toString(),
|
||||||
|
'mcp_oauth_popup',
|
||||||
|
'width=600,height=700,scrollbars=yes,resizable=yes'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!popup) {
|
||||||
|
throw new Error('Failed to open OAuth popup. Please allow popups for this site.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up message listener for the authorization code
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
if (event.origin !== window.location.origin) return;
|
||||||
|
if (event.data.type !== 'mcp_oauth_callback') return;
|
||||||
|
|
||||||
|
// Extract connection ID from state parameter
|
||||||
|
const state = event.data.state;
|
||||||
|
if (!state || !state.startsWith(this.connectionId + '.')) return;
|
||||||
|
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
popup.close();
|
||||||
|
|
||||||
|
if (event.data.error) {
|
||||||
|
console.error('❌ OAuth authorization failed:', event.data.error);
|
||||||
|
this.authError = event.data.error;
|
||||||
|
} else if (event.data.code) {
|
||||||
|
console.log('✅ OAuth authorization successful, exchanging code for tokens...');
|
||||||
|
this.processAuthorizationCode(event.data.code);
|
||||||
|
} else {
|
||||||
|
console.error('❌ OAuth callback missing authorization code');
|
||||||
|
this.authError = 'No authorization code received';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||||
|
const oauthState: MCPOAuthState = {
|
||||||
|
codeVerifier,
|
||||||
|
state: this.generateRandomString(16),
|
||||||
|
expiresAt: Date.now() + (10 * 60 * 1000), // 10 minutes
|
||||||
|
};
|
||||||
|
localStorage.setItem(`mcp_oauth_state_${this.connectionId}`, JSON.stringify(oauthState));
|
||||||
|
}
|
||||||
|
|
||||||
|
async codeVerifier(): Promise<string> {
|
||||||
|
const stored = localStorage.getItem(`mcp_oauth_state_${this.connectionId}`);
|
||||||
|
if (!stored) {
|
||||||
|
throw new Error('No OAuth state found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauthState: MCPOAuthState = JSON.parse(stored);
|
||||||
|
if (Date.now() > oauthState.expiresAt) {
|
||||||
|
localStorage.removeItem(`mcp_oauth_state_${this.connectionId}`);
|
||||||
|
throw new Error('OAuth state expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
return oauthState.codeVerifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateRandomString(length: number): string {
|
||||||
|
const array = new Uint8Array(length);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
return btoa(String.fromCharCode.apply(null, Array.from(array)))
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to process authorization code immediately when received from popup
|
||||||
|
private async processAuthorizationCode(authorizationCode: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Call the SDK's auth function with the authorization code to exchange for tokens
|
||||||
|
const result = await auth(this, {
|
||||||
|
serverUrl: this.serverUrl,
|
||||||
|
authorizationCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result === 'AUTHORIZED') {
|
||||||
|
console.log('🎉 OAuth authentication completed! Connecting to MCP server...');
|
||||||
|
this.notifyOAuthComplete();
|
||||||
|
} else {
|
||||||
|
console.error('❌ OAuth token exchange failed');
|
||||||
|
this.authError = 'Authorization failed';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ OAuth token exchange error:', error instanceof Error ? error.message : error);
|
||||||
|
this.authError = error instanceof Error ? error.message : 'Authorization failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to notify connection manager that OAuth is complete
|
||||||
|
private notifyOAuthComplete(): void {
|
||||||
|
if (this.onOAuthComplete) {
|
||||||
|
this.onOAuthComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to get and clear the pending authorization code
|
||||||
|
getPendingAuthorizationCode(): string | undefined {
|
||||||
|
const code = this.pendingAuthorizationCode;
|
||||||
|
this.pendingAuthorizationCode = undefined;
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MCPConnectionManager implements MCPConnectionManager {
|
||||||
|
private connection: MCPConnection;
|
||||||
|
private client?: Client;
|
||||||
|
private transport?: Transport;
|
||||||
|
private reconnectTimeout?: NodeJS.Timeout;
|
||||||
|
private oauthProvider?: MCPOAuthProvider;
|
||||||
|
private onConnectionUpdate?: () => void;
|
||||||
|
|
||||||
|
constructor(id: string, config: MCPServerConfig) {
|
||||||
|
this.connection = {
|
||||||
|
id,
|
||||||
|
name: config.name,
|
||||||
|
url: config.url,
|
||||||
|
status: 'disconnected',
|
||||||
|
transport: 'streamable-http', // Will be determined during connection
|
||||||
|
authType: config.authType || 'none',
|
||||||
|
tools: [],
|
||||||
|
resources: [],
|
||||||
|
prompts: [],
|
||||||
|
connectionAttempts: 0,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize OAuth provider if auth is required
|
||||||
|
if (this.connection.authType === 'oauth') {
|
||||||
|
this.oauthProvider = new MCPOAuthProvider(id, config.name, config.url, () => {
|
||||||
|
// Callback when OAuth completes successfully
|
||||||
|
this.handleOAuthSuccess();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnection(): MCPConnection {
|
||||||
|
return { ...this.connection };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set callback for connection state updates
|
||||||
|
setConnectionUpdateCallback(callback: () => void): void {
|
||||||
|
this.onConnectionUpdate = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify about connection state changes
|
||||||
|
private notifyConnectionUpdate(): void {
|
||||||
|
if (this.onConnectionUpdate) {
|
||||||
|
this.onConnectionUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
this.connection.status = 'connecting';
|
||||||
|
this.connection.error = undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate URL format
|
||||||
|
console.log('Validating URL:', this.connection.url);
|
||||||
|
try {
|
||||||
|
const url = new URL(this.connection.url);
|
||||||
|
console.log('URL parsed successfully:', {
|
||||||
|
protocol: url.protocol,
|
||||||
|
hostname: url.hostname,
|
||||||
|
pathname: url.pathname,
|
||||||
|
port: url.port
|
||||||
|
});
|
||||||
|
} catch (urlError) {
|
||||||
|
throw new Error(`Invalid URL format: ${this.connection.url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any existing connections
|
||||||
|
await this.disconnect();
|
||||||
|
|
||||||
|
// Determine transport strategy
|
||||||
|
const transportPreference = this.connection.config.transport || 'auto';
|
||||||
|
|
||||||
|
if (transportPreference === 'auto' || transportPreference === 'streamable-http') {
|
||||||
|
try {
|
||||||
|
await this.tryStreamableHttp();
|
||||||
|
this.connection.transport = 'streamable-http';
|
||||||
|
} catch (error) {
|
||||||
|
if (transportPreference === 'streamable-http') {
|
||||||
|
throw error; // Don't fallback if explicitly requested
|
||||||
|
}
|
||||||
|
// Try SSE fallback
|
||||||
|
await this.trySSE();
|
||||||
|
this.connection.transport = 'sse';
|
||||||
|
}
|
||||||
|
} else if (transportPreference === 'sse') {
|
||||||
|
await this.trySSE();
|
||||||
|
this.connection.transport = 'sse';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize client capabilities
|
||||||
|
await this.initializeCapabilities();
|
||||||
|
|
||||||
|
this.connection.status = 'connected';
|
||||||
|
this.connection.lastConnected = new Date();
|
||||||
|
this.connection.connectionAttempts = 0;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.connection.status = 'failed';
|
||||||
|
this.connection.error = error instanceof Error ? error.message : 'Unknown connection error';
|
||||||
|
this.connection.connectionAttempts++;
|
||||||
|
|
||||||
|
// Schedule auto-reconnect if enabled
|
||||||
|
if (this.connection.config.autoReconnect !== false &&
|
||||||
|
this.connection.connectionAttempts < (this.connection.config.maxReconnectAttempts || 5)) {
|
||||||
|
const delay = this.getBackoffDelay();
|
||||||
|
console.log(`Scheduling reconnect attempt ${this.connection.connectionAttempts} in ${delay}ms`);
|
||||||
|
this.reconnectTimeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await this.reconnect();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto-reconnect failed:', error);
|
||||||
|
// Don't throw here to prevent uncaught promise rejection
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw this.createMCPError('connection', this.connection.error, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.reconnectTimeout) {
|
||||||
|
clearTimeout(this.reconnectTimeout);
|
||||||
|
this.reconnectTimeout = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.client) {
|
||||||
|
try {
|
||||||
|
await this.client.close();
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore disconnect errors
|
||||||
|
}
|
||||||
|
this.client = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.transport) {
|
||||||
|
try {
|
||||||
|
await this.transport.close();
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore transport close errors
|
||||||
|
}
|
||||||
|
this.transport = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connection.status = 'disconnected';
|
||||||
|
this.connection.client = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to handle OAuth callback with authorization code
|
||||||
|
async handleOAuthCallback(authorizationCode: string): Promise<void> {
|
||||||
|
if (!this.oauthProvider) {
|
||||||
|
throw new Error('OAuth provider not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Processing OAuth callback with authorization code...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If we have an active transport, use its finishAuth method
|
||||||
|
if (this.transport && typeof (this.transport as any).finishAuth === 'function') {
|
||||||
|
console.log('Calling transport.finishAuth...');
|
||||||
|
await (this.transport as any).finishAuth(authorizationCode);
|
||||||
|
console.log('OAuth authorization completed via transport');
|
||||||
|
|
||||||
|
// Now attempt to connect - the transport should be authenticated
|
||||||
|
await this.connect();
|
||||||
|
} else {
|
||||||
|
// If no transport yet, store the authorization code and try connecting
|
||||||
|
// The transport creation will handle the auth flow
|
||||||
|
this.oauthProvider.pendingAuthorizationCode = authorizationCode;
|
||||||
|
console.log('Stored authorization code, attempting connection...');
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('OAuth callback processed successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OAuth callback processing failed:', error);
|
||||||
|
throw new Error(`OAuth callback failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to clear stored OAuth data
|
||||||
|
clearOAuthData(): void {
|
||||||
|
if (this.connection.authType === 'oauth' && this.oauthProvider) {
|
||||||
|
// Clear connection-specific data
|
||||||
|
localStorage.removeItem(`mcp_oauth_tokens_${this.connection.id}`);
|
||||||
|
localStorage.removeItem(`mcp_oauth_state_${this.connection.id}`);
|
||||||
|
|
||||||
|
// Note: We intentionally don't clear client information here since it's
|
||||||
|
// shared across connections to the same server. Use clearSharedClientData()
|
||||||
|
// if you need to clear the client registration for this server.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to clear shared client data for this server (affects all connections to the same server)
|
||||||
|
clearSharedClientData(): void {
|
||||||
|
if (this.connection.authType === 'oauth' && this.oauthProvider) {
|
||||||
|
const serverKey = this.oauthProvider.getServerKey();
|
||||||
|
localStorage.removeItem(`mcp_oauth_client_${serverKey}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconnect(): Promise<void> {
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle successful OAuth completion
|
||||||
|
private async handleOAuthSuccess(): Promise<void> {
|
||||||
|
console.log('OAuth completed successfully, retrying connection...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Reset connection state and retry
|
||||||
|
this.connection.status = 'connecting';
|
||||||
|
this.connection.error = undefined;
|
||||||
|
this.notifyConnectionUpdate();
|
||||||
|
|
||||||
|
// Attempt to connect now that we have valid tokens
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
console.log('Post-OAuth connection successful!');
|
||||||
|
this.notifyConnectionUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Post-OAuth connection failed:', error);
|
||||||
|
this.connection.status = 'failed';
|
||||||
|
this.connection.error = error instanceof Error ? error.message : 'Post-OAuth connection failed';
|
||||||
|
this.notifyConnectionUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleOAuthAuthentication(): Promise<void> {
|
||||||
|
if (!this.oauthProvider) {
|
||||||
|
throw new Error('OAuth provider not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Starting OAuth authentication flow...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await auth(this.oauthProvider, {
|
||||||
|
serverUrl: this.connection.url,
|
||||||
|
scope: this.connection.config.oauthConfig?.scope,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result === 'REDIRECT') {
|
||||||
|
// OAuth flow was initiated via popup, no further action needed here
|
||||||
|
console.log('OAuth flow initiated via popup');
|
||||||
|
} else if (result === 'AUTHORIZED') {
|
||||||
|
console.log('OAuth authentication successful');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OAuth authentication failed:', error);
|
||||||
|
throw new Error(`OAuth authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryStreamableHttp(): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('Attempting StreamableHTTP connection to:', this.connection.url);
|
||||||
|
|
||||||
|
// Create transport options with OAuth provider if configured
|
||||||
|
const transportOptions: any = {};
|
||||||
|
|
||||||
|
if (this.connection.authType === 'oauth' && this.oauthProvider) {
|
||||||
|
transportOptions.authProvider = this.oauthProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = new StreamableHTTPClientTransport(new URL(this.connection.url), transportOptions);
|
||||||
|
await this.initializeClient(transport);
|
||||||
|
console.log('StreamableHTTP connection successful');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('StreamableHTTP connection failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async trySSE(): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('Attempting SSE connection to:', this.connection.url);
|
||||||
|
|
||||||
|
// Create transport options with OAuth provider if configured
|
||||||
|
const transportOptions: any = {};
|
||||||
|
|
||||||
|
if (this.connection.authType === 'oauth' && this.oauthProvider) {
|
||||||
|
transportOptions.authProvider = this.oauthProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = new SSEClientTransport(new URL(this.connection.url), transportOptions);
|
||||||
|
console.log('SSE transport created, attempting client connection...');
|
||||||
|
await this.initializeClient(transport);
|
||||||
|
console.log('SSE connection successful');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SSE connection failed:', error);
|
||||||
|
console.error('Error details:', {
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
url: this.connection.url
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initializeClient(transport: Transport): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('Initializing MCP client...');
|
||||||
|
this.transport = transport;
|
||||||
|
this.client = new Client(
|
||||||
|
{
|
||||||
|
name: 'example-remote-client',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Connecting client to transport...');
|
||||||
|
console.log('Transport type:', transport.constructor.name);
|
||||||
|
console.log('Transport details:', transport);
|
||||||
|
|
||||||
|
await this.client.connect(transport);
|
||||||
|
this.connection.client = this.client;
|
||||||
|
console.log('Client connected successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Client initialization failed:', error);
|
||||||
|
console.error('Error type:', error?.constructor?.name);
|
||||||
|
console.error('Error message:', error instanceof Error ? error.message : error);
|
||||||
|
console.error('Full error object:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initializeCapabilities(): Promise<void> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('Client not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Discover tools
|
||||||
|
this.connection.tools = await this.discoverTools();
|
||||||
|
|
||||||
|
// Discover resources (if supported)
|
||||||
|
try {
|
||||||
|
this.connection.resources = await this.discoverResources();
|
||||||
|
} catch (error) {
|
||||||
|
// Resources not supported by this server
|
||||||
|
this.connection.resources = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover prompts (if supported)
|
||||||
|
try {
|
||||||
|
this.connection.prompts = await this.discoverPrompts();
|
||||||
|
} catch (error) {
|
||||||
|
// Prompts not supported by this server
|
||||||
|
this.connection.prompts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
throw this.createMCPError('protocol', 'Failed to initialize server capabilities', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async discoverTools(): Promise<Tool[]> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('Client not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.client.listTools();
|
||||||
|
|
||||||
|
// Transform MCP tools to our Tool interface with name prefixing
|
||||||
|
return result.tools.map(tool => ({
|
||||||
|
type: 'function' as const,
|
||||||
|
function: {
|
||||||
|
name: `${this.connection.name}.${tool.name}`,
|
||||||
|
description: `[${this.connection.name}] ${tool.description || ''}`,
|
||||||
|
parameters: tool.inputSchema || {},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw this.createMCPError('protocol', 'Failed to discover tools', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async discoverResources(): Promise<MCPResource[]> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('Client not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.client.listResources();
|
||||||
|
|
||||||
|
return result.resources.map(resource => ({
|
||||||
|
uri: resource.uri,
|
||||||
|
name: resource.name,
|
||||||
|
description: resource.description,
|
||||||
|
mimeType: resource.mimeType,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw this.createMCPError('protocol', 'Failed to discover resources', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async discoverPrompts(): Promise<MCPPrompt[]> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('Client not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.client.listPrompts();
|
||||||
|
|
||||||
|
return result.prompts.map(prompt => ({
|
||||||
|
name: prompt.name,
|
||||||
|
description: prompt.description,
|
||||||
|
arguments: prompt.arguments,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw this.createMCPError('protocol', 'Failed to discover prompts', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(toolName: string, args: any): Promise<any> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('Client not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the server prefix from the tool name
|
||||||
|
const unprefixedName = toolName.startsWith(`${this.connection.name}.`)
|
||||||
|
? toolName.slice(this.connection.name.length + 1)
|
||||||
|
: toolName;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.client.callTool({
|
||||||
|
name: unprefixedName,
|
||||||
|
arguments: args,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
throw this.createMCPError('tool_execution', `Failed to call tool ${unprefixedName}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): MCPConnection['status'] {
|
||||||
|
return this.connection.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBackoffDelay(): number {
|
||||||
|
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
|
||||||
|
return Math.min(1000 * Math.pow(2, this.connection.connectionAttempts - 1), 16000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createMCPError(type: MCPError['type'], message: string, details?: any): MCPError {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
connectionId: this.connection.id,
|
||||||
|
retryable: type === 'connection' || type === 'transport',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/types/mcp.ts
Normal file
132
src/types/mcp.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
// MCP provider types and interfaces
|
||||||
|
|
||||||
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import type { Tool } from './inference';
|
||||||
|
|
||||||
|
export interface MCPServerConfig {
|
||||||
|
name: string; // User-provided display name
|
||||||
|
url: string; // Server endpoint URL
|
||||||
|
transport?: 'sse' | 'streamable-http' | 'auto'; // Default: auto-detect
|
||||||
|
authType?: 'none' | 'oauth'; // Default: none
|
||||||
|
oauthConfig?: {
|
||||||
|
clientId?: string;
|
||||||
|
authUrl?: string; // OAuth authorization endpoint
|
||||||
|
tokenUrl?: string; // Token exchange endpoint
|
||||||
|
scope?: string; // OAuth scope
|
||||||
|
redirectUri?: string; // Override default redirect URI
|
||||||
|
};
|
||||||
|
autoReconnect?: boolean; // Default: true
|
||||||
|
maxReconnectAttempts?: number; // Default: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MCPMessage {
|
||||||
|
timestamp: Date;
|
||||||
|
direction: 'sent' | 'received';
|
||||||
|
type: 'request' | 'response' | 'notification';
|
||||||
|
method?: string;
|
||||||
|
id?: string | number;
|
||||||
|
content: any;
|
||||||
|
error?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MCPResource {
|
||||||
|
uri: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MCPPrompt {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
arguments?: Array<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MCPConnection {
|
||||||
|
id: string; // Unique connection identifier
|
||||||
|
name: string; // User-provided server name
|
||||||
|
url: string; // Server URL
|
||||||
|
status: 'connecting' | 'connected' | 'failed' | 'disconnected';
|
||||||
|
client?: Client; // MCP SDK client instance
|
||||||
|
transport: 'sse' | 'streamable-http';
|
||||||
|
authType: 'none' | 'oauth';
|
||||||
|
|
||||||
|
// Available capabilities
|
||||||
|
tools: Tool[]; // Tools with name-prefixed identifiers
|
||||||
|
resources: MCPResource[]; // Available resources
|
||||||
|
prompts: MCPPrompt[]; // Available prompts
|
||||||
|
|
||||||
|
// Connection metadata
|
||||||
|
error?: string; // Last error message
|
||||||
|
lastConnected?: Date; // Last successful connection
|
||||||
|
connectionAttempts: number; // Number of reconnection attempts
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
config: MCPServerConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MCPConnectionManager {
|
||||||
|
// Connection lifecycle
|
||||||
|
connect(): Promise<void>;
|
||||||
|
disconnect(): void;
|
||||||
|
reconnect(): Promise<void>;
|
||||||
|
|
||||||
|
// Capability discovery
|
||||||
|
discoverTools(): Promise<Tool[]>;
|
||||||
|
discoverResources(): Promise<MCPResource[]>;
|
||||||
|
discoverPrompts(): Promise<MCPPrompt[]>;
|
||||||
|
|
||||||
|
// Tool execution
|
||||||
|
callTool(toolName: string, args: any): Promise<any>;
|
||||||
|
|
||||||
|
// Status
|
||||||
|
getStatus(): MCPConnection['status'];
|
||||||
|
getConnection(): MCPConnection;
|
||||||
|
|
||||||
|
// Connection state updates
|
||||||
|
setConnectionUpdateCallback(callback: () => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MCPError {
|
||||||
|
type: 'connection' | 'auth' | 'tool_execution' | 'protocol' | 'transport';
|
||||||
|
message: string;
|
||||||
|
details?: any;
|
||||||
|
connectionId?: string;
|
||||||
|
retryable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MCPContextValue {
|
||||||
|
// Connection state
|
||||||
|
connections: MCPConnection[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Connection management
|
||||||
|
addMcpServer: (config: MCPServerConfig) => Promise<string>; // Returns connection ID
|
||||||
|
removeMcpServer: (connectionId: string) => void;
|
||||||
|
reconnectServer: (connectionId: string) => Promise<void>;
|
||||||
|
|
||||||
|
// Tool access (explicit server routing)
|
||||||
|
getAllTools: () => Tool[]; // All tools with prefixed names
|
||||||
|
getToolsForServer: (connectionId: string) => Tool[];
|
||||||
|
callTool: (connectionId: string, toolName: string, args: any) => Promise<any>;
|
||||||
|
|
||||||
|
// Resource access
|
||||||
|
getAllResources: () => MCPResource[];
|
||||||
|
getResourcesForServer: (connectionId: string) => MCPResource[];
|
||||||
|
|
||||||
|
// Status and debugging
|
||||||
|
getConnectedServers: () => MCPConnection[];
|
||||||
|
getServerStatus: (connectionId: string) => MCPConnection['status'];
|
||||||
|
getConnectionById: (connectionId: string) => MCPConnection | undefined;
|
||||||
|
|
||||||
|
// Connection configuration
|
||||||
|
updateServerConfig: (connectionId: string, config: Partial<MCPServerConfig>) => void;
|
||||||
|
|
||||||
|
// OAuth handling
|
||||||
|
handleOAuthCallback: (connectionId: string, authorizationCode: string) => Promise<void>;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue