Compare commits

..

1 Commits

Author SHA1 Message Date
samanhappy
a55405b974 feat: add cluster routing support 2025-10-26 21:41:03 +08:00
27 changed files with 810 additions and 1497 deletions

View File

@@ -9,25 +9,9 @@ RUN apt-get update && apt-get install -y curl gnupg git \
RUN npm install -g pnpm
ENV MCP_DATA_DIR=/app/data
ENV MCP_SERVERS_DIR=$MCP_DATA_DIR/servers
ENV MCP_NPM_DIR=$MCP_SERVERS_DIR/npm
ENV MCP_PYTHON_DIR=$MCP_SERVERS_DIR/python
ENV PNPM_HOME=$MCP_DATA_DIR/pnpm
ENV NPM_CONFIG_PREFIX=$MCP_DATA_DIR/npm-global
ENV NPM_CONFIG_CACHE=$MCP_DATA_DIR/npm-cache
ENV UV_TOOL_DIR=$MCP_DATA_DIR/uv/tools
ENV UV_CACHE_DIR=$MCP_DATA_DIR/uv/cache
ENV PATH=$PNPM_HOME:$NPM_CONFIG_PREFIX/bin:$UV_TOOL_DIR/bin:$PATH
RUN mkdir -p \
$PNPM_HOME \
$NPM_CONFIG_PREFIX/bin \
$NPM_CONFIG_PREFIX/lib/node_modules \
$NPM_CONFIG_CACHE \
$UV_TOOL_DIR \
$UV_CACHE_DIR \
$MCP_NPM_DIR \
$MCP_PYTHON_DIR && \
ENV PNPM_HOME=/usr/local/share/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN mkdir -p $PNPM_HOME && \
pnpm add -g @amap/amap-maps-mcp-server @playwright/mcp@latest tavily-mcp@latest @modelcontextprotocol/server-github @modelcontextprotocol/server-slack
ARG INSTALL_EXT=false

View File

@@ -98,34 +98,6 @@ Manual registration example:
For manual providers, create the OAuth App in the upstream console, set the redirect URI to `http://localhost:3000/oauth/callback` (or your deployed domain), and then plug the credentials into the dashboard or config file.
#### Connection Modes (Optional)
MCPHub supports two connection strategies:
- **`persistent` (default)**: Maintains long-running connections for stateful servers
- **`on-demand`**: Connects only when needed, ideal for ephemeral servers that exit after operations
Example for one-time use servers:
```json
{
"mcpServers": {
"pdf-reader": {
"command": "npx",
"args": ["-y", "pdf-mcp-server"],
"connectionMode": "on-demand"
}
}
}
```
Use `on-demand` mode for servers that:
- Don't support long-running connections
- Exit automatically after handling requests
- Experience "Connection closed" errors
See the [Configuration Guide](docs/configuration/mcp-settings.mdx) for more details.
### Docker Deployment
**Recommended**: Mount your custom config:

View File

