Compare commits

..

1 Commits

Author SHA1 Message Date
samanhappy
a55405b974 feat: add cluster routing support 2025-10-26 21:41:03 +08:00
24 changed files with 785 additions and 1030 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

@@ -1,182 +0,0 @@
# Transport Event Handlers Fix
## Problem Statement
After adding SSE (Server-Sent Events) or Streamable HTTP protocol servers, the server status did not automatically update when connections failed or closed. The status remained "connected" even when the connection was lost.
## Root Cause
The MCP SDK provides `onclose` and `onerror` event handlers for all transport types (SSE, StreamableHTTP, and stdio), but the MCPHub implementation was not setting up these handlers. This meant that:
1. When a connection closed unexpectedly, the server status remained "connected"
2. When transport errors occurred, the status was not updated
3. Users could not see the actual connection state in the dashboard
## Solution
Added a `setupTransportEventHandlers()` helper function that:
1. Sets up `onclose` handler to update status to 'disconnected' when connections close
2. Sets up `onerror` handler to update status and capture error messages
3. Clears keep-alive ping intervals when connections fail
4. Logs connection state changes for debugging
The handlers are set up in two places:
1. After successful initial connection in `initializeClientsFromSettings()`
2. After reconnection in `callToolWithReconnect()`
## Changes Made
### File: `src/services/mcpService.ts`
#### New Function: `setupTransportEventHandlers()`
```typescript
const setupTransportEventHandlers = (serverInfo: ServerInfo): void => {
if (!serverInfo.transport) {
return;
}
// Set up onclose handler to update status when connection closes
serverInfo.transport.onclose = () => {
console.log(`Transport closed for server: ${serverInfo.name}`);
if (serverInfo.status === 'connected') {
serverInfo.status = 'disconnected';
serverInfo.error = 'Connection closed';
}
// Clear keep-alive interval if it exists
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId);
serverInfo.keepAliveIntervalId = undefined;
}
};
// Set up onerror handler to update status on connection errors
serverInfo.transport.onerror = (error: Error) => {
console.error(`Transport error for server ${serverInfo.name}:`, error);
if (serverInfo.status === 'connected') {
serverInfo.status = 'disconnected';
serverInfo.error = `Transport error: ${error.message}`;
}
// Clear keep-alive interval if it exists
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId);
serverInfo.keepAliveIntervalId = undefined;
}
};
console.log(`Transport event handlers set up for server: ${serverInfo.name}`);
};
```
#### Integration Points
1. **Initial Connection** - Added call after successful connection:
```typescript
if (!dataError) {
serverInfo.status = 'connected';
serverInfo.error = null;
// Set up transport event handlers for connection monitoring
setupTransportEventHandlers(serverInfo);
// Set up keep-alive ping for SSE connections
setupKeepAlive(serverInfo, expandedConf);
}
```
2. **Reconnection** - Added call after reconnection succeeds:
```typescript
// Update server info with new client and transport
serverInfo.client = client;
serverInfo.transport = newTransport;
serverInfo.status = 'connected';
// Set up transport event handlers for the new connection
setupTransportEventHandlers(serverInfo);
```
## Testing
### Automated Tests
All 169 existing tests pass, including:
- Integration tests for SSE transport (`tests/integration/sse-service-real-client.test.ts`)
- Integration tests for StreamableHTTP transport
- Unit tests for MCP service functionality
### Manual Testing
To manually test the fix:
1. **Add an SSE server** to `mcp_settings.json`:
```json
{
"mcpServers": {
"test-sse-server": {
"type": "sse",
"url": "http://localhost:9999/sse",
"enabled": true
}
}
}
```
2. **Start MCPHub**: `pnpm dev`
3. **Observe the behavior**:
- Server will initially show as "connecting"
- When connection fails (port 9999 not available), status will update to "disconnected"
- Error message will show: "Transport error: ..." or "Connection closed"
4. **Test connection recovery**:
- Start an MCP server on the configured URL
- The status should update to "connected" when available
- Stop the MCP server
- The status should update back to "disconnected"
### StreamableHTTP Testing
1. **Add a StreamableHTTP server** to `mcp_settings.json`:
```json
{
"mcpServers": {
"test-http-server": {
"type": "streamable-http",
"url": "http://localhost:9999/mcp",
"enabled": true
}
}
}
```
2. Follow the same testing steps as SSE
## Benefits
1. **Accurate Status**: Server status now reflects actual connection state
2. **Better UX**: Users can see when connections fail in real-time
3. **Debugging**: Error messages help diagnose connection issues
4. **Resource Management**: Keep-alive intervals are properly cleaned up on connection failures
5. **Consistent Behavior**: All transport types (SSE, StreamableHTTP, stdio) now have proper event handling
## Compatibility
- **Backwards Compatible**: No breaking changes to existing functionality
- **SDK Version**: Requires `@modelcontextprotocol/sdk` v1.20.2 or higher (current version in use)
- **Node.js**: Compatible with all supported Node.js versions
- **Transport Types**: Works with SSEClientTransport, StreamableHTTPClientTransport, and StdioClientTransport
Note: The `onclose` and `onerror` event handlers are part of the Transport interface in the MCP SDK and have been available since early versions. The current implementation has been tested with SDK v1.20.2.
## Future Enhancements
Potential improvements for the future:
1. Add automatic reconnection logic for transient failures
2. Add connection health metrics (uptime, error count)
3. Emit events for UI notifications when status changes
4. Add configurable retry strategies per server

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

