hana-cli
Version:
HANA Developer Command Line Interface
261 lines (220 loc) • 9.94 kB
text/typescript
/**
* MCP Server for SAP HANA CLI
*
* CRITICAL: MCP communicates via JSON-RPC over STDIO. All logging MUST use
* console.error() (stderr), never console.log() (stdout). Any non-JSON output
* to stdout will break the protocol.
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
ListPromptsRequestSchema,
GetPromptRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { pathToFileURL } from 'url';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { readFileSync } from 'fs';
import { listResources, readResource } from './resources.js';
import { listPrompts, getPrompt } from './prompts.js';
import { getDiscoveryToolDefinitions, handleDiscoveryTool } from './tools/discovery-tools.js';
import { getContentToolDefinitions, handleContentTool } from './tools/content-tools.js';
import { getSearchToolDefinitions, handleSearchTool } from './tools/search-tools.js';
import { initCliTools, getCliToolDefinitions, getAllCliToolDefinitions, getCliToolDefinitionsForCategory, handleCliTool } from './tools/cli-tools.js';
import { initRouterTool, getRouterToolDefinition, handleRouterTool } from './tools/router-tool.js';
import { isRouterTool, MAX_DYNAMIC_TOOLS } from './tools/tier-config.js';
import { errorResponse } from './tools/types.js';
import type { ToolDefinition } from './tools/types.js';
const fullMode = process.argv.includes('--full');
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'mcp-server', 'package.json'), 'utf-8')) as {
version: string;
};
class HanaCliMcpServer {
private server: Server;
private commands: Map<string, any> = new Map();
private activatedCategories: string[] = [];
private dynamicToolDefinitions: ToolDefinition[] = [];
constructor() {
const iconPngDataUri =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAM0SURBVFhHxZLfT5JhFMf5E/wPdCRkhWLXXXWvN93VHRVG1szNdVFtaUUta5mhVFKrtF+WAgFmzkz7AWrJfFADMU0gimyllGnLuvg2nhce3vd9rFgXb2f7jHO+5znnfAeoAKj+J5ygNJygNJygNJygNJygNJygNJygNCqN+z0yFLr4vNCVEGmJNOJcINsXz4o14VOiu+eIquBODCny0580b8/mf+rlt0d/+06ip9+JZ9V3YqSgM56nym+dRoY9XXFKpSfOtPzWGVE+jQJZLfRX06S5eE7dNkMKbszm0Z9A3RLCJe8HJJd+Qh4D4S9QtwSx6UpY0m/oT2CNLYTKzlnJ+0xMJr6h0h5BandqXm0LMQptk0R9OUyPUwNHPFH5PItHEwvQnAvgiDsi0UPxJWgtAZhuvZLo4kh+/YHyy0FoLAH6NoWmaYxorePsODXQF/jEhm48SWBbyzhqbobhHJpDuTUAzakXeD71WbI8FWXNAZiuhVjtHJ6j9XA4ybSLvXE6rz01grWnR4jmjF9ynBroHf3IBhYWV9DW/xblZ/1Yax6E1jyIsgY/64uj9VEcu2zjrD7fHYH22CBqWrOmLnRH6Y4i8xApOjHMHacGjNZRNiAO38t5bG30o74j+zU3u16zPPp+GRXnCasHg/Owemapnon6u1PQHn5KNHXeVY9TA+sO9MPY+ALj0/zXHEssUzJRZvaiZyjB6mbnNMvlsfBlBZvrnpL1hx7/9jg1oKvpRYatx7246pmR72LhG/uI6LslVovzTCx8XkFHXwxbjj4jG/Y//ONxwUBVN13U1D4J40kfqs8+l+/MKSy3Q9BVPRCo7iG66p6/HqcGDjaPyHetGs/8c4z55Hd5G5abQegqu1C8p4vo9t7P6Tg1sK/eh7HQvHwfIm8WJXnxLjdKTAInbPwf13J9AsUmDynZ7cn5ODWgN95DBmPtAHbWDtC8RKRLqHAJ8G9ISYUrT2+wl5YaHDmbUG3c4USK0hTbHUK+3ZEl3WM6rbP99CzR73TmfFRiILVEb7ALCw0CrKY9kRlRznSDg+gN9n86Tg3IBaXhBKXhBKXhBKXhBKXhBKXhBKXhBKX5BUtWC46LA416AAAAAElFTkSuQmCC';
this.server = new Server(
{
name: 'hana-cli-mcp-server',
version: pkg.version,
icons: [
{
src: iconPngDataUri,
mimeType: 'image/png',
sizes: ['any'],
},
],
},
{
capabilities: {
tools: { listChanged: true },
resources: {},
prompts: {},
},
}
);
this.setupHandlers();
this.setupErrorHandling();
}
private setupErrorHandling(): void {
this.server.onerror = (error) => {
console.error('[MCP Error]', error);
};
process.on('SIGINT', async () => {
await this.server.close();
process.exit(0);
});
}
private setupHandlers(): void {
this.setupResourceHandlers();
this.setupPromptHandlers();
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
if (fullMode) {
const tools = [
...getDiscoveryToolDefinitions(),
...getContentToolDefinitions(),
...getSearchToolDefinitions(),
...getAllCliToolDefinitions(),
];
return { tools };
}
const tools = [
...getDiscoveryToolDefinitions(),
...getContentToolDefinitions(),
...getSearchToolDefinitions(),
getRouterToolDefinition(),
...getCliToolDefinitions(),
...this.dynamicToolDefinitions,
];
return { tools };
});
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const commandName = name.startsWith('hana_') ? name.slice(5) : name;
if (isRouterTool(name)) {
const result = await handleRouterTool(args as Record<string, any>);
if (result) return result;
}
const discoveryOptions = fullMode ? undefined : {
onCategoryActivated: (category: string) => this.activateCategory(category),
};
const result =
handleDiscoveryTool(commandName, args as Record<string, any>, discoveryOptions) ??
handleContentTool(commandName, args as Record<string, any>) ??
handleSearchTool(commandName, args as Record<string, any>) ??
await handleCliTool(name, args as Record<string, any>);
if (result) return result;
return errorResponse(`Unknown tool: ${name}`);
});
}
private setupResourceHandlers(): void {
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
try {
return { resources: listResources() };
} catch (error) {
console.error('[MCP] Error listing resources:', error);
return { resources: [] };
}
});
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
try {
const content = await readResource(request.params.uri);
return { contents: [content] };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[MCP] Error reading resource ${request.params.uri}:`, errorMsg);
throw new Error(`Failed to read resource: ${errorMsg}`);
}
});
}
private setupPromptHandlers(): void {
this.server.setRequestHandler(ListPromptsRequestSchema, async () => {
try {
return { prompts: listPrompts() };
} catch (error) {
console.error('[MCP] Error listing prompts:', error);
return { prompts: [] };
}
});
this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
return getPrompt(name, args as Record<string, string> | undefined);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[MCP] Error getting prompt ${request.params.name}:`, errorMsg);
throw new Error(`Failed to get prompt: ${errorMsg}`);
}
});
}
private activateCategory(category: string): void {
if (this.activatedCategories.includes(category)) return;
const tier1Names = new Set(getCliToolDefinitions().map(t => t.name));
const categoryTools = getCliToolDefinitionsForCategory(category)
.filter(t => !tier1Names.has(t.name));
if (categoryTools.length === 0) return;
this.activatedCategories.push(category);
this.dynamicToolDefinitions.push(...categoryTools);
// Enforce accumulation cap by removing oldest category's tools
while (this.dynamicToolDefinitions.length > MAX_DYNAMIC_TOOLS && this.activatedCategories.length > 1) {
const oldestCategory = this.activatedCategories.shift()!;
this.dynamicToolDefinitions = this.dynamicToolDefinitions.filter(
t => !getCliToolDefinitionsForCategory(oldestCategory).some(ct => ct.name === t.name)
);
}
this.server.sendToolListChanged().catch(() => {});
}
async loadCommands(): Promise<void> {
try {
const indexPath = join(__dirname, '..', '..', 'bin', 'index.js');
const indexUrl = pathToFileURL(indexPath).href;
const indexModule = await import(indexUrl);
if (typeof indexModule.init !== 'function') {
throw new Error('index.js does not export an init() function');
}
const commands = await indexModule.init();
if (!Array.isArray(commands)) {
throw new Error('init() did not return an array of commands');
}
console.error(`[MCP] Loaded ${commands.length} command modules from hana-cli`);
for (const cmd of commands) {
if (cmd && typeof cmd.command === 'string') {
const commandName = cmd.command.split(' ')[0];
this.commands.set(commandName, cmd);
}
}
console.error(`[MCP] Registered ${this.commands.size} unique commands`);
if (this.commands.size === 0) {
throw new Error('No valid commands were registered');
}
initCliTools(this.commands);
initRouterTool(this.commands);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[MCP] Failed to load commands: ${errorMsg}`);
throw error;
}
}
async run(): Promise<void> {
await this.loadCommands();
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('[MCP] SAP HANA CLI MCP Server running on stdio');
}
}
const server = new HanaCliMcpServer();
server.run().catch((error) => {
console.error('[MCP] Fatal error:', error);
process.exit(1);
});