feat(mcp): add in-memory MCP server support and configuration management (#4359)
This commit is contained in:
+4
-4
@@ -1,8 +1,8 @@
|
||||
declare function decrypt(app: string, s: string): string;
|
||||
declare function decrypt(app: string, s: string): string
|
||||
|
||||
interface Secret {
|
||||
app: string;
|
||||
app: string
|
||||
}
|
||||
declare function createOAuthUrl(secret: Secret): string;
|
||||
declare function createOAuthUrl(secret: Secret): string
|
||||
|
||||
export { type Secret, createOAuthUrl, decrypt };
|
||||
export { type Secret, createOAuthUrl, decrypt }
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-1
@@ -26,7 +26,7 @@ import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { getResourcePath } from './utils'
|
||||
import { decrypt, encrypt } from './utils/aes'
|
||||
import { getFilesDir } from './utils/file'
|
||||
import { getConfigDir, getFilesDir } from './utils/file'
|
||||
import { compress, decompress } from './utils/zip'
|
||||
|
||||
const fileManager = new FileStorage()
|
||||
@@ -42,6 +42,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
isPackaged: app.isPackaged,
|
||||
appPath: app.getAppPath(),
|
||||
filesPath: getFilesDir(),
|
||||
configPath: getConfigDir(),
|
||||
appDataPath: app.getPath('userData'),
|
||||
resourcesPath: getResourcePath(),
|
||||
logsPath: log.transports.file.getFile().path
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
// Brave Search MCP Server
|
||||
// port https://github.com/modelcontextprotocol/servers/blob/main/src/brave-search/index.ts
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
const WEB_SEARCH_TOOL: Tool = {
|
||||
name: 'brave_web_search',
|
||||
description:
|
||||
'Performs a web search using the Brave Search API, ideal for general queries, news, articles, and online content. ' +
|
||||
'Use this for broad information gathering, recent events, or when you need diverse web sources. ' +
|
||||
'Supports pagination, content filtering, and freshness controls. ' +
|
||||
'Maximum 20 results per request, with offset for pagination. ',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query (max 400 chars, 50 words)'
|
||||
},
|
||||
count: {
|
||||
type: 'number',
|
||||
description: 'Number of results (1-20, default 10)',
|
||||
default: 10
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Pagination offset (max 9, default 0)',
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
required: ['query']
|
||||
}
|
||||
}
|
||||
|
||||
const LOCAL_SEARCH_TOOL: Tool = {
|
||||
name: 'brave_local_search',
|
||||
description:
|
||||
"Searches for local businesses and places using Brave's Local Search API. " +
|
||||
'Best for queries related to physical locations, businesses, restaurants, services, etc. ' +
|
||||
'Returns detailed information including:\n' +
|
||||
'- Business names and addresses\n' +
|
||||
'- Ratings and review counts\n' +
|
||||
'- Phone numbers and opening hours\n' +
|
||||
"Use this when the query implies 'near me' or mentions specific locations. " +
|
||||
'Automatically falls back to web search if no local results are found.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: "Local search query (e.g. 'pizza near Central Park')"
|
||||
},
|
||||
count: {
|
||||
type: 'number',
|
||||
description: 'Number of results (1-20, default 5)',
|
||||
default: 5
|
||||
}
|
||||
},
|
||||
required: ['query']
|
||||
}
|
||||
}
|
||||
|
||||
const RATE_LIMIT = {
|
||||
perSecond: 1,
|
||||
perMonth: 15000
|
||||
}
|
||||
|
||||
const requestCount = {
|
||||
second: 0,
|
||||
month: 0,
|
||||
lastReset: Date.now()
|
||||
}
|
||||
|
||||
function checkRateLimit() {
|
||||
const now = Date.now()
|
||||
if (now - requestCount.lastReset > 1000) {
|
||||
requestCount.second = 0
|
||||
requestCount.lastReset = now
|
||||
}
|
||||
if (requestCount.second >= RATE_LIMIT.perSecond || requestCount.month >= RATE_LIMIT.perMonth) {
|
||||
throw new Error('Rate limit exceeded')
|
||||
}
|
||||
requestCount.second++
|
||||
requestCount.month++
|
||||
}
|
||||
|
||||
interface BraveWeb {
|
||||
web?: {
|
||||
results?: Array<{
|
||||
title: string
|
||||
description: string
|
||||
url: string
|
||||
language?: string
|
||||
published?: string
|
||||
rank?: number
|
||||
}>
|
||||
}
|
||||
locations?: {
|
||||
results?: Array<{
|
||||
id: string // Required by API
|
||||
title?: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
interface BraveLocation {
|
||||
id: string
|
||||
name: string
|
||||
address: {
|
||||
streetAddress?: string
|
||||
addressLocality?: string
|
||||
addressRegion?: string
|
||||
postalCode?: string
|
||||
}
|
||||
coordinates?: {
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
phone?: string
|
||||
rating?: {
|
||||
ratingValue?: number
|
||||
ratingCount?: number
|
||||
}
|
||||
openingHours?: string[]
|
||||
priceRange?: string
|
||||
}
|
||||
|
||||
interface BravePoiResponse {
|
||||
results: BraveLocation[]
|
||||
}
|
||||
|
||||
interface BraveDescription {
|
||||
descriptions: { [id: string]: string }
|
||||
}
|
||||
|
||||
function isBraveWebSearchArgs(args: unknown): args is { query: string; count?: number } {
|
||||
return (
|
||||
typeof args === 'object' &&
|
||||
args !== null &&
|
||||
'query' in args &&
|
||||
typeof (args as { query: string }).query === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isBraveLocalSearchArgs(args: unknown): args is { query: string; count?: number } {
|
||||
return (
|
||||
typeof args === 'object' &&
|
||||
args !== null &&
|
||||
'query' in args &&
|
||||
typeof (args as { query: string }).query === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
async function performWebSearch(apiKey: string, query: string, count: number = 10, offset: number = 0) {
|
||||
checkRateLimit()
|
||||
const url = new URL('https://api.search.brave.com/res/v1/web/search')
|
||||
url.searchParams.set('q', query)
|
||||
url.searchParams.set('count', Math.min(count, 20).toString()) // API limit
|
||||
url.searchParams.set('offset', offset.toString())
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'X-Subscription-Token': apiKey
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as BraveWeb
|
||||
|
||||
// Extract just web results
|
||||
const results = (data.web?.results || []).map((result) => ({
|
||||
title: result.title || '',
|
||||
description: result.description || '',
|
||||
url: result.url || ''
|
||||
}))
|
||||
|
||||
return results.map((r) => `Title: ${r.title}\nDescription: ${r.description}\nURL: ${r.url}`).join('\n\n')
|
||||
}
|
||||
|
||||
async function performLocalSearch(apiKey: string, query: string, count: number = 5) {
|
||||
checkRateLimit()
|
||||
// Initial search to get location IDs
|
||||
const webUrl = new URL('https://api.search.brave.com/res/v1/web/search')
|
||||
webUrl.searchParams.set('q', query)
|
||||
webUrl.searchParams.set('search_lang', 'en')
|
||||
webUrl.searchParams.set('result_filter', 'locations')
|
||||
webUrl.searchParams.set('count', Math.min(count, 20).toString())
|
||||
|
||||
const webResponse = await fetch(webUrl, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'X-Subscription-Token': apiKey
|
||||
}
|
||||
})
|
||||
|
||||
if (!webResponse.ok) {
|
||||
throw new Error(`Brave API error: ${webResponse.status} ${webResponse.statusText}\n${await webResponse.text()}`)
|
||||
}
|
||||
|
||||
const webData = (await webResponse.json()) as BraveWeb
|
||||
const locationIds =
|
||||
webData.locations?.results?.filter((r): r is { id: string; title?: string } => r.id != null).map((r) => r.id) || []
|
||||
|
||||
if (locationIds.length === 0) {
|
||||
return performWebSearch(apiKey, query, count) // Fallback to web search
|
||||
}
|
||||
|
||||
// Get POI details and descriptions in parallel
|
||||
const [poisData, descriptionsData] = await Promise.all([
|
||||
getPoisData(apiKey, locationIds),
|
||||
getDescriptionsData(apiKey, locationIds)
|
||||
])
|
||||
|
||||
return formatLocalResults(poisData, descriptionsData)
|
||||
}
|
||||
|
||||
async function getPoisData(apiKey: string, ids: string[]): Promise<BravePoiResponse> {
|
||||
checkRateLimit()
|
||||
const url = new URL('https://api.search.brave.com/res/v1/local/pois')
|
||||
ids.filter(Boolean).forEach((id) => url.searchParams.append('ids', id))
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'X-Subscription-Token': apiKey
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`)
|
||||
}
|
||||
|
||||
const poisResponse = (await response.json()) as BravePoiResponse
|
||||
return poisResponse
|
||||
}
|
||||
|
||||
async function getDescriptionsData(apiKey: string, ids: string[]): Promise<BraveDescription> {
|
||||
checkRateLimit()
|
||||
const url = new URL('https://api.search.brave.com/res/v1/local/descriptions')
|
||||
ids.filter(Boolean).forEach((id) => url.searchParams.append('ids', id))
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'X-Subscription-Token': apiKey
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`)
|
||||
}
|
||||
|
||||
const descriptionsData = (await response.json()) as BraveDescription
|
||||
return descriptionsData
|
||||
}
|
||||
|
||||
function formatLocalResults(poisData: BravePoiResponse, descData: BraveDescription): string {
|
||||
return (
|
||||
(poisData.results || [])
|
||||
.map((poi) => {
|
||||
const address =
|
||||
[
|
||||
poi.address?.streetAddress ?? '',
|
||||
poi.address?.addressLocality ?? '',
|
||||
poi.address?.addressRegion ?? '',
|
||||
poi.address?.postalCode ?? ''
|
||||
]
|
||||
.filter((part) => part !== '')
|
||||
.join(', ') || 'N/A'
|
||||
|
||||
return `Name: ${poi.name}
|
||||
Address: ${address}
|
||||
Phone: ${poi.phone || 'N/A'}
|
||||
Rating: ${poi.rating?.ratingValue ?? 'N/A'} (${poi.rating?.ratingCount ?? 0} reviews)
|
||||
Price Range: ${poi.priceRange || 'N/A'}
|
||||
Hours: ${(poi.openingHours || []).join(', ') || 'N/A'}
|
||||
Description: ${descData.descriptions[poi.id] || 'No description available'}
|
||||
`
|
||||
})
|
||||
.join('\n---\n') || 'No local results found'
|
||||
)
|
||||
}
|
||||
|
||||
class BraveSearchServer {
|
||||
public server: Server
|
||||
private apiKey: string
|
||||
|
||||
constructor(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
throw new Error('BRAVE_API_KEY is required for Brave Search MCP server')
|
||||
}
|
||||
this.apiKey = apiKey
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'brave-search-server',
|
||||
version: '0.1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
initialize() {
|
||||
// Tool handlers
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [WEB_SEARCH_TOOL, LOCAL_SEARCH_TOOL]
|
||||
}))
|
||||
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
try {
|
||||
const { name, arguments: args } = request.params
|
||||
|
||||
if (!args) {
|
||||
throw new Error('No arguments provided')
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case 'brave_web_search': {
|
||||
if (!isBraveWebSearchArgs(args)) {
|
||||
throw new Error('Invalid arguments for brave_web_search')
|
||||
}
|
||||
const { query, count = 10 } = args
|
||||
const results = await performWebSearch(this.apiKey, query, count)
|
||||
return {
|
||||
content: [{ type: 'text', text: results }],
|
||||
isError: false
|
||||
}
|
||||
}
|
||||
|
||||
case 'brave_local_search': {
|
||||
if (!isBraveLocalSearchArgs(args)) {
|
||||
throw new Error('Invalid arguments for brave_local_search')
|
||||
}
|
||||
const { query, count = 5 } = args
|
||||
const results = await performLocalSearch(this.apiKey, query, count)
|
||||
return {
|
||||
content: [{ type: 'text', text: results }],
|
||||
isError: false
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default BraveSearchServer
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,36 @@
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import BraveSearchServer from './brave-search'
|
||||
import EverythingServer from './everything'
|
||||
import FetchServer from './fetch'
|
||||
import FileSystemServer from './filesystem'
|
||||
import MemoryServer from './memory'
|
||||
import ThinkingServer from './sequentialthinking'
|
||||
|
||||
export function createInMemoryMCPServer(name: string, args: string[] = [], envs: Record<string, string> = {}): Server {
|
||||
Logger.info(`[MCP] Creating in-memory MCP server: ${name} with args: ${args} and envs: ${JSON.stringify(envs)}`)
|
||||
switch (name) {
|
||||
case '@cherry/memory': {
|
||||
const envPath = envs.MEMORY_FILE_PATH
|
||||
return new MemoryServer(envPath).server
|
||||
}
|
||||
case '@cherry/sequentialthinking': {
|
||||
return new ThinkingServer().server
|
||||
}
|
||||
case '@cherry/brave-search': {
|
||||
return new BraveSearchServer(envs.BRAVE_API_KEY).server
|
||||
}
|
||||
case '@cherry/everything': {
|
||||
return new EverythingServer().server
|
||||
}
|
||||
case '@cherry/fetch': {
|
||||
return new FetchServer().server
|
||||
}
|
||||
case '@cherry/filesystem': {
|
||||
return new FileSystemServer(args).server
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown in-memory MCP server: ${name}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// port https://github.com/zcaceres/fetch-mcp/blob/main/src/index.ts
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import TurndownService from 'turndown'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const RequestPayloadSchema = z.object({
|
||||
url: z.string().url(),
|
||||
headers: z.record(z.string()).optional()
|
||||
})
|
||||
|
||||
export type RequestPayload = z.infer<typeof RequestPayloadSchema>
|
||||
|
||||
export class Fetcher {
|
||||
private static async _fetch({ url, headers }: RequestPayload): Promise<Response> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
...headers
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error: ${response.status}`)
|
||||
}
|
||||
return response
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
throw new Error(`Failed to fetch ${url}: ${e.message}`)
|
||||
} else {
|
||||
throw new Error(`Failed to fetch ${url}: Unknown error`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async html(requestPayload: RequestPayload) {
|
||||
try {
|
||||
const response = await this._fetch(requestPayload)
|
||||
const html = await response.text()
|
||||
return { content: [{ type: 'text', text: html }], isError: false }
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: (error as Error).message }],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async json(requestPayload: RequestPayload) {
|
||||
try {
|
||||
const response = await this._fetch(requestPayload)
|
||||
const json = await response.json()
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(json) }],
|
||||
isError: false
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: (error as Error).message }],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async txt(requestPayload: RequestPayload) {
|
||||
try {
|
||||
const response = await this._fetch(requestPayload)
|
||||
const html = await response.text()
|
||||
|
||||
const dom = new JSDOM(html)
|
||||
const document = dom.window.document
|
||||
|
||||
const scripts = document.getElementsByTagName('script')
|
||||
const styles = document.getElementsByTagName('style')
|
||||
Array.from(scripts).forEach((script: any) => script.remove())
|
||||
Array.from(styles).forEach((style: any) => style.remove())
|
||||
|
||||
const text = document.body.textContent || ''
|
||||
|
||||
const normalizedText = text.replace(/\s+/g, ' ').trim()
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: normalizedText }],
|
||||
isError: false
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: (error as Error).message }],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async markdown(requestPayload: RequestPayload) {
|
||||
try {
|
||||
const response = await this._fetch(requestPayload)
|
||||
const html = await response.text()
|
||||
const turndownService = new TurndownService()
|
||||
const markdown = turndownService.turndown(html)
|
||||
return { content: [{ type: 'text', text: markdown }], isError: false }
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: (error as Error).message }],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'zcaceres/fetch',
|
||||
version: '0.1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: {},
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'fetch_html',
|
||||
description: 'Fetch a website and return the content as HTML',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'URL of the website to fetch'
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Optional headers to include in the request'
|
||||
}
|
||||
},
|
||||
required: ['url']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'fetch_markdown',
|
||||
description: 'Fetch a website and return the content as Markdown',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'URL of the website to fetch'
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Optional headers to include in the request'
|
||||
}
|
||||
},
|
||||
required: ['url']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'fetch_txt',
|
||||
description: 'Fetch a website, return the content as plain text (no HTML)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'URL of the website to fetch'
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Optional headers to include in the request'
|
||||
}
|
||||
},
|
||||
required: ['url']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'fetch_json',
|
||||
description: 'Fetch a JSON file from a URL',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'URL of the JSON to fetch'
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Optional headers to include in the request'
|
||||
}
|
||||
},
|
||||
required: ['url']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { arguments: args } = request.params
|
||||
|
||||
const validatedArgs = RequestPayloadSchema.parse(args)
|
||||
|
||||
if (request.params.name === 'fetch_html') {
|
||||
const fetchResult = await Fetcher.html(validatedArgs)
|
||||
return fetchResult
|
||||
}
|
||||
if (request.params.name === 'fetch_json') {
|
||||
const fetchResult = await Fetcher.json(validatedArgs)
|
||||
return fetchResult
|
||||
}
|
||||
if (request.params.name === 'fetch_txt') {
|
||||
const fetchResult = await Fetcher.txt(validatedArgs)
|
||||
return fetchResult
|
||||
}
|
||||
if (request.params.name === 'fetch_markdown') {
|
||||
const fetchResult = await Fetcher.markdown(validatedArgs)
|
||||
return fetchResult
|
||||
}
|
||||
throw new Error('Tool not found')
|
||||
})
|
||||
|
||||
class FetchServer {
|
||||
public server: Server
|
||||
constructor() {
|
||||
this.server = server
|
||||
}
|
||||
}
|
||||
export default FetchServer
|
||||
@@ -0,0 +1,655 @@
|
||||
// port https://github.com/modelcontextprotocol/servers/blob/main/src/filesystem/index.ts
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { createTwoFilesPatch } from 'diff'
|
||||
import fs from 'fs/promises'
|
||||
import { minimatch } from 'minimatch'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { z } from 'zod'
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema'
|
||||
|
||||
// Normalize all paths consistently
|
||||
function normalizePath(p: string): string {
|
||||
return path.normalize(p)
|
||||
}
|
||||
|
||||
function expandHome(filepath: string): string {
|
||||
if (filepath.startsWith('~/') || filepath === '~') {
|
||||
return path.join(os.homedir(), filepath.slice(1))
|
||||
}
|
||||
return filepath
|
||||
}
|
||||
|
||||
// Security utilities
|
||||
async function validatePath(allowedDirectories: string[], requestedPath: string): Promise<string> {
|
||||
const expandedPath = expandHome(requestedPath)
|
||||
const absolute = path.isAbsolute(expandedPath)
|
||||
? path.resolve(expandedPath)
|
||||
: path.resolve(process.cwd(), expandedPath)
|
||||
|
||||
const normalizedRequested = normalizePath(absolute)
|
||||
|
||||
// Check if path is within allowed directories
|
||||
const isAllowed = allowedDirectories.some((dir) => normalizedRequested.startsWith(dir))
|
||||
if (!isAllowed) {
|
||||
throw new Error(
|
||||
`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
// Handle symlinks by checking their real path
|
||||
try {
|
||||
const realPath = await fs.realpath(absolute)
|
||||
const normalizedReal = normalizePath(realPath)
|
||||
const isRealPathAllowed = allowedDirectories.some((dir) => normalizedReal.startsWith(dir))
|
||||
if (!isRealPathAllowed) {
|
||||
throw new Error('Access denied - symlink target outside allowed directories')
|
||||
}
|
||||
return realPath
|
||||
} catch (error) {
|
||||
// For new files that don't exist yet, verify parent directory
|
||||
const parentDir = path.dirname(absolute)
|
||||
try {
|
||||
const realParentPath = await fs.realpath(parentDir)
|
||||
const normalizedParent = normalizePath(realParentPath)
|
||||
const isParentAllowed = allowedDirectories.some((dir) => normalizedParent.startsWith(dir))
|
||||
if (!isParentAllowed) {
|
||||
throw new Error('Access denied - parent directory outside allowed directories')
|
||||
}
|
||||
return absolute
|
||||
} catch {
|
||||
throw new Error(`Parent directory does not exist: ${parentDir}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schema definitions
|
||||
const ReadFileArgsSchema = z.object({
|
||||
path: z.string()
|
||||
})
|
||||
|
||||
const ReadMultipleFilesArgsSchema = z.object({
|
||||
paths: z.array(z.string())
|
||||
})
|
||||
|
||||
const WriteFileArgsSchema = z.object({
|
||||
path: z.string(),
|
||||
content: z.string()
|
||||
})
|
||||
|
||||
const EditOperation = z.object({
|
||||
oldText: z.string().describe('Text to search for - must match exactly'),
|
||||
newText: z.string().describe('Text to replace with')
|
||||
})
|
||||
|
||||
const EditFileArgsSchema = z.object({
|
||||
path: z.string(),
|
||||
edits: z.array(EditOperation),
|
||||
dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format')
|
||||
})
|
||||
|
||||
const CreateDirectoryArgsSchema = z.object({
|
||||
path: z.string()
|
||||
})
|
||||
|
||||
const ListDirectoryArgsSchema = z.object({
|
||||
path: z.string()
|
||||
})
|
||||
|
||||
const DirectoryTreeArgsSchema = z.object({
|
||||
path: z.string()
|
||||
})
|
||||
|
||||
const MoveFileArgsSchema = z.object({
|
||||
source: z.string(),
|
||||
destination: z.string()
|
||||
})
|
||||
|
||||
const SearchFilesArgsSchema = z.object({
|
||||
path: z.string(),
|
||||
pattern: z.string(),
|
||||
excludePatterns: z.array(z.string()).optional().default([])
|
||||
})
|
||||
|
||||
const GetFileInfoArgsSchema = z.object({
|
||||
path: z.string()
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const ToolInputSchema = ToolSchema.shape.inputSchema
|
||||
type ToolInput = z.infer<typeof ToolInputSchema>
|
||||
|
||||
interface FileInfo {
|
||||
size: number
|
||||
created: Date
|
||||
modified: Date
|
||||
accessed: Date
|
||||
isDirectory: boolean
|
||||
isFile: boolean
|
||||
permissions: string
|
||||
}
|
||||
|
||||
// Tool implementations
|
||||
async function getFileStats(filePath: string): Promise<FileInfo> {
|
||||
const stats = await fs.stat(filePath)
|
||||
return {
|
||||
size: stats.size,
|
||||
created: stats.birthtime,
|
||||
modified: stats.mtime,
|
||||
accessed: stats.atime,
|
||||
isDirectory: stats.isDirectory(),
|
||||
isFile: stats.isFile(),
|
||||
permissions: stats.mode.toString(8).slice(-3)
|
||||
}
|
||||
}
|
||||
|
||||
async function searchFiles(
|
||||
allowedDirectories: string[],
|
||||
rootPath: string,
|
||||
pattern: string,
|
||||
excludePatterns: string[] = []
|
||||
): Promise<string[]> {
|
||||
const results: string[] = []
|
||||
|
||||
async function search(currentPath: string) {
|
||||
const entries = await fs.readdir(currentPath, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentPath, entry.name)
|
||||
|
||||
try {
|
||||
// Validate each path before processing
|
||||
await validatePath(allowedDirectories, fullPath)
|
||||
|
||||
// Check if path matches any exclude pattern
|
||||
const relativePath = path.relative(rootPath, fullPath)
|
||||
const shouldExclude = excludePatterns.some((pattern) => {
|
||||
const globPattern = pattern.includes('*') ? pattern : `**/${pattern}/**`
|
||||
return minimatch(relativePath, globPattern, { dot: true })
|
||||
})
|
||||
|
||||
if (shouldExclude) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {
|
||||
results.push(fullPath)
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await search(fullPath)
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip invalid paths during search
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await search(rootPath)
|
||||
return results
|
||||
}
|
||||
|
||||
// file editing and diffing utilities
|
||||
function normalizeLineEndings(text: string): string {
|
||||
return text.replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string {
|
||||
// Ensure consistent line endings for diff
|
||||
const normalizedOriginal = normalizeLineEndings(originalContent)
|
||||
const normalizedNew = normalizeLineEndings(newContent)
|
||||
|
||||
return createTwoFilesPatch(filepath, filepath, normalizedOriginal, normalizedNew, 'original', 'modified')
|
||||
}
|
||||
|
||||
async function applyFileEdits(
|
||||
filePath: string,
|
||||
edits: Array<{ oldText: string; newText: string }>,
|
||||
dryRun = false
|
||||
): Promise<string> {
|
||||
// Read file content and normalize line endings
|
||||
const content = normalizeLineEndings(await fs.readFile(filePath, 'utf-8'))
|
||||
|
||||
// Apply edits sequentially
|
||||
let modifiedContent = content
|
||||
for (const edit of edits) {
|
||||
const normalizedOld = normalizeLineEndings(edit.oldText)
|
||||
const normalizedNew = normalizeLineEndings(edit.newText)
|
||||
|
||||
// If exact match exists, use it
|
||||
if (modifiedContent.includes(normalizedOld)) {
|
||||
modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew)
|
||||
continue
|
||||
}
|
||||
|
||||
// Otherwise, try line-by-line matching with flexibility for whitespace
|
||||
const oldLines = normalizedOld.split('\n')
|
||||
const contentLines = modifiedContent.split('\n')
|
||||
let matchFound = false
|
||||
|
||||
for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
|
||||
const potentialMatch = contentLines.slice(i, i + oldLines.length)
|
||||
|
||||
// Compare lines with normalized whitespace
|
||||
const isMatch = oldLines.every((oldLine, j) => {
|
||||
const contentLine = potentialMatch[j]
|
||||
return oldLine.trim() === contentLine.trim()
|
||||
})
|
||||
|
||||
if (isMatch) {
|
||||
// Preserve original indentation of first line
|
||||
const originalIndent = contentLines[i].match(/^\s*/)?.[0] || ''
|
||||
const newLines = normalizedNew.split('\n').map((line, j) => {
|
||||
if (j === 0) return originalIndent + line.trimStart()
|
||||
// For subsequent lines, try to preserve relative indentation
|
||||
const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || ''
|
||||
const newIndent = line.match(/^\s*/)?.[0] || ''
|
||||
if (oldIndent && newIndent) {
|
||||
const relativeIndent = newIndent.length - oldIndent.length
|
||||
return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart()
|
||||
}
|
||||
return line
|
||||
})
|
||||
|
||||
contentLines.splice(i, oldLines.length, ...newLines)
|
||||
modifiedContent = contentLines.join('\n')
|
||||
matchFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchFound) {
|
||||
throw new Error(`Could not find exact match for edit:\n${edit.oldText}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Create unified diff
|
||||
const diff = createUnifiedDiff(content, modifiedContent, filePath)
|
||||
|
||||
// Format diff with appropriate number of backticks
|
||||
let numBackticks = 3
|
||||
while (diff.includes('`'.repeat(numBackticks))) {
|
||||
numBackticks++
|
||||
}
|
||||
const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`
|
||||
|
||||
if (!dryRun) {
|
||||
await fs.writeFile(filePath, modifiedContent, 'utf-8')
|
||||
}
|
||||
|
||||
return formattedDiff
|
||||
}
|
||||
|
||||
class FileSystemServer {
|
||||
public server: Server
|
||||
private allowedDirectories: string[]
|
||||
constructor(allowedDirs: string[]) {
|
||||
if (!Array.isArray(allowedDirs) || allowedDirs.length === 0) {
|
||||
throw new Error('No allowed directories provided, please specify at least one directory in args')
|
||||
}
|
||||
|
||||
this.allowedDirectories = allowedDirs.map((dir) => normalizePath(path.resolve(expandHome(dir))))
|
||||
|
||||
// Validate that all directories exist and are accessible
|
||||
this.validateDirs().catch((error) => {
|
||||
console.error('Error validating allowed directories:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'secure-filesystem-server',
|
||||
version: '0.2.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
async validateDirs() {
|
||||
// Validate that all directories exist and are accessible
|
||||
await Promise.all(
|
||||
this.allowedDirectories.map(async (dir) => {
|
||||
try {
|
||||
const stats = await fs.stat(expandHome(dir))
|
||||
if (!stats.isDirectory()) {
|
||||
console.error(`Error: ${dir} is not a directory`)
|
||||
process.exit(1)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error accessing directory ${dir}:`, error)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
initialize() {
|
||||
// Tool handlers
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'read_file',
|
||||
description:
|
||||
'Read the complete contents of a file from the file system. ' +
|
||||
'Handles various text encodings and provides detailed error messages ' +
|
||||
'if the file cannot be read. Use this tool when you need to examine ' +
|
||||
'the contents of a single file. Only works within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(ReadFileArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'read_multiple_files',
|
||||
description:
|
||||
'Read the contents of multiple files simultaneously. This is more ' +
|
||||
'efficient than reading files one by one when you need to analyze ' +
|
||||
"or compare multiple files. Each file's content is returned with its " +
|
||||
"path as a reference. Failed reads for individual files won't stop " +
|
||||
'the entire operation. Only works within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(ReadMultipleFilesArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'write_file',
|
||||
description:
|
||||
'Create a new file or completely overwrite an existing file with new content. ' +
|
||||
'Use with caution as it will overwrite existing files without warning. ' +
|
||||
'Handles text content with proper encoding. Only works within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(WriteFileArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'edit_file',
|
||||
description:
|
||||
'Make line-based edits to a text file. Each edit replaces exact line sequences ' +
|
||||
'with new content. Returns a git-style diff showing the changes made. ' +
|
||||
'Only works within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'create_directory',
|
||||
description:
|
||||
'Create a new directory or ensure a directory exists. Can create multiple ' +
|
||||
'nested directories in one operation. If the directory already exists, ' +
|
||||
'this operation will succeed silently. Perfect for setting up directory ' +
|
||||
'structures for projects or ensuring required paths exist. Only works within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(CreateDirectoryArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'list_directory',
|
||||
description:
|
||||
'Get a detailed listing of all files and directories in a specified path. ' +
|
||||
'Results clearly distinguish between files and directories with [FILE] and [DIR] ' +
|
||||
'prefixes. This tool is essential for understanding directory structure and ' +
|
||||
'finding specific files within a directory. Only works within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(ListDirectoryArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'directory_tree',
|
||||
description:
|
||||
'Get a recursive tree view of files and directories as a JSON structure. ' +
|
||||
"Each entry includes 'name', 'type' (file/directory), and 'children' for directories. " +
|
||||
'Files have no children array, while directories always have a children array (which may be empty). ' +
|
||||
'The output is formatted with 2-space indentation for readability. Only works within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(DirectoryTreeArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'move_file',
|
||||
description:
|
||||
'Move or rename files and directories. Can move files between directories ' +
|
||||
'and rename them in a single operation. If the destination exists, the ' +
|
||||
'operation will fail. Works across different directories and can be used ' +
|
||||
'for simple renaming within the same directory. Both source and destination must be within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(MoveFileArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'search_files',
|
||||
description:
|
||||
'Recursively search for files and directories matching a pattern. ' +
|
||||
'Searches through all subdirectories from the starting path. The search ' +
|
||||
'is case-insensitive and matches partial names. Returns full paths to all ' +
|
||||
"matching items. Great for finding files when you don't know their exact location. " +
|
||||
'Only searches within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(SearchFilesArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'get_file_info',
|
||||
description:
|
||||
'Retrieve detailed metadata about a file or directory. Returns comprehensive ' +
|
||||
'information including size, creation time, last modified time, permissions, ' +
|
||||
'and type. This tool is perfect for understanding file characteristics ' +
|
||||
'without reading the actual content. Only works within allowed directories.',
|
||||
inputSchema: zodToJsonSchema(GetFileInfoArgsSchema) as ToolInput
|
||||
},
|
||||
{
|
||||
name: 'list_allowed_directories',
|
||||
description:
|
||||
'Returns the list of directories that this server is allowed to access. ' +
|
||||
'Use this to understand which directories are available before trying to access files.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
try {
|
||||
const { name, arguments: args } = request.params
|
||||
|
||||
switch (name) {
|
||||
case 'read_file': {
|
||||
const parsed = ReadFileArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for read_file: ${parsed.error}`)
|
||||
}
|
||||
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
|
||||
const content = await fs.readFile(validPath, 'utf-8')
|
||||
return {
|
||||
content: [{ type: 'text', text: content }]
|
||||
}
|
||||
}
|
||||
|
||||
case 'read_multiple_files': {
|
||||
const parsed = ReadMultipleFilesArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for read_multiple_files: ${parsed.error}`)
|
||||
}
|
||||
const results = await Promise.all(
|
||||
parsed.data.paths.map(async (filePath: string) => {
|
||||
try {
|
||||
const validPath = await validatePath(this.allowedDirectories, filePath)
|
||||
const content = await fs.readFile(validPath, 'utf-8')
|
||||
return `${filePath}:\n${content}\n`
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return `${filePath}: Error - ${errorMessage}`
|
||||
}
|
||||
})
|
||||
)
|
||||
return {
|
||||
content: [{ type: 'text', text: results.join('\n---\n') }]
|
||||
}
|
||||
}
|
||||
|
||||
case 'write_file': {
|
||||
const parsed = WriteFileArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for write_file: ${parsed.error}`)
|
||||
}
|
||||
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
|
||||
await fs.writeFile(validPath, parsed.data.content, 'utf-8')
|
||||
return {
|
||||
content: [{ type: 'text', text: `Successfully wrote to ${parsed.data.path}` }]
|
||||
}
|
||||
}
|
||||
|
||||
case 'edit_file': {
|
||||
const parsed = EditFileArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for edit_file: ${parsed.error}`)
|
||||
}
|
||||
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
|
||||
const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun)
|
||||
return {
|
||||
content: [{ type: 'text', text: result }]
|
||||
}
|
||||
}
|
||||
|
||||
case 'create_directory': {
|
||||
const parsed = CreateDirectoryArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for create_directory: ${parsed.error}`)
|
||||
}
|
||||
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
|
||||
await fs.mkdir(validPath, { recursive: true })
|
||||
return {
|
||||
content: [{ type: 'text', text: `Successfully created directory ${parsed.data.path}` }]
|
||||
}
|
||||
}
|
||||
|
||||
case 'list_directory': {
|
||||
const parsed = ListDirectoryArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for list_directory: ${parsed.error}`)
|
||||
}
|
||||
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
|
||||
const entries = await fs.readdir(validPath, { withFileTypes: true })
|
||||
const formatted = entries
|
||||
.map((entry) => `${entry.isDirectory() ? '[DIR]' : '[FILE]'} ${entry.name}`)
|
||||
.join('\n')
|
||||
return {
|
||||
content: [{ type: 'text', text: formatted }]
|
||||
}
|
||||
}
|
||||
|
||||
case 'directory_tree': {
|
||||
const parsed = DirectoryTreeArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for directory_tree: ${parsed.error}`)
|
||||
}
|
||||
|
||||
interface TreeEntry {
|
||||
name: string
|
||||
type: 'file' | 'directory'
|
||||
children?: TreeEntry[]
|
||||
}
|
||||
|
||||
async function buildTree(allowedDirectories: string[], currentPath: string): Promise<TreeEntry[]> {
|
||||
const validPath = await validatePath(allowedDirectories, currentPath)
|
||||
const entries = await fs.readdir(validPath, { withFileTypes: true })
|
||||
const result: TreeEntry[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryData: TreeEntry = {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? 'directory' : 'file'
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const subPath = path.join(currentPath, entry.name)
|
||||
entryData.children = await buildTree(allowedDirectories, subPath)
|
||||
}
|
||||
|
||||
result.push(entryData)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const treeData = await buildTree(this.allowedDirectories, parsed.data.path)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(treeData, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
case 'move_file': {
|
||||
const parsed = MoveFileArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for move_file: ${parsed.error}`)
|
||||
}
|
||||
const validSourcePath = await validatePath(this.allowedDirectories, parsed.data.source)
|
||||
const validDestPath = await validatePath(this.allowedDirectories, parsed.data.destination)
|
||||
await fs.rename(validSourcePath, validDestPath)
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: `Successfully moved ${parsed.data.source} to ${parsed.data.destination}` }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
case 'search_files': {
|
||||
const parsed = SearchFilesArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for search_files: ${parsed.error}`)
|
||||
}
|
||||
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
|
||||
const results = await searchFiles(
|
||||
this.allowedDirectories,
|
||||
validPath,
|
||||
parsed.data.pattern,
|
||||
parsed.data.excludePatterns
|
||||
)
|
||||
return {
|
||||
content: [{ type: 'text', text: results.length > 0 ? results.join('\n') : 'No matches found' }]
|
||||
}
|
||||
}
|
||||
|
||||
case 'get_file_info': {
|
||||
const parsed = GetFileInfoArgsSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for get_file_info: ${parsed.error}`)
|
||||
}
|
||||
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
|
||||
const info = await getFileStats(validPath)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: Object.entries(info)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n')
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
case 'list_allowed_directories': {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Allowed directories:\n${this.allowedDirectories.join('\n')}`
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${errorMessage}` }],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default FileSystemServer
|
||||
@@ -0,0 +1,490 @@
|
||||
// port https://github.com/modelcontextprotocol/servers/blob/main/src/memory/index.ts
|
||||
import { getConfigDir } from '@main/utils/file'
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
// Define memory file path using environment variable with fallback
|
||||
const defaultMemoryPath = path.join(getConfigDir(), 'memory.json')
|
||||
|
||||
// We are storing our memory using entities, relations, and observations in a graph structure
|
||||
interface Entity {
|
||||
name: string
|
||||
entityType: string
|
||||
observations: string[]
|
||||
}
|
||||
|
||||
interface Relation {
|
||||
from: string
|
||||
to: string
|
||||
relationType: string
|
||||
}
|
||||
|
||||
interface KnowledgeGraph {
|
||||
entities: Entity[]
|
||||
relations: Relation[]
|
||||
}
|
||||
|
||||
// The KnowledgeGraphManager class contains all operations to interact with the knowledge graph
|
||||
class KnowledgeGraphManager {
|
||||
private memoryPath: string
|
||||
|
||||
constructor(memoryPath: string) {
|
||||
this.memoryPath = memoryPath
|
||||
}
|
||||
|
||||
private async loadGraph(): Promise<KnowledgeGraph> {
|
||||
try {
|
||||
const data = await fs.readFile(this.memoryPath, 'utf-8')
|
||||
const lines = data.split('\n').filter((line) => line.trim() !== '')
|
||||
return lines.reduce(
|
||||
(graph: KnowledgeGraph, line) => {
|
||||
const item = JSON.parse(line)
|
||||
if (item.type === 'entity') graph.entities.push(item as Entity)
|
||||
if (item.type === 'relation') graph.relations.push(item as Relation)
|
||||
return graph
|
||||
},
|
||||
{ entities: [], relations: [] }
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && (error as any).code === 'ENOENT') {
|
||||
return { entities: [], relations: [] }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async saveGraph(graph: KnowledgeGraph): Promise<void> {
|
||||
const lines = [
|
||||
...graph.entities.map((e) => JSON.stringify({ type: 'entity', ...e })),
|
||||
...graph.relations.map((r) => JSON.stringify({ type: 'relation', ...r }))
|
||||
]
|
||||
await fs.writeFile(this.memoryPath, lines.join('\n'))
|
||||
}
|
||||
|
||||
async createEntities(entities: Entity[]): Promise<Entity[]> {
|
||||
const graph = await this.loadGraph()
|
||||
const newEntities = entities.filter((e) => !graph.entities.some((existingEntity) => existingEntity.name === e.name))
|
||||
graph.entities.push(...newEntities)
|
||||
await this.saveGraph(graph)
|
||||
return newEntities
|
||||
}
|
||||
|
||||
async createRelations(relations: Relation[]): Promise<Relation[]> {
|
||||
const graph = await this.loadGraph()
|
||||
const newRelations = relations.filter(
|
||||
(r) =>
|
||||
!graph.relations.some(
|
||||
(existingRelation) =>
|
||||
existingRelation.from === r.from &&
|
||||
existingRelation.to === r.to &&
|
||||
existingRelation.relationType === r.relationType
|
||||
)
|
||||
)
|
||||
graph.relations.push(...newRelations)
|
||||
await this.saveGraph(graph)
|
||||
return newRelations
|
||||
}
|
||||
|
||||
async addObservations(
|
||||
observations: { entityName: string; contents: string[] }[]
|
||||
): Promise<{ entityName: string; addedObservations: string[] }[]> {
|
||||
const graph = await this.loadGraph()
|
||||
const results = observations.map((o) => {
|
||||
const entity = graph.entities.find((e) => e.name === o.entityName)
|
||||
if (!entity) {
|
||||
throw new Error(`Entity with name ${o.entityName} not found`)
|
||||
}
|
||||
const newObservations = o.contents.filter((content) => !entity.observations.includes(content))
|
||||
entity.observations.push(...newObservations)
|
||||
return { entityName: o.entityName, addedObservations: newObservations }
|
||||
})
|
||||
await this.saveGraph(graph)
|
||||
return results
|
||||
}
|
||||
|
||||
async deleteEntities(entityNames: string[]): Promise<void> {
|
||||
const graph = await this.loadGraph()
|
||||
graph.entities = graph.entities.filter((e) => !entityNames.includes(e.name))
|
||||
graph.relations = graph.relations.filter((r) => !entityNames.includes(r.from) && !entityNames.includes(r.to))
|
||||
await this.saveGraph(graph)
|
||||
}
|
||||
|
||||
async deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise<void> {
|
||||
const graph = await this.loadGraph()
|
||||
deletions.forEach((d) => {
|
||||
const entity = graph.entities.find((e) => e.name === d.entityName)
|
||||
if (entity) {
|
||||
entity.observations = entity.observations.filter((o) => !d.observations.includes(o))
|
||||
}
|
||||
})
|
||||
await this.saveGraph(graph)
|
||||
}
|
||||
|
||||
async deleteRelations(relations: Relation[]): Promise<void> {
|
||||
const graph = await this.loadGraph()
|
||||
graph.relations = graph.relations.filter(
|
||||
(r) =>
|
||||
!relations.some(
|
||||
(delRelation) =>
|
||||
r.from === delRelation.from && r.to === delRelation.to && r.relationType === delRelation.relationType
|
||||
)
|
||||
)
|
||||
await this.saveGraph(graph)
|
||||
}
|
||||
|
||||
async readGraph(): Promise<KnowledgeGraph> {
|
||||
return this.loadGraph()
|
||||
}
|
||||
|
||||
// Very basic search function
|
||||
async searchNodes(query: string): Promise<KnowledgeGraph> {
|
||||
const graph = await this.loadGraph()
|
||||
|
||||
// Filter entities
|
||||
const filteredEntities = graph.entities.filter(
|
||||
(e) =>
|
||||
e.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
e.entityType.toLowerCase().includes(query.toLowerCase()) ||
|
||||
e.observations.some((o) => o.toLowerCase().includes(query.toLowerCase()))
|
||||
)
|
||||
|
||||
// Create a Set of filtered entity names for quick lookup
|
||||
const filteredEntityNames = new Set(filteredEntities.map((e) => e.name))
|
||||
|
||||
// Filter relations to only include those between filtered entities
|
||||
const filteredRelations = graph.relations.filter(
|
||||
(r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)
|
||||
)
|
||||
|
||||
const filteredGraph: KnowledgeGraph = {
|
||||
entities: filteredEntities,
|
||||
relations: filteredRelations
|
||||
}
|
||||
|
||||
return filteredGraph
|
||||
}
|
||||
|
||||
async openNodes(names: string[]): Promise<KnowledgeGraph> {
|
||||
const graph = await this.loadGraph()
|
||||
|
||||
// Filter entities
|
||||
const filteredEntities = graph.entities.filter((e) => names.includes(e.name))
|
||||
|
||||
// Create a Set of filtered entity names for quick lookup
|
||||
const filteredEntityNames = new Set(filteredEntities.map((e) => e.name))
|
||||
|
||||
// Filter relations to only include those between filtered entities
|
||||
const filteredRelations = graph.relations.filter(
|
||||
(r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)
|
||||
)
|
||||
|
||||
const filteredGraph: KnowledgeGraph = {
|
||||
entities: filteredEntities,
|
||||
relations: filteredRelations
|
||||
}
|
||||
|
||||
return filteredGraph
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryServer {
|
||||
public server: Server
|
||||
private knowledgeGraphManager: KnowledgeGraphManager
|
||||
|
||||
constructor(envPath: string = '') {
|
||||
const memoryPath = envPath
|
||||
? path.isAbsolute(envPath)
|
||||
? envPath
|
||||
: path.join(path.dirname(fileURLToPath(import.meta.url)), envPath)
|
||||
: defaultMemoryPath
|
||||
this.knowledgeGraphManager = new KnowledgeGraphManager(memoryPath)
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'memory-server',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
initialize() {
|
||||
// The server instance and tools exposed to Claude
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'create_entities',
|
||||
description: 'Create multiple new entities in the knowledge graph',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entities: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'The name of the entity' },
|
||||
entityType: { type: 'string', description: 'The type of the entity' },
|
||||
observations: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'An array of observation contents associated with the entity'
|
||||
}
|
||||
},
|
||||
required: ['name', 'entityType', 'observations']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['entities']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'create_relations',
|
||||
description:
|
||||
'Create multiple new relations between entities in the knowledge graph. Relations should be in active voice',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
relations: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: { type: 'string', description: 'The name of the entity where the relation starts' },
|
||||
to: { type: 'string', description: 'The name of the entity where the relation ends' },
|
||||
relationType: { type: 'string', description: 'The type of the relation' }
|
||||
},
|
||||
required: ['from', 'to', 'relationType']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['relations']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'add_observations',
|
||||
description: 'Add new observations to existing entities in the knowledge graph',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
observations: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entityName: { type: 'string', description: 'The name of the entity to add the observations to' },
|
||||
contents: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'An array of observation contents to add'
|
||||
}
|
||||
},
|
||||
required: ['entityName', 'contents']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['observations']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'delete_entities',
|
||||
description: 'Delete multiple entities and their associated relations from the knowledge graph',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entityNames: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'An array of entity names to delete'
|
||||
}
|
||||
},
|
||||
required: ['entityNames']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'delete_observations',
|
||||
description: 'Delete specific observations from entities in the knowledge graph',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
deletions: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entityName: { type: 'string', description: 'The name of the entity containing the observations' },
|
||||
observations: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'An array of observations to delete'
|
||||
}
|
||||
},
|
||||
required: ['entityName', 'observations']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['deletions']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'delete_relations',
|
||||
description: 'Delete multiple relations from the knowledge graph',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
relations: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: { type: 'string', description: 'The name of the entity where the relation starts' },
|
||||
to: { type: 'string', description: 'The name of the entity where the relation ends' },
|
||||
relationType: { type: 'string', description: 'The type of the relation' }
|
||||
},
|
||||
required: ['from', 'to', 'relationType']
|
||||
},
|
||||
description: 'An array of relations to delete'
|
||||
}
|
||||
},
|
||||
required: ['relations']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'read_graph',
|
||||
description: 'Read the entire knowledge graph',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'search_nodes',
|
||||
description: 'Search for nodes in the knowledge graph based on a query',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'The search query to match against entity names, types, and observation content'
|
||||
}
|
||||
},
|
||||
required: ['query']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'open_nodes',
|
||||
description: 'Open specific nodes in the knowledge graph by their names',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
names: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'An array of entity names to retrieve'
|
||||
}
|
||||
},
|
||||
required: ['names']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params
|
||||
|
||||
if (!args) {
|
||||
throw new Error(`No arguments provided for tool: ${name}`)
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case 'create_entities':
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
await this.knowledgeGraphManager.createEntities(args.entities as Entity[]),
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
case 'create_relations':
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
await this.knowledgeGraphManager.createRelations(args.relations as Relation[]),
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
case 'add_observations':
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
await this.knowledgeGraphManager.addObservations(
|
||||
args.observations as { entityName: string; contents: string[] }[]
|
||||
),
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
case 'delete_entities':
|
||||
await this.knowledgeGraphManager.deleteEntities(args.entityNames as string[])
|
||||
return { content: [{ type: 'text', text: 'Entities deleted successfully' }] }
|
||||
case 'delete_observations':
|
||||
await this.knowledgeGraphManager.deleteObservations(
|
||||
args.deletions as { entityName: string; observations: string[] }[]
|
||||
)
|
||||
return { content: [{ type: 'text', text: 'Observations deleted successfully' }] }
|
||||
case 'delete_relations':
|
||||
await this.knowledgeGraphManager.deleteRelations(args.relations as Relation[])
|
||||
return { content: [{ type: 'text', text: 'Relations deleted successfully' }] }
|
||||
case 'read_graph':
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(await this.knowledgeGraphManager.readGraph(), null, 2) }]
|
||||
}
|
||||
case 'search_nodes':
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(await this.knowledgeGraphManager.searchNodes(args.query as string), null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
case 'open_nodes':
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(await this.knowledgeGraphManager.openNodes(args.names as string[]), null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default MemoryServer
|
||||
@@ -0,0 +1,289 @@
|
||||
// Sequential Thinking MCP Server
|
||||
// port https://github.com/modelcontextprotocol/servers/blob/main/src/sequentialthinking/index.ts
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
// Fixed chalk import for ESM
|
||||
import chalk from 'chalk'
|
||||
|
||||
interface ThoughtData {
|
||||
thought: string
|
||||
thoughtNumber: number
|
||||
totalThoughts: number
|
||||
isRevision?: boolean
|
||||
revisesThought?: number
|
||||
branchFromThought?: number
|
||||
branchId?: string
|
||||
needsMoreThoughts?: boolean
|
||||
nextThoughtNeeded: boolean
|
||||
}
|
||||
|
||||
class SequentialThinkingServer {
|
||||
private thoughtHistory: ThoughtData[] = []
|
||||
private branches: Record<string, ThoughtData[]> = {}
|
||||
|
||||
private validateThoughtData(input: unknown): ThoughtData {
|
||||
const data = input as Record<string, unknown>
|
||||
|
||||
if (!data.thought || typeof data.thought !== 'string') {
|
||||
throw new Error('Invalid thought: must be a string')
|
||||
}
|
||||
if (!data.thoughtNumber || typeof data.thoughtNumber !== 'number') {
|
||||
throw new Error('Invalid thoughtNumber: must be a number')
|
||||
}
|
||||
if (!data.totalThoughts || typeof data.totalThoughts !== 'number') {
|
||||
throw new Error('Invalid totalThoughts: must be a number')
|
||||
}
|
||||
if (typeof data.nextThoughtNeeded !== 'boolean') {
|
||||
throw new Error('Invalid nextThoughtNeeded: must be a boolean')
|
||||
}
|
||||
|
||||
return {
|
||||
thought: data.thought,
|
||||
thoughtNumber: data.thoughtNumber,
|
||||
totalThoughts: data.totalThoughts,
|
||||
nextThoughtNeeded: data.nextThoughtNeeded,
|
||||
isRevision: data.isRevision as boolean | undefined,
|
||||
revisesThought: data.revisesThought as number | undefined,
|
||||
branchFromThought: data.branchFromThought as number | undefined,
|
||||
branchId: data.branchId as string | undefined,
|
||||
needsMoreThoughts: data.needsMoreThoughts as boolean | undefined
|
||||
}
|
||||
}
|
||||
|
||||
private formatThought(thoughtData: ThoughtData): string {
|
||||
const { thoughtNumber, totalThoughts, thought, isRevision, revisesThought, branchFromThought, branchId } =
|
||||
thoughtData
|
||||
|
||||
let prefix = ''
|
||||
let context = ''
|
||||
|
||||
if (isRevision) {
|
||||
prefix = chalk.yellow('🔄 Revision')
|
||||
context = ` (revising thought ${revisesThought})`
|
||||
} else if (branchFromThought) {
|
||||
prefix = chalk.green('🌿 Branch')
|
||||
context = ` (from thought ${branchFromThought}, ID: ${branchId})`
|
||||
} else {
|
||||
prefix = chalk.blue('💭 Thought')
|
||||
context = ''
|
||||
}
|
||||
|
||||
const header = `${prefix} ${thoughtNumber}/${totalThoughts}${context}`
|
||||
const border = '─'.repeat(Math.max(header.length, thought.length) + 4)
|
||||
|
||||
return `
|
||||
┌${border}┐
|
||||
│ ${header} │
|
||||
├${border}┤
|
||||
│ ${thought.padEnd(border.length - 2)} │
|
||||
└${border}┘`
|
||||
}
|
||||
|
||||
public processThought(input: unknown): { content: Array<{ type: string; text: string }>; isError?: boolean } {
|
||||
try {
|
||||
const validatedInput = this.validateThoughtData(input)
|
||||
|
||||
if (validatedInput.thoughtNumber > validatedInput.totalThoughts) {
|
||||
validatedInput.totalThoughts = validatedInput.thoughtNumber
|
||||
}
|
||||
|
||||
this.thoughtHistory.push(validatedInput)
|
||||
|
||||
if (validatedInput.branchFromThought && validatedInput.branchId) {
|
||||
if (!this.branches[validatedInput.branchId]) {
|
||||
this.branches[validatedInput.branchId] = []
|
||||
}
|
||||
this.branches[validatedInput.branchId].push(validatedInput)
|
||||
}
|
||||
|
||||
const formattedThought = this.formatThought(validatedInput)
|
||||
console.error(formattedThought)
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
thoughtNumber: validatedInput.thoughtNumber,
|
||||
totalThoughts: validatedInput.totalThoughts,
|
||||
nextThoughtNeeded: validatedInput.nextThoughtNeeded,
|
||||
branches: Object.keys(this.branches),
|
||||
thoughtHistoryLength: this.thoughtHistory.length
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
status: 'failed'
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SEQUENTIAL_THINKING_TOOL: Tool = {
|
||||
name: 'sequentialthinking',
|
||||
description: `A detailed tool for dynamic and reflective problem-solving through thoughts.
|
||||
This tool helps analyze problems through a flexible thinking process that can adapt and evolve.
|
||||
Each thought can build on, question, or revise previous insights as understanding deepens.
|
||||
|
||||
When to use this tool:
|
||||
- Breaking down complex problems into steps
|
||||
- Planning and design with room for revision
|
||||
- Analysis that might need course correction
|
||||
- Problems where the full scope might not be clear initially
|
||||
- Problems that require a multi-step solution
|
||||
- Tasks that need to maintain context over multiple steps
|
||||
- Situations where irrelevant information needs to be filtered out
|
||||
|
||||
Key features:
|
||||
- You can adjust total_thoughts up or down as you progress
|
||||
- You can question or revise previous thoughts
|
||||
- You can add more thoughts even after reaching what seemed like the end
|
||||
- You can express uncertainty and explore alternative approaches
|
||||
- Not every thought needs to build linearly - you can branch or backtrack
|
||||
- Generates a solution hypothesis
|
||||
- Verifies the hypothesis based on the Chain of Thought steps
|
||||
- Repeats the process until satisfied
|
||||
- Provides a correct answer
|
||||
|
||||
Parameters explained:
|
||||
- thought: Your current thinking step, which can include:
|
||||
* Regular analytical steps
|
||||
* Revisions of previous thoughts
|
||||
* Questions about previous decisions
|
||||
* Realizations about needing more analysis
|
||||
* Changes in approach
|
||||
* Hypothesis generation
|
||||
* Hypothesis verification
|
||||
- next_thought_needed: True if you need more thinking, even if at what seemed like the end
|
||||
- thought_number: Current number in sequence (can go beyond initial total if needed)
|
||||
- total_thoughts: Current estimate of thoughts needed (can be adjusted up/down)
|
||||
- is_revision: A boolean indicating if this thought revises previous thinking
|
||||
- revises_thought: If is_revision is true, which thought number is being reconsidered
|
||||
- branch_from_thought: If branching, which thought number is the branching point
|
||||
- branch_id: Identifier for the current branch (if any)
|
||||
- needs_more_thoughts: If reaching end but realizing more thoughts needed
|
||||
|
||||
You should:
|
||||
1. Start with an initial estimate of needed thoughts, but be ready to adjust
|
||||
2. Feel free to question or revise previous thoughts
|
||||
3. Don't hesitate to add more thoughts if needed, even at the "end"
|
||||
4. Express uncertainty when present
|
||||
5. Mark thoughts that revise previous thinking or branch into new paths
|
||||
6. Ignore information that is irrelevant to the current step
|
||||
7. Generate a solution hypothesis when appropriate
|
||||
8. Verify the hypothesis based on the Chain of Thought steps
|
||||
9. Repeat the process until satisfied with the solution
|
||||
10. Provide a single, ideally correct answer as the final output
|
||||
11. Only set next_thought_needed to false when truly done and a satisfactory answer is reached`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
thought: {
|
||||
type: 'string',
|
||||
description: 'Your current thinking step'
|
||||
},
|
||||
nextThoughtNeeded: {
|
||||
type: 'boolean',
|
||||
description: 'Whether another thought step is needed'
|
||||
},
|
||||
thoughtNumber: {
|
||||
type: 'integer',
|
||||
description: 'Current thought number',
|
||||
minimum: 1
|
||||
},
|
||||
totalThoughts: {
|
||||
type: 'integer',
|
||||
description: 'Estimated total thoughts needed',
|
||||
minimum: 1
|
||||
},
|
||||
isRevision: {
|
||||
type: 'boolean',
|
||||
description: 'Whether this revises previous thinking'
|
||||
},
|
||||
revisesThought: {
|
||||
type: 'integer',
|
||||
description: 'Which thought is being reconsidered',
|
||||
minimum: 1
|
||||
},
|
||||
branchFromThought: {
|
||||
type: 'integer',
|
||||
description: 'Branching point thought number',
|
||||
minimum: 1
|
||||
},
|
||||
branchId: {
|
||||
type: 'string',
|
||||
description: 'Branch identifier'
|
||||
},
|
||||
needsMoreThoughts: {
|
||||
type: 'boolean',
|
||||
description: 'If more thoughts are needed'
|
||||
}
|
||||
},
|
||||
required: ['thought', 'nextThoughtNeeded', 'thoughtNumber', 'totalThoughts']
|
||||
}
|
||||
}
|
||||
|
||||
class ThinkingServer {
|
||||
public server: Server
|
||||
private thinkingServer: SequentialThinkingServer
|
||||
|
||||
constructor() {
|
||||
this.thinkingServer = new SequentialThinkingServer()
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'sequential-thinking-server',
|
||||
version: '0.2.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [SEQUENTIAL_THINKING_TOOL]
|
||||
}))
|
||||
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (request.params.name === 'sequentialthinking') {
|
||||
return this.thinkingServer.processThought(request.params.arguments)
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Unknown tool: ${request.params.name}`
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default ThinkingServer
|
||||
@@ -2,11 +2,13 @@ import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isLinux, isMac, isWin } from '@main/constant'
|
||||
import { createInMemoryMCPServer } from '@main/mcpServers/factory'
|
||||
import { makeSureDirExists } from '@main/utils'
|
||||
import { getBinaryName, getBinaryPath } from '@main/utils/process'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { MCPServer, MCPTool } from '@types'
|
||||
import { app } from 'electron'
|
||||
@@ -61,11 +63,25 @@ class McpService {
|
||||
|
||||
const args = [...(server.args || [])]
|
||||
|
||||
let transport: StdioClientTransport | SSEClientTransport
|
||||
let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport
|
||||
|
||||
try {
|
||||
// Create appropriate transport based on configuration
|
||||
if (server.baseUrl) {
|
||||
if (server.type === 'inMemory') {
|
||||
Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||
// start the in-memory server with the given name and environment variables
|
||||
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
|
||||
try {
|
||||
await inMemoryServer.connect(serverTransport)
|
||||
Logger.info(`[MCP] In-memory server started: ${server.name}`)
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Error starting in-memory server: ${error}`)
|
||||
throw new Error(`Failed to start in-memory server: ${error}`)
|
||||
}
|
||||
// set the client transport to the client
|
||||
transport = clientTransport
|
||||
} else if (server.baseUrl) {
|
||||
transport = new SSEClientTransport(new URL(server.baseUrl))
|
||||
} else if (server.command) {
|
||||
let cmd = server.command
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
|
||||
@@ -74,3 +75,7 @@ export function getTempDir() {
|
||||
export function getFilesDir() {
|
||||
return path.join(app.getPath('userData'), 'Data', 'Files')
|
||||
}
|
||||
|
||||
export function getConfigDir() {
|
||||
return path.join(os.homedir(), '.cherrystudio', 'config')
|
||||
}
|
||||
|
||||
+35
-35
@@ -1,42 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
#spinner {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#spinner {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: none;
|
||||
}
|
||||
#spinner img {
|
||||
width: 100px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
#spinner img {
|
||||
width: 100px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="spinner">
|
||||
<img src="/src/assets/images/logo.png" />
|
||||
</div>
|
||||
<script type="module" src="/src/init.ts"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="spinner">
|
||||
<img src="/src/assets/images/logo.png" />
|
||||
</div>
|
||||
<script type="module" src="/src/init.ts"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
h6 {
|
||||
margin: 1em 0 1em 0;
|
||||
font-weight: 800;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', sans-serif;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@@ -170,8 +171,9 @@
|
||||
th {
|
||||
background-color: var(--color-background-mute);
|
||||
font-weight: bold;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', sans-serif;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
img {
|
||||
@@ -295,10 +297,10 @@ emoji-picker {
|
||||
--border-size: 0;
|
||||
}
|
||||
|
||||
.katex-display{
|
||||
.katex-display {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
mjx-container{
|
||||
mjx-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -999,6 +999,9 @@
|
||||
"argsTooltip": "Each argument on a new line",
|
||||
"baseUrlTooltip": "Remote server base URL",
|
||||
"command": "Command",
|
||||
"sse": "Server-Sent Events(sse)",
|
||||
"stdio": "Standard Input/Output(stdio)",
|
||||
"inMemory": "Memory",
|
||||
"config_description": "Configure Model Context Protocol servers",
|
||||
"deleteError": "Failed to delete server",
|
||||
"deleteSuccess": "Server deleted successfully",
|
||||
|
||||
@@ -998,6 +998,9 @@
|
||||
"argsTooltip": "1行に1つの引数を入力してください",
|
||||
"baseUrlTooltip": "リモートURLアドレス",
|
||||
"command": "コマンド",
|
||||
"sse": "サーバー送信イベント(sse)",
|
||||
"stdio": "標準入力/出力(stdio)",
|
||||
"inMemory": "メモリ",
|
||||
"config_description": "モデルコンテキストプロトコルサーバーの設定",
|
||||
"deleteError": "サーバーの削除に失敗しました",
|
||||
"deleteSuccess": "サーバーが正常に削除されました",
|
||||
|
||||
@@ -998,6 +998,9 @@
|
||||
"argsTooltip": "Каждый аргумент с новой строки",
|
||||
"baseUrlTooltip": "Адрес удаленного URL",
|
||||
"command": "Команда",
|
||||
"sse": "События, отправляемые сервером(sse)",
|
||||
"stdio": "Стандартный ввод/вывод(stdio)",
|
||||
"inMemory": "Память",
|
||||
"config_description": "Настройка серверов протокола контекста модели",
|
||||
"deleteError": "Не удалось удалить сервер",
|
||||
"deleteSuccess": "Сервер успешно удален",
|
||||
|
||||
@@ -999,6 +999,9 @@
|
||||
"argsTooltip": "每个参数占一行",
|
||||
"baseUrlTooltip": "远程 URL 地址",
|
||||
"command": "命令",
|
||||
"sse": "服务器发送事件(sse)",
|
||||
"stdio": "标准输入/输出(stdio)",
|
||||
"inMemory": "内存",
|
||||
"config_description": "配置模型上下文协议服务器",
|
||||
"deleteError": "删除服务器失败",
|
||||
"deleteSuccess": "服务器删除成功",
|
||||
|
||||
@@ -998,6 +998,9 @@
|
||||
"argsTooltip": "每個參數佔一行",
|
||||
"baseUrlTooltip": "遠端 URL 地址",
|
||||
"command": "指令",
|
||||
"sse": "伺服器傳送事件(sse)",
|
||||
"stdio": "標準輸入/輸出(stdio)",
|
||||
"inMemory": "記憶體",
|
||||
"config_description": "設定模型上下文協議伺服器",
|
||||
"deleteError": "刪除伺服器失敗",
|
||||
"deleteSuccess": "伺服器刪除成功",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CodeOutlined } from '@ant-design/icons'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { initializeMCPServers } from '@renderer/store/mcp'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { Dropdown, Switch, Tooltip } from 'antd'
|
||||
import { FC, useRef, useState } from 'react'
|
||||
import { FC, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
@@ -13,16 +15,21 @@ interface Props {
|
||||
}
|
||||
|
||||
const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton }) => {
|
||||
const { activedMcpServers } = useMCPServers()
|
||||
const { activedMcpServers, mcpServers } = useMCPServers()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<any>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const availableMCPs = activedMcpServers.filter((server) => enabledMCPs.some((s) => s.id === server.id))
|
||||
|
||||
const buttonEnabled = availableMCPs.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
initializeMCPServers(mcpServers, dispatch)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const truncateText = (text: string, maxLength: number = 50) => {
|
||||
if (!text || text.length <= maxLength) return text
|
||||
return text.substring(0, maxLength) + '...'
|
||||
|
||||
@@ -17,7 +17,7 @@ interface Props {
|
||||
interface MCPFormValues {
|
||||
name: string
|
||||
description?: string
|
||||
serverType: 'sse' | 'stdio'
|
||||
serverType: MCPServer['type']
|
||||
baseUrl?: string
|
||||
command?: string
|
||||
registryUrl?: string
|
||||
@@ -42,19 +42,19 @@ const PipRegistry: Registry[] = [
|
||||
|
||||
const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
const { t } = useTranslation()
|
||||
const { deleteMCPServer } = useMCPServers()
|
||||
const [serverType, setServerType] = useState<'sse' | 'stdio'>('stdio')
|
||||
const { deleteMCPServer, updateMCPServer } = useMCPServers()
|
||||
const [serverType, setServerType] = useState<MCPServer['type']>('stdio')
|
||||
const [form] = Form.useForm<MCPFormValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isFormChanged, setIsFormChanged] = useState(false)
|
||||
const [loadingServer, setLoadingServer] = useState<string | null>(null)
|
||||
const { updateMCPServer } = useMCPServers()
|
||||
|
||||
const [tools, setTools] = useState<MCPTool[]>([])
|
||||
const [isShowRegistry, setIsShowRegistry] = useState(false)
|
||||
const [registry, setRegistry] = useState<Registry[]>()
|
||||
|
||||
useEffect(() => {
|
||||
const serverType = server.baseUrl ? 'sse' : 'stdio'
|
||||
const serverType: MCPServer['type'] = server.type || (server.baseUrl ? 'sse' : 'stdio')
|
||||
setServerType(serverType)
|
||||
|
||||
// Set registry UI state based on command and registryUrl
|
||||
@@ -103,7 +103,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [form.getFieldValue('serverType')])
|
||||
|
||||
const fetchTools = useCallback(async () => {
|
||||
const fetchTools = async () => {
|
||||
if (server.isActive) {
|
||||
try {
|
||||
setLoadingServer(server.id)
|
||||
@@ -119,15 +119,14 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
setLoadingServer(null)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [server.id])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Loading tools for server:', server.id, 'Active:', server.isActive)
|
||||
if (server.isActive) {
|
||||
fetchTools()
|
||||
}
|
||||
}, [server.id, server.isActive, fetchTools])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [server.id, server.isActive])
|
||||
|
||||
// Save the form data
|
||||
const onSave = async () => {
|
||||
@@ -139,6 +138,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
const mcpServer: MCPServer = {
|
||||
id: server.id,
|
||||
name: values.name,
|
||||
type: values.serverType,
|
||||
description: values.description,
|
||||
isActive: values.isActive,
|
||||
registryUrl: values.registryUrl
|
||||
@@ -171,7 +171,6 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
await window.api.mcp.restartServer(mcpServer)
|
||||
updateMCPServer({ ...mcpServer, isActive: true })
|
||||
window.message.success({ content: t('settings.mcp.updateSuccess'), key: 'mcp-update-success' })
|
||||
await fetchTools()
|
||||
setLoading(false)
|
||||
setIsFormChanged(false)
|
||||
} catch (error: any) {
|
||||
@@ -312,7 +311,9 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
<SettingTitle>
|
||||
<Flex justify="space-between" align="center" gap={5} style={{ marginRight: 10 }}>
|
||||
<ServerName className="text-nowrap">{server?.name}</ServerName>
|
||||
<Button danger icon={<DeleteOutlined />} type="text" onClick={() => onDeleteMcpServer(server)} />
|
||||
{!(server.type === 'inMemory') && (
|
||||
<Button danger icon={<DeleteOutlined />} type="text" onClick={() => onDeleteMcpServer(server)} />
|
||||
)}
|
||||
</Flex>
|
||||
<Flex align="center" gap={16}>
|
||||
<Switch
|
||||
@@ -347,8 +348,9 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
<Radio.Group
|
||||
onChange={(e) => setServerType(e.target.value)}
|
||||
options={[
|
||||
{ label: 'STDIO', value: 'stdio' },
|
||||
{ label: 'SSE', value: 'sse' }
|
||||
{ label: t('settings.mcp.stdio'), value: 'stdio' },
|
||||
{ label: t('settings.mcp.sse'), value: 'sse' },
|
||||
{ label: t('settings.mcp.inMemory'), value: 'inMemory' }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -402,6 +404,17 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
<TextArea rows={3} placeholder={`arg1\narg2`} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="env" label={t('settings.mcp.env')} tooltip={t('settings.mcp.envTooltip')}>
|
||||
<TextArea rows={3} placeholder={`KEY1=value1\nKEY2=value2`} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{serverType === 'inMemory' && (
|
||||
<>
|
||||
<Form.Item name="args" label={t('settings.mcp.args')} tooltip={t('settings.mcp.argsTooltip')}>
|
||||
<TextArea rows={3} placeholder={`arg1\narg2`} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="env" label={t('settings.mcp.env')} tooltip={t('settings.mcp.envTooltip')}>
|
||||
<TextArea rows={3} placeholder={`KEY1=value1\nKEY2=value2`} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -8,11 +8,13 @@ import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { EventEmitter } from '@renderer/services/EventService'
|
||||
import { initializeMCPServers } from '@renderer/store/mcp'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { Dropdown, MenuProps } from 'antd'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingContainer } from '..'
|
||||
@@ -26,6 +28,7 @@ const MCPSettings: FC = () => {
|
||||
const [selectedMcpServer, setSelectedMcpServer] = useState<MCPServer | null>(mcpServers[0])
|
||||
const [route, setRoute] = useState<'npx-search' | 'mcp-install' | null>(null)
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
const unsubs = [
|
||||
@@ -35,6 +38,11 @@ const MCPSettings: FC = () => {
|
||||
return () => unsubs.forEach((unsub) => unsub())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
initializeMCPServers(mcpServers, dispatch)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []) // Empty dependency array to run only once
|
||||
|
||||
const onAddMcpServer = async () => {
|
||||
const newServer = {
|
||||
id: nanoid(),
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { createSlice, nanoid, type PayloadAction } from '@reduxjs/toolkit'
|
||||
import type { MCPConfig, MCPServer } from '@renderer/types'
|
||||
|
||||
const initialState: MCPConfig = {
|
||||
servers: [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'mcp-auto-install',
|
||||
description: 'Automatically install MCP services (Beta version)',
|
||||
baseUrl: '',
|
||||
command: 'npx',
|
||||
args: ['-y', '@mcpmarket/mcp-auto-install', 'connect', '--json'],
|
||||
registryUrl: 'https://registry.npmmirror.com',
|
||||
env: {},
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
export const initialState: MCPConfig = {
|
||||
servers: []
|
||||
}
|
||||
|
||||
const mcpSlice = createSlice({
|
||||
@@ -63,3 +50,79 @@ export const selectMCP = (state: { mcp: MCPConfig }) => state.mcp
|
||||
export { mcpSlice }
|
||||
// Export the reducer as default export
|
||||
export default mcpSlice.reducer
|
||||
|
||||
export const builtinMCPServers: MCPServer[] = [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: '@cherry/mcp-auto-install',
|
||||
description: 'Automatically install MCP services (Beta version)',
|
||||
command: 'npx',
|
||||
args: ['-y', '@mcpmarket/mcp-auto-install', 'connect', '--json'],
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
name: '@cherry/memory',
|
||||
type: 'inMemory',
|
||||
description:
|
||||
'A basic implementation of persistent memory using a local knowledge graph. This lets Claude remember information about the user across chats. https://github.com/modelcontextprotocol/servers/tree/main/src/memory',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
name: '@cherry/sequentialthinking',
|
||||
type: 'inMemory',
|
||||
description:
|
||||
'An MCP server implementation that provides a tool for dynamic and reflective problem-solving through a structured thinking process.',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
name: '@cherry/brave-search',
|
||||
type: 'inMemory',
|
||||
description:
|
||||
'An MCP server implementation that integrates the Brave Search API, providing both web and local search capabilities.',
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
name: '@cherry/everything',
|
||||
type: 'inMemory',
|
||||
description:
|
||||
'This MCP server attempts to exercise all the features of the MCP protocol. It is not intended to be a useful server, but rather a test server for builders of MCP clients. It implements prompts, tools, resources, sampling, and more to showcase MCP capabilities.',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
name: '@cherry/fetch',
|
||||
type: 'inMemory',
|
||||
description: 'An MCP server for fetching URLs / Youtube video transcript.',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
name: '@cherry/filesystem',
|
||||
type: 'inMemory',
|
||||
description: 'Node.js server implementing Model Context Protocol (MCP) for filesystem operations.',
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Utility function to add servers to the MCP store during app initialization
|
||||
* @param servers Array of MCP servers to add
|
||||
* @param dispatch Redux dispatch function
|
||||
*/
|
||||
export const initializeMCPServers = (existingServers: MCPServer[], dispatch: (action: any) => void): void => {
|
||||
// Check if the existing servers already contain the built-in servers
|
||||
const serverIds = new Set(existingServers.map((server) => server.name))
|
||||
|
||||
// Filter out any built-in servers that are already present
|
||||
const newServers = builtinMCPServers.filter((server) => !serverIds.has(server.name))
|
||||
|
||||
console.log('Adding new servers:', newServers)
|
||||
// Add the new built-in servers to the existing servers
|
||||
newServers.forEach((server) => {
|
||||
dispatch(addMCPServer(server))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -227,6 +227,7 @@ export type AppInfo = {
|
||||
version: string
|
||||
isPackaged: boolean
|
||||
appPath: string
|
||||
configPath: string
|
||||
appDataPath: string
|
||||
resourcesPath: string
|
||||
filesPath: string
|
||||
@@ -365,6 +366,7 @@ export interface MCPServerParameter {
|
||||
export interface MCPServer {
|
||||
id: string
|
||||
name: string
|
||||
type?: 'stdio' | 'sse' | 'inMemory'
|
||||
description?: string
|
||||
baseUrl?: string
|
||||
command?: string
|
||||
|
||||
@@ -15,9 +15,7 @@ import {
|
||||
SimpleStringSchema,
|
||||
Tool as geminiTool
|
||||
} from '@google/generative-ai'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import store from '@renderer/store'
|
||||
import { addMCPServer } from '@renderer/store/mcp'
|
||||
import { MCPServer, MCPTool, MCPToolResponse } from '@renderer/types'
|
||||
import { ChatCompletionMessageToolCall, ChatCompletionTool } from 'openai/resources'
|
||||
|
||||
@@ -234,24 +232,6 @@ export async function callMCPTool(tool: MCPTool): Promise<any> {
|
||||
})
|
||||
|
||||
console.log(`[MCP] Tool called: ${tool.serverName} ${tool.name}`, resp)
|
||||
|
||||
if (tool.serverName === 'mcp-auto-install') {
|
||||
if (resp.data) {
|
||||
const mcpServer: MCPServer = {
|
||||
id: `f${nanoid()}`,
|
||||
name: resp.data.name,
|
||||
description: resp.data.description,
|
||||
baseUrl: resp.data.baseUrl,
|
||||
command: resp.data.command,
|
||||
args: resp.data.args,
|
||||
env: resp.data.env,
|
||||
registryUrl: '',
|
||||
isActive: false
|
||||
}
|
||||
store.dispatch(addMCPServer(mcpServer))
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
} catch (e) {
|
||||
console.error(`[MCP] Error calling Tool: ${tool.serverName} ${tool.name}`, e)
|
||||
|
||||
@@ -1,146 +1,144 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Selection Menu</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: rgba(255, 255, 255, 0.95);
|
||||
--button-bg: #f5f5f5;
|
||||
--button-hover: #e8e8e8;
|
||||
--text-color: #333;
|
||||
--border-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Selection Menu</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: rgba(80, 80, 80, 0.95);
|
||||
--button-bg: #2c2c2c;
|
||||
--button-hover: #383838;
|
||||
--text-color: #e0e0e0;
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--bg-color: rgba(255, 255, 255, 0.95);
|
||||
--button-bg: #f5f5f5;
|
||||
--button-hover: #e8e8e8;
|
||||
--text-color: #333;
|
||||
--border-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-color: rgba(80, 80, 80, 0.95);
|
||||
--button-bg: #2c2c2c;
|
||||
--button-hover: #383838;
|
||||
--text-color: #e0e0e0;
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
width: 280px;
|
||||
height: 40px;
|
||||
background: var(--bg-color);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
body {
|
||||
width: 280px;
|
||||
height: 40px;
|
||||
background: var(--bg-color);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.drag-handle::before,
|
||||
.drag-handle::after {
|
||||
content: '';
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.drag-handle {
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
flex: 1;
|
||||
margin-right: 10px;
|
||||
gap: 5px;
|
||||
}
|
||||
.drag-handle::before,
|
||||
.drag-handle::after {
|
||||
content: '';
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
outline: none;
|
||||
}
|
||||
menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
flex: 1;
|
||||
margin-right: 10px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--button-hover);
|
||||
}
|
||||
button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
button:hover {
|
||||
background: var(--button-hover);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
<body>
|
||||
<div class="drag-handle"></div>
|
||||
<menu>
|
||||
<button data-action="chat">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M20,2H4C2.9,2,2,2.9,2,4v18l4-4h14c1.1,0,2-0.9,2-2V4C22,2.9,21.1,2,20,2z M20,16H6l-2,2V4h16V16z" />
|
||||
</svg>
|
||||
提问
|
||||
</button>
|
||||
<button data-action="explanation">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12,2C6.48,2,2,6.48,2,12s4.48,10,10,10s10-4.48,10-10S17.52,2,12,2z M13,17h-2v-6h2V17z M13,9h-2V7h2V9z" />
|
||||
</svg>
|
||||
释义
|
||||
</button>
|
||||
<button data-action="translate">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M6 4h12v2H6zM6 10h12v2H6zM6 16h8v2H6z" />
|
||||
</svg>
|
||||
翻译
|
||||
</button>
|
||||
<button data-action="summary">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M14,17H4v2h10V17z M20,9H4v2h16V9z M4,15h16v-2H4V15z M4,5v2h16V5H4z" />
|
||||
</svg>
|
||||
总结
|
||||
</button>
|
||||
</menu>
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('button').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const action = button.getAttribute('data-action')
|
||||
window.api.selectionMenu.action(action)
|
||||
<body>
|
||||
<div class="drag-handle"></div>
|
||||
<menu>
|
||||
<button data-action="chat">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M20,2H4C2.9,2,2,2.9,2,4v18l4-4h14c1.1,0,2-0.9,2-2V4C22,2.9,21.1,2,20,2z M20,16H6l-2,2V4h16V16z" />
|
||||
</svg>
|
||||
提问
|
||||
</button>
|
||||
<button data-action="explanation">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12,2C6.48,2,2,6.48,2,12s4.48,10,10,10s10-4.48,10-10S17.52,2,12,2z M13,17h-2v-6h2V17z M13,9h-2V7h2V9z" />
|
||||
</svg>
|
||||
释义
|
||||
</button>
|
||||
<button data-action="translate">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M6 4h12v2H6zM6 10h12v2H6zM6 16h8v2H6z" />
|
||||
</svg>
|
||||
翻译
|
||||
</button>
|
||||
<button data-action="summary">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M14,17H4v2h10V17z M20,9H4v2h16V9z M4,15h16v-2H4V15z M4,5v2h16V5H4z" />
|
||||
</svg>
|
||||
总结
|
||||
</button>
|
||||
</menu>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('button').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const action = button.getAttribute('data-action')
|
||||
window.api.selectionMenu.action(action)
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user