#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { randomUUID } from 'node:crypto';
import * as branches from './operations/codeup/branches.js';
import * as files from './operations/codeup/files.js';
import * as repositories from './operations/codeup/repositories.js';
import * as changeRequests from './operations/codeup/changeRequests.js';
import * as changeRequestComments from './operations/codeup/changeRequestComments.js';
import * as organization from './operations/organization/organization.js';
import * as members from './operations/organization/members.js';
import * as project from './operations/projex/project.js';
import * as workitem from './operations/projex/workitem.js';
import * as sprint from './operations/projex/sprint.js';
import * as compare from './operations/codeup/compare.js'
import * as pipeline from './operations/flow/pipeline.js'
import * as pipelineJob from './operations/flow/pipelineJob.js'
import * as serviceConnection from './operations/flow/serviceConnection.js'
import * as packageRepositories from './operations/packages/repositories.js'
import * as artifacts from './operations/packages/artifacts.js'
import {
isYunxiaoError,
YunxiaoError,
YunxiaoValidationError
} from "./common/errors.js";
import { VERSION } from "./common/version.js";
import {config} from "dotenv";
import * as types from "./common/types.js";
import { getAllTools, getEnabledTools } from "./tool-registry/index.js";
import { handleToolRequest, handleEnabledToolRequest } from "./tool-handlers/index.js";
import { Toolset } from "./common/toolsets.js";
// Factory function to create a new Server instance with all handlers registered
// This is used for HTTP mode where each request needs a fresh server instance
function createServer(enabledToolsets: Toolset[]): Server {
const server = new Server(
{
name: "alibabacloud-devops-mcp-server",
version: VERSION,
},
{
capabilities: {
tools: {},
},
}
);
// Register ListTools handler
server.setRequestHandler(ListToolsRequestSchema, async () => {
let tools: any[];
if (enabledToolsets.length > 0) {
// 获取基础工具(总是加载)
const baseTools = getEnabledTools([Toolset.BASE]);
// 获取启用的工具集工具
const enabledTools = getEnabledTools(enabledToolsets);
// 合并基础工具和启用的工具集工具
tools = [...baseTools, ...enabledTools];
} else {
// 如果没有指定启用的工具集,则获取所有工具(已包含基础工具)
tools = getAllTools();
}
return {
tools,
};
});
// Register CallTool handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
if (!request.params.arguments) {
throw new Error("Arguments are required");
}
// Delegate to our modular tool handler with toolset support
const result = enabledToolsets.length > 0
? await handleEnabledToolRequest(request, enabledToolsets)
: await handleToolRequest(request);
return result;
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid input: ${JSON.stringify(error.errors)}`);
}
if (isYunxiaoError(error)) {
throw new Error(formatYunxiaoError(error));
}
throw error;
}
});
return server;
}
// Create a global server instance for stdio and SSE modes
const server = createServer([]);
function formatYunxiaoError(error: YunxiaoError): string {
let message = `Yunxiao API Error: ${error.message}`;
// 添加请求上下文信息
if (error.method || error.url) {
message += `\n Request: ${error.method || 'GET'} ${error.url || 'unknown'}`;
}
if (error.requestHeaders) {
message += `\n Request Headers: ${JSON.stringify(error.requestHeaders, null, 2)}`;
}
if (error.requestBody) {
message += `\n Request Body: ${JSON.stringify(error.requestBody, null, 2)}`;
}
if (error instanceof YunxiaoValidationError) {
message = `Parameter validation failed: ${error.message}`;
if (error.response) {
message += `\n Response: ${JSON.stringify(error.response, null, 2)}`;
}
// 添加常见参数错误的提示
if (error.message.includes('name')) {
message += `\n Suggestion: Please check whether the pipeline name meets the requirements.`;
}
if (error.message.includes('content') || error.message.includes('yaml')) {
message += `\n Suggestion: Please check whether the generated YAML format is correct.`;
}
} else {
// 处理通用的Yunxiao错误
message = `Yunxiao API error (${error.status}): ${error.message}`;
if (error.response) {
const response = error.response as any;
if (response.errorCode) {
message += `\n errorCode: ${response.errorCode}`;
}
if (response.errorMessage && response.errorMessage !== error.message) {
message += `\n errorMessage: ${response.errorMessage}`;
}
if (response.data && typeof response.data === 'object') {
message += `\n data: ${JSON.stringify(response.data, null, 2)}`;
}
// 如果响应体中有更多详细信息,也一并显示
if (Object.keys(response).length > 0) {
message += `\n Full Response: ${JSON.stringify(response, null, 2)}`;
}
}
// 根据状态码提供通用建议
switch (error.status) {
case 400:
message += `\n Suggestion: Please check whether the request parameters are correct, especially whether all required parameters have been provided.`;
break;
case 500:
message += `\n Suggestion: Internal server error. Please try again later or contact technical support.`;
break;
case 502:
case 503:
case 504:
message += `\n Suggestion: The service is temporarily unavailable. Please try again later.`;
break;
}
}
return message;
}
config();
// 解析启用的工具集
const parseEnabledToolsets = (input: string | undefined): Toolset[] => {
if (!input) return [];
return input.split(',').map(toolset => {
const trimmed = toolset.trim() as Toolset;
// 验证工具集名称是否有效
if (!Object.values(Toolset).includes(trimmed)) {
throw new Error(`Unknown toolset: ${trimmed}`);
}
return trimmed;
});
};
// 获取启用的工具集(从命令行参数或环境变量)
const enabledToolsets = parseEnabledToolsets(
process.argv.find(arg => arg.startsWith('--toolsets='))?.split('=')[1] ||
process.env.DEVOPS_TOOLSETS
);
// Check transport mode - only one can be active
const useSSE = process.argv.includes('--sse') || process.env.MCP_TRANSPORT === 'sse';
const useHTTP = process.argv.includes('--http') || process.env.MCP_TRANSPORT === 'http';
// Validate that only one transport mode is selected
if (useSSE && useHTTP) {
console.error('Error: Cannot use both SSE and HTTP modes simultaneously');
process.exit(1);
}
async function runServer() {
if (useHTTP) {
// HTTP mode (Streamable HTTP)
const port = process.env.PORT || 3000;
console.info(`Yunxiao MCP Server starting in HTTP mode (stateless)`);
// Initialize Express application
const { default: express } = await import('express');
const app: any = express();
// Middleware to parse JSON bodies
app.use(express.json({ limit: '10mb' }));
// Well-known MCP configuration endpoint
app.get('/.well-known/mcp-config', (req: any, res: any) => {
res.json({
mcpServers: {
"alibabacloud-devops": {
url: "/"
}
}
});
});
// MCP endpoint handler function
// For Streamable HTTP, create a new Server and Transport for each request
const handleMcpRequest = async (req: any, res: any) => {
// Log detailed request information
console.log(`[MCP Request] ${req.method} ${req.path} from ${req.ip}`);
console.log(`[MCP Request] Headers:`, JSON.stringify(req.headers, null, 2));
try {
// Get token from query parameters or headers (priority: query > header > env)
// Support both camelCase (Smithery) and snake_case (legacy) parameter names
const yunxiao_access_token =
req.query.yunxiaoAccessToken || // Smithery uses camelCase
req.query.yunxiao_access_token || // Legacy snake_case
req.headers['x-yunxiao-token'] ||
process.env.YUNXIAO_ACCESS_TOKEN;
// Log token source for debugging
if (req.query.yunxiaoAccessToken) {
console.log('[MCP Auth] Using token from query parameter (yunxiaoAccessToken)');
} else if (req.query.yunxiao_access_token) {
console.log('[MCP Auth] Using token from query parameter (yunxiao_access_token)');
} else if (req.headers['x-yunxiao-token']) {
console.log('[MCP Auth] Using token from request header');
} else if (process.env.YUNXIAO_ACCESS_TOKEN) {
console.log('[MCP Auth] Using token from environment variable');
} else {
console.warn('[MCP Auth] No authentication token provided');
}
// Set the session token before handling the request
const utils = await import('./common/utils.js');
utils.setCurrentSessionToken(yunxiao_access_token);
// Create a fresh Server instance for this request
console.log('[MCP] Creating new server instance...');
const requestServer = createServer(enabledToolsets);
// Create a stateless transport for this request
console.log('[MCP] Creating transport...');
const httpTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // Stateless mode
});
// Set up transport event handlers for debugging
httpTransport.onerror = (error) => {
console.error('[MCP Transport Error]', error);
};
httpTransport.onclose = () => {
console.log('[MCP Transport] Connection closed');
};
httpTransport.onmessage = (message) => {
console.log('[MCP Transport] Message:', JSON.stringify(message));
};
// Connect the server to the transport (only for this request)
console.log('[MCP] Connecting server to transport...');
await requestServer.connect(httpTransport);
console.log('[MCP] Server connected successfully');
// Handle the request
// Note: For StreamableHTTP, this returns immediately after setting up the SSE stream
// The actual response is sent asynchronously through the stream
console.log('[MCP] Handling request with body:', JSON.stringify(req.body));
await httpTransport.handleRequest(req, res, req.body);
console.log('[MCP] handleRequest completed');
// Don't close the transport immediately - let the SSE stream complete naturally
// The transport will be garbage collected when the request/response cycle completes
} catch (error) {
console.error("[MCP Error] Error handling HTTP request:", error);
// Return JSON error response if headers not sent
if (!res.headersSent) {
res.status(500).json({
error: "Internal server error",
message: error instanceof Error ? error.message : "Unknown error",
timestamp: new Date().toISOString()
});
}
}
};
// Register MCP endpoint on both root and /mcp paths
app.all('/', handleMcpRequest);
app.all('/mcp', handleMcpRequest);
// Start server listening on all network interfaces
const serverInstance: any = app.listen(port, '0.0.0.0', () => {
console.log(`Yunxiao MCP Server running in HTTP mode on 0.0.0.0:${port}`);
console.log(`Well-known config: http://0.0.0.0:${port}/.well-known/mcp-config`);
console.log(`MCP endpoints: http://0.0.0.0:${port}/ and http://0.0.0.0:${port}/mcp`);
}).on('error', (error: any) => {
console.error('Failed to start server:', error);
process.exit(1);
});
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log('Shutting down HTTP server...');
serverInstance.close(() => {
console.log('Server closed.');
process.exit(0);
});
});
} else if (useSSE) {
// Import express only when needed for SSE mode
const { default: express } = await import('express');
const app: any = express();
const port = process.env.PORT || 3000;
// Store sessions with their tokens
const sessions: Record<string, { transport: SSEServerTransport; server: Server; yunxiao_access_token?: string }> = {};
// SSE endpoint - handles initial connection
app.get('/sse', async (req: any, res: any) => {
// In SSE mode, we can use console.log for debugging since it doesn't interfere with the protocol
console.log(`New SSE connection from ${req.ip}`);
// Get token from query parameters or headers
// Support both camelCase (Smithery) and snake_case (legacy) parameter names
const yunxiao_access_token =
req.query.yunxiaoAccessToken || // Smithery uses camelCase
req.query.yunxiao_access_token || // Legacy snake_case
req.headers['x-yunxiao-token'] ||
process.env.YUNXIAO_ACCESS_TOKEN;
// Create transport with endpoint for POST messages
const sseTransport = new SSEServerTransport('/messages', res);
const sessionId = sseTransport.sessionId;
if (sessionId) {
sessions[sessionId] = { transport: sseTransport, server, yunxiao_access_token };
}
try {
await server.connect(sseTransport);
// In SSE mode, console.error is acceptable for status messages
console.info(`Yunxiao MCP Server connected via SSE with session ${sessionId}`);
if (yunxiao_access_token) {
console.error(`Session ${sessionId} using custom token`);
} else {
console.error(`Session ${sessionId} using default token from environment`);
}
} catch (error) {
console.error("Failed to start SSE server:", error);
res.status(500).send("Server error");
}
});
// POST endpoint - handles incoming messages
app.use(express.json({ limit: '10mb' })); // Add JSON body parser
app.post('/messages', async (req: any, res: any) => {
const sessionId = req.query.sessionId as string;
const session = sessions[sessionId];
if (!session) {
res.status(404).send("Session not found");
return;
}
try {
// Set the session token before handling the message
const utils = await import('./common/utils.js');
utils.setCurrentSessionToken(session.yunxiao_access_token);
await session.transport.handlePostMessage(req, res, req.body);
} catch (error) {
console.error("Error handling POST message:", error);
res.status(500).send("Server error");
}
});
// Start server
const serverInstance: any = app.listen(port, () => {
console.log(`Yunxiao MCP Server running in SSE mode on port ${port}`);
console.log(`Connect via SSE at http://localhost:${port}/sse`);
console.log(`Send messages to http://localhost:${port}/messages?sessionId=<session-id>`);
});
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log('Shutting down SSE server...');
serverInstance.close(() => {
console.log('Server closed.');
process.exit(0);
});
});
} else {
// Stdio mode (default)
// In stdio mode, we must avoid console.log/console.error as they interfere with the JSON-RPC protocol
const transport = new StdioServerTransport();
await server.connect(transport);
// Don't output anything to stdout/stderr in stdio mode - only JSON-RPC messages should go through the transport
}
}
runServer().catch((error) => {
// Only output error to stderr in SSE mode, not in stdio mode
if (useSSE) {
console.error("Fatal error in main():", error);
}
process.exit(1);
});