mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-20 10:20:15 +01:00
🔧 feat: Initial MCP Support (Tools) (#5015)
* 📝 chore: Add comment to clarify purpose of check_updates.sh script
* feat: mcp package
* feat: add librechat-mcp package and update dependencies
* feat: refactor MCPConnectionSingleton to handle transport initialization and connection management
* feat: change private methods to public in MCPConnectionSingleton for improved accessibility
* feat: filesystem demo
* chore: everything demo and move everything under mcp workspace
* chore: move ts-node to mcp workspace
* feat: mcp examples
* feat: working sse MCP example
* refactor: rename MCPConnectionSingleton to MCPConnection for clarity
* refactor: replace MCPConnectionSingleton with MCPConnection for consistency
* refactor: manager/connections
* refactor: update MCPConnection to use type definitions from mcp types
* refactor: update MCPManager to use winston logger and enhance server initialization
* refactor: share logger between connections and manager
* refactor: add schema definitions and update MCPManager to accept logger parameter
* feat: map available MCP tools
* feat: load manifest tools
* feat: add MCP tools delimiter constant and update plugin key generation
* feat: call MCP tools
* feat: update librechat-data-provider version to 0.7.63 and enhance StdioOptionsSchema with additional properties
* refactor: simplify typing
* chore: update types/packages
* feat: MCP Tool Content parsing
* chore: update dependencies and improve package configurations
* feat: add 'mcp' directory to package and update configurations
* refactor: return CONTENT_AND_ARTIFACT format for MCP callTool
* chore: bump @librechat/agents
* WIP: MCP artifacts
* chore: bump @librechat/agents to v1.8.7
* fix: ensure filename has extension when saving base64 image
* fix: move base64 buffer conversion before filename extension check
* chore: update backend review workflow to install MCP package
* fix: use correct `mime` method
* fix: enhance file metadata with message and tool call IDs in image saving process
* fix: refactor ToolCall component to handle MCP tool calls and improve domain extraction
* fix: update ToolItem component for default isInstalled value and improve localization in ToolSelectDialog
* fix: update ToolItem component to use consistent text color for tool description
* style: add theming to ToolSelectDialog
* fix: improve domain extraction logic in ToolCall component
* refactor: conversation item theming, fix rename UI bug, optimize props, add missing types
* feat: enhance MCP options schema with base options (iconPath to start) and make transport type optional, infer based on other option fields
* fix: improve reconnection logic with parallel init and exponential backoff and enhance transport debug logging
* refactor: improve logging format
* refactor: improve logging of available tools by displaying tool names
* refactor: improve reconnection/connection logic
* feat: add MCP package build process to Dockerfile
* feat: add fallback icon for tools without an image in ToolItem component
* feat: Assistants Support for MCP Tools
* fix(build): configure rollup to use output.dir for dynamic imports
* chore: update @librechat/agents to version 1.8.8 and add @langchain/anthropic dependency
* fix: update CONFIG_VERSION to 1.2.0
This commit is contained in:
parent
0a97ad3915
commit
e391347b9e
58 changed files with 4322 additions and 234 deletions
2
packages/mcp/.gitignore
vendored
Normal file
2
packages/mcp/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
test_bundle/
|
||||
4
packages/mcp/babel.config.js
Normal file
4
packages/mcp/babel.config.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
module.exports = {
|
||||
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
|
||||
plugins: ['babel-plugin-replace-ts-export-assignment'],
|
||||
};
|
||||
18
packages/mcp/jest.config.js
Normal file
18
packages/mcp/jest.config.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
module.exports = {
|
||||
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!<rootDir>/node_modules/'],
|
||||
coveragePathIgnorePatterns: ['/node_modules/', '/dist/'],
|
||||
coverageReporters: ['text', 'cobertura'],
|
||||
testResultsProcessor: 'jest-junit',
|
||||
moduleNameMapper: {
|
||||
'^@src/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
// coverageThreshold: {
|
||||
// global: {
|
||||
// statements: 58,
|
||||
// branches: 49,
|
||||
// functions: 50,
|
||||
// lines: 57,
|
||||
// },
|
||||
// },
|
||||
restoreMocks: true,
|
||||
};
|
||||
77
packages/mcp/package.json
Normal file
77
packages/mcp/package.json
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
{
|
||||
"name": "librechat-mcp",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "MCP services for LibreChat",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.es.js",
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.es.js",
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/types/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"build": "npm run clean && rollup -c --silent --bundleConfigAsCjs",
|
||||
"build:watch": "rollup -c -w",
|
||||
"rollup:api": "npx rollup -c server-rollup.config.js --bundleConfigAsCjs",
|
||||
"test": "jest --coverage --watch",
|
||||
"test:ci": "jest --coverage --ci",
|
||||
"verify": "npm run test:ci",
|
||||
"b:clean": "bun run rimraf dist",
|
||||
"b:build": "bun run b:clean && bun run rollup -c --silent --bundleConfigAsCjs",
|
||||
"start:everything-sse": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/examples/everything/sse.ts",
|
||||
"start:everything": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/demo/everything.ts",
|
||||
"start:filesystem": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/demo/filesystem.ts",
|
||||
"start:servers": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/demo/servers.ts"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/danny-avila/LibreChat.git"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/danny-avila/LibreChat/issues"
|
||||
},
|
||||
"homepage": "https://librechat.ai",
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.21.5",
|
||||
"@babel/preset-react": "^7.18.6",
|
||||
"@babel/preset-typescript": "^7.21.0",
|
||||
"@rollup/plugin-alias": "^5.1.0",
|
||||
"@rollup/plugin-commonjs": "^25.0.2",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^15.1.0",
|
||||
"@rollup/plugin-replace": "^5.0.5",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@types/diff": "^6.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/node": "^20.3.0",
|
||||
"@types/react": "^18.2.18",
|
||||
"@types/winston": "^2.4.4",
|
||||
"jest": "^29.5.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"librechat-data-provider": "*",
|
||||
"rimraf": "^5.0.1",
|
||||
"rollup": "^4.22.4",
|
||||
"rollup-plugin-generate-package-json": "^3.2.0",
|
||||
"rollup-plugin-peer-deps-external": "^2.2.4",
|
||||
"rollup-plugin-typescript2": "^0.35.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.3",
|
||||
"diff": "^7.0.0",
|
||||
"eventsource": "^3.0.1",
|
||||
"express": "^4.21.2"
|
||||
}
|
||||
}
|
||||
46
packages/mcp/rollup.config.js
Normal file
46
packages/mcp/rollup.config.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import typescript from 'rollup-plugin-typescript2';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import pkg from './package.json';
|
||||
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import replace from '@rollup/plugin-replace';
|
||||
import terser from '@rollup/plugin-terser';
|
||||
|
||||
const plugins = [
|
||||
peerDepsExternal(),
|
||||
resolve(),
|
||||
replace({
|
||||
__IS_DEV__: process.env.NODE_ENV === 'development',
|
||||
}),
|
||||
commonjs(),
|
||||
typescript({
|
||||
tsconfig: './tsconfig.json',
|
||||
useTsconfigDeclarationDir: true,
|
||||
}),
|
||||
terser(),
|
||||
];
|
||||
|
||||
export default [
|
||||
{
|
||||
input: 'src/index.ts',
|
||||
output: [
|
||||
{
|
||||
file: pkg.main,
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
exports: 'named',
|
||||
},
|
||||
{
|
||||
file: pkg.module,
|
||||
format: 'esm',
|
||||
sourcemap: true,
|
||||
exports: 'named',
|
||||
},
|
||||
],
|
||||
...{
|
||||
external: [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {})],
|
||||
preserveSymlinks: true,
|
||||
plugins,
|
||||
},
|
||||
},
|
||||
];
|
||||
40
packages/mcp/server-rollup.config.js
Normal file
40
packages/mcp/server-rollup.config.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import path from 'path';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import alias from '@rollup/plugin-alias';
|
||||
import json from '@rollup/plugin-json';
|
||||
|
||||
const rootPath = path.resolve(__dirname, '../../');
|
||||
const rootServerPath = path.resolve(__dirname, '../../api');
|
||||
const entryPath = path.resolve(rootPath, 'api/server/index.js');
|
||||
|
||||
console.log('entryPath', entryPath);
|
||||
|
||||
// Define custom aliases here
|
||||
const customAliases = {
|
||||
entries: [{ find: '~', replacement: rootServerPath }],
|
||||
};
|
||||
|
||||
export default {
|
||||
input: entryPath,
|
||||
output: {
|
||||
file: 'test_bundle/bundle.js',
|
||||
format: 'cjs',
|
||||
},
|
||||
plugins: [
|
||||
alias(customAliases),
|
||||
resolve({
|
||||
preferBuiltins: true,
|
||||
extensions: ['.js', '.json', '.node'],
|
||||
}),
|
||||
commonjs(),
|
||||
json(),
|
||||
],
|
||||
external: (id) => {
|
||||
// More selective external function
|
||||
if (/node_modules/.test(id)) {
|
||||
return !id.startsWith('langchain/');
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
475
packages/mcp/src/connection.ts
Normal file
475
packages/mcp/src/connection.ts
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
|
||||
import { ResourceListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { Logger } from 'winston';
|
||||
import type * as t from './types/mcp.js';
|
||||
|
||||
function isStdioOptions(options: t.MCPOptions): options is t.StdioOptions {
|
||||
return 'command' in options;
|
||||
}
|
||||
|
||||
function isWebSocketOptions(options: t.MCPOptions): options is t.WebSocketOptions {
|
||||
if ('url' in options) {
|
||||
const protocol = new URL(options.url).protocol;
|
||||
return protocol === 'ws:' || protocol === 'wss:';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSSEOptions(options: t.MCPOptions): options is t.SSEOptions {
|
||||
if ('url' in options) {
|
||||
const protocol = new URL(options.url).protocol;
|
||||
return protocol !== 'ws:' && protocol !== 'wss:';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
export class MCPConnection extends EventEmitter {
|
||||
private static instance: MCPConnection | null = null;
|
||||
public client: Client;
|
||||
private transport: Transport | null = null; // Make this nullable
|
||||
private connectionState: t.ConnectionState = 'disconnected';
|
||||
private connectPromise: Promise<void> | null = null;
|
||||
private lastError: Error | null = null;
|
||||
private lastConfigUpdate = 0;
|
||||
private readonly CONFIG_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
private readonly MAX_RECONNECT_ATTEMPTS = 3;
|
||||
public readonly serverName: string;
|
||||
private shouldStopReconnecting = false;
|
||||
private isReconnecting = false;
|
||||
private isInitializing = false;
|
||||
private reconnectAttempts = 0;
|
||||
iconPath?: string;
|
||||
|
||||
constructor(serverName: string, private readonly options: t.MCPOptions, private logger?: Logger) {
|
||||
super();
|
||||
this.serverName = serverName;
|
||||
this.logger = logger;
|
||||
this.iconPath = options.iconPath;
|
||||
this.client = new Client(
|
||||
{
|
||||
name: 'librechat-mcp-client',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
public static getInstance(
|
||||
serverName: string,
|
||||
options: t.MCPOptions,
|
||||
logger?: Logger,
|
||||
): MCPConnection {
|
||||
if (!MCPConnection.instance) {
|
||||
MCPConnection.instance = new MCPConnection(serverName, options, logger);
|
||||
}
|
||||
return MCPConnection.instance;
|
||||
}
|
||||
|
||||
public static getExistingInstance(): MCPConnection | null {
|
||||
return MCPConnection.instance;
|
||||
}
|
||||
|
||||
public static async destroyInstance(): Promise<void> {
|
||||
if (MCPConnection.instance) {
|
||||
await MCPConnection.instance.disconnect();
|
||||
MCPConnection.instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
private emitError(error: unknown, errorContext: string): void {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger?.error(`[MCP][${this.serverName}] ${errorContext}: ${errorMessage}`);
|
||||
this.emit('error', new Error(`${errorContext}: ${errorMessage}`));
|
||||
}
|
||||
|
||||
private constructTransport(options: t.MCPOptions): Transport {
|
||||
try {
|
||||
let type: t.MCPOptions['type'];
|
||||
if (isStdioOptions(options)) {
|
||||
type = 'stdio';
|
||||
} else if (isWebSocketOptions(options)) {
|
||||
type = 'websocket';
|
||||
} else if (isSSEOptions(options)) {
|
||||
type = 'sse';
|
||||
} else {
|
||||
throw new Error(
|
||||
'Cannot infer transport type: options.type is not provided and cannot be inferred from other properties.',
|
||||
);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'stdio':
|
||||
if (!isStdioOptions(options)) {
|
||||
throw new Error('Invalid options for stdio transport.');
|
||||
}
|
||||
return new StdioClientTransport({
|
||||
command: options.command,
|
||||
args: options.args,
|
||||
env: options.env,
|
||||
});
|
||||
|
||||
case 'websocket':
|
||||
if (!isWebSocketOptions(options)) {
|
||||
throw new Error('Invalid options for websocket transport.');
|
||||
}
|
||||
return new WebSocketClientTransport(new URL(options.url));
|
||||
|
||||
case 'sse': {
|
||||
if (!isSSEOptions(options)) {
|
||||
throw new Error('Invalid options for sse transport.');
|
||||
}
|
||||
const url = new URL(options.url);
|
||||
this.logger?.info(`[MCP][${this.serverName}] Creating SSE transport: ${url.toString()}`);
|
||||
const transport = new SSEClientTransport(url);
|
||||
|
||||
transport.onclose = () => {
|
||||
this.logger?.info(`[MCP][${this.serverName}] SSE transport closed`);
|
||||
this.emit('connectionChange', 'disconnected');
|
||||
};
|
||||
|
||||
transport.onerror = (error) => {
|
||||
this.logger?.error(`[MCP][${this.serverName}] SSE transport error:`, error);
|
||||
this.emitError(error, 'SSE transport error:');
|
||||
};
|
||||
|
||||
transport.onmessage = (message) => {
|
||||
this.logger?.info(
|
||||
`[MCP][${this.serverName}] Message received: ${JSON.stringify(message)}`,
|
||||
);
|
||||
};
|
||||
|
||||
this.setupTransportErrorHandlers(transport);
|
||||
return transport;
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(`Unsupported transport type: ${type}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.emitError(error, 'Failed to construct transport:');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
this.isInitializing = true;
|
||||
this.on('connectionChange', (state: t.ConnectionState) => {
|
||||
this.connectionState = state;
|
||||
if (state === 'connected') {
|
||||
this.isReconnecting = false;
|
||||
this.isInitializing = false;
|
||||
this.shouldStopReconnecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
} else if (state === 'error' && !this.isReconnecting && !this.isInitializing) {
|
||||
this.handleReconnection().catch((error) => {
|
||||
this.logger?.error(`[MCP][${this.serverName}] Reconnection handler failed:`, error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.subscribeToResources();
|
||||
}
|
||||
|
||||
private async handleReconnection(): Promise<void> {
|
||||
if (this.isReconnecting || this.shouldStopReconnecting || this.isInitializing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isReconnecting = true;
|
||||
const backoffDelay = (attempt: number) => Math.min(1000 * Math.pow(2, attempt), 30000);
|
||||
|
||||
try {
|
||||
while (
|
||||
this.reconnectAttempts < this.MAX_RECONNECT_ATTEMPTS &&
|
||||
!(this.shouldStopReconnecting as boolean)
|
||||
) {
|
||||
this.reconnectAttempts++;
|
||||
const delay = backoffDelay(this.reconnectAttempts);
|
||||
|
||||
this.logger?.info(
|
||||
`[MCP][${this.serverName}] Reconnecting ${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS} (delay: ${delay}ms)`,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
||||
try {
|
||||
await this.connect();
|
||||
this.reconnectAttempts = 0;
|
||||
return;
|
||||
} catch (error) {
|
||||
this.logger?.error(`[MCP][${this.serverName}] Reconnection attempt failed:`, error);
|
||||
|
||||
if (
|
||||
this.reconnectAttempts === this.MAX_RECONNECT_ATTEMPTS ||
|
||||
(this.shouldStopReconnecting as boolean)
|
||||
) {
|
||||
this.logger?.error(`[MCP][${this.serverName}] Stopping reconnection attempts`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isReconnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private subscribeToResources(): void {
|
||||
this.client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => {
|
||||
this.invalidateCache();
|
||||
this.emit('resourcesChanged');
|
||||
});
|
||||
}
|
||||
|
||||
private invalidateCache(): void {
|
||||
// this.cachedConfig = null;
|
||||
this.lastConfigUpdate = 0;
|
||||
}
|
||||
|
||||
async connectClient(): Promise<void> {
|
||||
if (this.connectionState === 'connected') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.connectPromise) {
|
||||
return this.connectPromise;
|
||||
}
|
||||
|
||||
if (this.shouldStopReconnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit('connectionChange', 'connecting');
|
||||
|
||||
this.connectPromise = (async () => {
|
||||
try {
|
||||
if (this.transport) {
|
||||
try {
|
||||
await this.client.close();
|
||||
this.transport = null;
|
||||
} catch (error) {
|
||||
this.logger?.warn(`[MCP][${this.serverName}] Error closing connection:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.transport = this.constructTransport(this.options);
|
||||
this.setupTransportDebugHandlers();
|
||||
|
||||
const connectTimeout = 10000;
|
||||
await Promise.race([
|
||||
this.client.connect(this.transport),
|
||||
new Promise((_resolve, reject) =>
|
||||
setTimeout(() => reject(new Error('Connection timeout')), connectTimeout),
|
||||
),
|
||||
]);
|
||||
|
||||
this.connectionState = 'connected';
|
||||
this.emit('connectionChange', 'connected');
|
||||
this.reconnectAttempts = 0;
|
||||
} catch (error) {
|
||||
this.connectionState = 'error';
|
||||
this.emit('connectionChange', 'error');
|
||||
this.lastError = error instanceof Error ? error : new Error(String(error));
|
||||
throw error;
|
||||
} finally {
|
||||
this.connectPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.connectPromise;
|
||||
}
|
||||
|
||||
private setupTransportDebugHandlers(): void {
|
||||
if (!this.transport) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.transport.onmessage = (msg) => {
|
||||
this.logger?.debug(`[MCP][${this.serverName}] Transport received: ${JSON.stringify(msg)}`);
|
||||
};
|
||||
|
||||
const originalSend = this.transport.send.bind(this.transport);
|
||||
this.transport.send = async (msg) => {
|
||||
this.logger?.debug(`[MCP][${this.serverName}] Transport sending: ${JSON.stringify(msg)}`);
|
||||
return originalSend(msg);
|
||||
};
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
try {
|
||||
await this.disconnect();
|
||||
await this.connectClient();
|
||||
if (!this.isConnected()) {
|
||||
throw new Error('Connection not established');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger?.error(`[MCP][${this.serverName}] Connection failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private setupTransportErrorHandlers(transport: Transport): void {
|
||||
transport.onerror = (error) => {
|
||||
this.logger?.error(`[MCP][${this.serverName}] Transport error:`, error);
|
||||
this.emit('connectionChange', 'error');
|
||||
};
|
||||
|
||||
const errorHandler = (error: Error) => {
|
||||
try {
|
||||
this.logger?.error(`[MCP][${this.serverName}] Uncaught transport error:`, error);
|
||||
} catch {
|
||||
console.error(`[MCP][${this.serverName}] Critical error logging failed`, error);
|
||||
}
|
||||
this.emit('connectionChange', 'error');
|
||||
};
|
||||
|
||||
process.on('uncaughtException', errorHandler);
|
||||
process.on('unhandledRejection', errorHandler);
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
try {
|
||||
if (this.transport) {
|
||||
await this.client.close();
|
||||
this.transport = null;
|
||||
}
|
||||
if (this.connectionState === 'disconnected') {
|
||||
return;
|
||||
}
|
||||
this.connectionState = 'disconnected';
|
||||
this.emit('connectionChange', 'disconnected');
|
||||
} catch (error) {
|
||||
this.emit('error', error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.invalidateCache();
|
||||
this.connectPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchResources(): Promise<t.MCPResource[]> {
|
||||
try {
|
||||
const { resources } = await this.client.listResources();
|
||||
return resources;
|
||||
} catch (error) {
|
||||
this.emitError(error, 'Failed to fetch resources:');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async fetchTools() {
|
||||
try {
|
||||
const { tools } = await this.client.listTools();
|
||||
return tools;
|
||||
} catch (error) {
|
||||
this.emitError(error, 'Failed to fetch tools:');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async fetchPrompts(): Promise<t.MCPPrompt[]> {
|
||||
try {
|
||||
const { prompts } = await this.client.listPrompts();
|
||||
return prompts;
|
||||
} catch (error) {
|
||||
this.emitError(error, 'Failed to fetch prompts:');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// public async modifyConfig(config: ContinueConfig): Promise<ContinueConfig> {
|
||||
// try {
|
||||
// // Check cache
|
||||
// if (this.cachedConfig && Date.now() - this.lastConfigUpdate < this.CONFIG_TTL) {
|
||||
// return this.cachedConfig;
|
||||
// }
|
||||
|
||||
// await this.connectClient();
|
||||
|
||||
// // Fetch and process resources
|
||||
// const resources = await this.fetchResources();
|
||||
// const submenuItems = resources.map(resource => ({
|
||||
// title: resource.name,
|
||||
// description: resource.description,
|
||||
// id: resource.uri,
|
||||
// }));
|
||||
|
||||
// if (!config.contextProviders) {
|
||||
// config.contextProviders = [];
|
||||
// }
|
||||
|
||||
// config.contextProviders.push(
|
||||
// new MCPContextProvider({
|
||||
// submenuItems,
|
||||
// client: this.client,
|
||||
// }),
|
||||
// );
|
||||
|
||||
// // Fetch and process tools
|
||||
// const tools = await this.fetchTools();
|
||||
// const continueTools: Tool[] = tools.map(tool => ({
|
||||
// displayTitle: tool.name,
|
||||
// function: {
|
||||
// description: tool.description,
|
||||
// name: tool.name,
|
||||
// parameters: tool.inputSchema,
|
||||
// },
|
||||
// readonly: false,
|
||||
// type: 'function',
|
||||
// wouldLikeTo: `use the ${tool.name} tool`,
|
||||
// uri: `mcp://${tool.name}`,
|
||||
// }));
|
||||
|
||||
// config.tools = [...(config.tools || []), ...continueTools];
|
||||
|
||||
// // Fetch and process prompts
|
||||
// const prompts = await this.fetchPrompts();
|
||||
// if (!config.slashCommands) {
|
||||
// config.slashCommands = [];
|
||||
// }
|
||||
|
||||
// const slashCommands: SlashCommand[] = prompts.map(prompt =>
|
||||
// constructMcpSlashCommand(
|
||||
// this.client,
|
||||
// prompt.name,
|
||||
// prompt.description,
|
||||
// prompt.arguments?.map(a => a.name),
|
||||
// ),
|
||||
// );
|
||||
// config.slashCommands.push(...slashCommands);
|
||||
|
||||
// // Update cache
|
||||
// this.cachedConfig = config;
|
||||
// this.lastConfigUpdate = Date.now();
|
||||
|
||||
// return config;
|
||||
// } catch (error) {
|
||||
// this.emit('error', error);
|
||||
// // Return original config if modification fails
|
||||
// return config;
|
||||
// }
|
||||
// }
|
||||
|
||||
// Public getters for state information
|
||||
public getConnectionState(): t.ConnectionState {
|
||||
return this.connectionState;
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.connectionState === 'connected';
|
||||
}
|
||||
|
||||
public getLastError(): Error | null {
|
||||
return this.lastError;
|
||||
}
|
||||
}
|
||||
231
packages/mcp/src/demo/everything.ts
Normal file
231
packages/mcp/src/demo/everything.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import express from 'express';
|
||||
import { EventSource } from 'eventsource';
|
||||
import { MCPConnection } from '../connection';
|
||||
import type { MCPOptions } from '../types/mcp';
|
||||
|
||||
// Set up EventSource for Node environment
|
||||
global.EventSource = EventSource;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
let mcp: MCPConnection;
|
||||
|
||||
const initializeMCP = async () => {
|
||||
console.log('Initializing MCP with SSE transport...');
|
||||
|
||||
const mcpOptions: MCPOptions = {
|
||||
type: 'sse' as const,
|
||||
url: 'http://localhost:3001/sse',
|
||||
// type: 'stdio' as const,
|
||||
// 'command': 'npx',
|
||||
// 'args': [
|
||||
// '-y',
|
||||
// '@modelcontextprotocol/server-everything',
|
||||
// ],
|
||||
};
|
||||
|
||||
try {
|
||||
await MCPConnection.destroyInstance();
|
||||
mcp = MCPConnection.getInstance('everything', mcpOptions);
|
||||
|
||||
mcp.on('connectionChange', (state) => {
|
||||
console.log(`MCP connection state changed to: ${state}`);
|
||||
});
|
||||
|
||||
mcp.on('error', (error) => {
|
||||
console.error('MCP error:', error);
|
||||
});
|
||||
|
||||
console.log('Connecting to MCP server...');
|
||||
await mcp.connectClient();
|
||||
console.log('Connected to MCP server');
|
||||
|
||||
// Test the connection
|
||||
try {
|
||||
const resources = await mcp.fetchResources();
|
||||
console.log('Available resources:', resources);
|
||||
} catch (error) {
|
||||
console.error('Error fetching resources:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to MCP server:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// API Endpoints
|
||||
app.get('/status', (req, res) => {
|
||||
res.json({
|
||||
connected: mcp.isConnected(),
|
||||
state: mcp.getConnectionState(),
|
||||
error: mcp.getLastError()?.message,
|
||||
});
|
||||
});
|
||||
|
||||
// Resources endpoint
|
||||
app.get('/resources', async (req, res) => {
|
||||
try {
|
||||
const resources = await mcp.fetchResources();
|
||||
res.json({ resources });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Tools endpoint with all tool operations
|
||||
app.get('/tools', async (req, res) => {
|
||||
try {
|
||||
const tools = await mcp.fetchTools();
|
||||
res.json({ tools });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Echo tool endpoint
|
||||
app.post('/tools/echo', async (req, res) => {
|
||||
try {
|
||||
const { message } = req.body;
|
||||
const result = await mcp.client.callTool({
|
||||
name: 'echo',
|
||||
arguments: { message },
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Add tool endpoint
|
||||
app.post('/tools/add', async (req, res) => {
|
||||
try {
|
||||
const { a, b } = req.body;
|
||||
const result = await mcp.client.callTool({
|
||||
name: 'add',
|
||||
arguments: { a, b },
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Long running operation endpoint
|
||||
app.post('/tools/long-operation', async (req, res) => {
|
||||
try {
|
||||
const { duration, steps } = req.body;
|
||||
const result = await mcp.client.callTool({
|
||||
name: 'longRunningOperation',
|
||||
arguments: { duration, steps },
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Sample LLM endpoint
|
||||
app.post('/tools/sample', async (req, res) => {
|
||||
try {
|
||||
const { prompt, maxTokens } = req.body;
|
||||
const result = await mcp.client.callTool({
|
||||
name: 'sampleLLM',
|
||||
arguments: { prompt, maxTokens },
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get tiny image endpoint
|
||||
app.get('/tools/tiny-image', async (req, res) => {
|
||||
try {
|
||||
const result = await mcp.client.callTool({
|
||||
name: 'getTinyImage',
|
||||
arguments: {},
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Prompts endpoints
|
||||
app.get('/prompts', async (req, res) => {
|
||||
try {
|
||||
const prompts = await mcp.fetchPrompts();
|
||||
res.json({ prompts });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/prompts/simple', async (req, res) => {
|
||||
try {
|
||||
const result = await mcp.client.getPrompt({
|
||||
name: 'simple_prompt',
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/prompts/complex', async (req, res) => {
|
||||
try {
|
||||
const { temperature, style } = req.body;
|
||||
const result = await mcp.client.getPrompt({
|
||||
name: 'complex_prompt',
|
||||
arguments: { temperature, style },
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Resource subscription endpoints
|
||||
app.post('/resources/subscribe', async (req, res) => {
|
||||
try {
|
||||
const { uri } = req.body;
|
||||
await mcp.client.subscribeResource({ uri });
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/resources/unsubscribe', async (req, res) => {
|
||||
try {
|
||||
const { uri } = req.body;
|
||||
await mcp.client.unsubscribeResource({ uri });
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Error handling
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup on shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Shutting down...');
|
||||
await MCPConnection.destroyInstance();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Start server
|
||||
const PORT = process.env.MCP_PORT ?? 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
initializeMCP();
|
||||
});
|
||||
211
packages/mcp/src/demo/filesystem.ts
Normal file
211
packages/mcp/src/demo/filesystem.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import express from 'express';
|
||||
import { EventSource } from 'eventsource';
|
||||
import { MCPConnection } from '../connection';
|
||||
import type { MCPOptions } from '../types/mcp';
|
||||
|
||||
// Set up EventSource for Node environment
|
||||
global.EventSource = EventSource;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
let mcp: MCPConnection;
|
||||
|
||||
const initializeMCP = async () => {
|
||||
console.log('Initializing MCP with SSE transport...');
|
||||
|
||||
const mcpOptions: MCPOptions = {
|
||||
// type: 'sse' as const,
|
||||
// url: 'http://localhost:3001/sse',
|
||||
type: 'stdio' as const,
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-filesystem', '/home/danny/LibreChat/'],
|
||||
};
|
||||
|
||||
try {
|
||||
// Clean up any existing instance
|
||||
await MCPConnection.destroyInstance();
|
||||
|
||||
// Get singleton instance
|
||||
mcp = MCPConnection.getInstance('filesystem', mcpOptions);
|
||||
|
||||
// Add event listeners
|
||||
mcp.on('connectionChange', (state) => {
|
||||
console.log(`MCP connection state changed to: ${state}`);
|
||||
});
|
||||
|
||||
mcp.on('error', (error) => {
|
||||
console.error('MCP error:', error);
|
||||
});
|
||||
|
||||
// Connect to server
|
||||
console.log('Connecting to MCP server...');
|
||||
await mcp.connectClient();
|
||||
console.log('Connected to MCP server');
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to MCP server:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize MCP connection
|
||||
initializeMCP();
|
||||
|
||||
// API Endpoints
|
||||
app.get('/status', (req, res) => {
|
||||
res.json({
|
||||
connected: mcp.isConnected(),
|
||||
state: mcp.getConnectionState(),
|
||||
error: mcp.getLastError()?.message,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/resources', async (req, res) => {
|
||||
try {
|
||||
const resources = await mcp.fetchResources();
|
||||
res.json({ resources });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/tools', async (req, res) => {
|
||||
try {
|
||||
const tools = await mcp.fetchTools();
|
||||
res.json({ tools });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// File operations
|
||||
// @ts-ignore
|
||||
app.get('/files/read', async (req, res) => {
|
||||
const filePath = req.query.path as string;
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: 'Path parameter is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await mcp.client.callTool({
|
||||
name: 'read_file',
|
||||
arguments: { path: filePath },
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
app.post('/files/write', async (req, res) => {
|
||||
const { path, content } = req.body;
|
||||
if (!path || content === undefined) {
|
||||
return res.status(400).json({ error: 'Path and content are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await mcp.client.callTool({
|
||||
name: 'write_file',
|
||||
arguments: { path, content },
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
app.post('/files/edit', async (req, res) => {
|
||||
const { path, edits, dryRun = false } = req.body;
|
||||
if (!path || !edits) {
|
||||
return res.status(400).json({ error: 'Path and edits are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await mcp.client.callTool({
|
||||
name: 'edit_file',
|
||||
arguments: { path, edits, dryRun },
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Directory operations
|
||||
// @ts-ignore
|
||||
app.get('/directory/list', async (req, res) => {
|
||||
const dirPath = req.query.path as string;
|
||||
if (!dirPath) {
|
||||
return res.status(400).json({ error: 'Path parameter is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await mcp.client.callTool({
|
||||
name: 'list_directory',
|
||||
arguments: { path: dirPath },
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
app.post('/directory/create', async (req, res) => {
|
||||
const { path } = req.body;
|
||||
if (!path) {
|
||||
return res.status(400).json({ error: 'Path is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await mcp.client.callTool({
|
||||
name: 'create_directory',
|
||||
arguments: { path },
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Search endpoint
|
||||
// @ts-ignore
|
||||
app.get('/search', async (req, res) => {
|
||||
const { path, pattern } = req.query;
|
||||
if (!path || !pattern) {
|
||||
return res.status(400).json({ error: 'Path and pattern parameters are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await mcp.client.callTool({
|
||||
name: 'search_files',
|
||||
arguments: { path, pattern },
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Error handling
|
||||
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup on shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Shutting down...');
|
||||
await MCPConnection.destroyInstance();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Start server
|
||||
const PORT = process.env.MCP_PORT || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
226
packages/mcp/src/demo/servers.ts
Normal file
226
packages/mcp/src/demo/servers.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
// server.ts
|
||||
import express from 'express';
|
||||
import { EventSource } from 'eventsource';
|
||||
import { MCPManager } from '../manager';
|
||||
import { MCPConnection } from '../connection';
|
||||
import type * as t from '../types/mcp';
|
||||
|
||||
// Set up EventSource for Node environment
|
||||
global.EventSource = EventSource;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const mcpManager = MCPManager.getInstance();
|
||||
|
||||
const mcpServers: t.MCPServers = {
|
||||
everything: {
|
||||
type: 'sse' as const,
|
||||
url: 'http://localhost:3001/sse',
|
||||
},
|
||||
filesystem: {
|
||||
type: 'stdio' as const,
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-filesystem', '/home/danny/LibreChat/'],
|
||||
},
|
||||
};
|
||||
|
||||
// Generic helper to get connection and handle errors
|
||||
const withConnection = async (
|
||||
serverName: string,
|
||||
res: express.Response,
|
||||
callback: (connection: MCPConnection) => Promise<void>,
|
||||
) => {
|
||||
const connection = mcpManager.getConnection(serverName);
|
||||
if (!connection) {
|
||||
return res.status(404).json({ error: `Server "${serverName}" not found` });
|
||||
}
|
||||
try {
|
||||
await callback(connection);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// Common endpoints for all servers
|
||||
// @ts-ignore
|
||||
app.get('/status/:server', (req, res) => {
|
||||
const connection = mcpManager.getConnection(req.params.server);
|
||||
if (!connection) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
connected: connection.isConnected(),
|
||||
state: connection.getConnectionState(),
|
||||
error: connection.getLastError()?.message,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/resources/:server', async (req, res) => {
|
||||
await withConnection(req.params.server, res, async (connection) => {
|
||||
const resources = await connection.fetchResources();
|
||||
res.json({ resources });
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/tools/:server', async (req, res) => {
|
||||
await withConnection(req.params.server, res, async (connection) => {
|
||||
const tools = await connection.fetchTools();
|
||||
res.json({ tools });
|
||||
});
|
||||
});
|
||||
|
||||
// "Everything" server specific endpoints
|
||||
app.post('/everything/tools/echo', async (req, res) => {
|
||||
await withConnection('everything', res, async (connection) => {
|
||||
const { message } = req.body;
|
||||
const result = await connection.client.callTool({
|
||||
name: 'echo',
|
||||
arguments: { message },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/everything/tools/add', async (req, res) => {
|
||||
await withConnection('everything', res, async (connection) => {
|
||||
const { a, b } = req.body;
|
||||
const result = await connection.client.callTool({
|
||||
name: 'add',
|
||||
arguments: { a, b },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/everything/tools/long-operation', async (req, res) => {
|
||||
await withConnection('everything', res, async (connection) => {
|
||||
const { duration, steps } = req.body;
|
||||
const result = await connection.client.callTool({
|
||||
name: 'longRunningOperation',
|
||||
arguments: { duration, steps },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
});
|
||||
|
||||
// Filesystem server specific endpoints
|
||||
// @ts-ignore
|
||||
app.get('/filesystem/files/read', async (req, res) => {
|
||||
const filePath = req.query.path as string;
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: 'Path parameter is required' });
|
||||
}
|
||||
|
||||
await withConnection('filesystem', res, async (connection) => {
|
||||
const result = await connection.client.callTool({
|
||||
name: 'read_file',
|
||||
arguments: { path: filePath },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
app.post('/filesystem/files/write', async (req, res) => {
|
||||
const { path, content } = req.body;
|
||||
if (!path || content === undefined) {
|
||||
return res.status(400).json({ error: 'Path and content are required' });
|
||||
}
|
||||
|
||||
await withConnection('filesystem', res, async (connection) => {
|
||||
const result = await connection.client.callTool({
|
||||
name: 'write_file',
|
||||
arguments: { path, content },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
app.post('/filesystem/files/edit', async (req, res) => {
|
||||
const { path, edits, dryRun = false } = req.body;
|
||||
if (!path || !edits) {
|
||||
return res.status(400).json({ error: 'Path and edits are required' });
|
||||
}
|
||||
|
||||
await withConnection('filesystem', res, async (connection) => {
|
||||
const result = await connection.client.callTool({
|
||||
name: 'edit_file',
|
||||
arguments: { path, edits, dryRun },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
app.get('/filesystem/directory/list', async (req, res) => {
|
||||
const dirPath = req.query.path as string;
|
||||
if (!dirPath) {
|
||||
return res.status(400).json({ error: 'Path parameter is required' });
|
||||
}
|
||||
|
||||
await withConnection('filesystem', res, async (connection) => {
|
||||
const result = await connection.client.callTool({
|
||||
name: 'list_directory',
|
||||
arguments: { path: dirPath },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
app.post('/filesystem/directory/create', async (req, res) => {
|
||||
const { path } = req.body;
|
||||
if (!path) {
|
||||
return res.status(400).json({ error: 'Path is required' });
|
||||
}
|
||||
|
||||
await withConnection('filesystem', res, async (connection) => {
|
||||
const result = await connection.client.callTool({
|
||||
name: 'create_directory',
|
||||
arguments: { path },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
app.get('/filesystem/search', async (req, res) => {
|
||||
const { path, pattern } = req.query;
|
||||
if (!path || !pattern) {
|
||||
return res.status(400).json({ error: 'Path and pattern parameters are required' });
|
||||
}
|
||||
|
||||
await withConnection('filesystem', res, async (connection) => {
|
||||
const result = await connection.client.callTool({
|
||||
name: 'search_files',
|
||||
arguments: { path, pattern },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
});
|
||||
|
||||
// Error handling
|
||||
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup on shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Shutting down...');
|
||||
await MCPManager.destroyInstance();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Start server
|
||||
const PORT = process.env.MCP_PORT ?? 3000;
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
await mcpManager.initializeMCP(mcpServers);
|
||||
});
|
||||
3
packages/mcp/src/enum.ts
Normal file
3
packages/mcp/src/enum.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export enum CONSTANTS {
|
||||
mcp_delimiter = '_mcp_',
|
||||
}
|
||||
426
packages/mcp/src/examples/everything/everything.ts
Normal file
426
packages/mcp/src/examples/everything/everything.ts
Normal file
File diff suppressed because one or more lines are too long
23
packages/mcp/src/examples/everything/index.ts
Normal file
23
packages/mcp/src/examples/everything/index.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { createServer } from './everything';
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
const { server, cleanup } = createServer();
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
// Cleanup on exit
|
||||
process.on('SIGINT', async () => {
|
||||
await cleanup();
|
||||
await server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Server error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
24
packages/mcp/src/examples/everything/sse.ts
Normal file
24
packages/mcp/src/examples/everything/sse.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import express from 'express';
|
||||
import { createServer } from './everything.js';
|
||||
const app = express();
|
||||
const { server, cleanup } = createServer();
|
||||
let transport: SSEServerTransport;
|
||||
app.get('/sse', async (req, res) => {
|
||||
console.log('Received connection');
|
||||
transport = new SSEServerTransport('/message', res);
|
||||
await server.connect(transport);
|
||||
server.onclose = async () => {
|
||||
await cleanup();
|
||||
await server.close();
|
||||
process.exit(0);
|
||||
};
|
||||
});
|
||||
app.post('/message', async (req, res) => {
|
||||
console.log('Received message');
|
||||
await transport.handlePostMessage(req, res);
|
||||
});
|
||||
const PORT = process.env.SSE_PORT ?? 3001;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
});
|
||||
700
packages/mcp/src/examples/filesystem.ts
Normal file
700
packages/mcp/src/examples/filesystem.ts
Normal file
|
|
@ -0,0 +1,700 @@
|
|||
#!/usr/bin/env node
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import {
|
||||
JSONRPCMessage,
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
InitializeRequestSchema,
|
||||
ToolSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { diffLines, createTwoFilesPatch } from 'diff';
|
||||
import { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { minimatch } from 'minimatch';
|
||||
import express from 'express';
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
return path.normalize(p).toLowerCase();
|
||||
}
|
||||
|
||||
function expandHome(filepath: string): string {
|
||||
if (filepath.startsWith('~/') || filepath === '~') {
|
||||
return path.join(os.homedir(), filepath.slice(1));
|
||||
}
|
||||
return filepath;
|
||||
}
|
||||
|
||||
// Command line argument parsing
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Parse command line arguments for transport type
|
||||
const transportArg = args.find((arg) => arg.startsWith('--transport='));
|
||||
const portArg = args.find((arg) => arg.startsWith('--port='));
|
||||
const directories = args.filter((arg) => !arg.startsWith('--'));
|
||||
|
||||
if (directories.length === 0) {
|
||||
console.error(
|
||||
'Usage: mcp-server-filesystem [--transport=stdio|sse] [--port=3000] <allowed-directory> [additional-directories...]',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Extract transport type and port from arguments
|
||||
const transport = transportArg ? (transportArg.split('=')[1] as 'stdio' | 'sse') : 'stdio';
|
||||
|
||||
const port = portArg ? parseInt(portArg.split('=')[1], 10) : undefined;
|
||||
|
||||
// Store allowed directories in normalized form
|
||||
const allowedDirectories = directories.map((dir) => normalizePath(path.resolve(expandHome(dir))));
|
||||
|
||||
// Validate that all directories exist and are accessible
|
||||
/** @ts-ignore */
|
||||
await Promise.all(
|
||||
directories.map(async (dir) => {
|
||||
try {
|
||||
const stats = await fs.stat(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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Security utilities
|
||||
async function validatePath(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 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(),
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Server setup
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'secure-filesystem-server',
|
||||
version: '0.2.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// 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(
|
||||
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(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 = '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;
|
||||
}
|
||||
|
||||
// Tool handlers
|
||||
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: '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: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
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(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(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(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(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(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(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 'move_file': {
|
||||
const parsed = MoveFileArgsSchema.safeParse(args);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for move_file: ${parsed.error}`);
|
||||
}
|
||||
const validSourcePath = await validatePath(parsed.data.source);
|
||||
const validDestPath = await validatePath(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(parsed.data.path);
|
||||
const results = await searchFiles(
|
||||
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(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${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,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
// async function runServer() {
|
||||
// const transport = new StdioServerTransport();
|
||||
// await server.connect(transport);
|
||||
// console.error('Secure MCP Filesystem Server running on stdio');
|
||||
// console.error('Allowed directories:', allowedDirectories);
|
||||
// }
|
||||
|
||||
// runServer().catch((error) => {
|
||||
// console.error('Fatal error running server:', error);
|
||||
// process.exit(1);
|
||||
// });
|
||||
|
||||
async function runServer(transport: 'stdio' | 'sse', port?: number) {
|
||||
if (transport === 'stdio') {
|
||||
const stdioTransport = new StdioServerTransport();
|
||||
await server.connect(stdioTransport);
|
||||
console.error('Secure MCP Filesystem Server running on stdio');
|
||||
console.error('Allowed directories:', allowedDirectories);
|
||||
} else {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Set up CORS
|
||||
app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(200);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
let transport: SSEServerTransport;
|
||||
|
||||
// SSE endpoint
|
||||
app.get('/sse', async (req, res) => {
|
||||
console.log('New SSE connection');
|
||||
transport = new SSEServerTransport('/message', res);
|
||||
await server.connect(transport);
|
||||
|
||||
// Cleanup on close
|
||||
res.on('close', async () => {
|
||||
console.log('SSE connection closed');
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
// Message endpoint
|
||||
app.post('/message', async (req, res) => {
|
||||
if (!transport) {
|
||||
return res.status(503).send('SSE connection not established');
|
||||
}
|
||||
await transport.handlePostMessage(req, res);
|
||||
});
|
||||
|
||||
const serverPort = port || 3001;
|
||||
app.listen(serverPort, () => {
|
||||
console.log(
|
||||
`Secure MCP Filesystem Server running on SSE at http://localhost:${serverPort}/sse`,
|
||||
);
|
||||
console.log('Allowed directories:', allowedDirectories);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (directories.length === 0) {
|
||||
console.error(
|
||||
'Usage: mcp-server-filesystem [--transport=stdio|sse] [--port=3000] <allowed-directory> [additional-directories...]',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Start the server with the specified transport
|
||||
runServer(transport, port).catch((error) => {
|
||||
console.error('Fatal error running server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
4
packages/mcp/src/index.ts
Normal file
4
packages/mcp/src/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/* MCP */
|
||||
export * from './manager';
|
||||
/* types */
|
||||
export type * from './types/mcp';
|
||||
238
packages/mcp/src/manager.ts
Normal file
238
packages/mcp/src/manager.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { JsonSchemaType } from 'librechat-data-provider';
|
||||
import type { Logger } from 'winston';
|
||||
import type * as t from './types/mcp';
|
||||
import { formatToolContent } from './parsers';
|
||||
import { MCPConnection } from './connection';
|
||||
import { CONSTANTS } from './enum';
|
||||
|
||||
export class MCPManager {
|
||||
private static instance: MCPManager | null = null;
|
||||
private connections: Map<string, MCPConnection> = new Map();
|
||||
private logger: Logger;
|
||||
|
||||
private static getDefaultLogger(): Logger {
|
||||
return {
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
info: console.info,
|
||||
debug: console.debug,
|
||||
} as Logger;
|
||||
}
|
||||
|
||||
private constructor(logger?: Logger) {
|
||||
this.logger = logger || MCPManager.getDefaultLogger();
|
||||
}
|
||||
|
||||
public static getInstance(logger?: Logger): MCPManager {
|
||||
if (!MCPManager.instance) {
|
||||
MCPManager.instance = new MCPManager(logger);
|
||||
}
|
||||
return MCPManager.instance;
|
||||
}
|
||||
|
||||
public async initializeMCP(mcpServers: t.MCPServers): Promise<void> {
|
||||
this.logger.info('[MCP] Initializing servers');
|
||||
|
||||
const entries = Object.entries(mcpServers);
|
||||
const initializedServers = new Set();
|
||||
const connectionResults = await Promise.allSettled(
|
||||
entries.map(async ([serverName, config], i) => {
|
||||
const connection = new MCPConnection(serverName, config, this.logger);
|
||||
|
||||
connection.on('connectionChange', (state) => {
|
||||
this.logger.info(`[MCP][${serverName}] Connection state: ${state}`);
|
||||
});
|
||||
|
||||
try {
|
||||
const connectionTimeout = new Promise<void>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Connection timeout')), 30000),
|
||||
);
|
||||
|
||||
const connectionAttempt = this.initializeServer(connection, serverName);
|
||||
await Promise.race([connectionAttempt, connectionTimeout]);
|
||||
|
||||
if (connection.isConnected()) {
|
||||
initializedServers.add(i);
|
||||
this.connections.set(serverName, connection);
|
||||
|
||||
const serverCapabilities = connection.client.getServerCapabilities();
|
||||
this.logger.info(
|
||||
`[MCP][${serverName}] Capabilities: ${JSON.stringify(serverCapabilities)}`,
|
||||
);
|
||||
|
||||
if (serverCapabilities?.tools) {
|
||||
const tools = await connection.client.listTools();
|
||||
if (tools.tools.length) {
|
||||
this.logger.info(
|
||||
`[MCP][${serverName}] Available tools: ${tools.tools
|
||||
.map((tool) => tool.name)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`[MCP][${serverName}] Initialization failed`, error);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const failedConnections = connectionResults.filter(
|
||||
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||
);
|
||||
|
||||
this.logger.info(`[MCP] Initialized ${initializedServers.size}/${entries.length} server(s)`);
|
||||
|
||||
if (failedConnections.length > 0) {
|
||||
this.logger.warn(
|
||||
`[MCP] ${failedConnections.length}/${entries.length} server(s) failed to initialize`,
|
||||
);
|
||||
}
|
||||
|
||||
entries.forEach(([serverName], index) => {
|
||||
if (initializedServers.has(index)) {
|
||||
this.logger.info(`[MCP][${serverName}] ✓ Initialized`);
|
||||
} else {
|
||||
this.logger.info(`[MCP][${serverName}] ✗ Failed`);
|
||||
}
|
||||
});
|
||||
|
||||
if (initializedServers.size === entries.length) {
|
||||
this.logger.info('[MCP] All servers initialized successfully');
|
||||
} else if (initializedServers.size === 0) {
|
||||
this.logger.error('[MCP] No servers initialized');
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeServer(connection: MCPConnection, serverName: string): Promise<void> {
|
||||
const maxAttempts = 3;
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
await connection.connect();
|
||||
|
||||
if (connection.isConnected()) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
attempts++;
|
||||
|
||||
if (attempts === maxAttempts) {
|
||||
this.logger.error(`[MCP][${serverName}] Failed after ${maxAttempts} attempts`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000 * attempts));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getConnection(serverName: string): MCPConnection | undefined {
|
||||
return this.connections.get(serverName);
|
||||
}
|
||||
|
||||
public getAllConnections(): Map<string, MCPConnection> {
|
||||
return this.connections;
|
||||
}
|
||||
|
||||
public async mapAvailableTools(availableTools: t.LCAvailableTools): Promise<void> {
|
||||
for (const [serverName, connection] of this.connections.entries()) {
|
||||
try {
|
||||
if (connection.isConnected() !== true) {
|
||||
this.logger.warn(`Connection ${serverName} is not connected. Skipping tool fetch.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tools = await connection.fetchTools();
|
||||
for (const tool of tools) {
|
||||
const name = `${tool.name}${CONSTANTS.mcp_delimiter}${serverName}`;
|
||||
availableTools[name] = {
|
||||
type: 'function',
|
||||
['function']: {
|
||||
name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema as JsonSchemaType,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`[MCP][${serverName}] Not connected, skipping tool fetch`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async loadManifestTools(manifestTools: t.LCToolManifest): Promise<void> {
|
||||
for (const [serverName, connection] of this.connections.entries()) {
|
||||
try {
|
||||
if (connection.isConnected() !== true) {
|
||||
this.logger.warn(`Connection ${serverName} is not connected. Skipping tool fetch.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tools = await connection.fetchTools();
|
||||
for (const tool of tools) {
|
||||
const pluginKey = `${tool.name}${CONSTANTS.mcp_delimiter}${serverName}`;
|
||||
manifestTools.push({
|
||||
name: tool.name,
|
||||
pluginKey,
|
||||
description: tool.description ?? '',
|
||||
icon: connection.iconPath,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`[MCP][${serverName}] Error fetching tools`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async callTool(
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
provider: t.Provider,
|
||||
toolArguments?: Record<string, unknown>,
|
||||
): Promise<t.FormattedToolResponse> {
|
||||
const connection = this.connections.get(serverName);
|
||||
if (!connection) {
|
||||
throw new Error(
|
||||
`No connection found for server: ${serverName}. Please make sure to use MCP servers available under 'Connected MCP Servers'.`,
|
||||
);
|
||||
}
|
||||
const result = await connection.client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: toolArguments,
|
||||
},
|
||||
},
|
||||
CallToolResultSchema,
|
||||
);
|
||||
return formatToolContent(result, provider);
|
||||
}
|
||||
|
||||
public async disconnectServer(serverName: string): Promise<void> {
|
||||
const connection = this.connections.get(serverName);
|
||||
if (connection) {
|
||||
await connection.disconnect();
|
||||
this.connections.delete(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
public async disconnectAll(): Promise<void> {
|
||||
const disconnectPromises = Array.from(this.connections.values()).map((connection) =>
|
||||
connection.disconnect(),
|
||||
);
|
||||
await Promise.all(disconnectPromises);
|
||||
this.connections.clear();
|
||||
}
|
||||
|
||||
public static async destroyInstance(): Promise<void> {
|
||||
if (MCPManager.instance) {
|
||||
await MCPManager.instance.disconnectAll();
|
||||
MCPManager.instance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
157
packages/mcp/src/parsers.ts
Normal file
157
packages/mcp/src/parsers.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import type * as t from './types/mcp';
|
||||
const RECOGNIZED_PROVIDERS = new Set(['google', 'anthropic', 'openAI']);
|
||||
|
||||
const imageFormatters: Record<string, undefined | t.ImageFormatter> = {
|
||||
// google: (item) => ({
|
||||
// type: 'image',
|
||||
// inlineData: {
|
||||
// mimeType: item.mimeType,
|
||||
// data: item.data,
|
||||
// },
|
||||
// }),
|
||||
// anthropic: (item) => ({
|
||||
// type: 'image',
|
||||
// source: {
|
||||
// type: 'base64',
|
||||
// media_type: item.mimeType,
|
||||
// data: item.data,
|
||||
// },
|
||||
// }),
|
||||
default: (item) => ({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: item.data.startsWith('http') ? item.data : `data:${item.mimeType};base64,${item.data}`,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
function isImageContent(item: t.ToolContentPart): item is t.ImageContent {
|
||||
return item.type === 'image';
|
||||
}
|
||||
|
||||
function parseAsString(result: t.MCPToolCallResponse): string {
|
||||
const content = result?.content ?? [];
|
||||
if (!content.length) {
|
||||
return '(No response)';
|
||||
}
|
||||
|
||||
const text = content
|
||||
.map((item) => {
|
||||
if (item.type === 'text') {
|
||||
return item.text;
|
||||
}
|
||||
if (item.type === 'resource') {
|
||||
const resourceText = [];
|
||||
if (item.resource.text != null && item.resource.text) {
|
||||
resourceText.push(item.resource.text);
|
||||
}
|
||||
if (item.resource.uri) {
|
||||
resourceText.push(`Resource URI: ${item.resource.uri}`);
|
||||
}
|
||||
if (item.resource.mimeType != null && item.resource.mimeType) {
|
||||
resourceText.push(`Type: ${item.resource.mimeType}`);
|
||||
}
|
||||
return resourceText.join('\n');
|
||||
}
|
||||
return JSON.stringify(item, null, 2);
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts MCPToolCallResponse content into recognized content block types
|
||||
* Recognized types: "image", "image_url", "text", "json"
|
||||
*
|
||||
* @param {t.MCPToolCallResponse} result - The MCPToolCallResponse object
|
||||
* @param {string} provider - The provider name (google, anthropic, openai)
|
||||
* @returns {Array<Object>} Formatted content blocks
|
||||
*/
|
||||
/**
|
||||
* Converts MCPToolCallResponse content into recognized content block types
|
||||
* First element: string or formatted content (excluding image_url)
|
||||
* Second element: image_url content if any
|
||||
*
|
||||
* @param {t.MCPToolCallResponse} result - The MCPToolCallResponse object
|
||||
* @param {string} provider - The provider name (google, anthropic, openai)
|
||||
* @returns {t.FormattedToolResponse} Tuple of content and image_urls
|
||||
*/
|
||||
export function formatToolContent(
|
||||
result: t.MCPToolCallResponse,
|
||||
provider: t.Provider,
|
||||
): t.FormattedToolResponse {
|
||||
if (!RECOGNIZED_PROVIDERS.has(provider)) {
|
||||
return [parseAsString(result), undefined];
|
||||
}
|
||||
|
||||
const content = result?.content ?? [];
|
||||
if (!content.length) {
|
||||
return [[{ type: 'text', text: '(No response)' }], undefined];
|
||||
}
|
||||
|
||||
const formattedContent: t.FormattedContent[] = [];
|
||||
const imageUrls: t.FormattedContent[] = [];
|
||||
let currentTextBlock = '';
|
||||
|
||||
type ContentHandler = undefined | ((item: t.ToolContentPart) => void);
|
||||
|
||||
const contentHandlers: {
|
||||
text: (item: Extract<t.ToolContentPart, { type: 'text' }>) => void;
|
||||
image: (item: t.ToolContentPart) => void;
|
||||
resource: (item: Extract<t.ToolContentPart, { type: 'resource' }>) => void;
|
||||
} = {
|
||||
text: (item) => {
|
||||
currentTextBlock += (currentTextBlock ? '\n\n' : '') + item.text;
|
||||
},
|
||||
|
||||
image: (item) => {
|
||||
if (!isImageContent(item)) {
|
||||
return;
|
||||
}
|
||||
if (currentTextBlock) {
|
||||
formattedContent.push({ type: 'text', text: currentTextBlock });
|
||||
currentTextBlock = '';
|
||||
}
|
||||
const formatter = imageFormatters.default as t.ImageFormatter;
|
||||
const formattedImage = formatter(item);
|
||||
|
||||
if (formattedImage.type === 'image_url') {
|
||||
imageUrls.push(formattedImage);
|
||||
} else {
|
||||
formattedContent.push(formattedImage);
|
||||
}
|
||||
},
|
||||
|
||||
resource: (item) => {
|
||||
const resourceText = [];
|
||||
if (item.resource.text != null && item.resource.text) {
|
||||
resourceText.push(item.resource.text);
|
||||
}
|
||||
if (item.resource.uri.length) {
|
||||
resourceText.push(`Resource URI: ${item.resource.uri}`);
|
||||
}
|
||||
if (item.resource.mimeType != null && item.resource.mimeType) {
|
||||
resourceText.push(`Type: ${item.resource.mimeType}`);
|
||||
}
|
||||
currentTextBlock += (currentTextBlock ? '\n\n' : '') + resourceText.join('\n');
|
||||
},
|
||||
};
|
||||
|
||||
for (const item of content) {
|
||||
const handler = contentHandlers[item.type as keyof typeof contentHandlers] as ContentHandler;
|
||||
if (handler) {
|
||||
handler(item as never);
|
||||
} else {
|
||||
const stringified = JSON.stringify(item, null, 2);
|
||||
currentTextBlock += (currentTextBlock ? '\n\n' : '') + stringified;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTextBlock) {
|
||||
formattedContent.push({ type: 'text', text: currentTextBlock });
|
||||
}
|
||||
|
||||
return [formattedContent, imageUrls.length ? { content: imageUrls } : undefined];
|
||||
}
|
||||
109
packages/mcp/src/types/mcp.ts
Normal file
109
packages/mcp/src/types/mcp.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { z } from 'zod';
|
||||
import {
|
||||
SSEOptionsSchema,
|
||||
MCPOptionsSchema,
|
||||
MCPServersSchema,
|
||||
StdioOptionsSchema,
|
||||
WebSocketOptionsSchema,
|
||||
} from 'librechat-data-provider';
|
||||
import type { JsonSchemaType, TPlugin } from 'librechat-data-provider';
|
||||
import { ToolSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export type StdioOptions = z.infer<typeof StdioOptionsSchema>;
|
||||
export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>;
|
||||
export type SSEOptions = z.infer<typeof SSEOptionsSchema>;
|
||||
export type MCPOptions = z.infer<typeof MCPOptionsSchema>;
|
||||
export type MCPServers = z.infer<typeof MCPServersSchema>;
|
||||
export interface MCPResource {
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
export interface LCTool {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters: JsonSchemaType;
|
||||
}
|
||||
|
||||
export interface LCFunctionTool {
|
||||
type: 'function';
|
||||
['function']: LCTool;
|
||||
}
|
||||
|
||||
export type LCAvailableTools = Record<string, LCFunctionTool>;
|
||||
|
||||
export type LCToolManifest = TPlugin[];
|
||||
export interface MCPPrompt {
|
||||
name: string;
|
||||
description?: string;
|
||||
arguments?: Array<{ name: string }>;
|
||||
}
|
||||
|
||||
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
export type MCPTool = z.infer<typeof ToolSchema>;
|
||||
export type MCPToolListResponse = z.infer<typeof ListToolsResultSchema>;
|
||||
export type ToolContentPart =
|
||||
| {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: 'image';
|
||||
data: string;
|
||||
mimeType: string;
|
||||
}
|
||||
| {
|
||||
type: 'resource';
|
||||
resource: {
|
||||
uri: string;
|
||||
mimeType?: string;
|
||||
text?: string;
|
||||
blob?: string;
|
||||
};
|
||||
};
|
||||
export type ImageContent = Extract<ToolContentPart, { type: 'image' }>;
|
||||
export type MCPToolCallResponse =
|
||||
| undefined
|
||||
| {
|
||||
_meta?: Record<string, unknown>;
|
||||
content?: Array<ToolContentPart>;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
export type Provider = 'google' | 'anthropic' | 'openAI';
|
||||
|
||||
export type FormattedContent =
|
||||
| {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: 'image';
|
||||
inlineData: {
|
||||
mimeType: string;
|
||||
data: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'image';
|
||||
source: {
|
||||
type: 'base64';
|
||||
media_type: string;
|
||||
data: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'image_url';
|
||||
image_url: {
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ImageFormatter = (item: ImageContent) => FormattedContent;
|
||||
|
||||
export type FormattedToolResponse = [
|
||||
string | FormattedContent[],
|
||||
{ content: FormattedContent[] } | undefined,
|
||||
];
|
||||
23
packages/mcp/tsconfig-paths-bootstrap.mjs
Normal file
23
packages/mcp/tsconfig-paths-bootstrap.mjs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
// @ts-ignore
|
||||
import { resolve as resolveTs } from 'ts-node/esm';
|
||||
import * as tsConfigPaths from 'tsconfig-paths';
|
||||
|
||||
// @ts-ignore
|
||||
const { absoluteBaseUrl, paths } = tsConfigPaths.loadConfig(
|
||||
path.resolve('./tsconfig.json'), // Updated path
|
||||
);
|
||||
const matchPath = tsConfigPaths.createMatchPath(absoluteBaseUrl, paths);
|
||||
|
||||
export function resolve(specifier, context, defaultResolve) {
|
||||
const match = matchPath(specifier);
|
||||
if (match) {
|
||||
return resolveTs(pathToFileURL(match).href, context, defaultResolve);
|
||||
}
|
||||
return resolveTs(specifier, context, defaultResolve);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
export { load, getFormat, transformSource } from 'ts-node/esm';
|
||||
// node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ../../api/demo/everything.ts
|
||||
30
packages/mcp/tsconfig.json
Normal file
30
packages/mcp/tsconfig.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationDir": "./dist/types",
|
||||
"module": "esnext",
|
||||
"noImplicitAny": true,
|
||||
"outDir": "./types",
|
||||
"target": "es2015",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"lib": ["es2017", "dom", "ES2021.String"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": "." // This should be the root of your package
|
||||
},
|
||||
"ts-node": {
|
||||
"experimentalSpecifierResolution": "node",
|
||||
"transpileOnly": true,
|
||||
"esm": true
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "types"],
|
||||
"include": ["src/**/*", "types/index.d.ts", "types/react-query/index.d.ts"]
|
||||
}
|
||||
10
packages/mcp/tsconfig.spec.json
Normal file
10
packages/mcp/tsconfig.spec.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"outDir": "./dist/tests",
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["specs/**/*", "src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue