Implement improved auto-reconnect logic and fix TypeScript errors

## Features
- Add proactive health check monitoring for MCP connections (30s intervals)
- Implement automatic reconnection on health check failures
- Add proper cleanup of health check intervals on disconnect

## Fixes
- Resolve OAuth type compatibility issues with MCP SDK
- Fix naming collision between InferenceProvider class and React component
- Remove unused React imports across all components (new JSX transform)
- Clean up unused type imports and variables
- Remove unused handleOAuthAuthentication function

## Technical Details
- Health checks use lightweight listTools() calls to verify connection state
- Automatic reconnection triggers when health checks fail
- Maintains existing exponential backoff retry logic
- All TypeScript compilation errors resolved

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jerome 2025-06-12 14:02:00 +01:00
commit 763742ed30
No known key found for this signature in database
17 changed files with 122 additions and 71 deletions

View file

@ -1,5 +1,5 @@
import React, { useState } from 'react' import { useState } from 'react'
import { InferenceProvider } from '@/contexts/InferenceContext' import { InferenceContextProvider } from '@/contexts/InferenceContext'
import { MCPProvider } from '@/contexts/MCPContext' import { MCPProvider } from '@/contexts/MCPContext'
import { InferenceTest } from '@/components/InferenceTest' import { InferenceTest } from '@/components/InferenceTest'
import { MCPTest } from '@/components/MCPTest' import { MCPTest } from '@/components/MCPTest'
@ -23,7 +23,7 @@ function App() {
const [activeTab, setActiveTab] = useState<'conversations' | 'inference' | 'mcp'>('conversations'); const [activeTab, setActiveTab] = useState<'conversations' | 'inference' | 'mcp'>('conversations');
return ( return (
<InferenceProvider> <InferenceContextProvider>
<MCPProvider> <MCPProvider>
<div className="min-h-screen bg-gray-100 dark:bg-gray-900"> <div className="min-h-screen bg-gray-100 dark:bg-gray-900">
{/* Tab Navigation */} {/* Tab Navigation */}
@ -70,7 +70,7 @@ function App() {
{activeTab === 'mcp' && <MCPTest />} {activeTab === 'mcp' && <MCPTest />}
</div> </div>
</MCPProvider> </MCPProvider>
</InferenceProvider> </InferenceContextProvider>
) )
} }

View file

@ -1,6 +1,6 @@
// Main chat interface with message display and input // Main chat interface with message display and input
import React, { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { useConversation } from '@/contexts/ConversationContext'; import { useConversation } from '@/contexts/ConversationContext';
import { useInference } from '@/contexts/InferenceContext'; import { useInference } from '@/contexts/InferenceContext';
import { MessageList } from './MessageList'; import { MessageList } from './MessageList';
@ -16,7 +16,7 @@ export function ChatInterface() {
getAgentLoopState, getAgentLoopState,
} = useConversation(); } = useConversation();
const { provider: currentProvider, isAuthenticated } = useInference(); const { provider: currentProvider } = useInference();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);

View file

@ -1,6 +1,6 @@
// Main conversation application with sidebar and chat interface // Main conversation application with sidebar and chat interface
import React, { useState } from 'react'; import { useState } from 'react';
import { ConversationProvider } from '@/contexts/ConversationContext'; import { ConversationProvider } from '@/contexts/ConversationContext';
import { ConversationSidebar } from './ConversationSidebar'; import { ConversationSidebar } from './ConversationSidebar';
import { ChatInterface } from './ChatInterface'; import { ChatInterface } from './ChatInterface';

View file