@@ -72,13 +72,9 @@ MCPHub uses several configuration files:
### Optional Fields
| Field | Type | Default | Description |
| ---------------- | ------- | --------------- | --------------------------------------------------------------------- |
| `env` | object | `{}` | Environment variables |
| `connectionMode` | string | `"persistent"` | Connection strategy: `"persistent"` or `"on-demand"` |
| `enabled` | boolean | `true` | Enable/disable the server |
| `keepAliveInterval` | number | `60000` | Keep-alive ping interval for SSE connections (milliseconds) |
| `options` | object | `{}` | MCP request options (timeout, resetTimeoutOnProgress, maxTotalTimeout)|
| Field | Type | Default | Description |
| -------------- | ------- | --------------- | --------------------------- |
| `env` | object | `{}` | Environment variables |
## Common MCP Server Examples
@@ -242,68 +238,6 @@ MCPHub uses several configuration files:
}
```
## Connection Modes
MCPHub supports two connection strategies for MCP servers:
### Persistent Connection (Default)
Persistent mode maintains a long-running connection to the MCP server. This is the default and recommended mode for most servers.
**Use cases:**
- Servers that maintain state between requests
- Servers with slow startup times
- Servers designed for long-running connections
**Example:**
```json
{
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"connectionMode": "persistent",
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}"
}
}
}
```
### On-Demand Connection
On-demand mode connects only when a tool is invoked, then disconnects immediately after. This is ideal for servers that:
- Don't support long-running connections
- Are designed for one-time use
- Exit automatically after handling requests
**Use cases:**
- PDF processing tools that exit after each operation
- One-time command-line utilities
- Servers with connection stability issues
- Resource-intensive servers that shouldn't run continuously
**Example:**
```json
{
"pdf-reader": {
"command": "npx",
"args": ["-y", "pdf-mcp-server"],
"connectionMode": "on-demand",
"env": {
"PDF_CACHE_DIR": "/tmp/pdf-cache"
}
}
}
```
**Benefits of on-demand mode:**
- Avoids "Connection closed" errors for ephemeral services
- Reduces resource usage for infrequently used tools
- Better suited for stateless operations
- Handles servers that automatically exit after operations
**Note:** On-demand servers briefly connect during initialization to discover available tools, then disconnect. The connection is re-established only when a tool from that server is actually invoked.
## Advanced Configuration
### Environment Variable Substitution

View File

@@ -1,27 +1,5 @@
#!/bin/bash
DATA_DIR=${MCP_DATA_DIR:-/app/data}
SERVERS_DIR=${MCP_SERVERS_DIR:-$DATA_DIR/servers}
NPM_SERVER_DIR=${MCP_NPM_DIR:-$SERVERS_DIR/npm}
PYTHON_SERVER_DIR=${MCP_PYTHON_DIR:-$SERVERS_DIR/python}
PNPM_HOME=${PNPM_HOME:-$DATA_DIR/pnpm}
NPM_CONFIG_PREFIX=${NPM_CONFIG_PREFIX:-$DATA_DIR/npm-global}
NPM_CONFIG_CACHE=${NPM_CONFIG_CACHE:-$DATA_DIR/npm-cache}
UV_TOOL_DIR=${UV_TOOL_DIR:-$DATA_DIR/uv/tools}
UV_CACHE_DIR=${UV_CACHE_DIR:-$DATA_DIR/uv/cache}
mkdir -p \
"$PNPM_HOME" \
"$NPM_CONFIG_PREFIX/bin" \
"$NPM_CONFIG_PREFIX/lib/node_modules" \
"$NPM_CONFIG_CACHE" \
"$UV_TOOL_DIR" \
"$UV_CACHE_DIR" \
"$NPM_SERVER_DIR" \
"$PYTHON_SERVER_DIR"
export PATH="$PNPM_HOME:$NPM_CONFIG_PREFIX/bin:$UV_TOOL_DIR/bin:$PATH"
NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
echo "Setting npm registry to ${NPM_REGISTRY}"
npm config set registry "$NPM_REGISTRY"

View File

@@ -1,61 +0,0 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"description": "Example MCP settings showing different connection modes",
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"connectionMode": "persistent",
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}"
},
"enabled": true
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless"],
"connectionMode": "persistent",
"enabled": true
},
"pdf-reader": {
"command": "npx",
"args": ["-y", "pdf-mcp-server"],
"connectionMode": "on-demand",
"env": {
"PDF_CACHE_DIR": "/tmp/pdf-cache"
},
"enabled": true
},
"image-processor": {
"command": "python",
"args": ["-m", "image_mcp_server"],
"connectionMode": "on-demand",
"env": {
"IMAGE_OUTPUT_DIR": "/tmp/images"
},
"enabled": true
},
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"],
"enabled": true
},
"slack": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-slack"],
"connectionMode": "persistent",
"env": {
"SLACK_BOT_TOKEN": "${SLACK_BOT_TOKEN}",
"SLACK_TEAM_ID": "${SLACK_TEAM_ID}"
},
"enabled": true
}
},
"users": [
{
"username": "admin",
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
"isAdmin": true
}
]
}

View File

@@ -19,7 +19,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
onBack,
onInstall,
installing = false,
isInstalled = false,
isInstalled = false
}) => {
const { t } = useTranslation();
const [modalVisible, setModalVisible] = useState(false);
@@ -32,23 +32,21 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
const getButtonProps = () => {
if (isInstalled) {
return {
className: 'bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white',
className: "bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white",
disabled: true,
text: t('market.installed'),
text: t('market.installed')
};
} else if (installing) {
return {
className:
'bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white',
className: "bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white",
disabled: true,
text: t('market.installing'),
text: t('market.installing')
};
} else {
return {
className:
'bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary',
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary",
disabled: false,
text: t('market.install'),
text: t('market.install')
};
}
};
@@ -135,18 +133,12 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
return (
<div className="bg-white rounded-lg shadow-md p-6">
<div className="mb-4">
<button onClick={onBack} className="text-gray-600 hover:text-gray-900 flex items-center">
<svg
className="h-5 w-5 mr-1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
clipRule="evenodd"
/>
<button
onClick={onBack}
className="text-gray-600 hover:text-gray-900 flex items-center"
>
<svg className="h-5 w-5 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
</svg>
{t('market.backToList')}
</button>
@@ -158,8 +150,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
{server.display_name}
<span className="text-sm font-normal text-gray-500 ml-2">({server.name})</span>
<span className="text-sm font-normal text-gray-600 ml-4">
{t('market.author')}: {server.author?.name || t('market.unknown')} {' '}
{t('market.license')}: {server.license}
{t('market.author')}: {server.author.name} {t('market.license')}: {server.license}
<a
href={server.repository.url}
target="_blank"
@@ -191,24 +182,18 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<p className="text-gray-700 mb-6">{server.description}</p>
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">
{t('market.categories')} & {t('market.tags')}
</h3>
<h3 className="text-lg font-semibold mb-3">{t('market.categories')} & {t('market.tags')}</h3>
<div className="flex flex-wrap gap-2">
{server.categories?.map((category, index) => (
<span key={`cat-${index}`} className="bg-gray-100 text-gray-800 px-3 py-1 rounded">
{category}
</span>
))}
{server.tags &&
server.tags.map((tag, index) => (
<span
key={`tag-${index}`}
className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm"
>
#{tag}
</span>
))}
{server.tags && server.tags.map((tag, index) => (
<span key={`tag-${index}`} className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm">
#{tag}
</span>
))}
</div>
</div>
@@ -239,7 +224,9 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{name}
</td>
<td className="px-6 py-4 text-sm text-gray-500">{arg.description}</td>
<td className="px-6 py-4 text-sm text-gray-500">
{arg.description}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{arg.required ? (
<span className="text-green-600"></span>
@@ -281,10 +268,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
</h4>
<p className="text-gray-600 mb-2">{tool.description}</p>
<div className="mt-2">
<pre
id={`schema-${index}`}
className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2"
>
<pre id={`schema-${index}`} className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2">
{JSON.stringify(tool.inputSchema, null, 2)}
</pre>
</div>
@@ -301,7 +285,9 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<div key={index} className="border border-gray-200 rounded p-4">
<h4 className="font-medium mb-2">{example.title}</h4>
<p className="text-gray-600 mb-2">{example.description}</p>
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">{example.prompt}</pre>
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">
{example.prompt}
</pre>
</div>
))}
</div>
@@ -330,11 +316,11 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
status: 'disconnected',
config: preferredInstallation
? {
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {},
}
: undefined,
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {}
}
: undefined
}}
/>
</div>
@@ -346,16 +332,14 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{t('server.confirmVariables')}
</h3>
<p className="text-gray-600 mb-4">{t('server.variablesDetected')}</p>
<p className="text-gray-600 mb-4">
{t('server.variablesDetected')}
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
@@ -372,12 +356,14 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
</div>
</div>
</div>
<p className="text-gray-600 text-sm mb-6">{t('market.confirmVariablesMessage')}</p>
<p className="text-gray-600 text-sm mb-6">
{t('market.confirmVariablesMessage')}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => {
setConfirmationVisible(false);
setPendingPayload(null);
setConfirmationVisible(false)
setPendingPayload(null)
}}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
>

View File

@@ -287,13 +287,9 @@ export const useCloudData = () => {
const callServerTool = useCallback(
async (serverName: string, toolName: string, args: Record<string, any>) => {
try {
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const data = await apiPost(
`/cloud/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/call`,
{
arguments: args,
},
);
const data = await apiPost(`/cloud/servers/${serverName}/tools/${toolName}/call`, {
arguments: args,
});
if (data && data.success) {
return data.data;

View File

@@ -59,9 +59,8 @@ export const getPrompt = async (
server?: string,
): Promise<GetPromptResult> => {
try {
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const response = await apiPost(
`/mcp/${encodeURIComponent(server || '')}/prompts/${encodeURIComponent(request.promptName)}`,
`/mcp/${server}/prompts/${encodeURIComponent(request.promptName)}`,
{
name: request.promptName,
arguments: request.arguments,
@@ -95,13 +94,9 @@ export const togglePrompt = async (
enabled: boolean,
): Promise<{ success: boolean; error?: string }> => {
try {
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const response = await apiPost<any>(
`/servers/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}/toggle`,
{
enabled,
},
);
const response = await apiPost<any>(`/servers/${serverName}/prompts/${promptName}/toggle`, {
enabled,
});
return {
success: response.success,
@@ -125,9 +120,8 @@ export const updatePromptDescription = async (
description: string,
): Promise<{ success: boolean; error?: string }> => {
try {
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const response = await apiPut<any>(
`/servers/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}/description`,
`/servers/${serverName}/prompts/${promptName}/description`,
{ description },
{
headers: {

View File

@@ -25,10 +25,7 @@ export const callTool = async (
): Promise<ToolCallResult> => {
try {
// Construct the URL with optional server parameter
// URL-encode server and tool names to handle slashes in names (e.g., "com.atlassian/atlassian-mcp-server")
const url = server
? `/tools/${encodeURIComponent(server)}/${encodeURIComponent(request.toolName)}`
: '/tools/call';
const url = server ? `/tools/${server}/${request.toolName}` : '/tools/call';
const response = await apiPost<any>(url, request.arguments, {
headers: {
@@ -65,9 +62,8 @@ export const toggleTool = async (
enabled: boolean,
): Promise<{ success: boolean; error?: string }> => {
try {
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const response = await apiPost<any>(
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/toggle`,
`/servers/${serverName}/tools/${toolName}/toggle`,
{ enabled },
{
headers: {
@@ -98,9 +94,8 @@ export const updateToolDescription = async (
description: string,
): Promise<{ success: boolean; error?: string }> => {
try {
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const response = await apiPut<any>(
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/description`,
`/servers/${serverName}/tools/${toolName}/description`,
{ description },
{
headers: {

View File

@@ -207,8 +207,7 @@ export const getCloudServersByTag = async (req: Request, res: Response): Promise
// Get tools for a specific cloud server
export const getCloudServerToolsList = async (req: Request, res: Response): Promise<void> => {
try {
// Decode URL-encoded parameter to handle slashes in server name
const serverName = decodeURIComponent(req.params.serverName);
const { serverName } = req.params;
if (!serverName) {
res.status(400).json({
success: false,
@@ -237,9 +236,7 @@ export const getCloudServerToolsList = async (req: Request, res: Response): Prom
// Call a tool on a cloud server
export const callCloudTool = async (req: Request, res: Response): Promise<void> => {
try {
// Decode URL-encoded parameters to handle slashes in server/tool names
const serverName = decodeURIComponent(req.params.serverName);
const toolName = decodeURIComponent(req.params.toolName);
const { serverName, toolName } = req.params;
const { arguments: args } = req.body;
if (!serverName) {

View File

@@ -8,13 +8,82 @@ import {
import { getServerByName } from '../services/mcpService.js';
import { getGroupByIdOrName } from '../services/groupService.js';
import { getNameSeparator } from '../config/index.js';
import { convertParametersToTypes } from '../utils/parameterConversion.js';
/**
* Controller for OpenAPI generation endpoints
* Provides OpenAPI specifications for MCP tools to enable OpenWebUI integration
*/
/**
* Convert query parameters to their proper types based on the tool's input schema
*/
function convertQueryParametersToTypes(
queryParams: Record<string, any>,
inputSchema: Record<string, any>,
): Record<string, any> {
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
return queryParams;
}
const convertedParams: Record<string, any> = {};
const properties = inputSchema.properties;
for (const [key, value] of Object.entries(queryParams)) {
const propDef = properties[key];
if (!propDef || typeof propDef !== 'object') {
// No schema definition found, keep as is
convertedParams[key] = value;
continue;
}
const propType = propDef.type;
try {
switch (propType) {
case 'integer':
case 'number':
// Convert string to number
if (typeof value === 'string') {
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
convertedParams[key] = isNaN(numValue) ? value : numValue;
} else {
convertedParams[key] = value;
}
break;
case 'boolean':
// Convert string to boolean
if (typeof value === 'string') {
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
} else {
convertedParams[key] = value;
}
break;
case 'array':
// Handle array conversion if needed (e.g., comma-separated strings)
if (typeof value === 'string' && value.includes(',')) {
convertedParams[key] = value.split(',').map((item) => item.trim());
} else {
convertedParams[key] = value;
}
break;
default:
// For string and other types, keep as is
convertedParams[key] = value;
break;
}
} catch (error) {
// If conversion fails, keep the original value
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
convertedParams[key] = value;
}
}
return convertedParams;
}
/**
* Generate and return OpenAPI specification
* GET /api/openapi.json
@@ -98,9 +167,7 @@ export const getOpenAPIStats = async (req: Request, res: Response): Promise<void
*/
export const executeToolViaOpenAPI = async (req: Request, res: Response): Promise<void> => {
try {
// Decode URL-encoded parameters to handle slashes in server/tool names
const serverName = decodeURIComponent(req.params.serverName);
const toolName = decodeURIComponent(req.params.toolName);
const { serverName, toolName } = req.params;
// Import handleCallToolRequest function
const { handleCallToolRequest } = await import('../services/mcpService.js');
@@ -122,7 +189,7 @@ export const executeToolViaOpenAPI = async (req: Request, res: Response): Promis
// Prepare arguments from query params (GET) or body (POST)
let args = req.method === 'GET' ? req.query : req.body || {};
args = convertParametersToTypes(args, inputSchema);
args = convertQueryParametersToTypes(args, inputSchema);
// Create a mock request structure that matches what handleCallToolRequest expects
const mockRequest = {

View File

@@ -7,9 +7,7 @@ import { handleGetPromptRequest } from '../services/mcpService.js';
*/
export const getPrompt = async (req: Request, res: Response): Promise<void> => {
try {
// Decode URL-encoded parameters to handle slashes in server/prompt names
const serverName = decodeURIComponent(req.params.serverName);
const promptName = decodeURIComponent(req.params.promptName);
const { serverName, promptName } = req.params;
if (!serverName || !promptName) {
res.status(400).json({
success: false,

View File

@@ -375,9 +375,7 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
// Toggle tool status for a specific server
export const toggleTool = async (req: Request, res: Response): Promise<void> => {
try {
// Decode URL-encoded parameters to handle slashes in server/tool names
const serverName = decodeURIComponent(req.params.serverName);
const toolName = decodeURIComponent(req.params.toolName);
const { serverName, toolName } = req.params;
const { enabled } = req.body;
if (!serverName || !toolName) {
@@ -439,9 +437,7 @@ export const toggleTool = async (req: Request, res: Response): Promise<void> =>
// Update tool description for a specific server
export const updateToolDescription = async (req: Request, res: Response): Promise<void> => {
try {
// Decode URL-encoded parameters to handle slashes in server/tool names
const serverName = decodeURIComponent(req.params.serverName);
const toolName = decodeURIComponent(req.params.toolName);
const { serverName, toolName } = req.params;
const { description } = req.body;
if (!serverName || !toolName) {
@@ -751,9 +747,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
// Toggle prompt status for a specific server
export const togglePrompt = async (req: Request, res: Response): Promise<void> => {
try {
// Decode URL-encoded parameters to handle slashes in server/prompt names
const serverName = decodeURIComponent(req.params.serverName);
const promptName = decodeURIComponent(req.params.promptName);
const { serverName, promptName } = req.params;
const { enabled } = req.body;
if (!serverName || !promptName) {
@@ -815,9 +809,7 @@ export const togglePrompt = async (req: Request, res: Response): Promise<void> =
// Update prompt description for a specific server
export const updatePromptDescription = async (req: Request, res: Response): Promise<void> => {
try {
// Decode URL-encoded parameters to handle slashes in server/prompt names
const serverName = decodeURIComponent(req.params.serverName);
const promptName = decodeURIComponent(req.params.promptName);
const { serverName, promptName } = req.params;
const { description } = req.body;
if (!serverName || !promptName) {

View File

@@ -1,8 +1,6 @@
import { Request, Response } from 'express';
import { ApiResponse } from '../types/index.js';
import { handleCallToolRequest, getServerByName } from '../services/mcpService.js';
import { convertParametersToTypes } from '../utils/parameterConversion.js';
import { getNameSeparator } from '../config/index.js';
import { handleCallToolRequest } from '../services/mcpService.js';
/**
* Interface for tool call request
@@ -49,31 +47,13 @@ export const callTool = async (req: Request, res: Response): Promise<void> => {
return;
}
// Get the server info to access the tool's input schema
const serverInfo = getServerByName(server);
let inputSchema: Record<string, any> = {};
if (serverInfo) {
// Find the tool in the server's tools list
const fullToolName = `${server}${getNameSeparator()}${toolName}`;
const tool = serverInfo.tools.find(
(t: any) => t.name === fullToolName || t.name === toolName,
);
if (tool && tool.inputSchema) {
inputSchema = tool.inputSchema as Record<string, any>;
}
}
// Convert parameters to proper types based on the tool's input schema
const convertedArgs = convertParametersToTypes(toolArgs, inputSchema);
// Create a mock request structure for handleCallToolRequest
const mockRequest = {
params: {
name: 'call_tool',
arguments: {
toolName,
arguments: convertedArgs,
arguments: toolArgs,
},
},
};
@@ -91,7 +71,7 @@ export const callTool = async (req: Request, res: Response): Promise<void> => {
data: {
content: result.content || [],
toolName,
arguments: convertedArgs,
arguments: toolArgs,
},
};

View File

@@ -78,28 +78,28 @@ export class AppServer {
console.log('MCP server initialized successfully');
// Original routes (global and group-based)
this.app.get(`${this.basePath}/sse/:group(.*)?`, sseUserContextMiddleware, (req, res) =>
this.app.get(`${this.basePath}/sse/:group?`, sseUserContextMiddleware, (req, res) =>
handleSseConnection(req, res),
);
this.app.post(`${this.basePath}/messages`, sseUserContextMiddleware, handleSseMessage);
this.app.post(
`${this.basePath}/mcp/:group(.*)?`,
`${this.basePath}/mcp/:group?`,
sseUserContextMiddleware,
handleMcpPostRequest,
);
this.app.get(
`${this.basePath}/mcp/:group(.*)?`,
`${this.basePath}/mcp/:group?`,
sseUserContextMiddleware,
handleMcpOtherRequest,
);
this.app.delete(
`${this.basePath}/mcp/:group(.*)?`,
`${this.basePath}/mcp/:group?`,
sseUserContextMiddleware,
handleMcpOtherRequest,
);
// User-scoped routes with user context middleware
this.app.get(`${this.basePath}/:user/sse/:group(.*)?`, sseUserContextMiddleware, (req, res) =>
this.app.get(`${this.basePath}/:user/sse/:group?`, sseUserContextMiddleware, (req, res) =>
handleSseConnection(req, res),
);
this.app.post(
@@ -108,17 +108,17 @@ export class AppServer {
handleSseMessage,
);
this.app.post(
`${this.basePath}/:user/mcp/:group(.*)?`,
`${this.basePath}/:user/mcp/:group?`,
sseUserContextMiddleware,
handleMcpPostRequest,
);
this.app.get(
`${this.basePath}/:user/mcp/:group(.*)?`,
`${this.basePath}/:user/mcp/:group?`,
sseUserContextMiddleware,
handleMcpOtherRequest,
);
this.app.delete(
`${this.basePath}/:user/mcp/:group(.*)?`,
`${this.basePath}/:user/mcp/:group?`,
sseUserContextMiddleware,
handleMcpOtherRequest,
);

View File

@@ -0,0 +1,457 @@
import { Request, Response } from 'express';
import { URL } from 'url';
import config, { loadSettings } from '../config/index.js';
import { ClusterConfig, ClusterNodeConfig } from '../types/index.js';
interface ProxyContext {
node: ClusterNodeConfig;
targetUrl: URL;
}
const sessionBindings = new Map<string, string>();
const groupCounters = new Map<string, number>();
const DEFAULT_GROUP_KEY = '__default__';
const isIterableHeaderValue = (value: string | string[] | undefined): value is string[] => {
return Array.isArray(value);
};
const createHeadersFromRequest = (req: Request, node: ClusterNodeConfig): Headers => {
const headers = new Headers();
for (const [key, rawValue] of Object.entries(req.headers)) {
if (rawValue === undefined) {
continue;
}
if (key.toLowerCase() === 'host') {
continue;
}
if (isIterableHeaderValue(rawValue)) {
for (const value of rawValue) {
headers.append(key, value);
}
} else {
headers.set(key, String(rawValue));
}
}
if (node.forwardHeaders) {
for (const [key, value] of Object.entries(node.forwardHeaders)) {
if (value !== undefined) {
headers.set(key, value);
}
}
}
return headers;
};
const getClusterConfig = (): ClusterConfig | undefined => {
const settings = loadSettings();
return settings.systemConfig?.cluster;
};
const getClusterNodes = (): ClusterNodeConfig[] => {
const config = getClusterConfig();
if (!config?.enabled) {
return [];
}
return config.nodes ?? [];
};
const isClusterEnabled = (): boolean => {
return getClusterNodes().length > 0;
};
const sanitizePathSegment = (segment: string): string => {
return segment.replace(/^\/+/, '').replace(/\/+$/, '');
};
const joinUrlPaths = (...segments: (string | undefined)[]): string => {
const sanitizedSegments = segments
.filter((segment): segment is string => segment !== undefined && segment !== null && segment !== '')
.map((segment) => sanitizePathSegment(segment));
if (!sanitizedSegments.length) {
return '/';
}
const joined = sanitizedSegments.filter((segment) => segment.length > 0).join('/');
return joined ? `/${joined}` : '/';
};
const normalizeBasePath = (path?: string): string => {
if (!path) {
return '';
}
const normalized = path.startsWith('/') ? path : `/${path}`;
if (normalized === '/') {
return '';
}
if (normalized !== '/' && normalized.endsWith('/')) {
return normalized.slice(0, -1);
}
return normalized;
};
const buildTargetUrl = (node: ClusterNodeConfig, originalUrl: string): URL => {
const placeholderBase = 'http://cluster.local';
const requestUrl = new URL(originalUrl, placeholderBase);
const requestPath = requestUrl.pathname;
const hubBasePath = normalizeBasePath(config.basePath);
const relativePath = requestPath.startsWith(hubBasePath)
? requestPath.slice(hubBasePath.length) || '/'
: requestPath;
const nodePrefix = normalizeBasePath(node.pathPrefix ?? hubBasePath);
const targetUrl = new URL(node.url);
targetUrl.pathname = joinUrlPaths(targetUrl.pathname, nodePrefix, relativePath);
targetUrl.search = requestUrl.search;
targetUrl.hash = requestUrl.hash;
return targetUrl;
};
const matchesNodeGroup = (nodeGroup: string, targetGroup: string): boolean => {
if (!targetGroup) {
return nodeGroup === '' || nodeGroup === '*' || nodeGroup === 'global' || nodeGroup === 'default';
}
if (nodeGroup === '*') {
return true;
}
return nodeGroup === targetGroup;
};
const selectNodeForGroup = (group?: string): ClusterNodeConfig | undefined => {
const nodes = getClusterNodes();
if (!nodes.length) {
return undefined;
}
const key = group ?? DEFAULT_GROUP_KEY;
const normalizedGroup = group ?? '';
const candidates = nodes.filter((node) => {
if (!node.groups || node.groups.length === 0) {
return true;
}
return node.groups.some((nodeGroup) => matchesNodeGroup(nodeGroup, normalizedGroup));
});
if (!candidates.length) {
return undefined;
}
const weightedCandidates: ClusterNodeConfig[] = [];
for (const candidate of candidates) {
const weight = Math.max(1, candidate.weight ?? 1);
for (let i = 0; i < weight; i += 1) {
weightedCandidates.push(candidate);
}
}
const index = groupCounters.get(key) ?? 0;
const selected = weightedCandidates[index % weightedCandidates.length];
groupCounters.set(key, index + 1);
return selected;
};
const bindSessionToNode = (sessionId: string, nodeId: string): void => {
sessionBindings.set(sessionId, nodeId);
};
const releaseSession = (sessionId: string): void => {
sessionBindings.delete(sessionId);
};
const getNodeForSession = (sessionId: string): ClusterNodeConfig | undefined => {
const nodeId = sessionBindings.get(sessionId);
if (!nodeId) {
return undefined;
}
return getClusterNodes().find((node) => node.id === nodeId);
};
const resolveProxyContext = (req: Request, group?: string, sessionId?: string): ProxyContext | undefined => {
if (!isClusterEnabled()) {
return undefined;
}
if (sessionId) {
const node = getNodeForSession(sessionId);
if (node) {
return { node, targetUrl: buildTargetUrl(node, req.originalUrl) };
}
}
const node = selectNodeForGroup(group);
if (!node) {
return undefined;
}
return {
node,
targetUrl: buildTargetUrl(node, req.originalUrl),
};
};
const pipeReadableStreamToResponse = async (
response: globalThis.Response,
res: Response,
onData?: (chunk: string) => void,
): Promise<void> => {
if (!response.body) {
const text = await response.text();
res.send(text);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
try {
let finished = false;
while (!finished) {
const { value, done } = await reader.read();
finished = Boolean(done);
if (value) {
const chunkString = decoder.decode(value, { stream: true });
if (onData) {
onData(chunkString);
}
res.write(Buffer.from(value));
}
}
} catch (error) {
if ((error as Error).name !== 'AbortError') {
console.error('Cluster proxy stream error:', error);
}
} finally {
const finalChunk = decoder.decode();
if (finalChunk && onData) {
onData(finalChunk);
}
res.end();
}
};
const handleSseStream = async (
node: ClusterNodeConfig,
req: Request,
res: Response,
context: ProxyContext,
): Promise<void> => {
const controller = new AbortController();
const sessionIds = new Set<string>();
req.on('close', () => {
controller.abort();
for (const sessionId of sessionIds) {
releaseSession(sessionId);
}
});
let response: globalThis.Response;
try {
response = await fetch(context.targetUrl, {
method: 'GET',
headers: createHeadersFromRequest(req, node),
signal: controller.signal,
});
} catch (error) {
console.error('Failed to proxy SSE request to cluster node:', error);
if (!res.headersSent) {
res.status(502).send('Failed to reach cluster node');
}
for (const sessionId of sessionIds) {
releaseSession(sessionId);
}
return;
}
res.status(response.status);
response.headers.forEach((value, key) => {
if (key.toLowerCase() === 'content-length') {
return;
}
res.setHeader(key, value);
});
if (typeof res.flushHeaders === 'function') {
res.flushHeaders();
}
const isSse = response.headers.get('content-type')?.includes('text/event-stream');
let buffer = '';
await pipeReadableStreamToResponse(
response,
res,
isSse
? (chunk) => {
buffer += chunk;
let boundaryIndex = buffer.indexOf('\n\n');
while (boundaryIndex !== -1) {
const rawEvent = buffer.slice(0, boundaryIndex);
buffer = buffer.slice(boundaryIndex + 2);
const normalizedEvent = rawEvent.replace(/\r\n/g, '\n');
const lines = normalizedEvent.split('\n');
let eventName = '';
let data = '';
for (const line of lines) {
if (line.startsWith('event:')) {
eventName = line.slice(6).trim();
}
if (line.startsWith('data:')) {
data += `${line.slice(5).trim()}`;
}
}
if (eventName === 'endpoint' && data) {
try {
const sessionUrl = new URL(data, 'http://localhost');
const sessionId = sessionUrl.searchParams.get('sessionId');
if (sessionId) {
bindSessionToNode(sessionId, node.id);
sessionIds.add(sessionId);
}
} catch (error) {
console.warn('Failed to parse session endpoint from cluster response:', error);
}
}
boundaryIndex = buffer.indexOf('\n\n');
}
}
: undefined,
);
for (const sessionId of sessionIds) {
releaseSession(sessionId);
}
};
const forwardRequest = async (
req: Request,
res: Response,
context: ProxyContext,
options?: { releaseSession?: string },
): Promise<void> => {
const { node, targetUrl } = context;
const method = req.method.toUpperCase();
const init: RequestInit = {
method,
headers: createHeadersFromRequest(req, node),
};
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
if (req.body !== undefined) {
init.body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
}
}
const controller = new AbortController();
init.signal = controller.signal;
req.on('close', () => {
controller.abort();
});
let response: globalThis.Response;
try {
response = await fetch(targetUrl, init);
} catch (error) {
if ((error as Error).name !== 'AbortError') {
console.error('Failed to proxy request to cluster node:', error);
}
if (!res.headersSent) {
res.status(502).send('Failed to reach cluster node');
}
if (options?.releaseSession) {
releaseSession(options.releaseSession);
}
return;
}
const newSessionId = response.headers.get('mcp-session-id');
if (newSessionId) {
bindSessionToNode(newSessionId, node.id);
}
res.status(response.status);
response.headers.forEach((value, key) => {
if (key.toLowerCase() === 'content-length') {
return;
}
res.setHeader(key, value);
});
if (response.headers.get('content-type')?.includes('text/event-stream')) {
await pipeReadableStreamToResponse(response, res);
} else {
const buffer = await response.arrayBuffer();
if (buffer.byteLength === 0) {
res.end();
} else {
res.send(Buffer.from(buffer));
}
}
if (options?.releaseSession) {
releaseSession(options.releaseSession);
}
};
export const tryProxySseConnection = async (
req: Request,
res: Response,
group?: string,
): Promise<boolean> => {
const context = resolveProxyContext(req, group);
if (!context) {
return false;
}
await handleSseStream(context.node, req, res, context);
return true;
};
export const tryProxySseMessage = async (req: Request, res: Response): Promise<boolean> => {
const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : undefined;
if (!sessionId) {
return false;
}
const context = resolveProxyContext(req, undefined, sessionId);
if (!context) {
return false;
}
await forwardRequest(req, res, context);
return true;
};
export const tryProxyMcpRequest = async (
req: Request,
res: Response,
group?: string,
): Promise<boolean> => {
const sessionIdHeader = req.headers['mcp-session-id'];
const sessionId = Array.isArray(sessionIdHeader) ? sessionIdHeader[0] : sessionIdHeader;
const context = resolveProxyContext(req, group, sessionId);
if (!context) {
return false;
}
const releaseTarget = req.method.toUpperCase() === 'DELETE' ? sessionId : undefined;
await forwardRequest(req, res, context, { releaseSession: releaseTarget });
return true;
};
export const clearClusterSessionBindings = (): void => {
sessionBindings.clear();
groupCounters.clear();
};
export const __clusterInternals = {
joinUrlPaths,
normalizeBasePath,
matchesNodeGroup,
buildTargetUrl,
};

View File

@@ -1,6 +1,4 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import {
CallToolRequestSchema,
@@ -28,82 +26,12 @@ import { getDataService } from './services.js';
import { getServerDao, ServerConfigWithName } from '../dao/index.js';
import { initializeAllOAuthClients } from './oauthService.js';
import { createOAuthProvider } from './mcpOAuthProvider.js';
import { clearClusterSessionBindings } from './clusterService.js';
const servers: { [sessionId: string]: Server } = {};
const serverDao = getServerDao();
const ensureDirExists = (dir: string | undefined): string => {
if (!dir) {
throw new Error('Directory path is undefined');
}
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
return dir;
};
const getDataRootDir = (): string => {
return ensureDirExists(process.env.MCP_DATA_DIR || path.join(process.cwd(), 'data'));
};
const getServersStorageRoot = (): string => {
return ensureDirExists(process.env.MCP_SERVERS_DIR || path.join(getDataRootDir(), 'servers'));
};
const getNpmBaseDir = (): string => {
return ensureDirExists(process.env.MCP_NPM_DIR || path.join(getServersStorageRoot(), 'npm'));
};
const getPythonBaseDir = (): string => {
return ensureDirExists(
process.env.MCP_PYTHON_DIR || path.join(getServersStorageRoot(), 'python'),
);
};
const getNpmCacheDir = (): string => {
return ensureDirExists(process.env.NPM_CONFIG_CACHE || path.join(getDataRootDir(), 'npm-cache'));
};
const getNpmPrefixDir = (): string => {
const dir = ensureDirExists(
process.env.NPM_CONFIG_PREFIX || path.join(getDataRootDir(), 'npm-global'),
);
ensureDirExists(path.join(dir, 'bin'));
ensureDirExists(path.join(dir, 'lib', 'node_modules'));
return dir;
};
const getUvCacheDir = (): string => {
return ensureDirExists(process.env.UV_CACHE_DIR || path.join(getDataRootDir(), 'uv', 'cache'));
};
const getUvToolDir = (): string => {
const dir = ensureDirExists(process.env.UV_TOOL_DIR || path.join(getDataRootDir(), 'uv', 'tools'));
ensureDirExists(path.join(dir, 'bin'));
return dir;
};
const getServerInstallDir = (serverName: string, kind: 'npm' | 'python'): string => {
const baseDir = kind === 'npm' ? getNpmBaseDir() : getPythonBaseDir();
return ensureDirExists(path.join(baseDir, serverName));
};
const prependToPath = (currentPath: string, dir: string): string => {
if (!dir) {
return currentPath;
}
const delimiter = path.delimiter;
const segments = currentPath ? currentPath.split(delimiter) : [];
if (segments.includes(dir)) {
return currentPath;
}
return currentPath ? `${dir}${delimiter}${currentPath}` : dir;
};
const NODE_COMMANDS = new Set(['npm', 'npx', 'pnpm', 'yarn', 'node', 'bun', 'bunx']);
const PYTHON_COMMANDS = new Set(['uv', 'uvx', 'python', 'pip', 'pip3', 'pipx']);
// Helper function to set up keep-alive ping for SSE connections
const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): void => {
// Only set up keep-alive for SSE connections
@@ -234,6 +162,8 @@ export const cleanupAllServers = (): void => {
Object.keys(servers).forEach((sessionId) => {
delete servers[sessionId];
});
clearClusterSessionBindings();
};
// Helper function to create transport based on server configuration
@@ -286,7 +216,7 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
...(process.env as Record<string, string>),
...replaceEnvVars(conf.env || {}),
};
env['PATH'] = expandEnvVars(env['PATH'] || process.env.PATH || '');
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
const settings = loadSettings();
// Add UV_DEFAULT_INDEX and npm_config_registry if needed
@@ -308,52 +238,9 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
}
// Ensure stdio servers use persistent directories under /app/data (or configured override)
let workingDirectory = os.homedir();
const commandLower = conf.command.toLowerCase();
if (NODE_COMMANDS.has(commandLower)) {
const serverDir = getServerInstallDir(name, 'npm');
workingDirectory = serverDir;
const npmCacheDir = getNpmCacheDir();
const npmPrefixDir = getNpmPrefixDir();
if (!env['npm_config_cache']) {
env['npm_config_cache'] = npmCacheDir;
}
if (!env['NPM_CONFIG_CACHE']) {
env['NPM_CONFIG_CACHE'] = env['npm_config_cache'];
}
if (!env['npm_config_prefix']) {
env['npm_config_prefix'] = npmPrefixDir;
}
if (!env['NPM_CONFIG_PREFIX']) {
env['NPM_CONFIG_PREFIX'] = env['npm_config_prefix'];
}
env['PATH'] = prependToPath(env['PATH'], path.join(env['npm_config_prefix'], 'bin'));
} else if (PYTHON_COMMANDS.has(commandLower)) {
const serverDir = getServerInstallDir(name, 'python');
workingDirectory = serverDir;
const uvCacheDir = getUvCacheDir();
const uvToolDir = getUvToolDir();
if (!env['UV_CACHE_DIR']) {
env['UV_CACHE_DIR'] = uvCacheDir;
}
if (!env['UV_TOOL_DIR']) {
env['UV_TOOL_DIR'] = uvToolDir;
}
env['PATH'] = prependToPath(env['PATH'], path.join(env['UV_TOOL_DIR'], 'bin'));
}
// Expand environment variables in command
transport = new StdioClientTransport({
cwd: workingDirectory,
cwd: os.homedir(),
command: conf.command,
args: replaceEnvVars(conf.args) as string[],
env: env,
@@ -369,118 +256,6 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
return transport;
};
// Helper function to connect an on-demand server temporarily
const connectOnDemandServer = async (serverInfo: ServerInfo): Promise<void> => {
if (!serverInfo.config) {
throw new Error(`Server configuration not found for on-demand server: ${serverInfo.name}`);
}
console.log(`Connecting on-demand server: ${serverInfo.name}`);
// Create transport
const transport = await createTransportFromConfig(serverInfo.name, serverInfo.config);
// Create client
const client = new Client(
{
name: `mcp-client-${serverInfo.name}`,
version: '1.0.0',
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
},
},
);
// Get request options from server configuration
const serverRequestOptions = serverInfo.config.options || {};
const requestOptions = {
timeout: serverRequestOptions.timeout || 60000,
resetTimeoutOnProgress: serverRequestOptions.resetTimeoutOnProgress || false,
maxTotalTimeout: serverRequestOptions.maxTotalTimeout,
};
// Connect the client
await client.connect(transport, requestOptions);
// Update server info with client and transport
serverInfo.client = client;
serverInfo.transport = transport;
serverInfo.options = requestOptions;
serverInfo.status = 'connected';
console.log(`Successfully connected on-demand server: ${serverInfo.name}`);
// List tools if not already loaded
if (serverInfo.tools.length === 0) {
const capabilities = client.getServerCapabilities();
if (capabilities?.tools) {
try {
const tools = await client.listTools({}, requestOptions);
serverInfo.tools = tools.tools.map((tool) => ({
name: `${serverInfo.name}${getNameSeparator()}${tool.name}`,
description: tool.description || '',
inputSchema: cleanInputSchema(tool.inputSchema || {}),
}));
// Save tools as vector embeddings for search
saveToolsAsVectorEmbeddings(serverInfo.name, serverInfo.tools);
console.log(`Loaded ${serverInfo.tools.length} tools for on-demand server: ${serverInfo.name}`);
} catch (error) {
console.warn(`Failed to list tools for on-demand server ${serverInfo.name}:`, error);
}
}
// List prompts if available
if (capabilities?.prompts) {
try {
const prompts = await client.listPrompts({}, requestOptions);
serverInfo.prompts = prompts.prompts.map((prompt) => ({
name: `${serverInfo.name}${getNameSeparator()}${prompt.name}`,
title: prompt.title,
description: prompt.description,
arguments: prompt.arguments,
}));
console.log(`Loaded ${serverInfo.prompts.length} prompts for on-demand server: ${serverInfo.name}`);
} catch (error) {
console.warn(`Failed to list prompts for on-demand server ${serverInfo.name}:`, error);
}
}
}
};
// Helper function to disconnect an on-demand server
const disconnectOnDemandServer = (serverInfo: ServerInfo): void => {
if (serverInfo.connectionMode !== 'on-demand') {
return;
}
console.log(`Disconnecting on-demand server: ${serverInfo.name}`);
try {
if (serverInfo.client) {
serverInfo.client.close();
serverInfo.client = undefined;
}
if (serverInfo.transport) {
serverInfo.transport.close();
serverInfo.transport = undefined;
}
serverInfo.status = 'disconnected';
console.log(`Successfully disconnected on-demand server: ${serverInfo.name}`);
} catch (error) {
// Log disconnect errors but don't throw - this is cleanup code that shouldn't fail the request
// The connection is likely already closed if we get an error here
console.warn(`Error disconnecting on-demand server ${serverInfo.name}:`, error);
// Force status to disconnected even if cleanup had errors
serverInfo.status = 'disconnected';
serverInfo.client = undefined;
serverInfo.transport = undefined;
}
};
// Helper function to handle client.callTool with reconnection logic
const callToolWithReconnect = async (
serverInfo: ServerInfo,
@@ -641,6 +416,7 @@ export const initializeClientsFromSettings = async (
continue;
}
let transport;
let openApiClient;
if (expandedConf.type === 'openapi') {
// Handle OpenAPI type servers
@@ -711,43 +487,10 @@ export const initializeClientsFromSettings = async (
serverInfo.error = `Failed to initialize OpenAPI server: ${error}`;
continue;
}
} else {
transport = await createTransportFromConfig(name, expandedConf);
}
// Handle on-demand connection mode servers
// These servers connect briefly to get tools list, then disconnect
const connectionMode = expandedConf.connectionMode || 'persistent';
if (connectionMode === 'on-demand') {
console.log(`Initializing on-demand server: ${name}`);
const serverInfo: ServerInfo = {
name,
owner: expandedConf.owner,
status: 'disconnected',
error: null,
tools: [],
prompts: [],
createTime: Date.now(),
enabled: expandedConf.enabled === undefined ? true : expandedConf.enabled,
connectionMode: 'on-demand',
config: expandedConf,
};
nextServerInfos.push(serverInfo);
// Connect briefly to get tools list, then disconnect
try {
await connectOnDemandServer(serverInfo);
console.log(`Successfully initialized on-demand server: ${name} with ${serverInfo.tools.length} tools`);
// Disconnect immediately after getting tools
disconnectOnDemandServer(serverInfo);
} catch (error) {
console.error(`Failed to initialize on-demand server ${name}:`, error);
serverInfo.error = `Failed to initialize: ${error}`;
}
continue;
}
// Create transport for persistent connection mode servers (not OpenAPI, already handled above)
const transport = await createTransportFromConfig(name, expandedConf);
const client = new Client(
{
name: `mcp-client-${name}`,
@@ -788,7 +531,6 @@ export const initializeClientsFromSettings = async (
transport,
options: requestOptions,
createTime: Date.now(),
connectionMode: connectionMode,
config: expandedConf, // Store reference to expanded config
};
@@ -1156,11 +898,8 @@ export const handleListToolsRequest = async (_: any, extra: any) => {
const targetGroup = group?.startsWith('$smart/') ? group.substring(7) : undefined;
// Get info about available servers, filtered by target group if specified
// Include both connected persistent servers and on-demand servers (even if disconnected)
let availableServers = serverInfos.filter(
(server) =>
server.enabled !== false &&
(server.status === 'connected' || server.connectionMode === 'on-demand'),
(server) => server.status === 'connected' && server.enabled !== false,
);
// If a target group is specified, filter servers to only those in the group
@@ -1287,10 +1026,6 @@ Available servers: ${serversList}`,
export const handleCallToolRequest = async (request: any, extra: any) => {
console.log(`Handling CallToolRequest for tool: ${JSON.stringify(request.params)}`);
try {
// Note: On-demand server connection and disconnection are handled in the specific
// code paths below (call_tool and regular tool handling) with try-finally blocks.
// This outer try-catch only handles errors from operations that don't connect servers.
// Special handling for agent group tools
if (request.params.name === 'search_tools') {
const { query, limit = 10 } = request.params.arguments || {};
@@ -1436,11 +1171,10 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
targetServerInfo = getServerByName(extra.server);
} else {
// Find the first server that has this tool
// Include both connected servers and on-demand servers (even if disconnected)
targetServerInfo = serverInfos.find(
(serverInfo) =>
serverInfo.status === 'connected' &&
serverInfo.enabled !== false &&
(serverInfo.status === 'connected' || serverInfo.connectionMode === 'on-demand') &&
serverInfo.tools.some((tool) => tool.name === toolName),
);
}
@@ -1516,11 +1250,6 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
}
// Call the tool on the target server (MCP servers)
// Connect on-demand server if needed
if (targetServerInfo.connectionMode === 'on-demand' && !targetServerInfo.client) {
await connectOnDemandServer(targetServerInfo);
}
const client = targetServerInfo.client;
if (!client) {
throw new Error(`Client not found for server: ${targetServerInfo.name}`);
@@ -1537,23 +1266,17 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
const separator = getNameSeparator();
const prefix = `${targetServerInfo.name}${separator}`;
toolName = toolName.startsWith(prefix) ? toolName.substring(prefix.length) : toolName;
try {
const result = await callToolWithReconnect(
targetServerInfo,
{
name: toolName,
arguments: finalArgs,
},
targetServerInfo.options || {},
);
const result = await callToolWithReconnect(
targetServerInfo,
{
name: toolName,
arguments: finalArgs,
},
targetServerInfo.options || {},
);
console.log(`Tool invocation result: ${JSON.stringify(result)}`);
return result;
} finally {
// Disconnect on-demand server after tool call
disconnectOnDemandServer(targetServerInfo);
}
console.log(`Tool invocation result: ${JSON.stringify(result)}`);
return result;
}
// Regular tool handling
@@ -1623,11 +1346,6 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
}
// Handle MCP servers
// Connect on-demand server if needed
if (serverInfo.connectionMode === 'on-demand' && !serverInfo.client) {
await connectOnDemandServer(serverInfo);
}
const client = serverInfo.client;
if (!client) {
throw new Error(`Client not found for server: ${serverInfo.name}`);
@@ -1638,19 +1356,13 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
request.params.name = request.params.name.startsWith(prefix)
? request.params.name.substring(prefix.length)
: request.params.name;
try {
const result = await callToolWithReconnect(
serverInfo,
request.params,
serverInfo.options || {},
);
console.log(`Tool call result: ${JSON.stringify(result)}`);
return result;
} finally {
// Disconnect on-demand server after tool call
disconnectOnDemandServer(serverInfo);
}
const result = await callToolWithReconnect(
serverInfo,
request.params,
serverInfo.options || {},
);
console.log(`Tool call result: ${JSON.stringify(result)}`);
return result;
} catch (error) {
console.error(`Error handling CallToolRequest: ${error}`);
return {

View File

@@ -225,22 +225,13 @@ export async function generateOpenAPISpec(
// Generate paths from tools
const paths: OpenAPIV3.PathsObject = {};
const separator = getNameSeparator();
for (const { tool, serverName } of allTools) {
const operation = generateOperationFromTool(tool, serverName);
const { requestBody } = convertToolSchemaToOpenAPI(tool);
// Extract the tool name without server prefix
// Tool names are in format: serverName + separator + toolName
const prefix = `${serverName}${separator}`;
const toolNameOnly = tool.name.startsWith(prefix)
? tool.name.substring(prefix.length)
: tool.name;
// Create path for the tool with URL-encoded server and tool names
// This handles cases where names contain slashes (e.g., "com.atlassian/atlassian-mcp-server")
const pathName = `/tools/${encodeURIComponent(serverName)}/${encodeURIComponent(toolNameOnly)}`;
// Create path for the tool
const pathName = `/tools/${serverName}/${tool.name}`;
const method = requestBody ? 'post' : 'get';
if (!paths[pathName]) {

View File

@@ -9,6 +9,7 @@ import { loadSettings } from '../config/index.js';
import config from '../config/index.js';
import { UserContextService } from './userContextService.js';
import { RequestContextService } from './requestContextService.js';
import { tryProxyMcpRequest, tryProxySseConnection, tryProxySseMessage } from './clusterService.js';
const transports: { [sessionId: string]: { transport: Transport; group: string } } = {};
@@ -81,6 +82,10 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
console.log(`Creating SSE transport with messages path: ${messagesPath}`);
if (await tryProxySseConnection(req, res, group)) {
return;
}
const transport = new SSEServerTransport(messagesPath, res);
transports[transport.sessionId] = { transport, group: group };
@@ -117,6 +122,10 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
return;
}
if (await tryProxySseMessage(req, res)) {
return;
}
// Check if transport exists before destructuring
const transportData = transports[sessionId];
if (!transportData) {
@@ -174,6 +183,10 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
return;
}
if (await tryProxyMcpRequest(req, res, group)) {
return;
}
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
console.log(`Reusing existing transport for sessionId: ${sessionId}`);
@@ -239,6 +252,11 @@ export const handleMcpOtherRequest = async (req: Request, res: Response) => {
return;
}
const group = req.params.group;
if (await tryProxyMcpRequest(req, res, group)) {
return;
}
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');

View File

@@ -62,6 +62,20 @@ export interface MarketServerTool {
inputSchema: Record<string, any>;
}
export interface ClusterNodeConfig {
id: string; // Unique identifier for the node
url: string; // Base URL for the node (e.g. http://node-a:3000)
groups?: string[]; // Optional list of group identifiers served by this node; include empty string for global routes
weight?: number; // Optional weight for load balancing
forwardHeaders?: Record<string, string>; // Additional headers forwarded to the node on every request
pathPrefix?: string; // Optional prefix prepended before forwarding paths (defaults to hub base path)
}
export interface ClusterConfig {
enabled?: boolean; // Flag to enable/disable cluster routing
nodes?: ClusterNodeConfig[]; // Cluster node definitions
}
export interface MarketServer {
name: string;
display_name: string;
@@ -171,6 +185,7 @@ export interface SystemConfig {
};
nameSeparator?: string; // Separator used between server name and tool/prompt name (default: '-')
oauth?: OAuthProviderConfig; // OAuth provider configuration for upstream MCP servers
cluster?: ClusterConfig; // Cluster configuration for multi-node deployments
}
export interface UserConfig {
@@ -204,7 +219,6 @@ export interface ServerConfig {
enabled?: boolean; // Flag to enable/disable the server
owner?: string; // Owner of the server, defaults to 'admin' user
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
connectionMode?: 'persistent' | 'on-demand'; // Connection strategy: 'persistent' maintains long-running connections (default), 'on-demand' connects only when tools are called
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
@@ -313,7 +327,6 @@ export interface ServerInfo {
options?: RequestOptions; // Options for requests
createTime: number; // Timestamp of when the server was created
enabled?: boolean; // Flag to indicate if the server is enabled
connectionMode?: 'persistent' | 'on-demand'; // Connection strategy for this server
keepAliveIntervalId?: NodeJS.Timeout; // Timer ID for keep-alive ping interval
config?: ServerConfig; // Reference to the original server configuration for OpenAPI passthrough headers
oauth?: {

View File

@@ -1,93 +0,0 @@
/**
* Utility functions for converting parameter types based on JSON schema definitions
*/
/**
* Convert parameters to their proper types based on the tool's input schema
* This ensures that form-submitted string values are converted to the correct types
* (e.g., numbers, booleans, arrays) before being passed to MCP tools.
*
* @param params - The parameters to convert (typically from form submission)
* @param inputSchema - The JSON schema definition for the tool's input
* @returns The converted parameters with proper types
*/
export function convertParametersToTypes(
params: Record<string, any>,
inputSchema: Record<string, any>,
): Record<string, any> {
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
return params;
}
const convertedParams: Record<string, any> = {};
const properties = inputSchema.properties;
for (const [key, value] of Object.entries(params)) {
const propDef = properties[key];
if (!propDef || typeof propDef !== 'object') {
// No schema definition found, keep as is
convertedParams[key] = value;
continue;
}
const propType = propDef.type;
try {
switch (propType) {
case 'integer':
case 'number':
// Convert string to number
if (typeof value === 'string') {
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
convertedParams[key] = isNaN(numValue) ? value : numValue;
} else {
convertedParams[key] = value;
}
break;
case 'boolean':
// Convert string to boolean
if (typeof value === 'string') {
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
} else {
convertedParams[key] = value;
}
break;
case 'array':
// Handle array conversion if needed (e.g., comma-separated strings)
if (typeof value === 'string' && value.includes(',')) {
convertedParams[key] = value.split(',').map((item) => item.trim());
} else {
convertedParams[key] = value;
}
break;
case 'object':
// Handle object conversion if needed
if (typeof value === 'string') {
try {
convertedParams[key] = JSON.parse(value);
} catch {
// If parsing fails, keep as is
convertedParams[key] = value;
}
} else {
convertedParams[key] = value;
}
break;
default:
// For string and other types, keep as is
convertedParams[key] = value;
break;
}
} catch (error) {
// If conversion fails, keep the original value
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
convertedParams[key] = value;
}
}
return convertedParams;
}

View File

@@ -0,0 +1,67 @@
import { ClusterNodeConfig } from '../src/types/index.js';
import config from '../src/config/index.js';
import { __clusterInternals } from '../src/services/clusterService.js';
const { buildTargetUrl, normalizeBasePath, matchesNodeGroup, joinUrlPaths } = __clusterInternals;
describe('clusterService internals', () => {
const originalBasePath = config.basePath;
afterEach(() => {
config.basePath = originalBasePath;
});
test('normalizeBasePath trims trailing slashes and enforces leading slash', () => {
expect(normalizeBasePath('')).toBe('');
expect(normalizeBasePath('/')).toBe('');
expect(normalizeBasePath('/api/')).toBe('/api');
expect(normalizeBasePath('api')).toBe('/api');
});
test('matchesNodeGroup recognises global shortcuts', () => {
expect(matchesNodeGroup('', '')).toBe(true);
expect(matchesNodeGroup('global', '')).toBe(true);
expect(matchesNodeGroup('default', '')).toBe(true);
expect(matchesNodeGroup('*', '')).toBe(true);
expect(matchesNodeGroup('*', 'group-a')).toBe(true);
expect(matchesNodeGroup('group-a', 'group-a')).toBe(true);
expect(matchesNodeGroup('group-a', 'group-b')).toBe(false);
});
test('joinUrlPaths combines segments without duplicating slashes', () => {
expect(joinUrlPaths('/', '/api', '/messages')).toBe('/api/messages');
expect(joinUrlPaths('/root', '', '/')).toBe('/root');
expect(joinUrlPaths('', '', '/tools')).toBe('/tools');
});
test('buildTargetUrl respects hub base path and node prefix', () => {
config.basePath = '/hub';
const node: ClusterNodeConfig = {
id: 'node-1',
url: 'http://backend:3000',
};
const target = buildTargetUrl(node, '/hub/mcp/alpha?foo=bar');
expect(target.toString()).toBe('http://backend:3000/hub/mcp/alpha?foo=bar');
});
test('buildTargetUrl can override base path using node prefix', () => {
config.basePath = '/hub';
const node: ClusterNodeConfig = {
id: 'node-1',
url: 'http://backend:3000',
pathPrefix: '/',
};
const target = buildTargetUrl(node, '/hub/mcp/alpha?foo=bar');
expect(target.toString()).toBe('http://backend:3000/mcp/alpha?foo=bar');
});
test('buildTargetUrl appends to node URL path when provided', () => {
config.basePath = '';
const node: ClusterNodeConfig = {
id: 'node-1',
url: 'http://backend:3000/root',
};
const target = buildTargetUrl(node, '/messages?sessionId=123');
expect(target.toString()).toBe('http://backend:3000/root/messages?sessionId=123');
});
});

View File

@@ -1,7 +1,73 @@
import { convertParametersToTypes } from '../../src/utils/parameterConversion.js';
// Simple unit test to validate the type conversion logic
describe('Parameter Type Conversion Logic', () => {
// Extract the conversion function for testing
function convertQueryParametersToTypes(
queryParams: Record<string, any>,
inputSchema: Record<string, any>
): Record<string, any> {
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
return queryParams;
}
const convertedParams: Record<string, any> = {};
const properties = inputSchema.properties;
for (const [key, value] of Object.entries(queryParams)) {
const propDef = properties[key];
if (!propDef || typeof propDef !== 'object') {
// No schema definition found, keep as is
convertedParams[key] = value;
continue;
}
const propType = propDef.type;
try {
switch (propType) {
case 'integer':
case 'number':
// Convert string to number
if (typeof value === 'string') {
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
convertedParams[key] = isNaN(numValue) ? value : numValue;
} else {
convertedParams[key] = value;
}
break;
case 'boolean':
// Convert string to boolean
if (typeof value === 'string') {
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
} else {
convertedParams[key] = value;
}
break;
case 'array':
// Handle array conversion if needed (e.g., comma-separated strings)
if (typeof value === 'string' && value.includes(',')) {
convertedParams[key] = value.split(',').map(item => item.trim());
} else {
convertedParams[key] = value;
}
break;
default:
// For string and other types, keep as is
convertedParams[key] = value;
break;
}
} catch (error) {
// If conversion fails, keep the original value
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
convertedParams[key] = value;
}
}
return convertedParams;
}
// Integration tests for OpenAPI controller's parameter type conversion
describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
test('should convert integer parameters correctly', () => {
const queryParams = {
limit: '5',
@@ -18,7 +84,7 @@ describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
}
};
const result = convertParametersToTypes(queryParams, inputSchema);
const result = convertQueryParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
limit: 5, // Converted to integer
@@ -41,7 +107,7 @@ describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
}
};
const result = convertParametersToTypes(queryParams, inputSchema);
const result = convertQueryParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
price: 19.99,
@@ -67,7 +133,7 @@ describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
}
};
const result = convertParametersToTypes(queryParams, inputSchema);
const result = convertQueryParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
enabled: true,
@@ -91,7 +157,7 @@ describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
}
};
const result = convertParametersToTypes(queryParams, inputSchema);
const result = convertQueryParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
tags: ['tag1', 'tag2', 'tag3'],
@@ -105,7 +171,7 @@ describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
name: 'test'
};
const result = convertParametersToTypes(queryParams, {});
const result = convertQueryParametersToTypes(queryParams, {});
expect(result).toEqual({
limit: '5', // Should remain as string
@@ -126,7 +192,7 @@ describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
}
};
const result = convertParametersToTypes(queryParams, inputSchema);
const result = convertQueryParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
limit: 5, // Converted based on schema
@@ -148,7 +214,7 @@ describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
}
};
const result = convertParametersToTypes(queryParams, inputSchema);
const result = convertQueryParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
limit: 'not-a-number', // Should remain as string when conversion fails
@@ -233,16 +299,4 @@ describe('OpenAPI Granular Endpoints', () => {
const group = mockGetGroupByIdOrName('nonexistent');
expect(group).toBeNull();
});
test('should decode URL-encoded server and tool names with slashes', () => {
// Test that URL-encoded names with slashes are properly decoded
const encodedServerName = 'com.atlassian%2Fatlassian-mcp-server';
const encodedToolName = 'atlassianUserInfo';
const decodedServerName = decodeURIComponent(encodedServerName);
const decodedToolName = decodeURIComponent(encodedToolName);
expect(decodedServerName).toBe('com.atlassian/atlassian-mcp-server');
expect(decodedToolName).toBe('atlassianUserInfo');
});
});

View File

@@ -1,98 +0,0 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import request from 'supertest';
const handleSseConnectionMock = jest.fn();
const handleSseMessageMock = jest.fn();
const handleMcpPostRequestMock = jest.fn();
const handleMcpOtherRequestMock = jest.fn();
const sseUserContextMiddlewareMock = jest.fn((_req, _res, next) => next());
jest.mock('../../src/utils/i18n.js', () => ({
__esModule: true,
initI18n: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../src/models/User.js', () => ({
__esModule: true,
initializeDefaultUser: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../src/services/oauthService.js', () => ({
__esModule: true,
initOAuthProvider: jest.fn(),
getOAuthRouter: jest.fn(() => null),
}));
jest.mock('../../src/middlewares/index.js', () => ({
__esModule: true,
initMiddlewares: jest.fn(),
}));
jest.mock('../../src/routes/index.js', () => ({
__esModule: true,
initRoutes: jest.fn(),
}));
jest.mock('../../src/services/mcpService.js', () => ({
__esModule: true,
initUpstreamServers: jest.fn().mockResolvedValue(undefined),
connected: jest.fn().mockReturnValue(true),
}));
jest.mock('../../src/services/sseService.js', () => ({
__esModule: true,
handleSseConnection: handleSseConnectionMock,
handleSseMessage: handleSseMessageMock,
handleMcpPostRequest: handleMcpPostRequestMock,
handleMcpOtherRequest: handleMcpOtherRequestMock,
}));
jest.mock('../../src/middlewares/userContext.js', () => ({
__esModule: true,
userContextMiddleware: jest.fn((_req, _res, next) => next()),
sseUserContextMiddleware: sseUserContextMiddlewareMock,
}));
import { AppServer } from '../../src/server.js';
const flushPromises = async () => {
await new Promise((resolve) => setImmediate(resolve));
};
describe('AppServer smart routing group paths', () => {
beforeEach(() => {
jest.clearAllMocks();
handleMcpPostRequestMock.mockImplementation(async (_req, res) => {
res.status(204).send();
});
sseUserContextMiddlewareMock.mockImplementation((_req, _res, next) => next());
});
const createApp = async () => {
const appServer = new AppServer();
await appServer.initialize();
await flushPromises();
return appServer.getApp();
};
it('routes global MCP requests with nested smart group segments', async () => {
const app = await createApp();
await request(app).post('/mcp/$smart/test-group').send({}).expect(204);
expect(handleMcpPostRequestMock).toHaveBeenCalledTimes(1);
const [req] = handleMcpPostRequestMock.mock.calls[0];
expect(req.params.group).toBe('$smart/test-group');
});
it('routes user-scoped MCP requests with nested smart group segments', async () => {
const app = await createApp();
await request(app).post('/alice/mcp/$smart/staging').send({}).expect(204);
expect(handleMcpPostRequestMock).toHaveBeenCalledTimes(1);
const [req] = handleMcpPostRequestMock.mock.calls[0];
expect(req.params.group).toBe('$smart/staging');
expect(req.params.user).toBe('alice');
});
});

View File

@@ -1,340 +0,0 @@
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
// Mock dependencies before importing mcpService
jest.mock('../../src/services/oauthService.js', () => ({
initializeAllOAuthClients: jest.fn(),
}));
jest.mock('../../src/services/oauthClientRegistration.js', () => ({
registerOAuthClient: jest.fn(),
}));
jest.mock('../../src/services/mcpOAuthProvider.js', () => ({
createOAuthProvider: jest.fn(),
}));
jest.mock('../../src/services/groupService.js', () => ({
getServersInGroup: jest.fn(),
getServerConfigInGroup: jest.fn(),
}));
jest.mock('../../src/services/sseService.js', () => ({
getGroup: jest.fn(),
}));
jest.mock('../../src/services/vectorSearchService.js', () => ({
saveToolsAsVectorEmbeddings: jest.fn(),
searchToolsByVector: jest.fn(() => Promise.resolve([])),
}));
jest.mock('../../src/services/services.js', () => ({
getDataService: jest.fn(() => ({
filterData: (data: any) => data,
})),
}));
jest.mock('../../src/config/index.js', () => ({
default: {
mcpHubName: 'test-hub',
mcpHubVersion: '1.0.0',
initTimeout: 60000,
},
loadSettings: jest.fn(() => ({})),
expandEnvVars: jest.fn((val: string) => val),
replaceEnvVars: jest.fn((obj: any) => obj),
getNameSeparator: jest.fn(() => '-'),
}));
// Mock Client
const mockClient = {
connect: jest.fn(),
close: jest.fn(),
listTools: jest.fn(),
listPrompts: jest.fn(),
getServerCapabilities: jest.fn(() => ({
tools: {},
prompts: {},
})),
callTool: jest.fn(),
};
jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
Client: jest.fn(() => mockClient),
}));
// Mock StdioClientTransport
const mockTransport = {
close: jest.fn(),
stderr: null,
};
jest.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
StdioClientTransport: jest.fn(() => mockTransport),
}));
// Mock DAO
const mockServerDao = {
findAll: jest.fn(),
findById: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
exists: jest.fn(),
setEnabled: jest.fn(),
};
jest.mock('../../src/dao/index.js', () => ({
getServerDao: jest.fn(() => mockServerDao),
}));
import { initializeClientsFromSettings, handleCallToolRequest } from '../../src/services/mcpService.js';
describe('On-Demand MCP Server Connection Mode', () => {
beforeEach(() => {
jest.clearAllMocks();
mockClient.connect.mockResolvedValue(undefined);
mockClient.close.mockReturnValue(undefined);
mockClient.listTools.mockResolvedValue({
tools: [
{
name: 'test-tool',
description: 'Test tool',
inputSchema: { type: 'object' },
},
],
});
mockClient.listPrompts.mockResolvedValue({
prompts: [],
});
mockClient.callTool.mockResolvedValue({
content: [{ type: 'text', text: 'Success' }],
});
mockTransport.close.mockReturnValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('Server Initialization', () => {
it('should not maintain persistent connection for on-demand servers', async () => {
mockServerDao.findAll.mockResolvedValue([
{
name: 'on-demand-server',
command: 'node',
args: ['test.js'],
connectionMode: 'on-demand',
enabled: true,
},
]);
const serverInfos = await initializeClientsFromSettings(true);
expect(serverInfos).toHaveLength(1);
expect(serverInfos[0].name).toBe('on-demand-server');
expect(serverInfos[0].connectionMode).toBe('on-demand');
expect(serverInfos[0].status).toBe('disconnected');
// Should connect once to get tools, then disconnect
expect(mockClient.connect).toHaveBeenCalledTimes(1);
expect(mockTransport.close).toHaveBeenCalledTimes(1);
});
it('should load tools during initialization for on-demand servers', async () => {
mockServerDao.findAll.mockResolvedValue([
{
name: 'on-demand-server',
command: 'node',
args: ['test.js'],
connectionMode: 'on-demand',
enabled: true,
},
]);
const serverInfos = await initializeClientsFromSettings(true);
expect(serverInfos[0].tools).toHaveLength(1);
expect(serverInfos[0].tools[0].name).toBe('on-demand-server-test-tool');
expect(mockClient.listTools).toHaveBeenCalled();
});
it('should maintain persistent connection for default connection mode', async () => {
mockServerDao.findAll.mockResolvedValue([
{
name: 'persistent-server',
command: 'node',
args: ['test.js'],
enabled: true,
},
]);
const serverInfos = await initializeClientsFromSettings(true);
expect(serverInfos).toHaveLength(1);
expect(serverInfos[0].connectionMode).toBe('persistent');
expect(mockClient.connect).toHaveBeenCalledTimes(1);
// Should not disconnect immediately
expect(mockTransport.close).not.toHaveBeenCalled();
});
it('should handle initialization errors for on-demand servers gracefully', async () => {
mockClient.connect.mockRejectedValueOnce(new Error('Connection failed'));
mockServerDao.findAll.mockResolvedValue([
{
name: 'failing-server',
command: 'node',
args: ['test.js'],
connectionMode: 'on-demand',
enabled: true,
},
]);
const serverInfos = await initializeClientsFromSettings(true);
expect(serverInfos).toHaveLength(1);
expect(serverInfos[0].status).toBe('disconnected');
expect(serverInfos[0].error).toContain('Failed to initialize');
});
});
describe('Tool Invocation with On-Demand Servers', () => {
beforeEach(async () => {
// Set up server infos with an on-demand server that's disconnected
mockServerDao.findAll.mockResolvedValue([
{
name: 'on-demand-server',
command: 'node',
args: ['test.js'],
connectionMode: 'on-demand',
enabled: true,
},
]);
// Initialize to get the server set up
await initializeClientsFromSettings(true);
// Clear mocks after initialization
jest.clearAllMocks();
// Reset mock implementations
mockClient.connect.mockResolvedValue(undefined);
mockClient.listTools.mockResolvedValue({
tools: [
{
name: 'test-tool',
description: 'Test tool',
inputSchema: { type: 'object' },
},
],
});
mockClient.callTool.mockResolvedValue({
content: [{ type: 'text', text: 'Success' }],
});
});
it('should connect on-demand server before tool invocation', async () => {
const request = {
params: {
name: 'on-demand-server-test-tool',
arguments: { arg1: 'value1' },
},
};
await handleCallToolRequest(request, {});
// Should connect before calling the tool
expect(mockClient.connect).toHaveBeenCalledTimes(1);
expect(mockClient.callTool).toHaveBeenCalledWith(
{
name: 'test-tool',
arguments: { arg1: 'value1' },
},
undefined,
expect.any(Object),
);
});
it('should disconnect on-demand server after tool invocation', async () => {
const request = {
params: {
name: 'on-demand-server-test-tool',
arguments: {},
},
};
await handleCallToolRequest(request, {});
// Should disconnect after calling the tool
expect(mockTransport.close).toHaveBeenCalledTimes(1);
expect(mockClient.close).toHaveBeenCalledTimes(1);
});
it('should disconnect on-demand server even if tool invocation fails', async () => {
mockClient.callTool.mockRejectedValueOnce(new Error('Tool execution failed'));
const request = {
params: {
name: 'on-demand-server-test-tool',
arguments: {},
},
};
try {
await handleCallToolRequest(request, {});
} catch (error) {
// Expected to fail
}
// Should still disconnect after error
expect(mockTransport.close).toHaveBeenCalledTimes(1);
expect(mockClient.close).toHaveBeenCalledTimes(1);
});
it('should return error for call_tool if server not found', async () => {
const request = {
params: {
name: 'call_tool',
arguments: {
toolName: 'nonexistent-server-tool',
arguments: {},
},
},
};
const result = await handleCallToolRequest(request, {});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('No available servers found');
});
});
describe('Mixed Server Modes', () => {
it('should handle both persistent and on-demand servers together', async () => {
mockServerDao.findAll.mockResolvedValue([
{
name: 'persistent-server',
command: 'node',
args: ['persistent.js'],
enabled: true,
},
{
name: 'on-demand-server',
command: 'node',
args: ['on-demand.js'],
connectionMode: 'on-demand',
enabled: true,
},
]);
const serverInfos = await initializeClientsFromSettings(true);
expect(serverInfos).toHaveLength(2);
const persistentServer = serverInfos.find(s => s.name === 'persistent-server');
const onDemandServer = serverInfos.find(s => s.name === 'on-demand-server');
expect(persistentServer?.connectionMode).toBe('persistent');
expect(onDemandServer?.connectionMode).toBe('on-demand');
expect(onDemandServer?.status).toBe('disconnected');
});
});
});

View File

@@ -65,27 +65,6 @@ describe('OpenAPI Generator Service', () => {
expect(spec).toHaveProperty('paths');
expect(typeof spec.paths).toBe('object');
});
it('should URL-encode server and tool names with slashes in paths', async () => {
const spec = await generateOpenAPISpec();
// Check if any paths contain URL-encoded values
// Paths with slashes in server/tool names should be encoded
const paths = Object.keys(spec.paths);
// If there are any servers with slashes, verify encoding
// e.g., "com.atlassian/atlassian-mcp-server" should become "com.atlassian%2Fatlassian-mcp-server"
for (const path of paths) {
// Path should not have unencoded slashes in the middle segments
// Valid format: /tools/{encoded-server}/{encoded-tool}
const pathSegments = path.split('/').filter((s) => s.length > 0);
if (pathSegments[0] === 'tools' && pathSegments.length >= 3) {
// The server name (segment 1) and tool name (segment 2+) should not create extra segments
// If properly encoded, there should be exactly 3 segments: ['tools', serverName, toolName]
expect(pathSegments.length).toBe(3);
}
}
});
});
describe('getToolStats', () => {

View File

@@ -1,259 +0,0 @@
import { convertParametersToTypes } from '../../src/utils/parameterConversion.js';
describe('Parameter Conversion Utilities', () => {
describe('convertParametersToTypes', () => {
it('should convert string to number when schema type is number', () => {
const params = { count: '42' };
const schema = {
type: 'object',
properties: {
count: { type: 'number' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.count).toBe(42);
expect(typeof result.count).toBe('number');
});
it('should convert string to integer when schema type is integer', () => {
const params = { age: '25' };
const schema = {
type: 'object',
properties: {
age: { type: 'integer' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.age).toBe(25);
expect(typeof result.age).toBe('number');
expect(Number.isInteger(result.age)).toBe(true);
});
it('should convert string to boolean when schema type is boolean', () => {
const params = { enabled: 'true', disabled: 'false', flag: '1' };
const schema = {
type: 'object',
properties: {
enabled: { type: 'boolean' },
disabled: { type: 'boolean' },
flag: { type: 'boolean' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.enabled).toBe(true);
expect(result.disabled).toBe(false);
expect(result.flag).toBe(true);
});
it('should convert comma-separated string to array when schema type is array', () => {
const params = { tags: 'one,two,three' };
const schema = {
type: 'object',
properties: {
tags: { type: 'array' },
},
};
const result = convertParametersToTypes(params, schema);
expect(Array.isArray(result.tags)).toBe(true);
expect(result.tags).toEqual(['one', 'two', 'three']);
});
it('should parse JSON string to object when schema type is object', () => {
const params = { config: '{"key": "value", "nested": {"prop": 123}}' };
const schema = {
type: 'object',
properties: {
config: { type: 'object' },
},
};
const result = convertParametersToTypes(params, schema);
expect(typeof result.config).toBe('object');
expect(result.config).toEqual({ key: 'value', nested: { prop: 123 } });
});
it('should keep values unchanged when they already have the correct type', () => {
const params = { count: 42, enabled: true, tags: ['a', 'b'] };
const schema = {
type: 'object',
properties: {
count: { type: 'number' },
enabled: { type: 'boolean' },
tags: { type: 'array' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.count).toBe(42);
expect(result.enabled).toBe(true);
expect(result.tags).toEqual(['a', 'b']);
});
it('should keep string values unchanged when schema type is string', () => {
const params = { name: 'John Doe' };
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.name).toBe('John Doe');
expect(typeof result.name).toBe('string');
});
it('should handle parameters without schema definition', () => {
const params = { unknown: 'value' };
const schema = {
type: 'object',
properties: {
known: { type: 'string' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.unknown).toBe('value');
});
it('should return original params when schema has no properties', () => {
const params = { key: 'value' };
const schema = { type: 'object' };
const result = convertParametersToTypes(params, schema);
expect(result).toEqual(params);
});
it('should return original params when schema is null or undefined', () => {
const params = { key: 'value' };
const resultNull = convertParametersToTypes(params, null as any);
const resultUndefined = convertParametersToTypes(params, undefined as any);
expect(resultNull).toEqual(params);
expect(resultUndefined).toEqual(params);
});
it('should handle invalid number conversion gracefully', () => {
const params = { count: 'not-a-number' };
const schema = {
type: 'object',
properties: {
count: { type: 'number' },
},
};
const result = convertParametersToTypes(params, schema);
// When conversion fails, it should keep original value
expect(result.count).toBe('not-a-number');
});
it('should handle invalid JSON string for object gracefully', () => {
const params = { config: '{invalid json}' };
const schema = {
type: 'object',
properties: {
config: { type: 'object' },
},
};
const result = convertParametersToTypes(params, schema);
// When JSON parsing fails, it should keep original value
expect(result.config).toBe('{invalid json}');
});
it('should handle mixed parameter types correctly', () => {
const params = {
name: 'Test',
count: '10',
price: '19.99',
enabled: 'true',
tags: 'tag1,tag2',
config: '{"nested": true}',
};
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
count: { type: 'integer' },
price: { type: 'number' },
enabled: { type: 'boolean' },
tags: { type: 'array' },
config: { type: 'object' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.name).toBe('Test');
expect(result.count).toBe(10);
expect(result.price).toBe(19.99);
expect(result.enabled).toBe(true);
expect(result.tags).toEqual(['tag1', 'tag2']);
expect(result.config).toEqual({ nested: true });
});
it('should handle empty string values', () => {
const params = { name: '', count: '', enabled: '' };
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
count: { type: 'number' },
enabled: { type: 'boolean' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.name).toBe('');
// Empty string should remain as empty string for number (NaN check keeps original)
expect(result.count).toBe('');
// Empty string converts to false for boolean
expect(result.enabled).toBe(false);
});
it('should handle array that is already an array', () => {
const params = { tags: ['existing', 'array'] };
const schema = {
type: 'object',
properties: {
tags: { type: 'array' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.tags).toEqual(['existing', 'array']);
});
it('should handle object that is already an object', () => {
const params = { config: { key: 'value' } };
const schema = {
type: 'object',
properties: {
config: { type: 'object' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.config).toEqual({ key: 'value' });
});
});
});