@@ -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
@@ -136,48 +64,6 @@ const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): voi
);
};
// Helper function to clean up server resources on disconnection
const cleanupServerResources = (serverInfo: ServerInfo): void => {
// Clear keep-alive interval if it exists
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId);
serverInfo.keepAliveIntervalId = undefined;
}
};
// Helper function to set up transport event handlers for connection monitoring
const setupTransportEventHandlers = (serverInfo: ServerInfo): void => {
if (!serverInfo.transport) {
return;
}
// Set up onclose handler to update status when connection closes
serverInfo.transport.onclose = () => {
console.log(`Transport closed for server: ${serverInfo.name}`);
// Update status to disconnected if not already in a terminal state
if (serverInfo.status === 'connected' || serverInfo.status === 'connecting') {
serverInfo.status = 'disconnected';
serverInfo.error = 'Connection closed';
}
cleanupServerResources(serverInfo);
};
// Set up onerror handler to update status on connection errors
serverInfo.transport.onerror = (error: Error) => {
console.error(`Transport error for server ${serverInfo.name}:`, error);
// Update status to disconnected if not already in a terminal state
if (serverInfo.status === 'connected' || serverInfo.status === 'connecting') {
serverInfo.status = 'disconnected';
serverInfo.error = `Transport error: ${error.message}`;
}
cleanupServerResources(serverInfo);
};
console.log(`Transport event handlers set up for server: ${serverInfo.name}`);
};
export const initUpstreamServers = async (): Promise<void> => {
// Initialize OAuth clients for servers with dynamic registration
await initializeAllOAuthClients();
@@ -276,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
@@ -328,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
@@ -350,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,
@@ -482,9 +327,6 @@ const callToolWithReconnect = async (
serverInfo.transport = newTransport;
serverInfo.status = 'connected';
// Set up transport event handlers for the new connection
setupTransportEventHandlers(serverInfo);
// Reload tools list after reconnection
try {
const tools = await client.listTools({}, serverInfo.options || {});
@@ -759,9 +601,6 @@ export const initializeClientsFromSettings = async (
serverInfo.status = 'connected';
serverInfo.error = null;
// Set up transport event handlers for connection monitoring
setupTransportEventHandlers(serverInfo);
// Set up keep-alive ping for SSE connections
setupKeepAlive(serverInfo, expandedConf);
} else {

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 {

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

@@ -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' });
});
});
});