MCP working further test and improvements needed

This commit is contained in:
abhigyanpatwari 2026-01-16 13:08:34 +05:30
parent aa5d19fb4f
commit 27ddab7e0d
7 changed files with 734 additions and 249 deletions

118
package-lock.json generated
View file

@ -14,6 +14,7 @@
"@langchain/core": "^0.3.0",
"@langchain/google-genai": "^0.1.0",
"@langchain/langgraph": "^0.2.74",
"@langchain/ollama": "^1.2.0",
"@langchain/openai": "^0.3.0",
"@sigma/edge-curve": "^3.1.0",
"@tailwindcss/vite": "^4.1.18",
@ -564,6 +565,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -580,6 +582,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -596,6 +599,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -612,6 +616,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -628,6 +633,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -644,6 +650,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -660,6 +667,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -676,6 +684,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -692,6 +701,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -708,6 +718,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -724,6 +735,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -740,6 +752,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -756,6 +769,7 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -772,6 +786,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -788,6 +803,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -804,6 +820,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -820,6 +837,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -836,6 +854,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -852,6 +871,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -868,6 +888,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -884,6 +905,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -900,6 +922,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -916,6 +939,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -1722,6 +1746,35 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/@langchain/ollama": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@langchain/ollama/-/ollama-1.2.0.tgz",
"integrity": "sha512-OinxIhssKXdDQKnQoBF4TQTMBuMMV5OcNPk4Zze8UjcaSOGngn3CAI1FVbBxl0bTG5ov61w4AoWWsUwOwiSJFw==",
"license": "MIT",
"dependencies": {
"ollama": "^0.6.3",
"uuid": "^10.0.0"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@langchain/core": "^1.0.0"
}
},
"node_modules/@langchain/ollama/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@langchain/openai": {
"version": "0.3.17",
"resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-0.3.17.tgz",
@ -2013,6 +2066,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2026,6 +2080,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2039,6 +2094,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2052,6 +2108,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2065,6 +2122,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2078,6 +2136,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2091,6 +2150,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2104,6 +2164,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2117,6 +2178,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2130,6 +2192,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2143,6 +2206,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2156,6 +2220,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2169,6 +2234,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2182,6 +2248,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2195,6 +2262,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2208,6 +2276,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2221,6 +2290,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2234,6 +2304,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2247,6 +2318,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2260,6 +2332,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2273,6 +2346,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2286,6 +2360,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -2505,17 +2580,6 @@
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.18",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@swc/types": {
"version": "0.1.25",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz",
@ -3239,12 +3303,14 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@ -4225,6 +4291,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cytoscape": {
@ -5037,6 +5104,7 @@
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@ -5678,6 +5746,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@ -5886,13 +5955,6 @@
"graphology-types": ">=0.19.0"
}
},
"node_modules/graphology-types": {
"version": "0.24.8",
"resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz",
"integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==",
"license": "MIT",
"peer": true
},
"node_modules/graphology-utils": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz",
@ -8190,6 +8252,7 @@
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
@ -8304,6 +8367,15 @@
"integrity": "sha512-9gRK4+sRWzeN6AOewNBTLXir7Zl/i3GB6Yl26gK4flxz8BXVpD3kt8amREmWNb0mxYOGDotvE5a4N+PtGGKdkg==",
"license": "MIT"
},
"node_modules/ollama": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/ollama/-/ollama-0.6.3.tgz",
"integrity": "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg==",
"license": "MIT",
"dependencies": {
"whatwg-fetch": "^3.6.20"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -8527,6 +8599,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@ -8597,6 +8670,7 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -8991,6 +9065,7 @@
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@ -9984,6 +10059,7 @@
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
@ -10165,6 +10241,12 @@
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
"license": "MIT"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",

View file

@ -15,6 +15,7 @@
"@langchain/core": "^0.3.0",
"@langchain/google-genai": "^0.1.0",
"@langchain/langgraph": "^0.2.74",
"@langchain/ollama": "^1.2.0",
"@langchain/openai": "^0.3.0",
"@sigma/edge-curve": "^3.1.0",
"@tailwindcss/vite": "^4.1.18",

View file

@ -1,8 +1,9 @@
import { Search, Settings, HelpCircle, Sparkles, Github, Star } from 'lucide-react';
import { useAppState } from '../hooks/useAppState';
import { useState, useMemo, useRef, useEffect } from 'react';
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import { GraphNode } from '../core/graph/types';
import { EmbeddingStatus } from './EmbeddingStatus';
import { MCPToggle } from './MCPToggle';
// Color mapping for node types in search results
const NODE_TYPE_COLORS: Record<string, string> = {
@ -22,7 +23,17 @@ interface HeaderProps {
}
export const Header = ({ onFocusNode }: HeaderProps) => {
const { projectName, graph, openChatPanel, isRightPanelOpen, rightPanelTab, setSettingsPanelOpen } = useAppState();
const {
projectName,
graph,
openChatPanel,
isRightPanelOpen,
rightPanelTab,
setSettingsPanelOpen,
runQuery,
semanticSearch,
setHighlightedNodeIds,
} = useAppState();
const [searchQuery, setSearchQuery] = useState('');
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
@ -206,6 +217,35 @@ export const Header = ({ onFocusNode }: HeaderProps) => {
{/* Embedding Status */}
<EmbeddingStatus />
{/* MCP Toggle for external AI agents */}
<MCPToggle
showOnboardingTip={!!graph}
onSearch={async (query, limit = 10) => {
// Use semantic search from the app
const results = await semanticSearch(query, limit);
return results;
}}
onCypher={async (query) => {
// Execute Cypher query
const results = await runQuery(query);
return results;
}}
onBlastRadius={async (nodeId, hops = 2) => {
// Run blast radius query
const query = `
MATCH (start)-[*1..${hops}]-(connected)
WHERE start.id = '${nodeId}' OR start.name = '${nodeId}'
RETURN DISTINCT connected.id AS id, connected.name AS name, labels(connected) AS labels
`;
const results = await runQuery(query);
return results;
}}
onHighlight={(nodeIds) => {
// Highlight nodes in the graph
setHighlightedNodeIds(new Set(nodeIds));
}}
/>
{/* Icon buttons */}
<button
onClick={() => setSettingsPanelOpen(true)}

View file

@ -0,0 +1,265 @@
/**
* MCP Toggle Component
*
* Toggle switch for enabling/disabling MCP exposure to external AI agents.
* Shows connection status and setup instructions if bridge not found.
*/
import { useState, useEffect, useCallback } from 'react';
import { Copy, Check, AlertCircle, X, Sparkles } from 'lucide-react';
import { getMCPClient } from '../core/mcp/mcp-client';
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
interface MCPToggleProps {
/** Handler for search tool calls from external agents */
onSearch?: (query: string, limit?: number) => Promise<any>;
/** Handler for cypher tool calls from external agents */
onCypher?: (query: string) => Promise<any>;
/** Handler for blastRadius tool calls from external agents */
onBlastRadius?: (nodeId: string, hops?: number) => Promise<any>;
/** Handler for highlight tool calls from external agents */
onHighlight?: (nodeIds: string[], color?: string) => void;
/** Whether to show the onboarding tip */
showOnboardingTip?: boolean;
}
const MCP_TIP_DISMISSED_KEY = 'gitnexus-mcp-tip-dismissed';
export function MCPToggle({
onSearch,
onCypher,
onBlastRadius,
onHighlight,
showOnboardingTip = false,
}: MCPToggleProps = {}) {
const [status, setStatus] = useState<ConnectionState>('disconnected');
const [copied, setCopied] = useState(false);
const [showSetup, setShowSetup] = useState(false);
const [showTip, setShowTip] = useState(false);
const isConnected = status === 'connected';
const isConnecting = status === 'connecting';
// Show tip when graph becomes ready (only once per session)
useEffect(() => {
if (showOnboardingTip) {
const dismissed = localStorage.getItem(MCP_TIP_DISMISSED_KEY);
if (!dismissed) {
// Small delay so it appears after graph loads
const timer = setTimeout(() => setShowTip(true), 1500);
return () => clearTimeout(timer);
}
}
}, [showOnboardingTip]);
const dismissTip = () => {
setShowTip(false);
localStorage.setItem(MCP_TIP_DISMISSED_KEY, 'true');
};
const connect = useCallback(async () => {
const client = getMCPClient();
setStatus('connecting');
setShowTip(false);
try {
await client.connect();
// Register tool handlers if provided
if (onSearch) {
client.registerHandler('search', async (params) => {
return await onSearch(params.query, params.limit);
});
}
if (onCypher) {
client.registerHandler('cypher', async (params) => {
return await onCypher(params.query);
});
}
if (onBlastRadius) {
client.registerHandler('blastRadius', async (params) => {
return await onBlastRadius(params.nodeId, params.hops);
});
}
if (onHighlight) {
client.registerHandler('highlight', async (params) => {
onHighlight(params.nodeIds, params.color);
return { highlighted: params.nodeIds.length };
});
}
setStatus('connected');
setShowSetup(false);
localStorage.setItem(MCP_TIP_DISMISSED_KEY, 'true');
} catch {
setStatus('error');
setShowSetup(true);
}
}, [onSearch, onCypher, onBlastRadius, onHighlight]);
const disconnect = useCallback(() => {
const client = getMCPClient();
client.disconnect();
setStatus('disconnected');
}, []);
const toggle = useCallback(() => {
if (isConnected) {
disconnect();
} else if (!isConnecting) {
connect();
}
}, [isConnected, isConnecting, connect, disconnect]);
const copyCommand = () => {
navigator.clipboard.writeText('npx gitnexus-mcp setup');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
// Listen for connection changes
useEffect(() => {
const client = getMCPClient();
const unsubscribe = client.onConnectionChange((connected) => {
setStatus(connected ? 'connected' : 'disconnected');
});
return () => { unsubscribe(); };
}, []);
// Error state - show setup popup
if (status === 'error' && showSetup) {
return (
<div className="relative flex items-center gap-2">
<span className="text-xs text-text-primary font-medium">MCP</span>
<button
onClick={() => setShowSetup(!showSetup)}
className="flex items-center gap-1.5 px-2 py-1 text-xs text-amber-400 hover:bg-amber-400/10 rounded transition-colors"
>
<AlertCircle className="w-3.5 h-3.5" />
<span>Setup</span>
</button>
<div className="absolute top-full right-0 mt-2 p-4 bg-surface border border-border rounded-lg shadow-xl z-50 w-80">
<p className="text-sm text-text-secondary mb-3">
Run this command to enable external AI agents:
</p>
<div className="flex items-center gap-2 bg-background p-2 rounded font-mono text-sm">
<code className="flex-1 text-green-400">npx gitnexus-mcp setup</code>
<button
onClick={copyCommand}
className="p-1 hover:bg-surface-hover rounded"
title="Copy command"
>
{copied ? (
<Check className="w-4 h-4 text-green-400" />
) : (
<Copy className="w-4 h-4 text-text-secondary" />
)}
</button>
</div>
<button
onClick={() => { setShowSetup(false); connect(); }}
className="mt-3 w-full px-3 py-1.5 text-sm bg-green-500/20 text-green-400 rounded hover:bg-green-500/30 transition-colors"
>
Retry Connection
</button>
</div>
</div>
);
}
// Toggle switch UI
return (
<div className="relative flex items-center gap-2">
<span className="text-xs text-text-primary font-medium">MCP</span>
<button
onClick={toggle}
disabled={isConnecting}
className={`
relative w-10 h-5 rounded-full transition-colors duration-200 ease-in-out
focus:outline-none focus:ring-2 focus:ring-green-500/50 focus:ring-offset-2 focus:ring-offset-deep
${isConnected
? 'bg-green-500'
: isConnecting
? 'bg-surface-hover cursor-wait'
: 'bg-surface-hover hover:bg-border'
}
`}
title={isConnected
? 'MCP Connected - Click to disconnect'
: isConnecting
? 'Connecting...'
: 'Connect to MCP bridge for external AI tools'
}
>
{/* Toggle knob */}
<span
className={`
absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow-sm
transition-transform duration-200 ease-in-out
${isConnected ? 'translate-x-5' : 'translate-x-0'}
${isConnecting ? 'animate-pulse' : ''}
`}
/>
</button>
{isConnected && (
<span className="text-xs text-green-400 font-medium hidden sm:inline">Connected</span>
)}
{/* Onboarding Tip Popup */}
{showTip && !isConnected && (
<div className="absolute top-full right-0 mt-3 w-80 p-4 bg-surface border border-green-500/30 rounded-lg shadow-xl z-50 animate-in fade-in slide-in-from-top-2 duration-300">
<button
onClick={dismissTip}
className="absolute top-2 right-2 p-1 text-text-muted hover:text-text-primary rounded transition-colors"
>
<X className="w-4 h-4" />
</button>
<div className="flex items-start gap-3 mb-3">
<div className="p-2 bg-green-500/20 rounded-lg flex-shrink-0">
<Sparkles className="w-5 h-5 text-green-400" />
</div>
<div>
<h4 className="text-sm font-semibold text-text-primary mb-1">
Connect your AI coding tool
</h4>
<p className="text-xs text-text-secondary leading-relaxed">
Let Cursor, Claude Code, or Antigravity use GitNexus for code intelligence.
</p>
</div>
</div>
<div className="mb-3">
<p className="text-xs text-text-muted mb-2">Run this in your terminal:</p>
<div className="flex items-center gap-2 bg-background p-2 rounded font-mono text-sm">
<code className="flex-1 text-green-400">npx gitnexus-mcp setup</code>
<button
onClick={copyCommand}
className="p-1 hover:bg-surface-hover rounded"
title="Copy command"
>
{copied ? (
<Check className="w-4 h-4 text-green-400" />
) : (
<Copy className="w-4 h-4 text-text-secondary" />
)}
</button>
</div>
</div>
<button
onClick={() => { dismissTip(); connect(); }}
className="w-full px-3 py-2 text-sm font-medium bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
>
I've run setup — Connect Now
</button>
</div>
)}
</div>
);
}

View file

@ -1,10 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { X, Key, Server, Brain, Check, AlertCircle, Eye, EyeOff, RefreshCw, Loader2 } from 'lucide-react';
import { X, Key, Server, Brain, Check, AlertCircle, Eye, EyeOff, RefreshCw } from 'lucide-react';
import {
loadSettings,
saveSettings,
getProviderDisplayName,
getAvailableModels,
} from '../core/llm/settings-service';
import type { LLMSettings, LLMProvider } from '../core/llm/types';
@ -15,45 +14,28 @@ interface SettingsPanelProps {
}
/**
* Fetch available Gemini models from the API
* Check connection to local Ollama instance
*/
const fetchGeminiModels = async (apiKey: string): Promise<string[]> => {
const checkOllamaStatus = async (baseUrl: string): Promise<{ ok: boolean; error: string | null }> => {
try {
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`
);
const response = await fetch(`${baseUrl}/api/tags`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
if (response.status === 0 || response.status === 404) {
return { ok: false, error: 'Cannot connect to Ollama. Make sure it\'s running with `ollama serve`' };
}
return { ok: false, error: `Ollama API error: ${response.status}` };
}
const data = await response.json();
// Filter for chat-capable models and extract model names
const models = (data.models || [])
.filter((model: any) => {
const methods = model.supportedGenerationMethods || [];
return methods.includes('generateContent');
})
.map((model: any) => {
const name = model.name || '';
return name.replace('models/', '');
})
.filter((name: string) => name.length > 0)
.sort((a: string, b: string) => {
const score = (n: string) => {
if (n.includes('gemini-2')) return 0;
if (n.includes('gemini-1.5')) return 1;
if (n.includes('gemini-1')) return 2;
return 3;
};
return score(a) - score(b) || a.localeCompare(b);
});
return models;
return { ok: true, error: null };
} catch (error) {
console.warn('Failed to fetch Gemini models:', error);
return [];
return {
ok: false,
error: 'Cannot connect to Ollama. Make sure it\'s running with `ollama serve`'
};
}
};
@ -61,59 +43,38 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPane
const [settings, setSettings] = useState<LLMSettings>(loadSettings);
const [showApiKey, setShowApiKey] = useState<Record<string, boolean>>({});
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
// Gemini model fetching state
const [geminiModels, setGeminiModels] = useState<string[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [modelFetchError, setModelFetchError] = useState<string | null>(null);
const [useCustomModel, setUseCustomModel] = useState(false);
const [useCustomAnthropicModel, setUseCustomAnthropicModel] = useState(false);
// Ollama connection state
const [ollamaError, setOllamaError] = useState<string | null>(null);
const [isCheckingOllama, setIsCheckingOllama] = useState(false);
// Load settings when panel opens
useEffect(() => {
if (isOpen) {
setSettings(loadSettings());
setSaveStatus('idle');
setGeminiModels([]);
setModelFetchError(null);
setUseCustomModel(false);
setUseCustomAnthropicModel(false);
setOllamaError(null);
}
}, [isOpen]);
// Auto-fetch models when Gemini API key changes
const fetchModels = useCallback(async (apiKey: string) => {
if (!apiKey || apiKey.length < 10) {
setGeminiModels([]);
return;
}
// Check Ollama connection when provider is selected or base URL changes
const checkOllamaConnection = useCallback(async (baseUrl: string) => {
setIsCheckingOllama(true);
setOllamaError(null);
setIsLoadingModels(true);
setModelFetchError(null);
const models = await fetchGeminiModels(apiKey);
setIsLoadingModels(false);
if (models.length > 0) {
setGeminiModels(models);
setModelFetchError(null);
} else {
setGeminiModels([]);
setModelFetchError('Could not fetch models. Check your API key or enter model manually.');
}
const { error } = await checkOllamaStatus(baseUrl);
setIsCheckingOllama(false);
setOllamaError(error);
}, []);
// Fetch models when API key is entered (debounced)
useEffect(() => {
if (settings.activeProvider === 'gemini' && settings.gemini?.apiKey) {
const apiKey = settings.gemini.apiKey ?? '';
if (settings.activeProvider === 'ollama') {
const baseUrl = settings.ollama?.baseUrl ?? 'http://localhost:11434';
const timer = setTimeout(() => {
fetchModels(apiKey);
}, 500);
checkOllamaConnection(baseUrl);
}, 300);
return () => clearTimeout(timer);
}
}, [settings.gemini?.apiKey, settings.activeProvider, fetchModels]);
}, [settings.ollama?.baseUrl, settings.activeProvider, checkOllamaConnection]);
const handleProviderChange = (provider: LLMProvider) => {
setSettings(prev => ({ ...prev, activeProvider: provider }));
@ -136,17 +97,8 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPane
if (!isOpen) return null;
const providers: LLMProvider[] = ['openai', 'gemini', 'anthropic', 'azure-openai'];
const providers: LLMProvider[] = ['openai', 'gemini', 'anthropic', 'azure-openai', 'ollama'];
const availableGeminiModels = geminiModels.length > 0
? geminiModels
: getAvailableModels('gemini');
const currentGeminiModel = settings.gemini?.model ?? 'gemini-2.0-flash';
const isCustomModelSelected = !availableGeminiModels.includes(currentGeminiModel);
const availableAnthropicModels = getAvailableModels('anthropic');
const currentAnthropicModel = settings.anthropic?.model ?? 'claude-sonnet-4-20250514';
const isCustomAnthropicModelSelected = !availableAnthropicModels.includes(currentAnthropicModel);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
@ -201,7 +153,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPane
w-8 h-8 rounded-lg flex items-center justify-center text-lg
${settings.activeProvider === provider ? 'bg-accent/20' : 'bg-surface'}
`}>
{provider === 'openai' ? '🤖' : provider === 'gemini' ? '💎' : provider === 'anthropic' ? '🧠' : '☁️'}
{provider === 'openai' ? '🤖' : provider === 'gemini' ? '💎' : provider === 'anthropic' ? '🧠' : provider === 'ollama' ? '🦙' : '☁️'}
</div>
<span className="font-medium">{getProviderDisplayName(provider)}</span>
</button>
@ -251,18 +203,16 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPane
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">Model</label>
<select
<input
type="text"
value={settings.openai?.model ?? 'gpt-4o'}
onChange={e => setSettings(prev => ({
...prev,
openai: { ...prev.openai!, model: e.target.value }
}))}
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all appearance-none cursor-pointer"
>
{getAvailableModels('openai').map(model => (
<option key={model} value={model}>{model}</option>
))}
</select>
placeholder="e.g., gpt-4o, gpt-4-turbo, gpt-3.5-turbo"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
/>
</div>
<div className="space-y-2">
@ -328,92 +278,17 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPane
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-text-secondary">Model</label>
<div className="flex items-center gap-2">
{isLoadingModels && (
<span className="flex items-center gap-1 text-xs text-text-muted">
<Loader2 className="w-3 h-3 animate-spin" />
Fetching models...
</span>
)}
{geminiModels.length > 0 && (
<span className="text-xs text-green-400">
{geminiModels.length} models available
</span>
)}
{settings.gemini?.apiKey && !isLoadingModels && (
<button
type="button"
onClick={() => fetchModels(settings.gemini?.apiKey ?? '')}
className="p-1 text-text-muted hover:text-text-primary transition-colors"
title="Refresh models"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Model selector or manual input */}
{(useCustomModel || isCustomModelSelected) ? (
<div className="space-y-2">
<input
type="text"
value={settings.gemini?.model ?? ''}
onChange={e => setSettings(prev => ({
...prev,
gemini: { ...prev.gemini!, model: e.target.value }
}))}
placeholder="e.g., gemini-2.0-flash-exp"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
/>
<button
type="button"
onClick={() => {
setUseCustomModel(false);
if (availableGeminiModels.length > 0) {
setSettings(prev => ({
...prev,
gemini: { ...prev.gemini!, model: availableGeminiModels[0] }
}));
}
}}
className="text-xs text-accent hover:underline"
>
← Back to model list
</button>
</div>
) : (
<div className="space-y-2">
<select
value={settings.gemini?.model ?? 'gemini-2.0-flash'}
onChange={e => setSettings(prev => ({
...prev,
gemini: { ...prev.gemini!, model: e.target.value }
}))}
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all appearance-none cursor-pointer"
>
{availableGeminiModels.map(model => (
<option key={model} value={model}>{model}</option>
))}
</select>
<button
type="button"
onClick={() => setUseCustomModel(true)}
className="text-xs text-text-muted hover:text-text-primary transition-colors"
>
Enter model name manually →
</button>
</div>
)}
{modelFetchError && !useCustomModel && (
<p className="text-xs text-amber-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{modelFetchError}
</p>
)}
<label className="text-sm font-medium text-text-secondary">Model</label>
<input
type="text"
value={settings.gemini?.model ?? 'gemini-2.0-flash'}
onChange={e => setSettings(prev => ({
...prev,
gemini: { ...prev.gemini!, model: e.target.value }
}))}
placeholder="e.g., gemini-2.0-flash, gemini-1.5-pro"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
/>
</div>
</div>
)}
@ -460,58 +335,16 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPane
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">Model</label>
{(useCustomAnthropicModel || isCustomAnthropicModelSelected) ? (
<div className="space-y-2">
<input
type="text"
value={settings.anthropic?.model ?? ''}
onChange={e => setSettings(prev => ({
...prev,
anthropic: { ...prev.anthropic!, model: e.target.value }
}))}
placeholder="e.g., claude-sonnet-4-20250514"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
/>
<button
type="button"
onClick={() => {
setUseCustomAnthropicModel(false);
if (availableAnthropicModels.length > 0) {
setSettings(prev => ({
...prev,
anthropic: { ...prev.anthropic!, model: availableAnthropicModels[0] }
}));
}
}}
className="text-xs text-accent hover:underline"
>
← Back to model list
</button>
</div>
) : (
<div className="space-y-2">
<select
value={currentAnthropicModel}
onChange={e => setSettings(prev => ({
...prev,
anthropic: { ...prev.anthropic!, model: e.target.value }
}))}
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all appearance-none cursor-pointer"
>
{availableAnthropicModels.map(model => (
<option key={model} value={model}>{model}</option>
))}
</select>
<button
type="button"
onClick={() => setUseCustomAnthropicModel(true)}
className="text-xs text-text-muted hover:text-text-primary transition-colors"
>
Enter model name manually →
</button>
</div>
)}
<input
type="text"
value={settings.anthropic?.model ?? 'claude-sonnet-4-20250514'}
onChange={e => setSettings(prev => ({
...prev,
anthropic: { ...prev.anthropic!, model: e.target.value }
}))}
placeholder="e.g., claude-sonnet-4-20250514, claude-3-opus"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
/>
</div>
</div>
)}
@ -620,6 +453,87 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPane
</div>
)}
{/* Ollama Settings */}
{settings.activeProvider === 'ollama' && (
<div className="space-y-4 animate-fade-in">
{/* How to run Ollama */}
<div className="p-3 bg-amber-500/10 border border-amber-500/30 rounded-xl">
<p className="text-xs text-amber-300 leading-relaxed">
<span className="font-medium">📋 Quick Start:</span> Install Ollama from{' '}
<a
href="https://ollama.ai"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
ollama.ai
</a>, then run:
</p>
<code className="block mt-2 px-3 py-2 bg-black/30 rounded-lg text-amber-200 font-mono text-sm">
ollama serve
</code>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Server className="w-4 h-4" />
Base URL
</label>
<div className="flex gap-2">
<input
type="url"
value={settings.ollama?.baseUrl ?? 'http://localhost:11434'}
onChange={e => setSettings(prev => ({
...prev,
ollama: { ...prev.ollama!, baseUrl: e.target.value }
}))}
placeholder="http://localhost:11434"
className="flex-1 px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
/>
<button
type="button"
onClick={() => checkOllamaConnection(settings.ollama?.baseUrl ?? 'http://localhost:11434')}
disabled={isCheckingOllama}
className="px-3 py-3 bg-elevated border border-border-subtle rounded-xl text-text-secondary hover:text-text-primary hover:border-accent/50 transition-colors disabled:opacity-50"
title="Check connection"
>
<RefreshCw className={`w-4 h-4 ${isCheckingOllama ? 'animate-spin' : ''}`} />
</button>
</div>
<p className="text-xs text-text-muted">
Default port is <code className="px-1 py-0.5 bg-elevated rounded">11434</code>.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">Model</label>
{ollamaError && !isCheckingOllama && (
<div className="p-2 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-xs text-red-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{ollamaError}
</p>
</div>
)}
<input
type="text"
value={settings.ollama?.model ?? ''}
onChange={e => setSettings(prev => ({
...prev,
ollama: { ...prev.ollama!, model: e.target.value }
}))}
placeholder="e.g., llama3.2, mistral, codellama"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
/>
<p className="text-xs text-text-muted">
Pull a model with <code className="px-1 py-0.5 bg-elevated rounded">ollama pull llama3.2</code>
</p>
</div>
</div>
)}
{/* Privacy Note */}
<div className="p-4 bg-elevated/50 border border-border-subtle rounded-xl">
<div className="flex gap-3">

View file

@ -10,6 +10,7 @@ import { SystemMessage } from '@langchain/core/messages';
import { ChatOpenAI, AzureChatOpenAI } from '@langchain/openai';
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { ChatAnthropic } from '@langchain/anthropic';
import { ChatOllama } from '@langchain/ollama';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { createGraphRAGTools } from './tools';
import type {
@ -18,6 +19,7 @@ import type {
AzureOpenAIConfig,
GeminiConfig,
AnthropicConfig,
OllamaConfig,
AgentStreamChunk,
} from './types';
import {
@ -160,6 +162,21 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
});
}
case 'ollama': {
const ollamaConfig = config as OllamaConfig;
return new ChatOllama({
baseUrl: ollamaConfig.baseUrl ?? 'http://localhost:11434',
model: ollamaConfig.model,
temperature: ollamaConfig.temperature ?? 0.1,
streaming: true,
// Allow longer responses (Ollama default is often 128-2048)
numPredict: 30000,
// Increase context window (Ollama default is only 2048!)
// This is critical for agentic workflows with tool calls
numCtx: 32768,
});
}
default:
throw new Error(`Unsupported provider: ${(config as any).provider}`);
}
@ -289,6 +306,13 @@ export async function* streamAgentResponse(
data = event;
}
// DEBUG: Enhanced logging
if (import.meta.env.DEV) {
const msgType = mode === 'messages' && data?.[0]?._getType?.() || 'n/a';
const hasContent = mode === 'messages' && data?.[0]?.content;
const hasToolCalls = mode === 'messages' && data?.[0]?.tool_calls?.length > 0;
console.log(`🔄 [${mode}] type:${msgType} content:${!!hasContent} tools:${hasToolCalls}`);
}
// Handle 'messages' mode - token-by-token streaming
if (mode === 'messages') {
const [msg] = Array.isArray(data) ? data : [data];
@ -298,11 +322,23 @@ export async function* streamAgentResponse(
// AIMessageChunk - streaming text tokens
if (msgType === 'ai' || msgType === 'AIMessage' || msgType === 'AIMessageChunk') {
const content = msg.content;
const rawContent = msg.content;
const toolCalls = msg.tool_calls || [];
// Handle content that can be string or array of content blocks
let content: string = '';
if (typeof rawContent === 'string') {
content = rawContent;
} else if (Array.isArray(rawContent)) {
// Content blocks format: [{type: 'text', text: '...'}, ...]
content = rawContent
.filter((block: any) => block.type === 'text' || typeof block === 'string')
.map((block: any) => typeof block === 'string' ? block : block.text || '')
.join('');
}
// If chunk has content, stream it
if (content && typeof content === 'string' && content.length > 0) {
if (content && content.length > 0) {
// Determine if this is reasoning/narration vs final answer content.
// - Before the first tool call: treat as reasoning (narration)
// - Between tool calls/results: treat as reasoning
@ -416,9 +452,17 @@ export async function* streamAgentResponse(
}
}
// DEBUG: Stream completed normally
if (import.meta.env.DEV) {
console.log('✅ Stream completed normally, yielding done');
}
yield { type: 'done' };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// DEBUG: Stream error
if (import.meta.env.DEV) {
console.error('❌ Stream error:', message, error);
}
yield {
type: 'error',
error: message,

139
src/core/mcp/mcp-client.ts Normal file
View file

@ -0,0 +1,139 @@
/**
* MCP Browser Client
*
* WebSocket client that connects to the gitnexus-mcp bridge.
* Receives tool calls from external AI agents and executes them.
*/
export interface MCPMessage {
id: string;
method?: string;
params?: Record<string, any>;
result?: any;
error?: { message: string };
}
type ToolHandler = (params: Record<string, any>) => Promise<any>;
export class MCPBrowserClient {
private ws: WebSocket | null = null;
private handlers: Map<string, ToolHandler> = new Map();
private connectionListeners: Set<(connected: boolean) => void> = new Set();
constructor(private port = 54319) {}
/**
* Connect to the MCP bridge
*/
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(`ws://localhost:${this.port}`);
this.ws.onopen = () => {
this.notifyConnectionListeners(true);
resolve();
};
this.ws.onerror = (event) => {
this.notifyConnectionListeners(false);
reject(new Error('Failed to connect to MCP bridge'));
};
this.ws.onmessage = (event) => {
try {
const msg: MCPMessage = JSON.parse(event.data);
this.handleMessage(msg);
} catch (error) {
console.error('[MCP] Failed to parse message:', error);
}
};
this.ws.onclose = () => {
this.ws = null;
this.notifyConnectionListeners(false);
};
} catch (error) {
reject(error);
}
});
}
/**
* Handle incoming messages from bridge
*/
private async handleMessage(msg: MCPMessage) {
// This is a tool call request from an external agent
if (msg.method && msg.id) {
const handler = this.handlers.get(msg.method);
if (handler) {
try {
const result = await handler(msg.params || {});
this.send({ id: msg.id, result });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.send({ id: msg.id, error: { message } });
}
} else {
this.send({
id: msg.id,
error: { message: `Unknown tool: ${msg.method}` }
});
}
}
}
/**
* Send a message to the bridge
*/
private send(msg: MCPMessage) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
/**
* Register a handler for a tool
*/
registerHandler(method: string, handler: ToolHandler) {
this.handlers.set(method, handler);
}
/**
* Listen for connection state changes
*/
onConnectionChange(listener: (connected: boolean) => void) {
this.connectionListeners.add(listener);
return () => this.connectionListeners.delete(listener);
}
private notifyConnectionListeners(connected: boolean) {
this.connectionListeners.forEach(listener => listener(connected));
}
/**
* Check if connected
*/
get isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
/**
* Disconnect from bridge
*/
disconnect() {
this.ws?.close();
this.ws = null;
}
}
// Singleton instance
let mcpClientInstance: MCPBrowserClient | null = null;
export function getMCPClient(): MCPBrowserClient {
if (!mcpClientInstance) {
mcpClientInstance = new MCPBrowserClient();
}
return mcpClientInstance;
}