@ -1,6 +1,5 @@
// Conversation sidebar with conversation list and management // Conversation sidebar with conversation list and management
import React from 'react';
import { useConversation } from '@/contexts/ConversationContext'; import { useConversation } from '@/contexts/ConversationContext';
export function ConversationSidebar() { export function ConversationSidebar() {
@ -91,10 +90,11 @@ export function ConversationSidebar() {
</p> </p>
{conversation.messages.length > 0 && ( {conversation.messages.length > 0 && (
<p className="text-xs text-gray-600 dark:text-gray-300 mt-1 truncate"> <p className="text-xs text-gray-600 dark:text-gray-300 mt-1 truncate">
{conversation.messages[conversation.messages.length - 1]?.content?.[0]?.type === 'text' {(() => {
? conversation.messages[conversation.messages.length - 1].content[0].text const lastMessage = conversation.messages[conversation.messages.length - 1];
: 'Tool interaction' const firstBlock = lastMessage?.content?.[0];
} return firstBlock?.type === 'text' ? firstBlock.text : 'Tool interaction';
})()}
</p> </p>
)} )}
</div> </div>

View file

@ -1,6 +1,6 @@
// Inference provider login component for inline authentication // Inference provider login component for inline authentication
import React, { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { useInference } from '@/contexts/InferenceContext'; import { useInference } from '@/contexts/InferenceContext';
import { OpenRouterApiProvider } from '@/providers/openrouter/api-provider'; import { OpenRouterApiProvider } from '@/providers/openrouter/api-provider';
import { OpenRouterOAuthProvider } from '@/providers/openrouter/oauth-provider'; import { OpenRouterOAuthProvider } from '@/providers/openrouter/oauth-provider';

View file

@ -1,6 +1,6 @@
// Test UI for inference provider functionality // Test UI for inference provider functionality
import React, { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useInference } from '@/contexts/InferenceContext'; import { useInference } from '@/contexts/InferenceContext';
import { OpenRouterApiProvider, OpenRouterOAuthProvider } from '@/providers/openrouter'; import { OpenRouterApiProvider, OpenRouterOAuthProvider } from '@/providers/openrouter';
import type { ChatMessage, InferenceRequest } from '@/types/inference'; import type { ChatMessage, InferenceRequest } from '@/types/inference';

View file

@ -1,6 +1,5 @@
// MCP status display for the conversation sidebar // MCP status display for the conversation sidebar
import React from 'react';
import { useMCP } from '@/contexts/MCPContext'; import { useMCP } from '@/contexts/MCPContext';
export function MCPStatus() { export function MCPStatus() {

View file

@ -1,6 +1,6 @@
// Test UI for MCP provider functionality // Test UI for MCP provider functionality
import React, { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useMCP } from '@/contexts/MCPContext'; import { useMCP } from '@/contexts/MCPContext';
import type { MCPServerConfig } from '@/types/mcp'; import type { MCPServerConfig } from '@/types/mcp';

View file

@ -1,7 +1,6 @@
// Message list component to display conversation messages with tool calls // Message list component to display conversation messages with tool calls
import React from 'react'; import type { ConversationMessage } from '@/types/conversation';
import type { ConversationMessage, ToolUseBlock, ToolResultBlock } from '@/types/conversation';
interface MessageListProps { interface MessageListProps {
messages: ConversationMessage[]; messages: ConversationMessage[];

View file

@ -1,6 +1,6 @@
// OAuth callback handler for popup-based OAuth flows // OAuth callback handler for popup-based OAuth flows
import React, { useEffect } from 'react'; import { useEffect } from 'react';
interface OAuthCallbackProps { interface OAuthCallbackProps {
type: 'inference' | 'mcp'; type: 'inference' | 'mcp';

View file

@ -1,6 +1,6 @@
// React context for conversation management and agent loop orchestration // React context for conversation management and agent loop orchestration
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode, useRef } from 'react'; import { createContext, useContext, useState, useCallback, useEffect, ReactNode, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import type { import type {
@ -8,7 +8,6 @@ import type {
ConversationMessage, ConversationMessage,
ConversationContextValue, ConversationContextValue,
AgentLoopState, AgentLoopState,
ConversationContentBlock,
} from '@/types/conversation'; } from '@/types/conversation';
import { useAgentLoop } from '@/hooks/useAgentLoop'; import { useAgentLoop } from '@/hooks/useAgentLoop';

View file

@ -1,6 +1,6 @@
// React context for inference provider management // React context for inference provider management
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'; import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import type { import type {
InferenceProvider, InferenceProvider,
InferenceRequest, InferenceRequest,
@ -38,12 +38,12 @@ interface InferenceProviderProps {
children: ReactNode; children: ReactNode;
} }
export function InferenceProvider({ children }: InferenceProviderProps) { export function InferenceContextProvider({ children }: InferenceProviderProps) {
const [provider, setProviderState] = useState<InferenceProvider | null>(null); const [provider, setProviderState] = useState<InferenceProvider | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedModelId, setSelectedModelId] = useState<string | undefined>(undefined); const [selectedModelId, setSelectedModelId] = useState<string | undefined>(undefined);
const [authStateVersion, setAuthStateVersion] = useState(0); // Force re-renders on auth changes const [_, setAuthStateVersion] = useState(0); // Force re-renders on auth changes
const setProvider = useCallback((newProvider: InferenceProvider) => { const setProvider = useCallback((newProvider: InferenceProvider) => {
setProviderState(newProvider); setProviderState(newProvider);

View file

@ -1,6 +1,6 @@
// React context for MCP server connection management // React context for MCP server connection management
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode, useRef } from 'react'; import { createContext, useContext, useState, useCallback, useEffect, ReactNode, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import type { import type {
@ -8,7 +8,6 @@ import type {
MCPServerConfig, MCPServerConfig,
MCPResource, MCPResource,
MCPContextValue, MCPContextValue,
MCPError,
} from '@/types/mcp'; } from '@/types/mcp';
import type { Tool } from '@/types/inference'; import type { Tool } from '@/types/inference';
import { MCPConnectionManager } from '@/mcp/connection'; import { MCPConnectionManager } from '@/mcp/connection';
@ -158,18 +157,14 @@ export function MCPProvider({ children }: MCPProviderProps) {
authType: config.authType, authType: config.authType,
errorType: typeof error, errorType: typeof error,
errorConstructor: error?.constructor?.name, errorConstructor: error?.constructor?.name,
errorMessage: error?.message, errorMessage: error instanceof Error ? error.message : 'Unknown error',
errorDetails: error?.details,
detailsConstructor: error?.details?.constructor?.name,
fullError: error fullError: error
}); });
const isUnauthorizedError = const isUnauthorizedError =
config.authType === 'oauth' && ( config.authType === 'oauth' && (
(error instanceof Error && error.message === 'Unauthorized') || (error instanceof Error && error.message === 'Unauthorized') ||
(error instanceof Error && error.constructor.name === 'UnauthorizedError') || (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) { if (isUnauthorizedError) {

View file

@ -9,7 +9,6 @@ import type {
UseAgentLoopReturn, UseAgentLoopReturn,
ConversationMessage, ConversationMessage,
Conversation, Conversation,
ToolUseBlock,
ToolResultBlock, ToolResultBlock,
TestTool, TestTool,
} from '@/types/conversation'; } from '@/types/conversation';

View file

@ -6,13 +6,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { import {
auth, auth,
discoverOAuthMetadata, type OAuthClientProvider
extractResourceMetadataUrl,
type OAuthClientProvider,
type OAuthClientMetadata,
type OAuthClientInformation,
type OAuthTokens,
type OAuthClientInformationFull
} from '@modelcontextprotocol/sdk/client/auth.js'; } from '@modelcontextprotocol/sdk/client/auth.js';
import type { import type {
@ -20,7 +14,6 @@ import type {
MCPServerConfig, MCPServerConfig,
MCPResource, MCPResource,
MCPPrompt, MCPPrompt,
MCPConnectionManager,
MCPError, MCPError,
} from '@/types/mcp'; } from '@/types/mcp';
import type { Tool } from '@/types/inference'; import type { Tool } from '@/types/inference';
@ -31,6 +24,33 @@ interface MCPOAuthState {
expiresAt: number; expiresAt: number;
} }
// OAuth types matching the MCP SDK's internal interfaces
interface OAuthClientMetadata {
redirect_uris: string[];
grant_types?: string[];
response_types?: string[];
client_name?: string;
token_endpoint_auth_method?: string;
scope?: string;
jwks_uri?: string;
}
interface OAuthClientInformation {
client_id: string;
client_secret?: string;
registration_access_token?: string;
registration_client_uri?: string;
client_secret_expires_at?: number;
}
interface OAuthTokens {
access_token: string;
token_type: string;
refresh_token?: string;
expires_in?: number;
scope?: string;
}
class MCPOAuthProvider implements OAuthClientProvider { class MCPOAuthProvider implements OAuthClientProvider {
private connectionId: string; private connectionId: string;
private serverName: string; private serverName: string;
@ -85,7 +105,7 @@ class MCPOAuthProvider implements OAuthClientProvider {
return stored ? JSON.parse(stored) : undefined; return stored ? JSON.parse(stored) : undefined;
} }
async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise<void> { async saveClientInformation(clientInformation: OAuthClientInformation): Promise<void> {
console.log('Registered OAuth client for MCP server'); console.log('Registered OAuth client for MCP server');
const serverKey = this.getServerKey(); const serverKey = this.getServerKey();
localStorage.setItem(`mcp_oauth_client_${serverKey}`, JSON.stringify(clientInformation)); localStorage.setItem(`mcp_oauth_client_${serverKey}`, JSON.stringify(clientInformation));
@ -212,11 +232,12 @@ class MCPOAuthProvider implements OAuthClientProvider {
} }
} }
export class MCPConnectionManager implements MCPConnectionManager { export class MCPConnectionManager {
private connection: MCPConnection; private connection: MCPConnection;
private client?: Client; private client?: Client;
private transport?: Transport; private transport?: Transport;
private reconnectTimeout?: NodeJS.Timeout; private reconnectTimeout?: NodeJS.Timeout;
private healthCheckInterval?: NodeJS.Timeout;
private oauthProvider?: MCPOAuthProvider; private oauthProvider?: MCPOAuthProvider;
private onConnectionUpdate?: () => void; private onConnectionUpdate?: () => void;
@ -309,6 +330,9 @@ export class MCPConnectionManager implements MCPConnectionManager {
this.connection.lastConnected = new Date(); this.connection.lastConnected = new Date();
this.connection.connectionAttempts = 0; this.connection.connectionAttempts = 0;
// Start health check monitoring
this.startHealthCheck();
} catch (error) { } catch (error) {
this.connection.status = 'failed'; this.connection.status = 'failed';
this.connection.error = error instanceof Error ? error.message : 'Unknown connection error'; this.connection.error = error instanceof Error ? error.message : 'Unknown connection error';
@ -339,6 +363,11 @@ export class MCPConnectionManager implements MCPConnectionManager {
this.reconnectTimeout = undefined; this.reconnectTimeout = undefined;
} }
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = undefined;
}
if (this.client) { if (this.client) {
try { try {
await this.client.close(); await this.client.close();
@ -441,30 +470,6 @@ export class MCPConnectionManager implements MCPConnectionManager {
} }
} }
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> { private async tryStreamableHttp(): Promise<void> {
try { try {
@ -663,6 +668,60 @@ export class MCPConnectionManager implements MCPConnectionManager {
return Math.min(1000 * Math.pow(2, this.connection.connectionAttempts - 1), 16000); return Math.min(1000 * Math.pow(2, this.connection.connectionAttempts - 1), 16000);
} }
private startHealthCheck(): void {
// Clear any existing health check
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
}
// Start health check every 30 seconds
this.healthCheckInterval = setInterval(async () => {
await this.performHealthCheck();
}, 30000);
console.log(`Started health check monitoring for ${this.connection.name}`);
}
private async performHealthCheck(): Promise<void> {
// Only check if we're supposed to be connected
if (this.connection.status !== 'connected' || !this.client) {
return;
}
try {
// Try to list tools as a health check - this is a lightweight operation
await this.client.listTools();
console.log(`Health check passed for ${this.connection.name}`);
} catch (error) {
console.warn(`Health check failed for ${this.connection.name}:`, error);
await this.handleHealthCheckFailure(error);
}
}
private async handleHealthCheckFailure(error: any): Promise<void> {
console.log(`Connection health check failed for ${this.connection.name}, attempting reconnection...`);
// Stop health check during reconnection attempt
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = undefined;
}
// Mark as disconnected and attempt reconnection
this.connection.status = 'connecting';
this.connection.error = error instanceof Error ? error.message : 'Health check failed';
this.notifyConnectionUpdate();
try {
// Attempt reconnection
await this.connect();
console.log(`Health check reconnection successful for ${this.connection.name}`);
} catch (reconnectError) {
console.error(`Health check reconnection failed for ${this.connection.name}:`, reconnectError);
// The connect method will handle scheduling retry attempts
}
}
private createMCPError(type: MCPError['type'], message: string, details?: any): MCPError { private createMCPError(type: MCPError['type'], message: string, details?: any): MCPError {
return { return {
type, type,

View file

@ -38,10 +38,10 @@ export class OpenRouterClient {
): Promise<T> { ): Promise<T> {
const url = `${this.baseUrl}${endpoint}`; const url = `${this.baseUrl}${endpoint}`;
const headers: HeadersInit = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`, 'Authorization': `Bearer ${authToken}`,
...options.headers, ...(options.headers as Record<string, string> || {}),
}; };
if (this.httpReferrer) { if (this.httpReferrer) {
@ -128,6 +128,7 @@ export class OpenRouterClient {
// Check if model supports tools by looking at supported_parameters // Check if model supports tools by looking at supported_parameters
const supportsTools = openRouterModel.supported_parameters?.includes('tools') || false; const supportsTools = openRouterModel.supported_parameters?.includes('tools') || false;
console.log(`Model ${openRouterModel.id} supports tools: ${supportsTools}`);
return { return {
id: openRouterModel.id, id: openRouterModel.id,

View file

@ -26,6 +26,6 @@
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src", "**/*.ts", "**/*.tsx"], "include": ["src/**/*"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
} }