From b1315aa6b12512592287d42532723fdf27ef1f14 Mon Sep 17 00:00:00 2001 From: duanfuxiang Date: Mon, 2 Jun 2025 20:38:40 +0800 Subject: [PATCH] update , add mcp server stdio and sse --- esbuild.config.mjs | 6 +- package.json | 7 + pnpm-lock.yaml | 95 +- src/ChatView.tsx | 21 +- src/components/chat-view/ChatView.tsx | 102 +- src/components/chat-view/CustomModeView.tsx | 10 +- .../chat-view/Markdown/MarkdownToolResult.tsx | 88 ++ .../chat-view/Markdown/UseMcpToolBlock.tsx | 109 ++ src/components/chat-view/McpHubView.tsx | 755 ++++++++++ src/components/chat-view/ReactMarkdown.tsx | 17 + .../chat-view/chat-input/ModeSelect.tsx | 6 +- src/components/inline-edit/InlineEdit.tsx | 2 + src/contexts/McpHubContext.tsx | 39 + .../autocomplete/states/predicting-state.ts | 138 +- src/core/mcp/McpHub.ts | 1335 ++++++++++++----- src/core/mcp/McpServerManager.ts | 55 +- src/core/mcp/type.ts | 81 + src/core/prompts/sections/mcp-servers.ts | 420 +----- src/core/prompts/system.ts | 6 +- src/core/prompts/tools/index.ts | 2 + src/core/prompts/tools/tool-groups.ts | 6 +- src/main.ts | 60 +- src/types/apply.ts | 9 +- src/types/chat.ts | 1 + src/types/settings.ts | 3 + src/utils/config.ts | 25 + src/utils/modes.ts | 20 +- src/utils/parse-infio-block.ts | 88 ++ src/utils/prompt-generator.ts | 22 +- src/utils/web-search.ts | 66 + 30 files changed, 2639 insertions(+), 955 deletions(-) create mode 100644 src/components/chat-view/Markdown/MarkdownToolResult.tsx create mode 100644 src/components/chat-view/Markdown/UseMcpToolBlock.tsx create mode 100644 src/components/chat-view/McpHubView.tsx create mode 100644 src/contexts/McpHubContext.tsx create mode 100644 src/core/mcp/type.ts create mode 100644 src/utils/config.ts diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 116e852..f184738 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -3,6 +3,7 @@ import esbuild from 'esbuild' import process from 'process' import builtins from 'builtin-modules' import inlineWorkerPlugin from "esbuild-plugin-inline-worker"; +const nodeBuiltins = [...builtins, ...builtins.map((mod) => `node:${mod}`)] const banner = `/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD @@ -29,7 +30,6 @@ const context = await esbuild.context({ 'electron', 'path', 'moment', - 'node:events', 'child_process', '@codemirror/autocomplete', '@codemirror/collab', @@ -43,12 +43,12 @@ const context = await esbuild.context({ '@lezer/highlight', '@lezer/lr', '@lexical/clipboard/clipboard', - ...builtins, + ...nodeBuiltins, ], format: 'cjs', define: { 'import.meta.url': 'import_meta_url', - process: '{}', + // process: '{}', 'process.env.NODE_ENV': JSON.stringify(prod ? 'production' : 'development'), }, inject: [path.resolve('import-meta-url-shim.js')], diff --git a/package.json b/package.json index 011db83..8b20642 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@lexical/react": "^0.17.1", "@lexical/rich-text": "^0.27.2", "@lexical/utils": "^0.27.2", + "@modelcontextprotocol/sdk": "^1.12.1", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-popover": "^1.1.2", @@ -70,12 +71,15 @@ "@radix-ui/react-tooltip": "^1.1.3", "@tanstack/react-query": "^5.56.2", "axios": "^1.8.3", + "chokidar": "^4.0.3", "clsx": "^2.1.1", + "delay": "^6.0.0", "diff": "^7.0.0", "diff-match-patch": "^1.0.5", "drizzle-orm": "^0.35.2", "esbuild-plugin-inline-worker": "^0.1.1", "exponential-backoff": "^3.1.1", + "fast-deep-equal": "^3.1.3", "fastest-levenshtein": "^1.0.16", "fuse.js": "^7.1.0", "fuzzysort": "^3.1.0", @@ -98,14 +102,17 @@ "p-limit": "^6.1.0", "parse5": "^7.1.2", "path": "^0.12.7", + "plausible-tracker": "^0.3.9", "radix-ui": "^1.3.4", "react": "^18.3.1", "react-contenteditable": "^3.3.7", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", "react-syntax-highlighter": "^15.5.0", + "reconnecting-eventsource": "^1.6.4", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", + "shell-env": "^4.0.1", "simple-git": "^3.27.0", "string-similarity": "^4.0.4", "uuid": "^10.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba568cc..4d4be8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: version: 0.2.14 '@google/genai': specifier: ^1.2.0 - version: 1.2.0(@modelcontextprotocol/sdk@1.12.0) + version: 1.2.0(@modelcontextprotocol/sdk@1.12.1) '@google/generative-ai': specifier: ^0.21.0 version: 0.21.0 @@ -44,6 +44,9 @@ importers: '@lexical/utils': specifier: ^0.27.2 version: 0.27.2 + '@modelcontextprotocol/sdk': + specifier: ^1.12.1 + version: 1.12.1 '@radix-ui/react-dialog': specifier: ^1.1.2 version: 1.1.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -65,9 +68,15 @@ importers: axios: specifier: ^1.8.3 version: 1.8.3 + chokidar: + specifier: ^4.0.3 + version: 4.0.3 clsx: specifier: ^2.1.1 version: 2.1.1 + delay: + specifier: ^6.0.0 + version: 6.0.0 diff: specifier: ^7.0.0 version: 7.0.0 @@ -83,6 +92,9 @@ importers: exponential-backoff: specifier: ^3.1.1 version: 3.1.2 + fast-deep-equal: + specifier: ^3.1.3 + version: 3.1.3 fastest-levenshtein: specifier: ^1.0.16 version: 1.0.16 @@ -149,6 +161,9 @@ importers: path: specifier: ^0.12.7 version: 0.12.7 + plausible-tracker: + specifier: ^0.3.9 + version: 0.3.9 radix-ui: specifier: ^1.3.4 version: 1.3.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -167,12 +182,18 @@ importers: react-syntax-highlighter: specifier: ^15.5.0 version: 15.6.1(react@18.3.1) + reconnecting-eventsource: + specifier: ^1.6.4 + version: 1.6.4 rehype-raw: specifier: ^7.0.0 version: 7.0.0 remark-gfm: specifier: ^4.0.0 version: 4.0.1 + shell-env: + specifier: ^4.0.1 + version: 4.0.1 simple-git: specifier: ^3.27.0 version: 3.27.0 @@ -1416,8 +1437,8 @@ packages: '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} - '@modelcontextprotocol/sdk@1.12.0': - resolution: {integrity: sha512-m//7RlINx1F3sz3KqwY1WWzVgTcYX52HYk4bJ1hkBXV3zccAEth+jRvG8DBRrdaQuRsPAJOx2MH3zaHNCKL7Zg==} + '@modelcontextprotocol/sdk@1.12.1': + resolution: {integrity: sha512-KG1CZhZfWg+u8pxeM/mByJDScJSrjjxLc8fwQqbsS8xCjBmQfMNEBTotYdNanKekepnfRI85GtgQlctLFpcYPw==} engines: {node: '>=18'} '@nodelib/fs.scandir@2.1.5': @@ -2725,6 +2746,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -2953,6 +2978,10 @@ packages: resolution: {integrity: sha512-GB+YYcb/2s1HpNXThiHyl1PO5evlkX+avFzggq9/4JZGLGbtMT5FE9GUFjxH+5nObb4Lfu72hAH4lqGljog0Mw==} engines: {node: '>= 0.6'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} @@ -3135,6 +3164,10 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + default-shell@2.2.0: + resolution: {integrity: sha512-sPpMZcVhRQ0nEMDtuMJ+RtCxt7iHPAMBU+I4tAlo5dU1sjRpNax0crj6nR3qKpvVnckaQ9U38enXcwW9nZJeCw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -3143,6 +3176,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delay@6.0.0: + resolution: {integrity: sha512-2NJozoOHQ4NuZuVIr5CWd0iiLVIRSDepakaovIN+9eIDHEhdCAEvSy2cuf1DCrPPQLvHmbqTHODlhHg8UCy4zw==} + engines: {node: '>=16'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -5029,6 +5066,10 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + plausible-tracker@0.3.9: + resolution: {integrity: sha512-hMhneYm3GCPyQon88SZrVJx+LlqhM1kZFQbuAgXPoh/Az2YvO1B6bitT9qlhpiTdJlsT5lsr3gPmzoVjb5CDXA==} + engines: {node: '>=10'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5212,6 +5253,14 @@ packages: readable-stream@1.1.14: resolution: {integrity: sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + reconnecting-eventsource@1.6.4: + resolution: {integrity: sha512-0L3IS3wxcNFApTPPHkcbY8Aya7XZIpYDzhxa8j6QSufVkUN018XJKfh2ZaThLBGP/iN5UTz2yweMhkqr0PKa7A==} + engines: {node: '>=12.0.0'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -5365,6 +5414,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-env@4.0.1: + resolution: {integrity: sha512-w3oeZ9qg/P6Lu6qqwavvMnB/bwfsz67gPB3WXmLd/n6zuh7TWQZtGa3iMEdmua0kj8rivkwl+vUjgLWlqZOMPw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -5477,6 +5530,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -6601,9 +6658,9 @@ snapshots: '@floating-ui/utils@0.2.9': {} - '@google/genai@1.2.0(@modelcontextprotocol/sdk@1.12.0)': + '@google/genai@1.2.0(@modelcontextprotocol/sdk@1.12.1)': dependencies: - '@modelcontextprotocol/sdk': 1.12.0 + '@modelcontextprotocol/sdk': 1.12.1 google-auth-library: 9.15.1 ws: 8.18.0 zod: 3.24.2 @@ -7104,7 +7161,7 @@ snapshots: '@marijn/find-cluster-break@1.0.2': {} - '@modelcontextprotocol/sdk@1.12.0': + '@modelcontextprotocol/sdk@1.12.1': dependencies: ajv: 6.12.6 content-type: 1.0.5 @@ -8534,6 +8591,8 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.1.0: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -8822,6 +8881,10 @@ snapshots: lodash: 2.4.2 optional: true + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + ci-info@3.9.0: {} cjs-module-lexer@1.4.3: {} @@ -8979,6 +9042,8 @@ snapshots: deepmerge@4.3.1: {} + default-shell@2.2.0: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -8991,6 +9056,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delay@6.0.0: {} + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -11482,6 +11549,8 @@ snapshots: dependencies: find-up: 4.1.0 + plausible-tracker@0.3.9: {} + possible-typed-array-names@1.1.0: {} postcss-resolve-nested-selector@0.1.6: {} @@ -11719,6 +11788,10 @@ snapshots: string_decoder: 0.10.31 optional: true + readdirp@4.1.2: {} + + reconnecting-eventsource@1.6.4: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -11929,6 +12002,12 @@ snapshots: shebang-regex@3.0.0: {} + shell-env@4.0.1: + dependencies: + default-shell: 2.2.0 + execa: 5.1.1 + strip-ansi: 7.1.0 + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -12076,6 +12155,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + strip-bom@3.0.0: {} strip-bom@4.0.0: {} diff --git a/src/ChatView.tsx b/src/ChatView.tsx index 6dac0af..daacc9a 100644 --- a/src/ChatView.tsx +++ b/src/ChatView.tsx @@ -11,6 +11,7 @@ import { DatabaseProvider } from './contexts/DatabaseContext' import { DialogProvider } from './contexts/DialogContext' import { DiffStrategyProvider } from './contexts/DiffStrategyContext' import { LLMProvider } from './contexts/LLMContext' +import { McpHubProvider } from './contexts/McpHubContext' import { RAGProvider } from './contexts/RAGContext' import { SettingsProvider } from './contexts/SettingsContext' import InfioPlugin from './main' @@ -87,15 +88,17 @@ export class ChatView extends ItemView { > this.plugin.getRAGEngine()}> - - - - - - - + this.plugin.getMcpHub()}> + + + + + + + + diff --git a/src/components/chat-view/ChatView.tsx b/src/components/chat-view/ChatView.tsx index fb1654f..9d18248 100644 --- a/src/components/chat-view/ChatView.tsx +++ b/src/components/chat-view/ChatView.tsx @@ -2,7 +2,7 @@ import * as path from 'path' import { BaseSerializedNode } from '@lexical/clipboard/clipboard' import { useMutation } from '@tanstack/react-query' -import { CircleStop, History, NotebookPen, Plus, SquareSlash } from 'lucide-react' +import { CircleStop, History, NotebookPen, Plus, Server, SquareSlash } from 'lucide-react' import { App, Notice } from 'obsidian' import { forwardRef, @@ -20,6 +20,7 @@ import { APPLY_VIEW_TYPE } from '../../constants' import { useApp } from '../../contexts/AppContext' import { useDiffStrategy } from '../../contexts/DiffStrategyContext' import { useLLM } from '../../contexts/LLMContext' +import { useMcpHub } from '../../contexts/McpHubContext' import { useRAG } from '../../contexts/RAGContext' import { useSettings } from '../../contexts/SettingsContext' import { @@ -49,19 +50,22 @@ import { import { readTFileContent } from '../../utils/obsidian' import { openSettingsModalWithError } from '../../utils/open-settings-modal' import { PromptGenerator, addLineNumbers } from '../../utils/prompt-generator' -import { fetchUrlsContent, webSearch } from '../../utils/web-search' +// Removed empty line above, added one below for group separation +import { fetchUrlsContent, onEnt, webSearch } from '../../utils/web-search' -import { ModeSelect } from './chat-input/ModeSelect' +import { ModeSelect } from './chat-input/ModeSelect' // Start of new group import PromptInputWithActions, { ChatUserInputRef } from './chat-input/PromptInputWithActions' import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text' import { ChatHistory } from './ChatHistoryView' import CommandsView from './CommandsView' import CustomModeView from './CustomModeView' import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock' +import McpHubView from './McpHubView' // Moved after MarkdownReasoningBlock import QueryProgress, { QueryProgressState } from './QueryProgress' import ReactMarkdown from './ReactMarkdown' import ShortcutInfo from './ShortcutInfo' import SimilaritySearchResults from './SimilaritySearchResults' + // Add an empty line here const getNewInputMessage = (app: App, defaultMention: string): ChatUserMessage => { const mentionables: Mentionable[] = []; @@ -103,6 +107,7 @@ const Chat = forwardRef((props, ref) => { const { settings, setSettings } = useSettings() const { getRAGEngine } = useRAG() const diffStrategy = useDiffStrategy() + const { getMcpHub } = useMcpHub() const { customModeList, customModePrompts } = useCustomModes() const { @@ -115,9 +120,9 @@ const Chat = forwardRef((props, ref) => { const { streamResponse, chatModel } = useLLM() const promptGenerator = useMemo(() => { - // @ts-expect-error - return new PromptGenerator(getRAGEngine, app, settings, diffStrategy, customModePrompts, customModeList) - }, [getRAGEngine, app, settings, diffStrategy, customModePrompts, customModeList]) + // @ts-expect-error TODO: Review PromptGenerator constructor parameters and types + return new PromptGenerator(getRAGEngine, app, settings, diffStrategy, customModePrompts, customModeList, getMcpHub) + }, [getRAGEngine, app, settings, diffStrategy, customModePrompts, customModeList, getMcpHub]) const [inputMessage, setInputMessage] = useState(() => { const newMessage = getNewInputMessage(app, settings.defaultMention) @@ -166,7 +171,8 @@ const Chat = forwardRef((props, ref) => { } } - const [tab, setTab] = useState<'chat' | 'commands' | 'custom-mode'>('chat') + const [tab, setTab] = useState<'chat' | 'commands' | 'custom-mode' | 'mcp'>('chat') + const [selectedSerializedNodes, setSelectedSerializedNodes] = useState([]) useEffect(() => { @@ -190,6 +196,11 @@ const Chat = forwardRef((props, ref) => { return () => scrollContainer.removeEventListener('scroll', handleScroll) }, [chatMessages]) + + useEffect(() => { + onEnt(`switch_tab/${tab}`) + }, [tab]) + const handleCreateCommand = (serializedNodes: BaseSerializedNode[]) => { setSelectedSerializedNodes(serializedNodes) setTab('commands') @@ -217,7 +228,7 @@ const Chat = forwardRef((props, ref) => { abortActiveStreams() const conversation = await getChatMessagesById(conversationId) if (!conversation) { - throw new Error(t('chat.errors.conversationNotFound')) + throw new Error(String(t('chat.errors.conversationNotFound'))) } setCurrentConversationId(conversationId) setChatMessages(conversation) @@ -228,8 +239,8 @@ const Chat = forwardRef((props, ref) => { type: 'idle', }) } catch (error) { - new Notice(t('chat.errors.failedToLoadConversation')) - console.error(t('chat.errors.failedToLoadConversation'), error) + new Notice(String(t('chat.errors.failedToLoadConversation'))) + console.error(String(t('chat.errors.failedToLoadConversation')), error) } } @@ -276,7 +287,7 @@ const Chat = forwardRef((props, ref) => { try { const abortController = new AbortController() activeStreamAbortControllersRef.current.push(abortController) - + onEnt('chat-submit') const { requestMessages, compiledMessages } = await promptGenerator.generateRequestMessages({ messages: newChatHistory, @@ -705,6 +716,42 @@ const Chat = forwardRef((props, ref) => { mentionables: [], } } + } else if (toolArgs.type === 'use_mcp_tool') { + const mcpHub = await getMcpHub() + if (!mcpHub) { + throw new Error('MCP hub not found') + } + const toolResult = await mcpHub.callTool(toolArgs.server_name, toolArgs.tool_name, toolArgs.parameters) + const toolResultPretty = + (toolResult?.isError ? "Error:\n" : "") + + toolResult?.content + .map((item) => { + if (item.type === "text") { + return item.text + } + if (item.type === "resource") { + const { blob: _, ...rest } = item.resource + return JSON.stringify(rest, null, 2) + } + return "" + }) + .filter(Boolean) + .join("\n\n") || "(No response)" + + const formattedContent = `[use_mcp_tool for '${toolArgs.server_name}'] Result:\n${toolResultPretty}\n`; + return { + type: 'use_mcp_tool', + applyMsgId, + applyStatus: ApplyStatus.Applied, + returnMsg: { + role: 'user', + applyStatus: ApplyStatus.Idle, + content: null, + promptContent: formattedContent, + id: uuidv4(), + mentionables: [], + } + } } } catch (error) { console.error('Failed to apply changes', error) @@ -723,6 +770,21 @@ const Chat = forwardRef((props, ref) => { } : message, ); } + if (result.returnMsg) { + newChatMessages.push({ + id: uuidv4(), + role: 'assistant', + applyStatus: ApplyStatus.Idle, + isToolResult: true, + content: `${result.returnMsg.promptContent}`, + reasoningContent: '', + metadata: { + usage: undefined, + model: undefined, + }, + }) + console.log('Updated chat messages:', newChatMessages); + } setChatMessages(newChatMessages); if (result.returnMsg) { @@ -953,6 +1015,18 @@ const Chat = forwardRef((props, ref) => { > + {/* main view */} @@ -1071,10 +1145,14 @@ const Chat = forwardRef((props, ref) => { selectedSerializedNodes={selectedSerializedNodes} /> - ) : ( + ) : tab === 'custom-mode' ? (
+ ) : ( +
+ +
)} ) diff --git a/src/components/chat-view/CustomModeView.tsx b/src/components/chat-view/CustomModeView.tsx index 95dbc1b..c373918 100644 --- a/src/components/chat-view/CustomModeView.tsx +++ b/src/components/chat-view/CustomModeView.tsx @@ -11,7 +11,7 @@ import { CustomMode, GroupEntry, ToolGroup } from '../../database/json/custom-mo import { useCustomModes } from '../../hooks/use-custom-mode'; import { t } from '../../lang/helpers'; import { PreviewView, PreviewViewState } from '../../PreviewView'; -import { modes as buildinModes } from '../../utils/modes'; +import { defaultModes as buildinModes } from '../../utils/modes'; import { openOrCreateMarkdownFile } from '../../utils/obsidian'; import { PromptGenerator, getFullLanguageName } from '../../utils/prompt-generator'; @@ -30,7 +30,7 @@ const CustomModeView = () => { const diffStrategy = useDiffStrategy() const promptGenerator = useMemo(() => { - // @ts-expect-error + // @ts-expect-error PromptGenerator constructor parameter types need to be reviewed return new PromptGenerator(getRAGEngine, app, settings, diffStrategy, customModePrompts, customModeList) }, [app, settings, diffStrategy, customModePrompts, customModeList]) @@ -76,7 +76,7 @@ const CustomModeView = () => { setModeName(newMode.name); setRoleDefinition(newMode.roleDefinition); setCustomInstructions(newMode.customInstructions || ''); - setSelectedTools(newMode.groups as GroupEntry[]); + setSelectedTools(newMode.groups); setCustomModeId(''); return; } @@ -87,7 +87,7 @@ const CustomModeView = () => { setModeName(builtinMode.slug); setRoleDefinition(builtinMode.roleDefinition); setCustomInstructions(builtinMode.customInstructions || ''); - setSelectedTools(builtinMode.groups as GroupEntry[]); + setSelectedTools(builtinMode.groups); setCustomModeId(''); // Built-in modes don't have custom IDs } else { setIsBuiltinMode(false); @@ -387,7 +387,7 @@ const CustomModeView = () => { type: PREVIEW_VIEW_TYPE, active: true, state: { - content: systemPrompt.content as string, + content: typeof systemPrompt.content === 'string' ? systemPrompt.content : '', title: `${modeName} system prompt`, } satisfies PreviewViewState, }) diff --git a/src/components/chat-view/Markdown/MarkdownToolResult.tsx b/src/components/chat-view/Markdown/MarkdownToolResult.tsx new file mode 100644 index 0000000..d7052f4 --- /dev/null +++ b/src/components/chat-view/Markdown/MarkdownToolResult.tsx @@ -0,0 +1,88 @@ +import { ChevronDown, ChevronRight, CheckCheck } from 'lucide-react' +import React, { PropsWithChildren, useEffect, useRef, useState } from 'react' + +import { useDarkModeContext } from "../../../contexts/DarkModeContext" +import { t } from '../../../lang/helpers' + +import { MemoizedSyntaxHighlighterWrapper } from "./SyntaxHighlighterWrapper" + +const processContent = (content: string): { serverName: string; processedContent: string } => { + const lines = content.split('\n'); + const firstLine = lines[0]; + + // 提取 serverName + const serverNameMatch = firstLine.match(/\[use_mcp_tool for '([^']+)'\]/); + const serverName = serverNameMatch ? serverNameMatch[1] : ''; + + // 移除第一行并重新组合内容 + const processedContent = lines.slice(1).join('\n'); + + return { serverName, processedContent }; +}; + +export default function MarkdownToolResult({ + content, +}: PropsWithChildren<{ + content: string +}>) { + const { isDarkMode } = useDarkModeContext() + const containerRef = useRef(null) + const [isOpen, setIsOpen] = useState(true) + + const { serverName, processedContent } = React.useMemo(() => processContent(content), [content]); + + useEffect(() => { + if (containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight + } + }, [processedContent]) + + return ( + processedContent && ( +
+
+
+ + response from tool + {serverName} +
+ +
+
+ + {processedContent} + +
+ +
+ ) + ) +} diff --git a/src/components/chat-view/Markdown/UseMcpToolBlock.tsx b/src/components/chat-view/Markdown/UseMcpToolBlock.tsx new file mode 100644 index 0000000..b15b5b7 --- /dev/null +++ b/src/components/chat-view/Markdown/UseMcpToolBlock.tsx @@ -0,0 +1,109 @@ +import { Server } from 'lucide-react' +import React from 'react' + +import { useSettings } from "../../../contexts/SettingsContext" +import { t } from '../../../lang/helpers' +import { ApplyStatus, SearchWebToolArgs } from "../../../types/apply" + +export default function UseMcpToolBlock({ + applyStatus, + onApply, + serverName, + toolName, + parameters, + finish +}: { + applyStatus: ApplyStatus + onApply: (args: SearchWebToolArgs) => void + serverName: string, + toolName: string, + parameters: Record, + finish: boolean +}) { + + const { settings } = useSettings() + + + React.useEffect(() => { + if (finish && applyStatus === ApplyStatus.Idle) { + onApply({ + type: 'use_mcp_tool', + server_name: serverName, + tool_name: toolName, + parameters: parameters, + }) + } + }, [finish]) + + return ( +
+
+
+ + use mcp tool from + {serverName} +
+
+
+
+
+
+ {toolName} +
+
+ 参数:
+
{JSON.stringify(parameters, null, 2)}
+
+
+
+ +
+ ) +} diff --git a/src/components/chat-view/McpHubView.tsx b/src/components/chat-view/McpHubView.tsx new file mode 100644 index 0000000..cc19596 --- /dev/null +++ b/src/components/chat-view/McpHubView.tsx @@ -0,0 +1,755 @@ +import { AlertTriangle, ChevronDown, ChevronRight, FileText, Folder, Power, RotateCcw, Trash2, Wrench } from 'lucide-react' +import React, { useEffect, useState } from 'react' + +import { useMcpHub } from '../../contexts/McpHubContext' +import { useSettings } from '../../contexts/SettingsContext' +import { McpErrorEntry, McpResource, McpResourceTemplate, McpServer, McpTool } from '../../core/mcp/type' +import { t } from '../../lang/helpers' + +const McpHubView = () => { + const { settings, setSettings } = useSettings() + const { getMcpHub } = useMcpHub() + const [mcpServers, setMcpServers] = useState([]) + const [expandedServers, setExpandedServers] = useState>({}); + const [activeServerDetailTab, setActiveServerDetailTab] = useState>({}); + + const fetchServers = async () => { + const hub = await getMcpHub() + console.log('Fetching MCP Servers from hub:', hub) + if (hub) { + const serversData = hub.getAllServers() + console.log('Fetched MCP Servers:', serversData) + setMcpServers(serversData) + } + } + + useEffect(() => { + fetchServers() + }, [getMcpHub]) + + const switchMcp = React.useCallback(() => { + setSettings({ + ...settings, + mcpEnabled: !settings.mcpEnabled, + }) + }, [settings, setSettings]) + + // const handleSearch = (e: React.ChangeEvent) => { + // setSearchTerm(e.target.value) + // } + + const handleRestart = async (serverName: string) => { + const hub = await getMcpHub(); + if (hub) { + await hub.restartConnection(serverName, "global") + const updatedServers = hub.getAllServers() + setMcpServers(updatedServers) + } + } + + const handleToggle = async (serverName: string, disabled: boolean) => { + const hub = await getMcpHub(); + if (hub) { + await hub.toggleServerDisabled(serverName, !disabled) + const updatedServers = hub.getAllServers() + setMcpServers(updatedServers) + } + } + + const handleDelete = async (serverName: string) => { + const hub = await getMcpHub(); + if (hub) { + if (confirm(`确定要删除服务器 "${serverName}" 吗?`)) { + await hub.deleteServer(serverName, "global") + const updatedServers = hub.getAllServers() + setMcpServers(updatedServers) + } + } + } + + + const toggleServerExpansion = (serverKey: string) => { + setExpandedServers(prev => ({ ...prev, [serverKey]: !prev[serverKey] })); + if (!expandedServers[serverKey] && !activeServerDetailTab[serverKey]) { + setActiveServerDetailTab(prev => ({ ...prev, [serverKey]: 'tools' })); + } + }; + + const handleDetailTabChange = (serverKey: string, tab: 'tools' | 'resources' | 'errors') => { + setActiveServerDetailTab(prev => ({ ...prev, [serverKey]: tab })); + }; + + const ToolRow = ({ tool }: { tool: McpTool }) => { + return ( +
+
+
+ {tool.name} +
+
+ {tool.description && ( +

{tool.description}

+ )} + {(tool.inputSchema && (() => { + const schema = tool.inputSchema; + const properties = schema && typeof schema === 'object' && 'properties' in schema ? schema.properties : undefined; + const required = schema && typeof schema === 'object' && 'required' in schema ? schema.required : undefined; + + if (properties && typeof properties === 'object' && Object.keys(properties).length > 0) { + return ( +
+
{t('parameters')}
+ {Object.entries(properties).map( + ([paramName, paramSchemaUntyped]) => { + const paramSchema = paramSchemaUntyped && typeof paramSchemaUntyped === 'object' ? paramSchemaUntyped : {}; + const paramDescription = 'description' in paramSchema && typeof paramSchema.description === 'string' ? paramSchema.description : undefined; + const isRequired = required && Array.isArray(required) && required.includes(paramName); + return ( +
+ + {paramName} + {isRequired && *} + + + {paramDescription || t('mcpHub.tool.noDescription')} + +
+ ); + } + )} +
+ ); + } + return null; + })())} +
+ ); + }; + + const ResourceRow = ({ resource }: { resource: McpResource | McpResourceTemplate }) => ( +
+
+ + {'uri' in resource ? resource.uri : resource.uriTemplate} +
+ {resource.description &&

{resource.description}

} +
+ ); + + const ErrorRow = ({ error }: { error: McpErrorEntry }) => ( +
+
+ +

+ {error.message} +

+
+

{new Date(error.timestamp).toLocaleString()}

+
+ ); + + return ( +
+ {/* Header Section */} +
+

MCP 服务器

+
+ + {/* MCP Settings */} +
+
+ +

+ 开启后 Roo 可用已连接 MCP 服务器的工具,能力更强。不用这些工具时建议关闭,节省 API Token 费用。 +

+
+
+ + {/* Servers List */} + {settings.mcpEnabled && ( +
+ {mcpServers.length === 0 ? ( +
+

{t('mcpHub.noServersFound')}

+
+ ) : ( + mcpServers.map(server => { + const serverKey = `${server.name}-${server.source || 'global'}`; + const isExpanded = !!expandedServers[serverKey]; + const currentDetailTab = activeServerDetailTab[serverKey] || 'tools'; + + return ( +
+
+
toggleServerExpansion(serverKey)}> +
+ {isExpanded ? : } +
+ +

{server.name}

+ {/* {server.source} */} +
+ +
e.stopPropagation()}> + + + + + +
+
+ +
+ + 状态: {server.status} + +
+ + {isExpanded && server.status === 'connected' && ( +
+
+ {(['tools', 'resources', 'errors'] as const).map(tabName => { + const count = tabName === 'tools' + ? server.tools?.length || 0 + : tabName === 'resources' + ? (server.resources?.length || 0) + (server.resourceTemplates?.length || 0) + : server.errorHistory?.length || 0; + + return ( + + ); + })} +
+
+ {currentDetailTab === 'tools' && ( +
+ {(server.tools && server.tools.length > 0) ? server.tools.map(tool => ) :

{t('mcpHub.noTools')}

} +
+ )} + {currentDetailTab === 'resources' && ( +
+ {((server.resources && server.resources.length > 0) || (server.resourceTemplates && server.resourceTemplates.length > 0)) + ? [...(server.resources || []), ...(server.resourceTemplates || [])].map(res => ) + :

{t('mcpHub.noResources')}

} +
+ )} + {currentDetailTab === 'errors' && ( +
+ {(server.errorHistory && server.errorHistory.length > 0) + ? [...server.errorHistory].sort((a, b) => b.timestamp - a.timestamp).map((err, idx) => ) + :

{t('mcpHub.noErrors')}

} +
+ )} +
+
+ )} + {isExpanded && server.status !== 'connected' && ( +
+

+ {t('mcpHub.serverNotConnectedError')} + {server.error &&

{server.error}
} +

+
+ )} +
+ ); + }) + )} +
+ )} + + +
+ ) +} + +export default McpHubView diff --git a/src/components/chat-view/ReactMarkdown.tsx b/src/components/chat-view/ReactMarkdown.tsx index 5b07299..2db19d7 100644 --- a/src/components/chat-view/ReactMarkdown.tsx +++ b/src/components/chat-view/ReactMarkdown.tsx @@ -18,7 +18,9 @@ import MarkdownSearchAndReplace from './Markdown/MarkdownSearchAndReplace' import MarkdownSearchWebBlock from './Markdown/MarkdownSearchWebBlock' import MarkdownSemanticSearchFilesBlock from './Markdown/MarkdownSemanticSearchFilesBlock' import MarkdownSwitchModeBlock from './Markdown/MarkdownSwitchModeBlock' +import MarkdownToolResult from './Markdown/MarkdownToolResult' import MarkdownWithIcons from './Markdown/MarkdownWithIcon' +import UseMcpToolBlock from './Markdown/UseMcpToolBlock' function ReactMarkdown({ applyStatus, @@ -178,6 +180,21 @@ function ReactMarkdown({ urls={block.urls} finish={block.finish} /> + ) : block.type === 'use_mcp_tool' ? ( + + ) : block.type === 'tool_result' ? ( + ) : ( {block.content} diff --git a/src/components/chat-view/chat-input/ModeSelect.tsx b/src/components/chat-view/chat-input/ModeSelect.tsx index c27872c..d14a788 100644 --- a/src/components/chat-view/chat-input/ModeSelect.tsx +++ b/src/components/chat-view/chat-input/ModeSelect.tsx @@ -4,7 +4,8 @@ import { useEffect, useMemo, useState } from 'react' import { useSettings } from '../../../contexts/SettingsContext' import { useCustomModes } from '../../../hooks/use-custom-mode' -import { modes } from '../../../utils/modes' +import { defaultModes } from '../../../utils/modes' +import { onEnt } from '../../../utils/web-search' export function ModeSelect() { const { settings, setSettings } = useSettings() @@ -13,9 +14,10 @@ export function ModeSelect() { const { customModeList } = useCustomModes() - const allModes = useMemo(() => [...modes, ...customModeList], [customModeList]) + const allModes = useMemo(() => [...defaultModes, ...customModeList], [customModeList]) useEffect(() => { + onEnt(`switch_mode/${settings.mode}`) setMode(settings.mode) }, [settings.mode]) diff --git a/src/components/inline-edit/InlineEdit.tsx b/src/components/inline-edit/InlineEdit.tsx index a52c95d..3fe4ded 100644 --- a/src/components/inline-edit/InlineEdit.tsx +++ b/src/components/inline-edit/InlineEdit.tsx @@ -10,6 +10,7 @@ import { GetProviderModelIds } from '../../utils/api'; import { ApplyEditToFile } from '../../utils/apply'; import { removeAITags } from '../../utils/content-filter'; import { PromptGenerator } from '../../utils/prompt-generator'; +import { onEnt } from '../../utils/web-search'; type InlineEditProps = { source?: string; @@ -191,6 +192,7 @@ export const InlineEdit: React.FC = ({ setIsSubmitting(true); try { const { activeFile, editor, selection } = await getActiveContext(); + onEnt('inline-edit-submit') if (!activeFile || !editor || !selection) { console.error(t("inlineEdit.noActiveContext")); setIsSubmitting(false); diff --git a/src/contexts/McpHubContext.tsx b/src/contexts/McpHubContext.tsx new file mode 100644 index 0000000..88ffe2e --- /dev/null +++ b/src/contexts/McpHubContext.tsx @@ -0,0 +1,39 @@ +import { + PropsWithChildren, + createContext, + useContext, + useEffect, + useMemo, +} from 'react' + +import { McpHub } from '../core/mcp/McpHub' + +export type McpHubContextType = { + getMcpHub: () => Promise +} + +const McpHubContext = createContext(null) + +export function McpHubProvider({ + getMcpHub, + children, +}: PropsWithChildren<{ getMcpHub: () => Promise }>) { + useEffect(() => { + // start initialization of mcpHub in the background + void getMcpHub() + }, [getMcpHub]) + + const value = useMemo(() => { + return { getMcpHub } + }, [getMcpHub]) + + return {children} +} + +export function useMcpHub() { + const context = useContext(McpHubContext) + if (!context) { + throw new Error('useMcpHub must be used within a McpHubProvider') + } + return context +} diff --git a/src/core/autocomplete/states/predicting-state.ts b/src/core/autocomplete/states/predicting-state.ts index 9565794..010c1b9 100644 --- a/src/core/autocomplete/states/predicting-state.ts +++ b/src/core/autocomplete/states/predicting-state.ts @@ -2,93 +2,95 @@ import { Notice } from "obsidian"; import EventListener from "../../../event-listener"; import { DocumentChanges } from "../../../render-plugin/document-changes-listener"; +import { onEnt } from "../../../utils/web-search"; import Context from "../context-detection"; import State from "./state"; class PredictingState extends State { - private predictionPromise: Promise | null = null; - private isStillNeeded = true; - private readonly prefix: string; - private readonly suffix: string; + private predictionPromise: Promise | null = null; + private isStillNeeded = true; + private readonly prefix: string; + private readonly suffix: string; - constructor(context: EventListener, prefix: string, suffix: string) { - super(context); - this.prefix = prefix; - this.suffix = suffix; - } + constructor(context: EventListener, prefix: string, suffix: string) { + super(context); + this.prefix = prefix; + this.suffix = suffix; + } - static createAndStartPredicting( - context: EventListener, - prefix: string, - suffix: string - ): PredictingState { - const predictingState = new PredictingState(context, prefix, suffix); - predictingState.startPredicting(); - context.setContext(Context.getContext(prefix, suffix)); - return predictingState; - } + static createAndStartPredicting( + context: EventListener, + prefix: string, + suffix: string + ): PredictingState { + const predictingState = new PredictingState(context, prefix, suffix); + predictingState.startPredicting(); + context.setContext(Context.getContext(prefix, suffix)); + return predictingState; + } - handleCancelKeyPressed(): boolean { - this.cancelPrediction(); - return true; - } + handleCancelKeyPressed(): boolean { + this.cancelPrediction(); + return true; + } - async handleDocumentChange( - documentChanges: DocumentChanges - ): Promise { - if ( - documentChanges.hasCursorMoved() || - documentChanges.hasUserTyped() || - documentChanges.hasUserDeleted() || - documentChanges.isTextAdded() - ) { - this.cancelPrediction(); - } - } + async handleDocumentChange( + documentChanges: DocumentChanges + ): Promise { + if ( + documentChanges.hasCursorMoved() || + documentChanges.hasUserTyped() || + documentChanges.hasUserDeleted() || + documentChanges.isTextAdded() + ) { + this.cancelPrediction(); + } + } - private cancelPrediction(): void { - this.isStillNeeded = false; - this.context.transitionToIdleState(); - } + private cancelPrediction(): void { + this.isStillNeeded = false; + this.context.transitionToIdleState(); + } - startPredicting(): void { - this.predictionPromise = this.predict(); - } + startPredicting(): void { + this.predictionPromise = this.predict(); + } - private async predict(): Promise { + private async predict(): Promise { + onEnt(`predict`) - const result = - await this.context.autocomplete?.fetchPredictions( - this.prefix, - this.suffix - ); + const result = + await this.context.autocomplete?.fetchPredictions( + this.prefix, + this.suffix + ); - if (!this.isStillNeeded) { - return; - } + if (!this.isStillNeeded) { + return; + } - if (result.isErr()) { - new Notice( - `Copilot: Something went wrong cannot make a prediction. Full error is available in the dev console. Please check your settings. ` - ); - console.error(result.error); - this.context.transitionToIdleState(); - } + if (result.isErr()) { + new Notice( + `Copilot: Something went wrong cannot make a prediction. Full error is available in the dev console. Please check your settings. ` + ); + console.error(result.error); + this.context.transitionToIdleState(); + } - const prediction = result.unwrapOr(""); + const prediction = result.unwrapOr(""); - if (prediction === "") { - this.context.transitionToIdleState(); - return; - } - this.context.transitionToSuggestingState(prediction, this.prefix, this.suffix); - } + if (prediction === "") { + this.context.transitionToIdleState(); + return; + } + this.context.transitionToSuggestingState(prediction, this.prefix, this.suffix); + } - getStatusBarText(): string { - return `Predicting for ${this.context.context}`; - } + getStatusBarText(): string { + return `Predicting for ${this.context.context}`; + } } export default PredictingState; diff --git a/src/core/mcp/McpHub.ts b/src/core/mcp/McpHub.ts index 7338c20..7593cb1 100644 --- a/src/core/mcp/McpHub.ts +++ b/src/core/mcp/McpHub.ts @@ -1,23 +1,34 @@ -// @ts-nocheck -import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { StdioClientTransport, StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js" +// Obsidian +import { App, EventRef, Notice, TFile, normalizePath } from 'obsidian'; + +// Node built-in +import * as path from "path"; + +// SDK / External Libraries +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { CallToolResultSchema, - ListResourcesResultSchema, ListResourceTemplatesResultSchema, + ListResourcesResultSchema, ListToolsResultSchema, ReadResourceResultSchema, -} from "@modelcontextprotocol/sdk/types.js" -import chokidar, { FSWatcher } from "chokidar" -import delay from "delay" -import deepEqual from "fast-deep-equal" -import * as fs from "fs/promises" -import * as path from "path" -import * as vscode from "vscode" -import { z } from "zod" +} from "@modelcontextprotocol/sdk/types.js"; +import chokidar, { FSWatcher } from "chokidar"; // Keep chokidar +import delay from "delay"; // Keep delay +import deepEqual from "fast-deep-equal"; // Keep fast-deep-equal +import ReconnectingEventSource from "reconnecting-eventsource"; // Keep reconnecting-eventsource +import { EnvironmentVariables, shellEnvSync } from 'shell-env'; +import { z } from "zod"; // Keep zod + +// Internal/Project imports +import { t } from "../../lang/helpers"; +import InfioPlugin from "../../main"; +// Assuming path is correct and will be resolved, if not, this will remain an error. +// Users should verify this path if issues persist. +import { injectEnv } from "../../utils/config"; -import { ClineProvider } from "../../core/webview/ClineProvider" -import { GlobalFileNames } from "../../shared/globalFileNames" import { McpResource, McpResourceResponse, @@ -25,46 +36,253 @@ import { McpServer, McpTool, McpToolCallResponse, -} from "../../shared/mcp" -import { fileExistsAtPath } from "../../utils/fs" -import { arePathsEqual } from "../../utils/path" +} from "./type"; + export type McpConnection = { server: McpServer client: Client - transport: StdioClientTransport + transport: StdioClientTransport | SSEClientTransport } -// StdioServerParameters -const AlwaysAllowSchema = z.array(z.string()).default([]) - -export const StdioConfigSchema = z.object({ - command: z.string(), - args: z.array(z.string()).optional(), - env: z.record(z.string()).optional(), - alwaysAllow: AlwaysAllowSchema.optional(), +// Base configuration schema for common settings +const BaseConfigSchema = z.object({ disabled: z.boolean().optional(), timeout: z.number().min(1).max(3600).optional().default(60), + alwaysAllow: z.array(z.string()).default([]), + watchPaths: z.array(z.string()).optional(), // paths to watch for changes and restart server }) +// Custom error messages for better user feedback +const typeErrorMessage = "Server type must be either 'stdio' or 'sse'" +const stdioFieldsErrorMessage = + "For 'stdio' type servers, you must provide a 'command' field and can optionally include 'args' and 'env'" +const sseFieldsErrorMessage = + "For 'sse' type servers, you must provide a 'url' field and can optionally include 'headers'" +const mixedFieldsErrorMessage = + "Cannot mix 'stdio' and 'sse' fields. For 'stdio' use 'command', 'args', and 'env'. For 'sse' use 'url' and 'headers'" +const missingFieldsErrorMessage = "Server configuration must include either 'command' (for stdio) or 'url' (for sse)" + +// Helper function to create a refined schema with better error messages +const createServerTypeSchema = () => { + return z.union([ + // Stdio config (has command field) + BaseConfigSchema.extend({ + type: z.enum(["stdio"]).optional(), + command: z.string().min(1, "Command cannot be empty"), + args: z.array(z.string()).optional(), + // cwd: z.string().default(() => { // `this` is not available in this context + // // TODO: Find a better way to set default CWD, perhaps during server initialization + // // For now, let's make it optional or require it explicitly. + // // const basePath = this.app?.vault?.adapter?.basePath; // this.app is not defined here + // // return basePath || process.cwd(); + // }), + cwd: z.string().optional(), // Made optional, to be handled during connection + env: z.record(z.string()).optional(), + // Ensure no SSE fields are present + url: z.undefined().optional(), + headers: z.undefined().optional(), + }) + .transform((data) => ({ + ...data, + type: "stdio" as const, + })) + .refine((data) => data.type === undefined || data.type === "stdio", { message: typeErrorMessage }), + // SSE config (has url field) + BaseConfigSchema.extend({ + type: z.enum(["sse"]).optional(), + url: z.string().url("URL must be a valid URL format"), + headers: z.record(z.string()).optional(), + // Ensure no stdio fields are present + command: z.undefined().optional(), + args: z.undefined().optional(), + env: z.undefined().optional(), + }) + .transform((data) => ({ + ...data, + type: "sse" as const, + })) + .refine((data) => data.type === undefined || data.type === "sse", { message: typeErrorMessage }), + ]) +} + +// Server configuration schema with automatic type inference and validation +export const ServerConfigSchema = createServerTypeSchema() + +// Settings schema const McpSettingsSchema = z.object({ - mcpServers: z.record(StdioConfigSchema), + mcpServers: z.record(ServerConfigSchema), }) export class McpHub { - private providerRef: WeakRef - private disposables: vscode.Disposable[] = [] - private settingsWatcher?: vscode.FileSystemWatcher - private fileWatchers: Map = new Map() + private app: App + private plugin: InfioPlugin + private mcpSettingsFilePath: string | null = null + // private globalMcpFilePath: string | null = null + private fileWatchers: Map = new Map() + private isDisposed: boolean = false connections: McpConnection[] = [] isConnecting: boolean = false + private refCount: number = 0 // Reference counter for active clients + private eventRefs: EventRef[] = []; // For managing Obsidian event listeners + // private providerRef: any; // TODO: Replace with actual type and initialize properly. Removed for now as it causes issues and its usage is unclear in the current scope. + private shellEnv: EnvironmentVariables - constructor(provider: ClineProvider) { - this.providerRef = new WeakRef(provider) - this.watchMcpSettingsFile() - this.initializeMcpServers() + constructor(app: App, plugin: InfioPlugin) { + this.app = app + this.plugin = plugin + this.shellEnv = shellEnvSync() + // Placeholder for providerRef initialization - this needs a proper solution if providerRef is essential. + // if ((this.app as any).plugins?.plugins['obsidian-infio-copilot']) { + // this.providerRef = (this.app as any).plugins.plugins['obsidian-infio-copilot']; + // } } + public async onload() { + // Ensure the MCP configuration directory exists + console.log("McpHub: Loading MCP Hub") + await this.ensureMcpFileExists() + console.log("McpHub: file exists") + await this.watchMcpSettingsFile(); + console.log("McpHub: watchMcpSettingsFile") + // this.setupWorkspaceWatcher(); + await this.initializeGlobalMcpServers(); + console.log("McpHub: initializeGlobalMcpServers") + } + + /** + * Registers a client (e.g., ClineProvider) using this hub. + * Increments the reference count. + */ + public registerClient(): void { + this.refCount++ + console.log(`McpHub: Client registered. Ref count: ${this.refCount}`) + } + + /** + * Unregisters a client. Decrements the reference count. + * If the count reaches zero, disposes the hub. + */ + public async unregisterClient(): Promise { + this.refCount-- + console.log(`McpHub: Client unregistered. Ref count: ${this.refCount}`) + if (this.refCount <= 0) { + console.log("McpHub: Last client unregistered. Disposing hub.") + await this.dispose() + } + } + + /** + * Validates and normalizes server configuration + * @param config The server configuration to validate + * @param serverName Optional server name for error messages + * @returns The validated configuration + * @throws Error if the configuration is invalid + */ + private validateServerConfig(config: unknown, serverName?: string): z.infer { + if (typeof config !== 'object' || config === null) { + throw new Error("Server configuration must be an object."); + } + const configObj = config as Record; // Cast after check + + // Detect configuration issues before validation + const hasStdioFields = configObj.command !== undefined + const hasSseFields = configObj.url !== undefined + + // Check for mixed fields + if (hasStdioFields && hasSseFields) { + throw new Error(mixedFieldsErrorMessage) + } + + const mutableConfig = { ...configObj }; // Create a mutable copy + + // Check if it's a stdio or SSE config and add type if missing + if (!mutableConfig.type) { + if (hasStdioFields) { + mutableConfig.type = "stdio" + } else if (hasSseFields) { + mutableConfig.type = "sse" + } else { + throw new Error(missingFieldsErrorMessage) + } + } else if (mutableConfig.type !== "stdio" && mutableConfig.type !== "sse") { + throw new Error(typeErrorMessage) + } + + // Check for type/field mismatch + if (mutableConfig.type === "stdio" && !hasStdioFields) { + throw new Error(stdioFieldsErrorMessage) + } + if (mutableConfig.type === "sse" && !hasSseFields) { + throw new Error(sseFieldsErrorMessage) + } + + // Validate the config against the schema + try { + return ServerConfigSchema.parse(mutableConfig) // Parse the mutable copy + } catch (validationError) { + if (validationError instanceof z.ZodError) { + // Extract and format validation errors + const errorMessages = validationError.errors + .map((err) => `${err.path.join(".")}: ${err.message}`) + .join("; ") + throw new Error( + serverName + ? `Invalid configuration for server "${serverName}": ${errorMessages}` + : `Invalid server configuration: ${errorMessages}`, + ) + } + throw validationError + } + } + + /** + * Formats and displays error messages to the user + * @param message The error message prefix + * @param error The error object + */ + private showErrorMessage(message: string, error: unknown): void { + console.error(`${message}:`, error) + new Notice(`${message}: ${error instanceof Error ? error.message : String(error)}`); + } + + public setupWorkspaceWatcher(): void { + this.eventRefs.push(this.app.vault.on('modify', async (file) => { + // Adjusted to use the new config file name and path logic + const configFilePath = await this.getMcpSettingsFilePath(); + if (file instanceof TFile && file.path === configFilePath) { + await this.handleConfigFileChange(file.path); + } + })); + } + + private async handleConfigFileChange(filePath: string): Promise { + try { + const content = await this.app.vault.adapter.read(filePath); + const config = JSON.parse(content) + const result = McpSettingsSchema.safeParse(config) + + if (!result.success) { + const errorMessages = result.error.errors + .map((err) => `${err.path.join(".")}: ${err.message}`) + .join("\n") + new Notice(String(t("common:errors.invalid_mcp_settings_validation")) + ": " + errorMessages) + return + } + + await this.updateServerConnections(result.data.mcpServers || {}) + } catch (error) { + if (error instanceof SyntaxError) { + new Notice(String(t("common:errors.invalid_mcp_settings_format"))) + } else { + this.showErrorMessage(`Failed to process MCP settings change`, error) + } + } + } + + // Removed watchProjectMcpFile, updateProjectMcpServers, cleanupProjectMcpServers, getProjectMcpPath, initializeProjectMcpServers + // Removed getMcpServersPath as it's unused and problematic with providerRef + getServers(): McpServer[] { // Only return enabled servers return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server) @@ -75,214 +293,352 @@ export class McpHub { return this.connections.map((conn) => conn.server) } - async getMcpServersPath(): Promise { - const provider = this.providerRef.deref() - if (!provider) { - throw new Error("Provider not available") + async ensureMcpFileExists(): Promise { + const mcpFolderPath = ".infio_json_db/mcp" + if (!await this.app.vault.adapter.exists(normalizePath(mcpFolderPath))) { + await this.app.vault.createFolder(mcpFolderPath); } - const mcpServersPath = await provider.ensureMcpServersDirectoryExists() - return mcpServersPath + this.mcpSettingsFilePath = normalizePath(path.join(mcpFolderPath, "settings.json")) + const fileExists = await this.app.vault.adapter.exists(this.mcpSettingsFilePath); + if (!fileExists) { + await this.app.vault.adapter.write( + this.mcpSettingsFilePath, + JSON.stringify({ mcpServers: {} }, null, 2) + ); + } + // this.globalMcpFilePath = normalizePath(path.join(mcpFolderPath, "global.json")) + // const fileExists1 = await this.app.vault.adapter.exists(this.globalMcpFilePath); + // if (!fileExists1) { + // await this.app.vault.adapter.write( + // this.globalMcpFilePath, + // JSON.stringify({ mcpServers: {} }, null, 2) + // ); + // } } async getMcpSettingsFilePath(): Promise { - const provider = this.providerRef.deref() - if (!provider) { - throw new Error("Provider not available") - } - const mcpSettingsFilePath = path.join( - await provider.ensureSettingsDirectoryExists(), - GlobalFileNames.mcpSettings, - ) - const fileExists = await fileExistsAtPath(mcpSettingsFilePath) - if (!fileExists) { - await fs.writeFile( - mcpSettingsFilePath, - `{ - "mcpServers": { - - } -}`, - ) - } - return mcpSettingsFilePath + return this.mcpSettingsFilePath } private async watchMcpSettingsFile(): Promise { - const settingsPath = await this.getMcpSettingsFilePath() - this.disposables.push( - vscode.workspace.onDidSaveTextDocument(async (document) => { - if (arePathsEqual(document.uri.fsPath, settingsPath)) { - const content = await fs.readFile(settingsPath, "utf-8") - const errorMessage = - "Invalid MCP settings format. Please ensure your settings follow the correct JSON format." - let config: any - try { - config = JSON.parse(content) - } catch (error) { - vscode.window.showErrorMessage(errorMessage) - return - } - const result = McpSettingsSchema.safeParse(config) - if (!result.success) { - vscode.window.showErrorMessage(errorMessage) - return - } - try { - await this.updateServerConnections(result.data.mcpServers || {}) - } catch (error) { - console.error("Failed to process MCP settings change:", error) - } - } - }), - ) + this.eventRefs.push(this.app.vault.on('modify', async (file) => { + if (file.path === this.mcpSettingsFilePath) { + await this.handleConfigFileChange(this.mcpSettingsFilePath) + } + })); } - private async initializeMcpServers(): Promise { + // Combined and simplified initializeMcpServers, only for global scope + private async initializeGlobalMcpServers(): Promise { try { - const settingsPath = await this.getMcpSettingsFilePath() - const content = await fs.readFile(settingsPath, "utf-8") - const config = JSON.parse(content) - await this.updateServerConnections(config.mcpServers || {}) + if (!await this.app.vault.adapter.exists(this.mcpSettingsFilePath)) { + // If config file doesn't exist after trying to create it in getMcpSettingsFilePath, + // which should create it, then something is wrong. + // However, getMcpSettingsFilePath should handle creation. + // This check is more of a safeguard. + console.log("MCP config file does not exist, skipping initialization."); + return; + } + + const content = await this.app.vault.adapter.read(this.mcpSettingsFilePath); + const config = JSON.parse(content); + const result = McpSettingsSchema.safeParse(config); + + if (result.success) { + await this.updateServerConnections(result.data.mcpServers || {}); + } else { + const errorMessages = result.error.errors + .map((err) => `${err.path.join(".")}: ${err.message}`) + .join("\n"); + console.error(`Invalid MCP settings format:`, errorMessages); + new Notice(String(t("common:errors.invalid_mcp_settings_validation")) + ": " + errorMessages); + // Still try to connect with the raw config for global, but show warnings + try { + // Ensure config.mcpServers is treated as the correct type for updateServerConnections + const serversToConnect = config.mcpServers as Record>> | undefined; + await this.updateServerConnections(serversToConnect || {} as Record>>); + } catch (error) { + this.showErrorMessage(`Failed to initialize MCP servers with raw config`, error); + } + } } catch (error) { - console.error("Failed to initialize MCP servers:", error) + if (error instanceof SyntaxError) { + const errorMessage = t("common:errors.invalid_mcp_settings_syntax"); + console.error(errorMessage, error); + new Notice(String(errorMessage)); + } else { + this.showErrorMessage(`Failed to initialize MCP servers`, error); + } } } - private async connectToServer(name: string, config: StdioServerParameters): Promise { - // Remove existing connection if it exists (should never happen, the connection should be deleted beforehand) - this.connections = this.connections.filter((conn) => conn.server.name !== name) + private async connectToServer( + name: string, + config: z.infer, + source: "global" | "project" = "global" + ): Promise { + // Remove existing connection if it exists + await this.deleteConnection(name) try { - // Each MCP server requires its own transport connection and has unique capabilities, configurations, and error handling. Having separate clients also allows proper scoping of resources/tools and independent server management like reconnection. + // Each MCP server requires its own transport connection and has unique capabilities, configurations, and error handling. Having separate clients also allows proper scoping of resources/tools and independent server management like reconnection. const client = new Client( { name: "Roo Code", - version: this.providerRef.deref()?.context.extension?.packageJSON?.version ?? "1.0.0", + // version: this.providerRef?.deref ? (this.providerRef.deref()?.context?.extension?.packageJSON?.version ?? "1.0.0") : (this.providerRef?.context?.extension?.packageJSON?.version ?? "1.0.0"), + // TODO: Get version properly if needed, e.g., from plugin manifest + version: "1.0.0", // Placeholder }, { capabilities: {}, }, ) - const transport = new StdioClientTransport({ - command: config.command, - args: config.args, - env: { - ...config.env, - ...(process.env.PATH ? { PATH: process.env.PATH } : {}), - // ...(process.env.NODE_PATH ? { NODE_PATH: process.env.NODE_PATH } : {}), - }, - stderr: "pipe", // necessary for stderr to be available - }) + let transport: StdioClientTransport | SSEClientTransport - transport.onerror = async (error) => { - console.error(`Transport error for "${name}":`, error) - const connection = this.connections.find((conn) => conn.server.name === name) - if (connection) { - connection.server.status = "disconnected" - this.appendErrorMessage(connection, error.message) + // Inject environment variables to the config + let configInjected = { ...config }; + try { + // injectEnv might return a modified structure, so we re-validate. + // config is z.infer, injectEnv expects Record + // This assumes injectEnv can handle the structure of ServerConfigSchema or its parts. + const tempConfigAfterInject = await injectEnv(config as any); + const validatedInjectedConfig = ServerConfigSchema.safeParse(tempConfigAfterInject); + if (validatedInjectedConfig.success) { + configInjected = validatedInjectedConfig.data; + } else { + console.warn("Failed to validate server config after injecting env vars. Using original config.", validatedInjectedConfig.error); + configInjected = config; // Fallback to original, already validated config } - await this.notifyWebviewOfServerChanges() + } catch (e) { + console.warn("Error injecting env vars. Using original config.", e); + configInjected = config; // Fallback to original config } + + if (configInjected.type === "stdio") { + // Ensure cwd is set, default to plugin's root directory if not provided + // Obsidian's DataAdapter doesn't have a direct `basePath`. + // For a general plugin context, `this.app.vault.getRoot().path` gives the vault root. + // If a path relative to the plugin is needed, it's more complex. + // For stdio commands, often they are system-wide or expect to be run from a specific project dir. + // Defaulting to "." (current working directory, typically the vault root when Obsidian runs it) is a safe bet if not specified. + const cwd = configInjected.cwd || "."; - transport.onclose = async () => { - const connection = this.connections.find((conn) => conn.server.name === name) - if (connection) { - connection.server.status = "disconnected" - } - await this.notifyWebviewOfServerChanges() - } - - // If the config is invalid, show an error - if (!StdioConfigSchema.safeParse(config).success) { - console.error(`Invalid config for "${name}": missing or invalid parameters`) - const connection: McpConnection = { - server: { - name, - config: JSON.stringify(config), - status: "disconnected", - error: "Invalid config: missing or invalid parameters", + transport = new StdioClientTransport({ + command: configInjected.command, + args: configInjected.args, + cwd: cwd, + env: { + ...(configInjected.env || {}), + ...(this.shellEnv.PATH ? { PATH: this.shellEnv.PATH } : {}), + ...(this.shellEnv.HOME ? { HOME: this.shellEnv.HOME } : {}), }, - client, - transport, + stderr: "pipe", + }) + + // Set up stdio specific error handling + transport.onerror = async (error) => { + console.error(`Transport error for "${name}":`, error) + const connection = this.findConnection(name) + if (connection) { + connection.server.status = "disconnected" + this.appendErrorMessage(connection, error instanceof Error ? error.message : String(error)) + } + // await this.notifyWebviewOfServerChanges() + } + + transport.onclose = async () => { + const connection = this.findConnection(name) + if (connection) { + connection.server.status = "disconnected" + } + // await this.notifyWebviewOfServerChanges() + } + + // transport.stderr is only available after the process has been started. However we can't start it separately from the .connect() call because it also starts the transport. And we can't place this after the connect call since we need to capture the stderr stream before the connection is established, in order to capture errors during the connection process. + // As a workaround, we start the transport ourselves, and then monkey-patch the start method to no-op so that .connect() doesn't try to start it again. + await transport.start() + const stderrStream = transport.stderr + if (stderrStream) { + stderrStream.on("data", async (data: Buffer) => { + const output = data.toString() + // Check if output contains INFO level log + const isInfoLog = /INFO/i.test(output) + + if (isInfoLog) { + // Log normal informational messages + console.log(`Server "${name}" info:`, output) + } else { + // Treat as error log + console.error(`Server "${name}" stderr:`, output) + const connection = this.findConnection(name) + if (connection) { + this.appendErrorMessage(connection, output) + if (connection.server.status === "disconnected") { + // await this.notifyWebviewOfServerChanges() + } + } + } + }) + } else { + console.error(`No stderr stream for ${name}`) + } + transport.start = async () => { } // No-op now, .connect() won't fail + } else { + // SSE connection + const sseOptions = { + requestInit: { + headers: configInjected.headers, + }, + } + // Configure ReconnectingEventSource options + const reconnectingEventSourceOptions = { + max_retry_time: 5000, // Maximum retry time in milliseconds + withCredentials: configInjected.headers?.["Authorization"] ? true : false, // Enable credentials if Authorization header exists + } + global.EventSource = ReconnectingEventSource + transport = new SSEClientTransport(new URL(configInjected.url), { + ...sseOptions, + eventSourceInit: reconnectingEventSourceOptions, + }) + + // Set up SSE specific error handling + transport.onerror = async (error) => { + console.error(`Transport error for "${name}":`, error) + const connection = this.findConnection(name, source) + if (connection) { + connection.server.status = "disconnected" + this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`) + } + // await this.notifyWebviewOfServerChanges() } - this.connections.push(connection) - return } - // valid schema - const parsedConfig = StdioConfigSchema.parse(config) const connection: McpConnection = { server: { name, - config: JSON.stringify(config), + config: JSON.stringify(configInjected), status: "connecting", - disabled: parsedConfig.disabled, + disabled: configInjected.disabled, + source, + projectPath: source === "project" ? this.app.vault.getRoot().path : undefined, + errorHistory: [], }, client, transport, } this.connections.push(connection) - // transport.stderr is only available after the process has been started. However we can't start it separately from the .connect() call because it also starts the transport. And we can't place this after the connect call since we need to capture the stderr stream before the connection is established, in order to capture errors during the connection process. - // As a workaround, we start the transport ourselves, and then monkey-patch the start method to no-op so that .connect() doesn't try to start it again. - await transport.start() - const stderrStream = transport.stderr - if (stderrStream) { - stderrStream.on("data", async (data: Buffer) => { - const errorOutput = data.toString() - console.error(`Server "${name}" stderr:`, errorOutput) - const connection = this.connections.find((conn) => conn.server.name === name) - if (connection) { - // NOTE: we do not set server status to "disconnected" because stderr logs do not necessarily mean the server crashed or disconnected, it could just be informational. In fact when the server first starts up, it immediately logs " server running on stdio" to stderr. - this.appendErrorMessage(connection, errorOutput) - // Only need to update webview right away if it's already disconnected - if (connection.server.status === "disconnected") { - await this.notifyWebviewOfServerChanges() - } - } - }) - } else { - console.error(`No stderr stream for ${name}`) - } - transport.start = async () => {} // No-op now, .connect() won't fail - - // Connect + // Connect (this will automatically start the transport) await client.connect(transport) connection.server.status = "connected" connection.server.error = "" // Initial fetch of tools and resources - connection.server.tools = await this.fetchToolsList(name) - connection.server.resources = await this.fetchResourcesList(name) - connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name) + connection.server.tools = await this.fetchToolsList(name, source) + connection.server.resources = await this.fetchResourcesList(name, source) + connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name, source) } catch (error) { // Update status with error - const connection = this.connections.find((conn) => conn.server.name === name) + const connection = this.findConnection(name, source) if (connection) { connection.server.status = "disconnected" - this.appendErrorMessage(connection, error instanceof Error ? error.message : String(error)) + this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`) } throw error } } - private appendErrorMessage(connection: McpConnection, error: string) { - const newError = connection.server.error ? `${connection.server.error}\n${error}` : error - connection.server.error = newError //.slice(0, 800) + private appendErrorMessage(connection: McpConnection, error: string, level: "error" | "warn" | "info" = "error") { + const MAX_ERROR_LENGTH = 1000 + const truncatedError = + error.length > MAX_ERROR_LENGTH + ? `${error.substring(0, MAX_ERROR_LENGTH)}...(error message truncated)` + : error + + // Add to error history + if (!connection.server.errorHistory) { + connection.server.errorHistory = [] + } + + connection.server.errorHistory.push({ + message: truncatedError, + timestamp: Date.now(), + level, + }) + + // Keep only the last 100 errors + if (connection.server.errorHistory.length > 100) { + connection.server.errorHistory = connection.server.errorHistory.slice(-100) + } + + // Update current error display + connection.server.error = truncatedError } - private async fetchToolsList(serverName: string): Promise { - try { - const response = await this.connections - .find((conn) => conn.server.name === serverName) - ?.client.request({ method: "tools/list" }, ListToolsResultSchema) + /** + * Helper method to find a connection by server name and source + * @param serverName The name of the server to find + * @param source Optional source to filter by (global or project) + * @returns The matching connection or undefined if not found + */ + private findConnection(serverName: string, source: "global" | "project" = "global"): McpConnection | undefined { + // If source is specified, only find servers with that source + if (source !== undefined) { + return this.connections.find((conn) => conn.server.name === serverName && conn.server.source === source) + } - // Get always allow settings - const settingsPath = await this.getMcpSettingsFilePath() - const content = await fs.readFile(settingsPath, "utf-8") - const config = JSON.parse(content) - const alwaysAllowConfig = config.mcpServers[serverName]?.alwaysAllow || [] + // If no source is specified, first look for project servers, then global servers + // This ensures that when servers have the same name, project servers are prioritized + const projectConn = this.connections.find( + (conn) => conn.server.name === serverName && conn.server.source === "project", + ) + if (projectConn) return projectConn + + // If no project server is found, look for global servers + return this.connections.find( + (conn) => conn.server.name === serverName && (conn.server.source === "global" || !conn.server.source), + ) + } + + private async fetchToolsList(serverName: string, source: "global" | "project" = "global"): Promise { + try { + // Use the helper method to find the connection + const connection = this.findConnection(serverName, source) + + if (!connection) { + throw new Error(`Server ${serverName} not found`) + } + + const response = await connection.client.request({ method: "tools/list" }, ListToolsResultSchema) + + // Determine the actual source of the server + const actualSource = connection.server.source || "global" + let configPath: string + let alwaysAllowConfig: string[] = [] + + // Read from the appropriate config file based on the actual source + try { + if (actualSource === "project") { + // Get project MCP config path + const projectMcpPath = normalizePath(".infio_json_db/mcp/mcp.json") + if (await this.app.vault.adapter.exists(projectMcpPath)) { + configPath = projectMcpPath + const content = await this.app.vault.adapter.read(configPath) + const config = JSON.parse(content) + alwaysAllowConfig = config.mcpServers?.[serverName]?.alwaysAllow || [] + } + } else { + // Get global MCP settings path + configPath = normalizePath(".infio_json_db/mcp/settings.json") + const content = await this.app.vault.adapter.read(configPath) + const config = JSON.parse(content) + alwaysAllowConfig = config.mcpServers?.[serverName]?.alwaysAllow || [] + } + } catch (error) { + console.error(`Failed to read alwaysAllow config for ${serverName}:`, error) + // Continue with empty alwaysAllowConfig + } // Mark tools as always allowed based on settings const tools = (response?.tools || []).map((tool) => ({ @@ -290,19 +646,20 @@ export class McpHub { alwaysAllow: alwaysAllowConfig.includes(tool.name), })) - console.log(`[MCP] Fetched tools for ${serverName}:`, tools) return tools } catch (error) { - // console.error(`Failed to fetch tools for ${serverName}:`, error) + console.error(`Failed to fetch tools for ${serverName}:`, error) return [] } } - private async fetchResourcesList(serverName: string): Promise { + private async fetchResourcesList(serverName: string, source?: "global" | "project"): Promise { try { - const response = await this.connections - .find((conn) => conn.server.name === serverName) - ?.client.request({ method: "resources/list" }, ListResourcesResultSchema) + const connection = this.findConnection(serverName, source) + if (!connection) { + return [] + } + const response = await connection.client.request({ method: "resources/list" }, ListResourcesResultSchema) return response?.resources || [] } catch (error) { // console.error(`Failed to fetch resources for ${serverName}:`, error) @@ -310,11 +667,19 @@ export class McpHub { } } - private async fetchResourceTemplatesList(serverName: string): Promise { + private async fetchResourceTemplatesList( + serverName: string, + source: "global" | "project" = "global", + ): Promise { try { - const response = await this.connections - .find((conn) => conn.server.name === serverName) - ?.client.request({ method: "resources/templates/list" }, ListResourceTemplatesResultSchema) + const connection = this.findConnection(serverName, source) + if (!connection) { + return [] + } + const response = await connection.client.request( + { method: "resources/templates/list" }, + ListResourceTemplatesResultSchema, + ) return response?.resourceTemplates || [] } catch (error) { // console.error(`Failed to fetch resource templates for ${serverName}:`, error) @@ -322,9 +687,13 @@ export class McpHub { } } - async deleteConnection(name: string): Promise { - const connection = this.connections.find((conn) => conn.server.name === name) - if (connection) { + async deleteConnection(name: string, source: "global" | "project" = "global"): Promise { + // If source is provided, only delete connections from that source + const connections = source + ? this.connections.filter((conn) => conn.server.name === name && conn.server.source === source) + : this.connections.filter((conn) => conn.server.name === name) + + for (const connection of connections) { try { await connection.transport.close() await connection.client.close() @@ -333,264 +702,377 @@ export class McpHub { } this.connections = this.connections.filter((conn) => conn.server.name !== name) } + + // Remove the connections from the array + this.connections = this.connections.filter((conn) => { + if (conn.server.name !== name) return true + if (source && conn.server.source !== source) return true + return false + }) } - async updateServerConnections(newServers: Record): Promise { + async updateServerConnections( + newServers: Record, + source: "global" | "project" = "global", + ): Promise { this.isConnecting = true this.removeAllFileWatchers() - const currentNames = new Set(this.connections.map((conn) => conn.server.name)) + // Filter connections by source + const currentConnections = this.connections.filter( + (conn) => conn.server.source === source || (!conn.server.source && source === "global"), + ) + const currentNames = new Set(currentConnections.map((conn) => conn.server.name)) const newNames = new Set(Object.keys(newServers)) // Delete removed servers for (const name of currentNames) { if (!newNames.has(name)) { - await this.deleteConnection(name) - console.log(`Deleted MCP server: ${name}`) + await this.deleteConnection(name, source) } } - // Update or add servers + // Update or add servers· for (const [name, config] of Object.entries(newServers)) { - const currentConnection = this.connections.find((conn) => conn.server.name === name) + // Only consider connections that match the current source + const currentConnection = this.findConnection(name, source) + + // Validate and transform the config + let validatedConfig: z.infer + try { + validatedConfig = this.validateServerConfig(config, name) + } catch (error) { + this.showErrorMessage(`Invalid configuration for MCP server "${name}"`, error) + continue + } if (!currentConnection) { // New server try { - this.setupFileWatcher(name, config) - await this.connectToServer(name, config) + this.setupFileWatcher(name, validatedConfig, source) + await this.connectToServer(name, validatedConfig, source) } catch (error) { - console.error(`Failed to connect to new MCP server ${name}:`, error) + this.showErrorMessage(`Failed to connect to new MCP server ${name}`, error) } } else if (!deepEqual(JSON.parse(currentConnection.server.config), config)) { // Existing server with changed config try { - this.setupFileWatcher(name, config) - await this.deleteConnection(name) - await this.connectToServer(name, config) - console.log(`Reconnected MCP server with updated config: ${name}`) + this.setupFileWatcher(name, validatedConfig, source) + await this.deleteConnection(name, source) + await this.connectToServer(name, validatedConfig, source) } catch (error) { - console.error(`Failed to reconnect MCP server ${name}:`, error) + this.showErrorMessage(`Failed to reconnect MCP server ${name}`, error) } } // If server exists with same config, do nothing } - await this.notifyWebviewOfServerChanges() + // await this.notifyWebviewOfServerChanges() this.isConnecting = false } - private setupFileWatcher(name: string, config: any) { - const filePath = config.args?.find((arg: string) => arg.includes("build/index.js")) - if (filePath) { - // we use chokidar instead of onDidSaveTextDocument because it doesn't require the file to be open in the editor. The settings config is better suited for onDidSave since that will be manually updated by the user or Cline (and we want to detect save events, not every file change) - const watcher = chokidar.watch(filePath, { - // persistent: true, - // ignoreInitial: true, - // awaitWriteFinish: true, // This helps with atomic writes - }) + private setupFileWatcher( + name: string, + config: z.infer, + source: "global" | "project" = "global", + ) { + // Initialize an empty array for this server if it doesn't exist + if (!this.fileWatchers.has(name)) { + this.fileWatchers.set(name, []) + } - watcher.on("change", () => { - console.log(`Detected change in ${filePath}. Restarting server ${name}...`) - this.restartConnection(name) - }) + const watchers = this.fileWatchers.get(name) || [] - this.fileWatchers.set(name, watcher) + // Only stdio type has args + if (config.type === "stdio") { + // Setup watchers for custom watchPaths if defined + if (config.watchPaths && config.watchPaths.length > 0) { + const watchPathsWatcher = chokidar.watch(config.watchPaths, { + // persistent: true, + // ignoreInitial: true, + // awaitWriteFinish: true, + }) + + watchPathsWatcher.on("change", async (changedPath) => { + try { + // Pass the source from the config to restartConnection + await this.restartConnection(name, source) + } catch (error) { + console.error(`Failed to restart server ${name} after change in ${changedPath}:`, error) + } + }) + + watchers.push(watchPathsWatcher) + } + + // Also setup the fallback build/index.js watcher if applicable + const filePath = config.args?.find((arg: string) => arg.includes("build/index.js")) + if (filePath) { + // we use chokidar instead of onDidSaveTextDocument because it doesn't require the file to be open in the editor + const indexJsWatcher = chokidar.watch(filePath, { + // persistent: true, + // ignoreInitial: true, + // awaitWriteFinish: true, // This helps with atomic writes + }) + + indexJsWatcher.on("change", async () => { + try { + // Pass the source from the config to restartConnection + await this.restartConnection(name, source) + } catch (error) { + console.error(`Failed to restart server ${name} after change in ${filePath}:`, error) + } + }) + + watchers.push(indexJsWatcher) + } + + // Update the fileWatchers map with all watchers for this server + if (watchers.length > 0) { + this.fileWatchers.set(name, watchers) + } } } private removeAllFileWatchers() { - this.fileWatchers.forEach((watcher) => watcher.close()) + this.fileWatchers.forEach((watchers) => watchers.forEach((watcher) => watcher.close())) this.fileWatchers.clear() } - async restartConnection(serverName: string): Promise { + async restartConnection(serverName: string, source?: "global" | "project"): Promise { this.isConnecting = true - const provider = this.providerRef.deref() - if (!provider) { - return - } + // const provider = this.providerRef.deref() + // if (!provider) { + // return + // } // Get existing connection and update its status - const connection = this.connections.find((conn) => conn.server.name === serverName) + const connection = this.findConnection(serverName, source) const config = connection?.server.config if (config) { - vscode.window.showInformationMessage(`Restarting ${serverName} MCP server...`) + // vscode.window.showInformationMessage(t("common:info.mcp_server_restarting", { serverName })) connection.server.status = "connecting" connection.server.error = "" - await this.notifyWebviewOfServerChanges() + // await this.notifyWebviewOfServerChanges() await delay(500) // artificial delay to show user that server is restarting try { - await this.deleteConnection(serverName) - // Try to connect again using existing config - await this.connectToServer(serverName, JSON.parse(config)) - vscode.window.showInformationMessage(`${serverName} MCP server connected`) + await this.deleteConnection(serverName, connection.server.source) + // Parse the config to validate it + const parsedConfig = JSON.parse(config) + try { + // Validate the config + const validatedConfig = this.validateServerConfig(parsedConfig, serverName) + + // Try to connect again using validated config + await this.connectToServer(serverName, validatedConfig) + // vscode.window.showInformationMessage(t("common:info.mcp_server_connected", { serverName })) + } catch (validationError) { + this.showErrorMessage(`Invalid configuration for MCP server "${serverName}"`, validationError) + } } catch (error) { - console.error(`Failed to restart connection for ${serverName}:`, error) - vscode.window.showErrorMessage(`Failed to connect to ${serverName} MCP server`) + this.showErrorMessage(`Failed to restart ${serverName} MCP server connection`, error) } } - await this.notifyWebviewOfServerChanges() + // await this.notifyWebviewOfServerChanges() this.isConnecting = false } - private async notifyWebviewOfServerChanges(): Promise { - // servers should always be sorted in the order they are defined in the settings file - const settingsPath = await this.getMcpSettingsFilePath() - const content = await fs.readFile(settingsPath, "utf-8") - const config = JSON.parse(content) - const serverOrder = Object.keys(config.mcpServers || {}) - await this.providerRef.deref()?.postMessageToWebview({ - type: "mcpServers", - mcpServers: [...this.connections] - .sort((a, b) => { - const indexA = serverOrder.indexOf(a.server.name) - const indexB = serverOrder.indexOf(b.server.name) - return indexA - indexB - }) - .map((connection) => connection.server), - }) - } + // private async notifyWebviewOfServerChanges(): Promise { + // // Get global server order from settings file + // const settingsPath = await this.getMcpSettingsFilePath() + // const content = await fs.readFile(settingsPath, "utf-8") + // const config = JSON.parse(content) + // const globalServerOrder = Object.keys(config.mcpServers || {}) - public async toggleServerDisabled(serverName: string, disabled: boolean): Promise { - let settingsPath: string + // // Get project server order if available + // const projectMcpPath = await this.getProjectMcpPath() + // let projectServerOrder: string[] = [] + // if (projectMcpPath) { + // try { + // const projectContent = await fs.readFile(projectMcpPath, "utf-8") + // const projectConfig = JSON.parse(projectContent) + // projectServerOrder = Object.keys(projectConfig.mcpServers || {}) + // } catch (error) { + // // Silently continue with empty project server order + // } + // } + + // // Sort connections: first project servers in their defined order, then global servers in their defined order + // // This ensures that when servers have the same name, project servers are prioritized + // const sortedConnections = [...this.connections].sort((a, b) => { + // const aIsGlobal = a.server.source === "global" || !a.server.source + // const bIsGlobal = b.server.source === "global" || !b.server.source + + // // If both are global or both are project, sort by their respective order + // if (aIsGlobal && bIsGlobal) { + // const indexA = globalServerOrder.indexOf(a.server.name) + // const indexB = globalServerOrder.indexOf(b.server.name) + // return indexA - indexB + // } else if (!aIsGlobal && !bIsGlobal) { + // const indexA = projectServerOrder.indexOf(a.server.name) + // const indexB = projectServerOrder.indexOf(b.server.name) + // return indexA - indexB + // } + + // // Project servers come before global servers (reversed from original) + // return aIsGlobal ? 1 : -1 + // }) + + // // Send sorted servers to webview + // await this.providerRef.deref()?.postMessageToWebview({ + // type: "mcpServers", + // mcpServers: sortedConnections.map((connection) => connection.server), + // }) + // } + + public async toggleServerDisabled( + serverName: string, + disabled: boolean, + source: "global" | "project" = "global", + ): Promise { try { - settingsPath = await this.getMcpSettingsFilePath() - - // Ensure the settings file exists and is accessible - try { - await fs.access(settingsPath) - } catch (error) { - console.error("Settings file not accessible:", error) - throw new Error("Settings file not accessible") - } - const content = await fs.readFile(settingsPath, "utf-8") - const config = JSON.parse(content) - - // Validate the config structure - if (!config || typeof config !== "object") { - throw new Error("Invalid config structure") + // Find the connection to determine if it's a global or project server + const connection = this.findConnection(serverName, source) + if (!connection) { + throw new Error(`Server ${serverName}${source ? ` with source ${source}` : ""} not found`) } - if (!config.mcpServers || typeof config.mcpServers !== "object") { - config.mcpServers = {} - } + const serverSource = connection.server.source + // Update the server config in the appropriate file + await this.updateServerConfig(serverName, { disabled }, serverSource) - if (config.mcpServers[serverName]) { - // Create a new server config object to ensure clean structure - const serverConfig = { - ...config.mcpServers[serverName], - disabled, - } + // Update the connection object + if (connection) { + try { + connection.server.disabled = disabled - // Ensure required fields exist - if (!serverConfig.alwaysAllow) { - serverConfig.alwaysAllow = [] - } - - config.mcpServers[serverName] = serverConfig - - // Write the entire config back - const updatedConfig = { - mcpServers: config.mcpServers, - } - - await fs.writeFile(settingsPath, JSON.stringify(updatedConfig, null, 2)) - - const connection = this.connections.find((conn) => conn.server.name === serverName) - if (connection) { - try { - connection.server.disabled = disabled - - // Only refresh capabilities if connected - if (connection.server.status === "connected") { - connection.server.tools = await this.fetchToolsList(serverName) - connection.server.resources = await this.fetchResourcesList(serverName) - connection.server.resourceTemplates = await this.fetchResourceTemplatesList(serverName) - } - } catch (error) { - console.error(`Failed to refresh capabilities for ${serverName}:`, error) + // Only refresh capabilities if connected + if (connection.server.status === "connected") { + connection.server.tools = await this.fetchToolsList(serverName, serverSource) + connection.server.resources = await this.fetchResourcesList(serverName, serverSource) + connection.server.resourceTemplates = await this.fetchResourceTemplatesList( + serverName, + serverSource, + ) } + } catch (error) { + console.error(`Failed to refresh capabilities for ${serverName}:`, error) } - - await this.notifyWebviewOfServerChanges() } } catch (error) { - console.error("Failed to update server disabled state:", error) - if (error instanceof Error) { - console.error("Error details:", error.message, error.stack) - } - vscode.window.showErrorMessage( - `Failed to update server state: ${error instanceof Error ? error.message : String(error)}`, - ) + this.showErrorMessage(`Failed to update server ${serverName} state`, error) throw error } } - public async updateServerTimeout(serverName: string, timeout: number): Promise { - let settingsPath: string + /** + * Helper method to update a server's configuration in the appropriate settings file + * @param serverName The name of the server to update + * @param configUpdate The configuration updates to apply + * @param source Whether to update the global or project config + */ + private async updateServerConfig( + serverName: string, + configUpdate: Record, + source: "global" | "project" = "global", + ): Promise { + // Determine which config file to update + let configPath: string + if (source === "project") { + const projectMcpPath = normalizePath(".infio_json_db/mcp/mcp.json") + if (!await this.app.vault.adapter.exists(projectMcpPath)) { + throw new Error("Project MCP configuration file not found") + } + configPath = projectMcpPath + } else { + configPath = await this.getMcpSettingsFilePath() + } + + // Read and parse the config file + const content = await this.app.vault.adapter.read(configPath) + const config = JSON.parse(content) + + // Validate the config structure + if (!config || typeof config !== "object") { + throw new Error("Invalid config structure") + } + + if (!config.mcpServers || typeof config.mcpServers !== "object") { + config.mcpServers = {} + } + + if (!config.mcpServers[serverName]) { + config.mcpServers[serverName] = {} + } + + // Create a new server config object to ensure clean structure + const serverConfig = { + ...config.mcpServers[serverName], + ...configUpdate, + } + + // Ensure required fields exist + if (!serverConfig.alwaysAllow) { + serverConfig.alwaysAllow = [] + } + + config.mcpServers[serverName] = serverConfig + + // Write the entire config back + const updatedConfig = { + mcpServers: config.mcpServers, + } + + await this.app.vault.adapter.write(configPath, JSON.stringify(updatedConfig, null, 2)) + } + + public async updateServerTimeout( + serverName: string, + timeout: number, + source: "global" | "project" = "global", + ): Promise { try { - settingsPath = await this.getMcpSettingsFilePath() - - // Ensure the settings file exists and is accessible - try { - await fs.access(settingsPath) - } catch (error) { - console.error("Settings file not accessible:", error) - throw new Error("Settings file not accessible") - } - const content = await fs.readFile(settingsPath, "utf-8") - const config = JSON.parse(content) - - // Validate the config structure - if (!config || typeof config !== "object") { - throw new Error("Invalid config structure") + // Find the connection to determine if it's a global or project server + const connection = this.findConnection(serverName, source) + if (!connection) { + throw new Error(`Server ${serverName}${source ? ` with source ${source}` : ""} not found`) } - if (!config.mcpServers || typeof config.mcpServers !== "object") { - config.mcpServers = {} - } + // Update the server config in the appropriate file + await this.updateServerConfig(serverName, { timeout }, connection.server.source || "global") - if (config.mcpServers[serverName]) { - // Create a new server config object to ensure clean structure - const serverConfig = { - ...config.mcpServers[serverName], - timeout, - } - - config.mcpServers[serverName] = serverConfig - - // Write the entire config back - const updatedConfig = { - mcpServers: config.mcpServers, - } - - await fs.writeFile(settingsPath, JSON.stringify(updatedConfig, null, 2)) - await this.notifyWebviewOfServerChanges() - } + // await this.notifyWebviewOfServerChanges() } catch (error) { - console.error("Failed to update server timeout:", error) - if (error instanceof Error) { - console.error("Error details:", error.message, error.stack) - } - vscode.window.showErrorMessage( - `Failed to update server timeout: ${error instanceof Error ? error.message : String(error)}`, - ) + this.showErrorMessage(`Failed to update server ${serverName} timeout settings`, error) throw error } } - public async deleteServer(serverName: string): Promise { + public async deleteServer(serverName: string, source?: "global" | "project"): Promise { try { - const settingsPath = await this.getMcpSettingsFilePath() - - // Ensure the settings file exists and is accessible - try { - await fs.access(settingsPath) - } catch (error) { - throw new Error("Settings file not accessible") + // Find the connection to determine if it's a global or project server + const connection = this.findConnection(serverName, source) + if (!connection) { + throw new Error(`Server ${serverName}${source ? ` with source ${source}` : ""} not found`) } - const content = await fs.readFile(settingsPath, "utf-8") + const serverSource = connection.server.source || "global" + // Determine config file based on server source + const isProjectServer = serverSource === "project" + let configPath: string + + if (isProjectServer) { + // Get project MCP config path + const projectMcpPath = normalizePath(".infio_json_db/mcp/mcp.json") + if (!await this.app.vault.adapter.exists(projectMcpPath)) { + throw new Error("Project MCP configuration file not found") + } + configPath = projectMcpPath + } else { + // Get global MCP settings path + configPath = await this.getMcpSettingsFilePath() + } + + const content = await this.app.vault.adapter.read(configPath) const config = JSON.parse(content) // Validate the config structure @@ -611,31 +1093,25 @@ export class McpHub { mcpServers: config.mcpServers, } - await fs.writeFile(settingsPath, JSON.stringify(updatedConfig, null, 2)) + await this.app.vault.adapter.write(configPath, JSON.stringify(updatedConfig, null, 2)) - // Update server connections - await this.updateServerConnections(config.mcpServers) + // Update server connections with the correct source + await this.updateServerConnections(config.mcpServers, serverSource) - vscode.window.showInformationMessage(`Deleted MCP server: ${serverName}`) + // vscode.window.showInformationMessage(t("common:info.mcp_server_deleted", { serverName })) } else { - vscode.window.showWarningMessage(`Server "${serverName}" not found in configuration`) + // vscode.window.showWarningMessage(t("common:info.mcp_server_not_found", { serverName })) } } catch (error) { - console.error("Failed to delete MCP server:", error) - if (error instanceof Error) { - console.error("Error details:", error.message, error.stack) - } - vscode.window.showErrorMessage( - `Failed to delete MCP server: ${error instanceof Error ? error.message : String(error)}`, - ) + this.showErrorMessage(`Failed to delete MCP server ${serverName}`, error) throw error } } - async readResource(serverName: string, uri: string): Promise { - const connection = this.connections.find((conn) => conn.server.name === serverName) + async readResource(serverName: string, uri: string, source: "global" | "project" = "global"): Promise { + const connection = this.findConnection(serverName, source) if (!connection) { - throw new Error(`No connection found for server: ${serverName}`) + throw new Error(`No connection found for server: ${serverName}${source ? ` with source ${source}` : ""}`) } if (connection.server.disabled) { throw new Error(`Server "${serverName}" is disabled`) @@ -655,11 +1131,12 @@ export class McpHub { serverName: string, toolName: string, toolArguments?: Record, + source: "global" | "project" = "global", ): Promise { - const connection = this.connections.find((conn) => conn.server.name === serverName) + const connection = this.findConnection(serverName, source) if (!connection) { throw new Error( - `No connection found for server: ${serverName}. Please make sure to use MCP servers available under 'Connected MCP Servers'.`, + `No connection found for server: ${serverName}${source ? ` with source ${source}` : ""}. Please make sure to use MCP servers available under 'Connected MCP Servers'.`, ) } if (connection.server.disabled) { @@ -668,7 +1145,7 @@ export class McpHub { let timeout: number try { - const parsedConfig = StdioConfigSchema.parse(JSON.parse(connection.server.config)) + const parsedConfig = ServerConfigSchema.parse(JSON.parse(connection.server.config)) timeout = (parsedConfig.timeout ?? 60) * 1000 } catch (error) { console.error("Failed to parse server config for timeout:", error) @@ -691,12 +1168,56 @@ export class McpHub { ) } - async toggleToolAlwaysAllow(serverName: string, toolName: string, shouldAllow: boolean): Promise { + async toggleToolAlwaysAllow( + serverName: string, + source: "global" | "project" = "global", + toolName: string, + shouldAllow: boolean, + ): Promise { try { - const settingsPath = await this.getMcpSettingsFilePath() - const content = await fs.readFile(settingsPath, "utf-8") + // Find the connection with matching name and source + const connection = this.findConnection(serverName, source) + + if (!connection) { + throw new Error(`Server ${serverName} with source ${source} not found`) + } + + // Determine the correct config path based on the source + let configPath: string + if (source === "project") { + // Get project MCP config path + const projectMcpPath = normalizePath(".infio_json_db/mcp/mcp.json") + if (!await this.app.vault.adapter.exists(projectMcpPath)) { + throw new Error("Project MCP configuration file not found") + } + configPath = projectMcpPath + } else { + // Get global MCP settings path + configPath = await this.getMcpSettingsFilePath() + } + + // Normalize path for cross-platform compatibility + // Use a consistent path format for both reading and writing + // const normalizedPath = configPath + + // Read the appropriate config file + const content = await this.app.vault.adapter.read(configPath) const config = JSON.parse(content) + // Initialize mcpServers if it doesn't exist + if (!config.mcpServers) { + config.mcpServers = {} + } + + // Initialize server config if it doesn't exist + if (!config.mcpServers[serverName]) { + config.mcpServers[serverName] = { + type: "stdio", + command: "node", + args: [], // Default to an empty array; can be set later if needed + } + } + // Initialize alwaysAllow if it doesn't exist if (!config.mcpServers[serverName].alwaysAllow) { config.mcpServers[serverName].alwaysAllow = [] @@ -714,34 +1235,38 @@ export class McpHub { } // Write updated config back to file - await fs.writeFile(settingsPath, JSON.stringify(config, null, 2)) + await this.app.vault.adapter.write(configPath, JSON.stringify(config, null, 2)) // Update the tools list to reflect the change - const connection = this.connections.find((conn) => conn.server.name === serverName) if (connection) { - connection.server.tools = await this.fetchToolsList(serverName) - await this.notifyWebviewOfServerChanges() + // Explicitly pass the source to ensure we're updating the correct server's tools + connection.server.tools = await this.fetchToolsList(serverName, source) + // await this.notifyWebviewOfServerChanges() } } catch (error) { - console.error("Failed to update always allow settings:", error) - vscode.window.showErrorMessage("Failed to update always allow settings") + this.showErrorMessage(`Failed to update always allow settings for tool ${toolName}`, error) throw error // Re-throw to ensure the error is properly handled } } async dispose(): Promise { + // Prevent multiple disposals + if (this.isDisposed) { + console.log("McpHub: Already disposed.") + return + } + console.log("McpHub: Disposing...") + this.isDisposed = true this.removeAllFileWatchers() for (const connection of this.connections) { try { - await this.deleteConnection(connection.server.name) + await this.deleteConnection(connection.server.name, connection.server.source) } catch (error) { console.error(`Failed to close connection for ${connection.server.name}:`, error) } } this.connections = [] - if (this.settingsWatcher) { - this.settingsWatcher.dispose() - } - this.disposables.forEach((d) => d.dispose()) + this.eventRefs.forEach((ref) => this.app.vault.offref(ref)) + this.eventRefs = [] } } diff --git a/src/core/mcp/McpServerManager.ts b/src/core/mcp/McpServerManager.ts index d01cfff..bd9b501 100644 --- a/src/core/mcp/McpServerManager.ts +++ b/src/core/mcp/McpServerManager.ts @@ -1,6 +1,6 @@ // @ts-nocheck -import { ClineProvider } from "../../core/webview/ClineProvider" +import { App } from "obsidian" import { McpHub } from "./McpHub" @@ -10,8 +10,8 @@ import { McpHub } from "./McpHub" */ export class McpServerManager { private static instance: McpHub | null = null - private static readonly GLOBAL_STATE_KEY = "mcpHubInstanceId" - private static providers: Set = new Set() + // private static readonly GLOBAL_STATE_KEY = "mcpHubInstanceId" + // private static providers: Set = new Set() private static initializationPromise: Promise | null = null /** @@ -19,9 +19,10 @@ export class McpServerManager { * Creates a new instance if one doesn't exist. * Thread-safe implementation using a promise-based lock. */ - static async getInstance(context: vscode.ExtensionContext, provider: ClineProvider): Promise { + static async getInstance(app: App): Promise { + // Register the provider - this.providers.add(provider) + // this.providers.add(provider) // If we already have an instance, return it if (this.instance) { @@ -38,9 +39,9 @@ export class McpServerManager { try { // Double-check instance in case it was created while we were waiting if (!this.instance) { - this.instance = new McpHub(provider) + this.instance = new McpHub(app) // Store a unique identifier in global state to track the primary instance - await context.globalState.update(this.GLOBAL_STATE_KEY, Date.now().toString()) + // await app.globalState.update(this.GLOBAL_STATE_KEY, Date.now().toString()) } return this.instance } finally { @@ -52,34 +53,34 @@ export class McpServerManager { return this.initializationPromise } - /** - * Remove a provider from the tracked set. - * This is called when a webview is disposed. - */ - static unregisterProvider(provider: ClineProvider): void { - this.providers.delete(provider) - } + // /** + // * Remove a provider from the tracked set. + // * This is called when a webview is disposed. + // */ + // static unregisterProvider(provider: ClineProvider): void { + // this.providers.delete(provider) + // } - /** - * Notify all registered providers of server state changes. - */ - static notifyProviders(message: any): void { - this.providers.forEach((provider) => { - provider.postMessageToWebview(message).catch((error) => { - console.error("Failed to notify provider:", error) - }) - }) - } + // /** + // * Notify all registered providers of server state changes. + // */ + // static notifyProviders(message: any): void { + // this.providers.forEach((provider) => { + // provider.postMessageToWebview(message).catch((error) => { + // console.error("Failed to notify provider:", error) + // }) + // }) + // } /** * Clean up the singleton instance and all its resources. */ - static async cleanup(context: vscode.ExtensionContext): Promise { + static async cleanup(): Promise { if (this.instance) { await this.instance.dispose() this.instance = null - await context.globalState.update(this.GLOBAL_STATE_KEY, undefined) + // await app.globalState.update(this.GLOBAL_STATE_KEY, undefined) } - this.providers.clear() + // this.providers.clear() } } diff --git a/src/core/mcp/type.ts b/src/core/mcp/type.ts new file mode 100644 index 0000000..5d451bf --- /dev/null +++ b/src/core/mcp/type.ts @@ -0,0 +1,81 @@ +export type McpErrorEntry = { + message: string + timestamp: number + level: "error" | "warn" | "info" +} + +export type McpServer = { + name: string + config: string + status: "connected" | "connecting" | "disconnected" + error?: string + errorHistory?: McpErrorEntry[] + tools?: McpTool[] + resources?: McpResource[] + resourceTemplates?: McpResourceTemplate[] + disabled?: boolean + timeout?: number + source: "global" | "project" + projectPath?: string +} + +export type McpTool = { + name: string + description?: string + inputSchema?: object + alwaysAllow?: boolean +} + +export type McpResource = { + uri: string + name: string + mimeType?: string + description?: string +} + +export type McpResourceTemplate = { + uriTemplate: string + name: string + description?: string + mimeType?: string +} + +export type McpResourceResponse = { + _meta?: Record + contents: Array<{ + uri: string + mimeType?: string + text?: string + blob?: string + }> +} + +export type McpToolCallResponse = { + _meta?: Record + content: Array< + | { + type: "text" + text: string + } + | { + type: "image" + data: string + mimeType: string + } + | { + type: "audio" + data: string + mimeType: string + } + | { + type: "resource" + resource: { + uri: string + mimeType?: string + text?: string + blob?: string + } + } + > + isError?: boolean +} diff --git a/src/core/prompts/sections/mcp-servers.ts b/src/core/prompts/sections/mcp-servers.ts index 856bc71..d102c76 100644 --- a/src/core/prompts/sections/mcp-servers.ts +++ b/src/core/prompts/sections/mcp-servers.ts @@ -13,43 +13,46 @@ export async function getMcpServersSection( const connectedServers = mcpHub.getServers().length > 0 ? `${mcpHub - .getServers() - .filter((server) => server.status === "connected") - .map((server) => { - const tools = server.tools - ?.map((tool) => { - const schemaStr = tool.inputSchema - ? ` Input Schema: - ${JSON.stringify(tool.inputSchema, null, 2).split("\n").join("\n ")}` - : "" + .getServers() + .filter((server) => server.status === "connected") + .map((server) => { + const tools = server.tools + ?.map((tool) => { + const schemaStr = tool.inputSchema + ? ` Input Schema: + ${JSON.stringify(tool.inputSchema, null, 2).split("\n").join("\n ")}` + : "" - return `- ${tool.name}: ${tool.description}\n${schemaStr}` - }) - .join("\n\n") + return `- ${tool.name}: ${tool.description}\n${schemaStr}` + }) + .join("\n\n") - const templates = server.resourceTemplates - ?.map((template) => `- ${template.uriTemplate} (${template.name}): ${template.description}`) - .join("\n") + const templates = server.resourceTemplates + ?.map((template) => `- ${template.uriTemplate} (${template.name}): ${template.description}`) + .join("\n") - const resources = server.resources - ?.map((resource) => `- ${resource.uri} (${resource.name}): ${resource.description}`) - .join("\n") + const resources = server.resources + ?.map((resource) => `- ${resource.uri} (${resource.name}): ${resource.description}`) + .join("\n") - const config = JSON.parse(server.config) + const config = JSON.parse(server.config) - return ( - `## ${server.name} (\`${config.command}${config.args && Array.isArray(config.args) ? ` ${config.args.join(" ")}` : ""}\`)` + - (tools ? `\n\n### Available Tools\n${tools}` : "") + - (templates ? `\n\n### Resource Templates\n${templates}` : "") + - (resources ? `\n\n### Direct Resources\n${resources}` : "") - ) - }) - .join("\n\n")}` + return ( + `## ${server.name} (\`${config.command}${config.args && Array.isArray(config.args) ? ` ${config.args.join(" ")}` : ""}\`)` + + (tools ? `\n\n### Available Tools\n${tools}` : "") + + (templates ? `\n\n### Resource Templates\n${templates}` : "") + + (resources ? `\n\n### Direct Resources\n${resources}` : "") + ) + }) + .join("\n\n")}` : "(No MCP servers currently connected)" const baseSection = `MCP SERVERS -The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities. +The Model Context Protocol (MCP) enables communication between the system and MCP servers that provide additional tools and resources to extend your capabilities. MCP servers can be one of two types: + +1. Local (Stdio-based) servers: These run locally on the user's machine and communicate via standard input/output +2. Remote (SSE-based) servers: These run on remote machines and communicate via Server-Sent Events (SSE) over HTTP/HTTPS # Connected MCP Servers @@ -64,364 +67,11 @@ ${connectedServers}` return ( baseSection + ` - ## Creating an MCP Server -The user may ask you something along the lines of "add a tool" that does some function, in other words to create an MCP server that provides tools and resources that may connect to external APIs for example. You have the ability to create an MCP server and add it to a configuration file that will then expose the tools and resources for you to use with \`use_mcp_tool\` and \`access_mcp_resource\`. - -When creating MCP servers, it's important to understand that they operate in a non-interactive environment. The server cannot initiate OAuth flows, open browser windows, or prompt for user input during runtime. All credentials and authentication tokens must be provided upfront through environment variables in the MCP settings configuration. For example, Spotify's API uses OAuth to get a refresh token for the user, but the MCP server cannot initiate this flow. While you can walk the user through obtaining an application client ID and secret, you may have to create a separate one-time setup script (like get-refresh-token.js) that captures and logs the final piece of the puzzle: the user's refresh token (i.e. you might run the script using execute_command which would open a browser for authentication, and then log the refresh token so that you can see it in the command output for you to use in the MCP settings configuration). - -Unless the user specifies otherwise, new MCP servers should be created in: ${await mcpHub.getMcpServersPath()} - -### Example MCP Server - -For example, if the user wanted to give you the ability to retrieve weather information, you could create an MCP server that uses the OpenWeather API to get weather information, add it to the MCP settings configuration file, and then notice that you now have access to new tools and resources in the system prompt that you might use to show the user your new capabilities. - -The following example demonstrates how to build an MCP server that provides weather data functionality. While this example shows how to implement resources, resource templates, and tools, in practice you should prefer using tools since they are more flexible and can handle dynamic parameters. The resource and resource template implementations are included here mainly for demonstration purposes of the different MCP capabilities, but a real weather server would likely just expose tools for fetching weather data. (The following steps are for macOS) - -1. Use the \`create-typescript-server\` tool to bootstrap a new project in the default MCP servers directory: - -\`\`\`bash -cd ${await mcpHub.getMcpServersPath()} -npx @modelcontextprotocol/create-server weather-server -cd weather-server -# Install dependencies -npm install axios -\`\`\` - -This will create a new project with the following structure: - -\`\`\` -weather-server/ - ├── package.json - { - ... - "type": "module", // added by default, uses ES module syntax (import/export) rather than CommonJS (require/module.exports) (Important to know if you create additional scripts in this server repository like a get-refresh-token.js script) - "scripts": { - "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", - ... - } - ... - } - ├── tsconfig.json - └── src/ - └── weather-server/ - └── index.ts # Main server implementation -\`\`\` - -2. Replace \`src/index.ts\` with the following: - -\`\`\`typescript -#!/usr/bin/env node -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { - CallToolRequestSchema, - ErrorCode, - ListResourcesRequestSchema, - ListResourceTemplatesRequestSchema, - ListToolsRequestSchema, - McpError, - ReadResourceRequestSchema, -} from '@modelcontextprotocol/sdk/types.js'; -import axios from 'axios'; - -const API_KEY = process.env.OPENWEATHER_API_KEY; // provided by MCP config -if (!API_KEY) { - throw new Error('OPENWEATHER_API_KEY environment variable is required'); -} - -interface OpenWeatherResponse { - main: { - temp: number; - humidity: number; - }; - weather: [{ description: string }]; - wind: { speed: number }; - dt_txt?: string; -} - -const isValidForecastArgs = ( - args: any -): args is { city: string; days?: number } => - typeof args === 'object' && - args !== null && - typeof args.city === 'string' && - (args.days === undefined || typeof args.days === 'number'); - -class WeatherServer { - private server: Server; - private axiosInstance; - - constructor() { - this.server = new Server( - { - name: 'example-weather-server', - version: '0.1.0', - }, - { - capabilities: { - resources: {}, - tools: {}, - }, - } - ); - - this.axiosInstance = axios.create({ - baseURL: 'http://api.openweathermap.org/data/2.5', - params: { - appid: API_KEY, - units: 'metric', - }, - }); - - this.setupResourceHandlers(); - this.setupToolHandlers(); - - // Error handling - this.server.onerror = (error) => console.error('[MCP Error]', error); - process.on('SIGINT', async () => { - await this.server.close(); - process.exit(0); - }); - } - - // MCP Resources represent any kind of UTF-8 encoded data that an MCP server wants to make available to clients, such as database records, API responses, log files, and more. Servers define direct resources with a static URI or dynamic resources with a URI template that follows the format \`[protocol]://[host]/[path]\`. - private setupResourceHandlers() { - // For static resources, servers can expose a list of resources: - this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ - resources: [ - // This is a poor example since you could use the resource template to get the same information but this demonstrates how to define a static resource - { - uri: \`weather://San Francisco/current\`, // Unique identifier for San Francisco weather resource - name: \`Current weather in San Francisco\`, // Human-readable name - mimeType: 'application/json', // Optional MIME type - // Optional description - description: - 'Real-time weather data for San Francisco including temperature, conditions, humidity, and wind speed', - }, - ], - })); - - // For dynamic resources, servers can expose resource templates: - this.server.setRequestHandler( - ListResourceTemplatesRequestSchema, - async () => ({ - resourceTemplates: [ - { - uriTemplate: 'weather://{city}/current', // URI template (RFC 6570) - name: 'Current weather for a given city', // Human-readable name - mimeType: 'application/json', // Optional MIME type - description: 'Real-time weather data for a specified city', // Optional description - }, - ], - }) - ); - - // ReadResourceRequestSchema is used for both static resources and dynamic resource templates - this.server.setRequestHandler( - ReadResourceRequestSchema, - async (request) => { - const match = request.params.uri.match( - /^weather:\/\/([^/]+)\/current$/ - ); - if (!match) { - throw new McpError( - ErrorCode.InvalidRequest, - \`Invalid URI format: \${request.params.uri}\` - ); - } - const city = decodeURIComponent(match[1]); - - try { - const response = await this.axiosInstance.get( - 'weather', // current weather - { - params: { q: city }, - } - ); - - return { - contents: [ - { - uri: request.params.uri, - mimeType: 'application/json', - text: JSON.stringify( - { - temperature: response.data.main.temp, - conditions: response.data.weather[0].description, - humidity: response.data.main.humidity, - wind_speed: response.data.wind.speed, - timestamp: new Date().toISOString(), - }, - null, - 2 - ), - }, - ], - }; - } catch (error) { - if (axios.isAxiosError(error)) { - throw new McpError( - ErrorCode.InternalError, - \`Weather API error: \${ - error.response?.data.message ?? error.message - }\` - ); - } - throw error; - } - } - ); - } - - /* MCP Tools enable servers to expose executable functionality to the system. Through these tools, you can interact with external systems, perform computations, and take actions in the real world. - * - Like resources, tools are identified by unique names and can include descriptions to guide their usage. However, unlike resources, tools represent dynamic operations that can modify state or interact with external systems. - * - While resources and tools are similar, you should prefer to create tools over resources when possible as they provide more flexibility. - */ - private setupToolHandlers() { - this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'get_forecast', // Unique identifier - description: 'Get weather forecast for a city', // Human-readable description - inputSchema: { - // JSON Schema for parameters - type: 'object', - properties: { - city: { - type: 'string', - description: 'City name', - }, - days: { - type: 'number', - description: 'Number of days (1-5)', - minimum: 1, - maximum: 5, - }, - }, - required: ['city'], // Array of required property names - }, - }, - ], - })); - - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { - if (request.params.name !== 'get_forecast') { - throw new McpError( - ErrorCode.MethodNotFound, - \`Unknown tool: \${request.params.name}\` - ); - } - - if (!isValidForecastArgs(request.params.arguments)) { - throw new McpError( - ErrorCode.InvalidParams, - 'Invalid forecast arguments' - ); - } - - const city = request.params.arguments.city; - const days = Math.min(request.params.arguments.days || 3, 5); - - try { - const response = await this.axiosInstance.get<{ - list: OpenWeatherResponse[]; - }>('forecast', { - params: { - q: city, - cnt: days * 8, - }, - }); - - return { - content: [ - { - type: 'text', - text: JSON.stringify(response.data.list, null, 2), - }, - ], - }; - } catch (error) { - if (axios.isAxiosError(error)) { - return { - content: [ - { - type: 'text', - text: \`Weather API error: \${ - error.response?.data.message ?? error.message - }\`, - }, - ], - isError: true, - }; - } - throw error; - } - }); - } - - async run() { - const transport = new StdioServerTransport(); - await this.server.connect(transport); - console.error('Weather MCP server running on stdio'); - } -} - -const server = new WeatherServer(); -server.run().catch(console.error); -\`\`\` - -(Remember: This is just an example–you may use different dependencies, break the implementation up into multiple files, etc.) - -3. Build and compile the executable JavaScript file - -\`\`\`bash -npm run build -\`\`\` - -4. Whenever you need an environment variable such as an API key to configure the MCP server, walk the user through the process of getting the key. For example, they may need to create an account and go to a developer dashboard to generate the key. Provide step-by-step instructions and URLs to make it easy for the user to retrieve the necessary information. Then use the ask_followup_question tool to ask the user for the key, in this case the OpenWeather API key. - -5. Install the MCP Server by adding the MCP server configuration to the settings file located at '${await mcpHub.getMcpSettingsFilePath()}'. The settings file may have other MCP servers already configured, so you would read it first and then add your new server to the existing \`mcpServers\` object. - -IMPORTANT: Regardless of what else you see in the MCP settings file, you must default any new MCP servers you create to disabled=false and alwaysAllow=[]. - -\`\`\`json -{ - "mcpServers": { - ..., - "weather": { - "command": "node", - "args": ["/path/to/weather-server/build/index.js"], - "env": { - "OPENWEATHER_API_KEY": "user-provided-api-key" - } - }, - } -} -\`\`\` - -(Note: the user may also ask you to install the MCP server to the Claude desktop app, in which case you would read then modify \`~/Library/Application\ Support/Claude/claude_desktop_config.json\` on macOS for example. It follows the same format of a top level \`mcpServers\` object.) - -6. After you have edited the MCP settings configuration file, the system will automatically run all the servers and expose the available tools and resources in the 'Connected MCP Servers' section. - -7. Now that you have access to these new tools and resources, you may suggest ways the user can command you to invoke them - for example, with this new weather tool now available, you can invite the user to ask "what's the weather in San Francisco?" - -## Editing MCP Servers - -The user may ask to add tools or resources that may make sense to add to an existing MCP server (listed under 'Connected MCP Servers' above: ${ - mcpHub - .getServers() - .map((server) => server.name) - .join(", ") || "(None running currently)" - }, e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use write_to_file${diffStrategy ? " or apply_diff" : ""} to make changes to the files. - -However some MCP servers may be running from installed packages rather than a local repository, in which case it may make more sense to create a new MCP server. - -# MCP Servers Are Not Always Necessary - -The user may not always request the use or creation of MCP servers. Instead, they might provide tasks that can be completed with existing tools. While using the MCP SDK to extend your capabilities can be useful, it's important to understand that this is just one specialized type of task you can accomplish. You should only implement MCP servers when the user explicitly requests it (e.g., "add a tool that..."). - -Remember: The MCP documentation and example provided above are to help you understand and work with existing MCP servers or create new ones when requested by the user. You already have access to tools and capabilities that can be used to accomplish a wide range of tasks.` +The user may ask you something along the lines of "add a tool" that does some function, in other words to create an MCP server that provides tools and resources that may connect to external APIs for example. If they do, you should obtain detailed instructions on this topic using the fetch_instructions tool, like this: + +create_mcp_server +` ) } diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 06433c8..e4f4891 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -11,7 +11,7 @@ import { defaultModeSlug, getGroupName, getModeBySlug, - modes + defaultModes } from "../../utils/modes" import { DiffStrategy } from "../diff/DiffStrategy" import { McpHub } from "../mcp/McpHub" @@ -88,7 +88,7 @@ export class SystemPrompt { // const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined // Get the full mode config to ensure we have the role definition - const modeConfig = getModeBySlug(mode, customModeConfigs) || modes.find((m) => m.slug === mode) || modes[0] + const modeConfig = getModeBySlug(mode, customModeConfigs) || defaultModes.find((m) => m.slug === mode) || defaultModes[0] const roleDefinition = promptComponent?.roleDefinition || modeConfig.roleDefinition const [modesSection, mcpServersSection] = await Promise.all([ @@ -175,7 +175,7 @@ ${await addCustomInstructions(this.app, promptComponent?.customInstructions || m const promptComponent = getPromptComponent(customModePrompts?.[mode]) // Get full mode config from custom modes or fall back to built-in modes - const currentMode = getModeBySlug(mode, customModes) || modes.find((m) => m.slug === mode) || modes[0] + const currentMode = getModeBySlug(mode, customModes) || defaultModes.find((m) => m.slug === mode) || defaultModes[0] // If a file-based custom system prompt exists, use it if (fileCustomSystemPrompt) { diff --git a/src/core/prompts/tools/index.ts b/src/core/prompts/tools/index.ts index 7ab865d..5f9b5f9 100644 --- a/src/core/prompts/tools/index.ts +++ b/src/core/prompts/tools/index.ts @@ -28,6 +28,8 @@ const toolDescriptionMap: Record string | undefined> attempt_completion: () => getAttemptCompletionDescription(), switch_mode: () => getSwitchModeDescription(), insert_content: (args) => getInsertContentDescription(args), + use_mcp_tool: (args) => getUseMcpToolDescription(args), + access_mcp_resource: (args) => getAccessMcpResourceDescription(args), search_and_replace: (args) => getSearchAndReplaceDescription(args), apply_diff: (args) => args.diffStrategy ? args.diffStrategy.getToolDescription({ cwd: args.cwd, toolOptions: args.toolOptions }) : "", diff --git a/src/core/prompts/tools/tool-groups.ts b/src/core/prompts/tools/tool-groups.ts index a478dd2..8d8af7e 100644 --- a/src/core/prompts/tools/tool-groups.ts +++ b/src/core/prompts/tools/tool-groups.ts @@ -39,9 +39,9 @@ export const TOOL_GROUPS: Record = { // command: { // tools: ["execute_command"], // }, - // mcp: { - // tools: ["use_mcp_tool", "access_mcp_resource"], - // }, + mcp: { + tools: ["use_mcp_tool", "access_mcp_resource"], + }, modes: { tools: ["switch_mode"], alwaysAvailable: true, diff --git a/src/main.ts b/src/main.ts index 5862cb0..3f7cebf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,10 +9,12 @@ import { ChatProps } from './components/chat-view/ChatView' import { APPLY_VIEW_TYPE, CHAT_VIEW_TYPE, PREVIEW_VIEW_TYPE } from './constants' import { getDiffStrategy } from "./core/diff/DiffStrategy" import { InlineEdit } from './core/edit/inline-edit-processor' +import { McpHub } from './core/mcp/McpHub' import { RAGEngine } from './core/rag/rag-engine' import { DBManager } from './database/database-manager' import { migrateToJsonDatabase } from './database/json/migrateToJsonDatabase' import EventListener from "./event-listener" +import { t } from './lang/helpers' import { PreviewView } from './PreviewView' import CompletionKeyWatcher from "./render-plugin/completion-key-watcher" import DocumentChangesListener, { @@ -25,24 +27,26 @@ import RenderSuggestionPlugin from "./render-plugin/render-surgestion-plugin" import { InlineSuggestionState } from "./render-plugin/states" import { InfioSettingTab } from './settings/SettingTab' import StatusBar from "./status-bar" -import { t } from './lang/helpers' import { InfioSettings, parseInfioSettings, } from './types/settings' import { getMentionableBlockData } from './utils/obsidian' import './utils/path' +import { onEnt } from './utils/web-search' export default class InfioPlugin extends Plugin { private metadataCacheUnloadFn: (() => void) | null = null private activeLeafChangeUnloadFn: (() => void) | null = null private dbManagerInitPromise: Promise | null = null private ragEngineInitPromise: Promise | null = null + private mcpHubInitPromise: Promise | null = null settings: InfioSettings settingTab: InfioSettingTab settingsListeners: ((newSettings: InfioSettings) => void)[] = [] initChatProps?: ChatProps dbManager: DBManager | null = null + mcpHub: McpHub | null = null ragEngine: RAGEngine | null = null inlineEdit: InlineEdit | null = null diffStrategy?: DiffStrategy @@ -51,6 +55,12 @@ export default class InfioPlugin extends Plugin { // load settings await this.loadSettings() + // migrate to json database + setTimeout(() => { + void this.migrateToJsonStorage().then(() => { }) + void onEnt('loaded') + }, 100) + // add settings tab this.settingTab = new InfioSettingTab(this.app, this) this.addSettingTab(this.settingTab) @@ -102,6 +112,14 @@ export default class InfioPlugin extends Plugin { this.settings.experimentalDiffStrategy, this.settings.multiSearchReplaceDiffStrategy, ) + // Update MCP Hub when settings change + if (this.settings.mcpEnabled && !this.mcpHub) { + void this.getMcpHub() + } else if (!this.settings.mcpEnabled && this.mcpHub) { + this.mcpHub.dispose() + this.mcpHub = null + this.mcpHubInitPromise = null + } }); // setup autocomplete render plugin @@ -355,22 +373,22 @@ export default class InfioPlugin extends Plugin { editor.replaceRange(customBlock, insertPos); }, }); - - // migrate to json database - void this.migrateToJsonStorage() } onunload() { - // RagEngine cleanup - this.ragEngine?.cleanup() - this.ragEngine = null - // Promise cleanup this.dbManagerInitPromise = null this.ragEngineInitPromise = null - + this.mcpHubInitPromise = null + // RagEngine cleanup + this.ragEngine?.cleanup() + this.ragEngine = null + // Database cleanup this.dbManager?.cleanup() this.dbManager = null + // MCP Hub cleanup + this.mcpHub?.dispose() + this.mcpHub = null } async loadSettings() { @@ -468,6 +486,30 @@ export default class InfioPlugin extends Plugin { return this.dbManagerInitPromise } + async getMcpHub(): Promise { + // MCP is not enabled + if (!this.settings.mcpEnabled) { + // new Notice('MCP is not enabled') + return null + } + + // if we already have an instance, return it + if (this.mcpHub) { + return this.mcpHub + } + + if (!this.mcpHubInitPromise) { + this.mcpHubInitPromise = (async () => { + this.mcpHub = new McpHub(this.app, this) + await this.mcpHub.onload() + return this.mcpHub + })() + } + + // if initialization is running, wait for it to complete instead of creating a new initialization promise + return this.mcpHubInitPromise + } + async getRAGEngine(): Promise { if (this.ragEngine) { return this.ragEngine diff --git a/src/types/apply.ts b/src/types/apply.ts index 62f45fe..60fca7b 100644 --- a/src/types/apply.ts +++ b/src/types/apply.ts @@ -90,4 +90,11 @@ export type SwitchModeToolArgs = { finish?: boolean; } -export type ToolArgs = ReadFileToolArgs | WriteToFileToolArgs | InsertContentToolArgs | SearchAndReplaceToolArgs | ListFilesToolArgs | RegexSearchFilesToolArgs | SemanticSearchFilesToolArgs | SearchWebToolArgs | FetchUrlsContentToolArgs | SwitchModeToolArgs | ApplyDiffToolArgs; +export type UseMcpToolArgs = { + type: 'use_mcp_tool'; + server_name: string; + tool_name: string; + parameters: Record; +} + +export type ToolArgs = ReadFileToolArgs | WriteToFileToolArgs | InsertContentToolArgs | SearchAndReplaceToolArgs | ListFilesToolArgs | RegexSearchFilesToolArgs | SemanticSearchFilesToolArgs | SearchWebToolArgs | FetchUrlsContentToolArgs | SwitchModeToolArgs | ApplyDiffToolArgs | UseMcpToolArgs; diff --git a/src/types/chat.ts b/src/types/chat.ts index 2d4d54d..792d794 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -26,6 +26,7 @@ export type ChatAssistantMessage = { content: string reasoningContent: string id: string + isToolResult?: boolean metadata?: { usage?: ResponseUsage model?: LLMModel diff --git a/src/types/settings.ts b/src/types/settings.ts index 35da43a..17dce53 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -220,6 +220,9 @@ export const InfioSettingsSchema = z.object({ grokProvider: GrokProviderSchema, openaicompatibleProvider: OpenAICompatibleProviderSchema, + // MCP Servers + mcpEnabled: z.boolean().catch(true), + // Chat Model start list collectedChatModels: z.array(z.object({ provider: z.nativeEnum(ApiProvider), diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..50dc7d6 --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,25 @@ +/** + * Deeply injects environment variables into a configuration object/string/json + * + * Uses VSCode env:name pattern: https://code.visualstudio.com/docs/reference/variables-reference#_environment-variables + * + * Does not mutate original object + */ +export async function injectEnv>(config: C, notFoundValue: any = "") { + // Use simple regex replace for now, will see if object traversal and recursion is needed here (e.g: for non-serializable objects) + + const isObject = typeof config === "object" + let _config: string = isObject ? JSON.stringify(config) : config + + _config = _config.replace(/\$\{env:([\w]+)\}/g, (_, name) => { + // Check if null or undefined + // intentionally using == to match null | undefined + if (process.env[name] == null) { + console.warn(`[injectEnv] env variable ${name} referenced but not found in process.env`) + } + + return process.env[name] ?? notFoundValue + }) + + return (isObject ? JSON.parse(_config) : _config) as C extends string ? string : C +} diff --git a/src/utils/modes.ts b/src/utils/modes.ts index 09920b0..80ca2e7 100644 --- a/src/utils/modes.ts +++ b/src/utils/modes.ts @@ -79,13 +79,13 @@ export function getToolsForMode(groups: readonly GroupEntry[]): string[] { } // Main modes configuration as an ordered array -export const modes: readonly ModeConfig[] = [ +export const defaultModes: readonly ModeConfig[] = [ { slug: "ask", name: "Ask", roleDefinition: "You are Infio, a versatile assistant dedicated to providing informative responses, thoughtful explanations, and practical guidance on virtually any topic or challenge you face.", - groups: ["read"], + groups: ["read", "mcp"], customInstructions: "You can analyze information, explain concepts across various domains, and access external resources when helpful. Make sure to address the user's questions thoroughly with thoughtful explanations and practical guidance. Use visual aids like Mermaid diagrams when they help make complex topics clearer. Offer solutions to challenges from diverse fields, not just technical ones, and provide context that helps users better understand the subject matter.", }, @@ -94,7 +94,7 @@ export const modes: readonly ModeConfig[] = [ name: "Write", roleDefinition: "You are Infio, a versatile content creator skilled in composing, editing, and organizing various text-based documents. You excel at structuring information clearly, creating well-formatted content, and helping users express their ideas effectively.", - groups: ["read", "edit"], + groups: ["read", "edit", "mcp"], customInstructions: "You can create and modify any text-based files, with particular expertise in Markdown formatting. Help users organize their thoughts, create documentation, take notes, or draft any written content they need. When appropriate, suggest structural improvements and formatting enhancements that make content more readable and accessible. Consider the purpose and audience of each document to provide the most relevant assistance." }, @@ -103,14 +103,14 @@ export const modes: readonly ModeConfig[] = [ name: "Research", roleDefinition: "You are Infio, an advanced research assistant specialized in comprehensive investigation and analytical thinking. You excel at breaking down complex questions, exploring multiple perspectives, and synthesizing information to provide well-reasoned conclusions.", - groups: ["research"], + groups: ["research", "mcp"], customInstructions: "You can conduct thorough research by analyzing available information, connecting related concepts, and applying structured reasoning methods. Help users explore topics in depth by considering multiple angles, identifying relevant evidence, and evaluating the reliability of sources. Use step-by-step analysis when tackling complex problems, explaining your thought process clearly. Create visual representations like Mermaid diagrams when they help clarify relationships between ideas. Use Markdown tables to present statistical data or comparative information when appropriate. Present balanced viewpoints while highlighting the strength of evidence behind different conclusions.", }, ] as const // Export the default mode slug -export const defaultModeSlug = modes[0].slug +export const defaultModeSlug = defaultModes[0].slug // Helper functions export function getModeBySlug(slug: string, customModes?: ModeConfig[]): ModeConfig | undefined { @@ -120,7 +120,7 @@ export function getModeBySlug(slug: string, customModes?: ModeConfig[]): ModeCon return customMode } // Then check built-in modes - return modes.find((mode) => mode.slug === slug) + return defaultModes.find((mode) => mode.slug === slug) } export function getModeConfig(slug: string, customModes?: ModeConfig[]): ModeConfig { @@ -134,11 +134,11 @@ export function getModeConfig(slug: string, customModes?: ModeConfig[]): ModeCon // Get all available modes, with custom modes overriding built-in modes export function getAllModes(customModes?: ModeConfig[]): ModeConfig[] { if (!customModes?.length) { - return [...modes] + return [...defaultModes] } // Start with built-in modes - const allModes = [...modes] + const allModes = [...defaultModes] // Process custom modes customModes.forEach((customMode) => { @@ -239,7 +239,7 @@ export function isToolAllowedForMode( // Create the mode-specific default prompts export const defaultPrompts: Readonly = Object.freeze( Object.fromEntries( - modes.map((mode) => [ + defaultModes.map((mode) => [ mode.slug, { roleDefinition: mode.roleDefinition, @@ -275,7 +275,7 @@ export async function getFullModeDetails( }, ): Promise { // First get the base mode config from custom modes or built-in modes - const baseMode = getModeBySlug(modeSlug, customModes) || modes.find((m) => m.slug === modeSlug) || modes[0] + const baseMode = getModeBySlug(modeSlug, customModes) || defaultModes.find((m) => m.slug === modeSlug) || defaultModes[0] // Check for any prompt component overrides const promptComponent = customModePrompts?.[modeSlug] diff --git a/src/utils/parse-infio-block.ts b/src/utils/parse-infio-block.ts index ba88579..3c5f149 100644 --- a/src/utils/parse-infio-block.ts +++ b/src/utils/parse-infio-block.ts @@ -82,6 +82,15 @@ export type ParsedMsgBlock = mode: string reason: string finish: boolean + } | { + type: 'use_mcp_tool' + server_name: string + tool_name: string + parameters: Record, + finish: boolean + } | { + type: 'tool_result' + content: string } export function parseMsgBlocks( @@ -569,6 +578,85 @@ export function parseMsgBlocks( finish: node.sourceCodeLocation.endTag !== undefined }) lastEndOffset = endOffset + } else if (node.nodeName === 'use_mcp_tool') { + if (!node.sourceCodeLocation) { + throw new Error('sourceCodeLocation is undefined') + } + const startOffset = node.sourceCodeLocation.startOffset + const endOffset = node.sourceCodeLocation.endOffset + if (startOffset > lastEndOffset) { + parsedResult.push({ + type: 'string', + content: input.slice(lastEndOffset, startOffset), + }) + } + + let server_name: string = '' + let tool_name: string = '' + let parameters: Record = {} + + for (const childNode of node.childNodes) { + if (childNode.nodeName === 'server_name' && childNode.childNodes.length > 0) { + // @ts-expect-error - 忽略 value 属性的类型错误 + server_name = childNode.childNodes[0].value + } else if (childNode.nodeName === 'tool_name' && childNode.childNodes.length > 0) { + // @ts-expect-error - 忽略 value 属性的类型错误 + tool_name = childNode.childNodes[0].value + } else if ((childNode.nodeName === 'parameters' + || childNode.nodeName === 'input' + || childNode.nodeName === 'arguments') + && childNode.childNodes.length > 0) { + try { + // @ts-expect-error - 忽略 value 属性的类型错误 + const parametersJson = childNode.childNodes[0].value + parameters = JSON5.parse(parametersJson) + } catch (error) { + console.debug('Failed to parse parameters JSON', error) + } + } + } + + parsedResult.push({ + type: 'use_mcp_tool', + server_name, + tool_name, + parameters, + finish: node.sourceCodeLocation.endTag !== undefined + }) + lastEndOffset = endOffset + } else if (node.nodeName === 'tool_result') { + if (!node.sourceCodeLocation) { + throw new Error('sourceCodeLocation is undefined') + } + const startOffset = node.sourceCodeLocation.startOffset + const endOffset = node.sourceCodeLocation.endOffset + if (startOffset > lastEndOffset) { + parsedResult.push({ + type: 'string', + content: input.slice(lastEndOffset, startOffset), + }) + } + + const children = node.childNodes + if (children.length === 0) { + parsedResult.push({ + type: 'tool_result', + content: '', + }) + } else { + const innerContentStartOffset = + children[0].sourceCodeLocation?.startOffset + const innerContentEndOffset = + children[children.length - 1].sourceCodeLocation?.endOffset + if (!innerContentStartOffset || !innerContentEndOffset) { + throw new Error('sourceCodeLocation is undefined') + } + parsedResult.push({ + type: 'tool_result', + content: input.slice(innerContentStartOffset, innerContentEndOffset), + }) + } + lastEndOffset = endOffset } } diff --git a/src/utils/prompt-generator.ts b/src/utils/prompt-generator.ts index 81d864a..5a58f8e 100644 --- a/src/utils/prompt-generator.ts +++ b/src/utils/prompt-generator.ts @@ -3,10 +3,11 @@ import { App, MarkdownView, TAbstractFile, TFile, TFolder, Vault, getLanguage, h import { editorStateToPlainText } from '../components/chat-view/chat-input/utils/editor-state-to-plain-text' import { QueryProgressState } from '../components/chat-view/QueryProgress' import { DiffStrategy } from '../core/diff/DiffStrategy' +import { McpHub } from '../core/mcp/McpHub' import { SystemPrompt } from '../core/prompts/system' import { RAGEngine } from '../core/rag/rag-engine' import { SelectVector } from '../database/schema' -import { ChatMessage, ChatUserMessage } from '../types/chat' +import { ChatAssistantMessage, ChatMessage, ChatUserMessage } from '../types/chat' import { ContentPart, RequestMessage } from '../types/llm/request' import { MentionableBlock, @@ -118,6 +119,7 @@ export class PromptGenerator { private systemPrompt: SystemPrompt private customModePrompts: CustomModePrompts | null = null private customModeList: ModeConfig[] | null = null + private getMcpHub: () => Promise | null = null private static readonly EMPTY_ASSISTANT_MESSAGE: RequestMessage = { role: 'assistant', content: '', @@ -130,6 +132,7 @@ export class PromptGenerator { diffStrategy?: DiffStrategy, customModePrompts?: CustomModePrompts, customModeList?: ModeConfig[], + getMcpHub?: () => Promise, ) { this.getRagEngine = getRagEngine this.app = app @@ -138,6 +141,7 @@ export class PromptGenerator { this.systemPrompt = new SystemPrompt(this.app) this.customModePrompts = customModePrompts ?? null this.customModeList = customModeList ?? null + this.getMcpHub = getMcpHub ?? null } public async generateRequestMessages({ @@ -188,7 +192,9 @@ export class PromptGenerator { const requestMessages: RequestMessage[] = [ systemMessage, - ...compiledMessages.slice(-19).map((message): RequestMessage => { + ...compiledMessages.slice(-19) + .filter((message) => !(message.role === 'assistant' && message.isToolResult)) + .map((message): RequestMessage => { if (message.role === 'user') { return { role: 'user', @@ -473,6 +479,7 @@ export class PromptGenerator { } public async getSystemMessageNew(mode: Mode, filesSearchMethod: string, preferredLanguage: string): Promise { + const mcpHub = await this.getMcpHub?.() const prompt = await this.systemPrompt.getSystemPrompt( this.app.vault.getRoot().path, false, @@ -482,6 +489,7 @@ export class PromptGenerator { this.diffStrategy, this.customModePrompts, this.customModeList, + mcpHub, ) return { @@ -627,14 +635,14 @@ ${fileContent} const fileContent = await readTFileContent(currentFile, this.app.vault); const lines = fileContent.split('\n'); - + // 计算上下文范围,并处理边界情况 const contextStartLine = Math.max(1, startLine - 20); const contextEndLine = Math.min(lines.length, endLine + 20); - + // 提取上下文行 const contextLines = lines.slice(contextStartLine - 1, contextEndLine); - + // 返回带行号的上下文内容 return addLineNumbers(contextLines.join('\n'), contextStartLine); } @@ -653,10 +661,10 @@ ${fileContent} endLine: number }): Promise { const systemMessage = this.getSystemMessage(false, 'edit'); - + // 获取适当大小的上下文 const context = await this.getContextForEdit(currentFile, startLine, endLine); - + let userPrompt = `\n${instruction}\n\n\n \n${selectedContent}\n`; diff --git a/src/utils/web-search.ts b/src/utils/web-search.ts index bab24d6..3a51a36 100644 --- a/src/utils/web-search.ts +++ b/src/utils/web-search.ts @@ -20,6 +20,72 @@ interface SearchResponse { organic_results?: SearchResult[]; } + +export interface EventProps { + [key: string]: string | number | boolean +} + +export async function onEnt( + N: string, + props?: EventProps, +): Promise { + return new Promise((resolve) => { + try { + const eventUrl = `obsidian://plugin/infio-copilot/${N}` + + const payload = { + name: N, + url: eventUrl, + domain: "copilot.infio.app", + ...(props && Object.keys(props).length > 0 && { props }) + } + + const postData = JSON.stringify(payload) + const apiUrl = new URL(`https://api.infio.com/e1/api/event`) + + const options = { + hostname: apiUrl.hostname, + port: apiUrl.port || 443, + path: apiUrl.pathname, + method: 'POST', + rejectUnauthorized: false, + headers: { + 'User-Agent': navigator.userAgent, + 'X-Forwarded-For': '127.0.0.1', + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + 'X-Debug-Request': 'true' + } + } + + const req = https.request(options, (res) => { + let data = '' + res.on('data', (chunk) => { data += chunk }) + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + // console.log(`✅ successfully: ${N}`) + } else { + console.error(`❌ (${res.statusCode}):`, data) + } + resolve() + }) + }) + + req.on('error', (error) => { + console.error('❌ Failed:', error) + resolve() + }) + + req.write(postData) + req.end() + + } catch (error) { + console.error('❌ Failed:', error) + resolve() + } + }) +} + // 添加余弦相似度计算函数 function cosineSimilarity(vecA: number[], vecB: number[]): number { const dotProduct = vecA.reduce((sum, a, i) => sum + a * vecB[i], 0);