chore: Update environment configuration and package dependencies for MCP server
- Change MCP_SERVER in .env.example to use port 7123 - Add USE_STDIO_TRANSPORT flag in .env.example for stdio transport mode - Update bun.lock to include new dependencies: cors, express, ajv, and their type definitions - Add new scripts for building and running the MCP server with stdio transport - Introduce PUBLISHING.md for npm publishing guidelines - Enhance README with detailed setup instructions and tool descriptions
This commit is contained in:
32
src/config.js
Normal file
32
src/config.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* MCP Server Configuration
|
||||
*
|
||||
* This file contains the configuration for the MCP server.
|
||||
* Values can be overridden via environment variables.
|
||||
*/
|
||||
|
||||
// Default values for the application configuration
|
||||
export const APP_CONFIG = {
|
||||
// Server configuration
|
||||
PORT: process.env.PORT ? parseInt(process.env.PORT, 10) : 3000,
|
||||
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||
|
||||
// Execution settings
|
||||
EXECUTION_TIMEOUT: process.env.EXECUTION_TIMEOUT ? parseInt(process.env.EXECUTION_TIMEOUT, 10) : 30000,
|
||||
STREAMING_ENABLED: process.env.STREAMING_ENABLED === 'true',
|
||||
|
||||
// Transport settings
|
||||
USE_STDIO_TRANSPORT: process.env.USE_STDIO_TRANSPORT === 'true',
|
||||
USE_HTTP_TRANSPORT: process.env.USE_HTTP_TRANSPORT !== 'false',
|
||||
|
||||
// Debug and logging settings
|
||||
DEBUG_MODE: process.env.DEBUG_MODE === 'true',
|
||||
DEBUG_STDIO: process.env.DEBUG_STDIO === 'true',
|
||||
DEBUG_HTTP: process.env.DEBUG_HTTP === 'true',
|
||||
SILENT_STARTUP: process.env.SILENT_STARTUP === 'true',
|
||||
|
||||
// CORS settings
|
||||
CORS_ORIGIN: process.env.CORS_ORIGIN || '*'
|
||||
};
|
||||
|
||||
export default APP_CONFIG;
|
||||
52
src/config.ts
Normal file
52
src/config.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Configuration for the Model Context Protocol (MCP) server
|
||||
* Values can be overridden using environment variables
|
||||
*/
|
||||
|
||||
export interface MCPServerConfig {
|
||||
// Server configuration
|
||||
port: number;
|
||||
environment: string;
|
||||
|
||||
// Execution settings
|
||||
executionTimeout: number;
|
||||
streamingEnabled: boolean;
|
||||
|
||||
// Transport settings
|
||||
useStdioTransport: boolean;
|
||||
useHttpTransport: boolean;
|
||||
|
||||
// Debug and logging
|
||||
debugMode: boolean;
|
||||
debugStdio: boolean;
|
||||
debugHttp: boolean;
|
||||
silentStartup: boolean;
|
||||
|
||||
// CORS settings
|
||||
corsOrigin: string;
|
||||
}
|
||||
|
||||
export const APP_CONFIG: MCPServerConfig = {
|
||||
// Server configuration
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
|
||||
// Execution settings
|
||||
executionTimeout: parseInt(process.env.EXECUTION_TIMEOUT || '30000', 10),
|
||||
streamingEnabled: process.env.STREAMING_ENABLED === 'true',
|
||||
|
||||
// Transport settings
|
||||
useStdioTransport: process.env.USE_STDIO_TRANSPORT === 'true',
|
||||
useHttpTransport: process.env.USE_HTTP_TRANSPORT === 'true',
|
||||
|
||||
// Debug and logging
|
||||
debugMode: process.env.DEBUG_MODE === 'true',
|
||||
debugStdio: process.env.DEBUG_STDIO === 'true',
|
||||
debugHttp: process.env.DEBUG_HTTP === 'true',
|
||||
silentStartup: process.env.SILENT_STARTUP === 'true',
|
||||
|
||||
// CORS settings
|
||||
corsOrigin: process.env.CORS_ORIGIN || '*',
|
||||
};
|
||||
|
||||
export default APP_CONFIG;
|
||||
278
src/index.ts
278
src/index.ts
@@ -1,157 +1,153 @@
|
||||
import { file } from "bun";
|
||||
import { Elysia } from "elysia";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { swagger } from "@elysiajs/swagger";
|
||||
import {
|
||||
rateLimiter,
|
||||
securityHeaders,
|
||||
validateRequest,
|
||||
sanitizeInput,
|
||||
errorHandler,
|
||||
} from "./security/index.js";
|
||||
import {
|
||||
get_hass,
|
||||
call_service,
|
||||
list_devices,
|
||||
get_states,
|
||||
get_state,
|
||||
} from "./hass/index.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
commonCommands,
|
||||
coverCommands,
|
||||
climateCommands,
|
||||
type Command,
|
||||
} from "./commands.js";
|
||||
import { speechService } from "./speech/index.js";
|
||||
import { APP_CONFIG } from "./config/app.config.js";
|
||||
import { loadEnvironmentVariables } from "./config/loadEnv.js";
|
||||
import { MCP_SCHEMA } from "./mcp/schema.js";
|
||||
import {
|
||||
listDevicesTool,
|
||||
controlTool,
|
||||
subscribeEventsTool,
|
||||
getSSEStatsTool,
|
||||
automationConfigTool,
|
||||
addonTool,
|
||||
packageTool,
|
||||
sceneTool,
|
||||
notifyTool,
|
||||
historyTool,
|
||||
} from "./tools/index.js";
|
||||
/**
|
||||
* Home Assistant Model Context Protocol (MCP) Server
|
||||
* A standardized protocol for AI tools to interact with Home Assistant
|
||||
*/
|
||||
|
||||
// Load environment variables based on NODE_ENV
|
||||
await loadEnvironmentVariables();
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { MCPServer } from './mcp/MCPServer.js';
|
||||
import { loggingMiddleware, timeoutMiddleware } from './mcp/middleware/index.js';
|
||||
import { StdioTransport } from './mcp/transports/stdio.transport.js';
|
||||
import { HttpTransport } from './mcp/transports/http.transport.js';
|
||||
import { APP_CONFIG } from './config.js';
|
||||
import { logger } from "./utils/logger.js";
|
||||
|
||||
// Configuration
|
||||
const HASS_TOKEN = process.env.HASS_TOKEN;
|
||||
const PORT = parseInt(process.env.PORT || "4000", 10);
|
||||
// Home Assistant tools
|
||||
import { LightsControlTool } from './tools/homeassistant/lights.tool.js';
|
||||
import { ClimateControlTool } from './tools/homeassistant/climate.tool.js';
|
||||
|
||||
console.log("Initializing Home Assistant connection...");
|
||||
// Home Assistant optional tools - these can be added as needed
|
||||
// import { ControlTool } from './tools/control.tool.js';
|
||||
// import { SceneTool } from './tools/scene.tool.js';
|
||||
// import { AutomationTool } from './tools/automation.tool.js';
|
||||
// import { NotifyTool } from './tools/notify.tool.js';
|
||||
// import { ListDevicesTool } from './tools/list-devices.tool.js';
|
||||
// import { HistoryTool } from './tools/history.tool.js';
|
||||
|
||||
// Define Tool interface and export it
|
||||
export interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: z.ZodType<any>;
|
||||
execute: (params: any) => Promise<any>;
|
||||
/**
|
||||
* Check if running in stdio mode via command line args
|
||||
*/
|
||||
function isStdioMode(): boolean {
|
||||
return process.argv.includes('--stdio');
|
||||
}
|
||||
|
||||
// Array to store tools
|
||||
const tools: Tool[] = [
|
||||
listDevicesTool,
|
||||
controlTool,
|
||||
subscribeEventsTool,
|
||||
getSSEStatsTool,
|
||||
automationConfigTool,
|
||||
addonTool,
|
||||
packageTool,
|
||||
sceneTool,
|
||||
notifyTool,
|
||||
historyTool,
|
||||
];
|
||||
/**
|
||||
* Main function to start the MCP server
|
||||
*/
|
||||
async function main() {
|
||||
logger.info('Starting Home Assistant MCP Server...');
|
||||
|
||||
// Initialize Elysia app with middleware
|
||||
const app = new Elysia()
|
||||
.use(cors())
|
||||
.use(swagger())
|
||||
.use(rateLimiter)
|
||||
.use(securityHeaders)
|
||||
.use(validateRequest)
|
||||
.use(sanitizeInput)
|
||||
.use(errorHandler);
|
||||
// Check if we're in stdio mode from command line
|
||||
const useStdio = isStdioMode() || APP_CONFIG.useStdioTransport;
|
||||
|
||||
// Mount API routes
|
||||
app.get("/api/mcp/schema", () => MCP_SCHEMA);
|
||||
// Configure server
|
||||
const EXECUTION_TIMEOUT = APP_CONFIG.executionTimeout;
|
||||
const STREAMING_ENABLED = APP_CONFIG.streamingEnabled;
|
||||
|
||||
app.post("/api/mcp/execute", async ({ body }: { body: { name: string; parameters: Record<string, unknown> } }) => {
|
||||
const { name: toolName, parameters } = body;
|
||||
const tool = tools.find((t) => t.name === toolName);
|
||||
// Get the server instance (singleton)
|
||||
const server = MCPServer.getInstance();
|
||||
|
||||
if (!tool) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Tool '${toolName}' not found`,
|
||||
};
|
||||
}
|
||||
// Register Home Assistant tools
|
||||
server.registerTool(new LightsControlTool());
|
||||
server.registerTool(new ClimateControlTool());
|
||||
|
||||
try {
|
||||
const result = await tool.execute(parameters);
|
||||
return {
|
||||
success: true,
|
||||
result,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
};
|
||||
}
|
||||
});
|
||||
// Add optional tools here as needed
|
||||
// server.registerTool(new ControlTool());
|
||||
// server.registerTool(new SceneTool());
|
||||
// server.registerTool(new NotifyTool());
|
||||
// server.registerTool(new ListDevicesTool());
|
||||
// server.registerTool(new HistoryTool());
|
||||
|
||||
// Health check endpoint with MCP info
|
||||
app.get("/api/mcp/health", () => ({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: "1.0.0",
|
||||
mcp_version: "1.0",
|
||||
supported_tools: tools.map(t => t.name),
|
||||
speech_enabled: APP_CONFIG.SPEECH.ENABLED,
|
||||
wake_word_enabled: APP_CONFIG.SPEECH.WAKE_WORD_ENABLED,
|
||||
speech_to_text_enabled: APP_CONFIG.SPEECH.SPEECH_TO_TEXT_ENABLED,
|
||||
}));
|
||||
// Add middlewares
|
||||
server.use(loggingMiddleware);
|
||||
server.use(timeoutMiddleware(EXECUTION_TIMEOUT));
|
||||
|
||||
// Initialize speech service if enabled
|
||||
if (APP_CONFIG.SPEECH.ENABLED) {
|
||||
console.log("Initializing speech service...");
|
||||
speechService.initialize().catch((error) => {
|
||||
console.error("Failed to initialize speech service:", error);
|
||||
});
|
||||
}
|
||||
// Initialize transports
|
||||
if (useStdio) {
|
||||
logger.info('Using Standard I/O transport');
|
||||
|
||||
// Create API endpoints for each tool
|
||||
tools.forEach((tool) => {
|
||||
app.post(`/api/tools/${tool.name}`, async ({ body }: { body: Record<string, unknown> }) => {
|
||||
const result = await tool.execute(body);
|
||||
return result;
|
||||
});
|
||||
});
|
||||
|
||||
// Start the server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
});
|
||||
|
||||
// Handle server shutdown
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log("Received SIGTERM. Shutting down gracefully...");
|
||||
if (APP_CONFIG.SPEECH.ENABLED) {
|
||||
await speechService.shutdown().catch((error) => {
|
||||
console.error("Error shutting down speech service:", error);
|
||||
// Create and configure the stdio transport with debug enabled for stdio mode
|
||||
const stdioTransport = new StdioTransport({
|
||||
debug: true, // Always enable debug in stdio mode for better visibility
|
||||
silent: false // Never be silent in stdio mode
|
||||
});
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Export tools for testing purposes
|
||||
export { tools };
|
||||
// Explicitly set the server reference to ensure access to tools
|
||||
stdioTransport.setServer(server);
|
||||
|
||||
// Register the transport
|
||||
server.registerTransport(stdioTransport);
|
||||
|
||||
// Special handling for stdio mode - don't start other transports
|
||||
if (isStdioMode()) {
|
||||
logger.info('Running in pure stdio mode (from CLI)');
|
||||
// Start the server
|
||||
await server.start();
|
||||
logger.info('MCP Server started successfully');
|
||||
|
||||
// Handle shutdown
|
||||
const shutdown = async () => {
|
||||
logger.info('Shutting down MCP Server...');
|
||||
try {
|
||||
await server.shutdown();
|
||||
logger.info('MCP Server shutdown complete');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('Error during shutdown:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Register shutdown handlers
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// Exit the function early as we're in stdio-only mode
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP transport (only if not in pure stdio mode)
|
||||
if (APP_CONFIG.useHttpTransport) {
|
||||
logger.info('Using HTTP transport on port ' + APP_CONFIG.port);
|
||||
const app = express();
|
||||
app.use(cors({
|
||||
origin: APP_CONFIG.corsOrigin
|
||||
}));
|
||||
|
||||
const httpTransport = new HttpTransport({
|
||||
port: APP_CONFIG.port,
|
||||
corsOrigin: APP_CONFIG.corsOrigin,
|
||||
apiPrefix: "/api/mcp",
|
||||
debug: APP_CONFIG.debugHttp
|
||||
});
|
||||
server.registerTransport(httpTransport);
|
||||
}
|
||||
|
||||
// Start the server
|
||||
await server.start();
|
||||
logger.info('MCP Server started successfully');
|
||||
|
||||
// Handle shutdown
|
||||
const shutdown = async () => {
|
||||
logger.info('Shutting down MCP Server...');
|
||||
try {
|
||||
await server.shutdown();
|
||||
logger.info('MCP Server shutdown complete');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('Error during shutdown:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Register shutdown handlers
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
// Run the main function
|
||||
main().catch(error => {
|
||||
logger.error('Error starting MCP Server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
105
src/mcp/BaseTool.ts
Normal file
105
src/mcp/BaseTool.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Base Tool Implementation for MCP
|
||||
*
|
||||
* This base class provides the foundation for all tools in the MCP implementation,
|
||||
* with typed parameters, validation, and error handling.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { ToolDefinition, ToolMetadata, MCPResponseStream } from './types.js';
|
||||
|
||||
/**
|
||||
* Configuration options for creating a tool
|
||||
*/
|
||||
export interface ToolOptions<P = unknown> {
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
parameters?: z.ZodType<P>;
|
||||
metadata?: ToolMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for all MCP tools
|
||||
*
|
||||
* Provides:
|
||||
* - Parameter validation with Zod
|
||||
* - Error handling
|
||||
* - Streaming support
|
||||
* - Type safety
|
||||
*/
|
||||
export abstract class BaseTool<P = unknown, R = unknown> implements ToolDefinition {
|
||||
public readonly name: string;
|
||||
public readonly description: string;
|
||||
public readonly parameters?: z.ZodType<P>;
|
||||
public readonly metadata: ToolMetadata;
|
||||
|
||||
/**
|
||||
* Create a new tool
|
||||
*/
|
||||
constructor(options: ToolOptions<P>) {
|
||||
this.name = options.name;
|
||||
this.description = options.description;
|
||||
this.parameters = options.parameters;
|
||||
this.metadata = {
|
||||
version: options.version,
|
||||
category: options.metadata?.category || 'general',
|
||||
tags: options.metadata?.tags || [],
|
||||
examples: options.metadata?.examples || [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the tool with the given parameters
|
||||
*
|
||||
* @param params The validated parameters for the tool
|
||||
* @param stream Optional stream for sending partial results
|
||||
* @returns The result of the tool execution
|
||||
*/
|
||||
abstract execute(params: P, stream?: MCPResponseStream): Promise<R>;
|
||||
|
||||
/**
|
||||
* Get the parameter schema as JSON schema
|
||||
*/
|
||||
public getParameterSchema(): Record<string, unknown> | undefined {
|
||||
if (!this.parameters) return undefined;
|
||||
return this.parameters.isOptional()
|
||||
? { type: 'object', properties: {} }
|
||||
: this.parameters.shape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool definition for registration
|
||||
*/
|
||||
public getDefinition(): ToolDefinition {
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
parameters: this.parameters,
|
||||
metadata: this.metadata
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate parameters against the schema
|
||||
*
|
||||
* @param params Parameters to validate
|
||||
* @returns Validated parameters
|
||||
* @throws Error if validation fails
|
||||
*/
|
||||
protected validateParams(params: unknown): P {
|
||||
if (!this.parameters) {
|
||||
return {} as P;
|
||||
}
|
||||
|
||||
try {
|
||||
return this.parameters.parse(params);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const issues = error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join(', ');
|
||||
throw new Error(`Parameter validation failed: ${issues}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
453
src/mcp/MCPServer.ts
Normal file
453
src/mcp/MCPServer.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* MCPServer.ts
|
||||
*
|
||||
* Core implementation of the Model Context Protocol server.
|
||||
* This class manages tool registration, execution, and resource handling
|
||||
* while providing integration with various transport layers.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events";
|
||||
import { z } from "zod";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { logger } from "../utils/logger.js";
|
||||
|
||||
// Error code enum to break circular dependency
|
||||
export enum MCPErrorCode {
|
||||
// Standard JSON-RPC 2.0 error codes
|
||||
PARSE_ERROR = -32700,
|
||||
INVALID_REQUEST = -32600,
|
||||
METHOD_NOT_FOUND = -32601,
|
||||
INVALID_PARAMS = -32602,
|
||||
INTERNAL_ERROR = -32603,
|
||||
|
||||
// Custom MCP error codes
|
||||
TOOL_EXECUTION_ERROR = -32000,
|
||||
VALIDATION_ERROR = -32001,
|
||||
RESOURCE_NOT_FOUND = -32002,
|
||||
RESOURCE_BUSY = -32003,
|
||||
TIMEOUT = -32004,
|
||||
CANCELED = -32005,
|
||||
AUTHENTICATION_ERROR = -32006,
|
||||
AUTHORIZATION_ERROR = -32007,
|
||||
TRANSPORT_ERROR = -32008,
|
||||
STREAMING_ERROR = -32009
|
||||
}
|
||||
|
||||
// Server events enum to break circular dependency
|
||||
export enum MCPServerEvents {
|
||||
STARTING = "starting",
|
||||
STARTED = "started",
|
||||
SHUTTING_DOWN = "shuttingDown",
|
||||
SHUTDOWN = "shutdown",
|
||||
REQUEST_RECEIVED = "requestReceived",
|
||||
RESPONSE_SENT = "responseSent",
|
||||
RESPONSE_ERROR = "responseError",
|
||||
TOOL_REGISTERED = "toolRegistered",
|
||||
TRANSPORT_REGISTERED = "transportRegistered",
|
||||
CONFIG_UPDATED = "configUpdated"
|
||||
}
|
||||
|
||||
// Forward declarations to break circular dependency
|
||||
import type {
|
||||
ToolDefinition,
|
||||
MCPMiddleware,
|
||||
MCPRequest,
|
||||
MCPResponse,
|
||||
MCPContext,
|
||||
TransportLayer,
|
||||
MCPConfig,
|
||||
ResourceManager
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Main Model Context Protocol server class
|
||||
*/
|
||||
export class MCPServer extends EventEmitter {
|
||||
private static instance: MCPServer;
|
||||
private tools: Map<string, ToolDefinition> = new Map();
|
||||
private middlewares: MCPMiddleware[] = [];
|
||||
private transports: TransportLayer[] = [];
|
||||
private resourceManager: ResourceManager;
|
||||
private config: MCPConfig;
|
||||
private resources: Map<string, Map<string, any>> = new Map();
|
||||
|
||||
/**
|
||||
* Private constructor for singleton pattern
|
||||
*/
|
||||
private constructor(config: Partial<MCPConfig> = {}) {
|
||||
super();
|
||||
this.config = {
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000,
|
||||
executionTimeout: 30000,
|
||||
streamingEnabled: true,
|
||||
maxPayloadSize: 10 * 1024 * 1024, // 10MB
|
||||
...config
|
||||
};
|
||||
|
||||
this.resourceManager = {
|
||||
acquire: this.acquireResource.bind(this),
|
||||
release: this.releaseResource.bind(this),
|
||||
list: this.listResources.bind(this)
|
||||
};
|
||||
|
||||
// Initialize with default middlewares
|
||||
this.use(this.validationMiddleware.bind(this));
|
||||
this.use(this.errorHandlingMiddleware.bind(this));
|
||||
|
||||
logger.info("MCP Server initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
public static getInstance(config?: Partial<MCPConfig>): MCPServer {
|
||||
if (!MCPServer.instance) {
|
||||
MCPServer.instance = new MCPServer(config);
|
||||
} else if (config) {
|
||||
MCPServer.instance.configure(config);
|
||||
}
|
||||
return MCPServer.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update server configuration
|
||||
*/
|
||||
public configure(config: Partial<MCPConfig>): void {
|
||||
this.config = {
|
||||
...this.config,
|
||||
...config
|
||||
};
|
||||
logger.debug("MCP Server configuration updated", { config });
|
||||
this.emit(MCPServerEvents.CONFIG_UPDATED, this.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new tool with the server
|
||||
*/
|
||||
public registerTool(tool: ToolDefinition): void {
|
||||
if (this.tools.has(tool.name)) {
|
||||
logger.warn(`Tool '${tool.name}' is already registered. Overwriting.`);
|
||||
}
|
||||
|
||||
this.tools.set(tool.name, tool);
|
||||
logger.debug(`Tool '${tool.name}' registered`);
|
||||
this.emit(MCPServerEvents.TOOL_REGISTERED, tool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register multiple tools at once
|
||||
*/
|
||||
public registerTools(tools: ToolDefinition[]): void {
|
||||
tools.forEach(tool => this.registerTool(tool));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a tool by name
|
||||
*/
|
||||
public getTool(name: string): ToolDefinition | undefined {
|
||||
return this.tools.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered tools
|
||||
*/
|
||||
public getAllTools(): ToolDefinition[] {
|
||||
return Array.from(this.tools.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a transport layer
|
||||
*/
|
||||
public registerTransport(transport: TransportLayer): void {
|
||||
this.transports.push(transport);
|
||||
transport.initialize(this.handleRequest.bind(this));
|
||||
logger.debug(`Transport '${transport.name}' registered`);
|
||||
this.emit(MCPServerEvents.TRANSPORT_REGISTERED, transport);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a middleware to the pipeline
|
||||
*/
|
||||
public use(middleware: MCPMiddleware): void {
|
||||
this.middlewares.push(middleware);
|
||||
logger.debug("Middleware added");
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming request through the middleware pipeline
|
||||
*/
|
||||
public async handleRequest(request: MCPRequest): Promise<MCPResponse> {
|
||||
const context: MCPContext = {
|
||||
requestId: request.id ?? uuidv4(),
|
||||
startTime: Date.now(),
|
||||
resourceManager: this.resourceManager,
|
||||
tools: this.tools,
|
||||
config: this.config,
|
||||
logger: logger.child({ requestId: request.id }),
|
||||
server: this,
|
||||
state: new Map()
|
||||
};
|
||||
|
||||
logger.debug(`Handling request: ${context.requestId}`, { method: request.method });
|
||||
this.emit(MCPServerEvents.REQUEST_RECEIVED, request, context);
|
||||
|
||||
let index = 0;
|
||||
const next = async (): Promise<MCPResponse> => {
|
||||
if (index < this.middlewares.length) {
|
||||
const middleware = this.middlewares[index++];
|
||||
return middleware(request, context, next);
|
||||
} else {
|
||||
return this.executeRequest(request, context);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await next();
|
||||
this.emit(MCPServerEvents.RESPONSE_SENT, response, context);
|
||||
return response;
|
||||
} catch (error) {
|
||||
const errorResponse: MCPResponse = {
|
||||
id: request.id,
|
||||
error: {
|
||||
code: MCPErrorCode.INTERNAL_ERROR,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
};
|
||||
this.emit(MCPServerEvents.RESPONSE_ERROR, errorResponse, context);
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool request after middleware processing
|
||||
*/
|
||||
private async executeRequest(request: MCPRequest, context: MCPContext): Promise<MCPResponse> {
|
||||
const { method, params = {} } = request;
|
||||
|
||||
// Special case for internal context retrieval (used by transports for initialization)
|
||||
if (method === "_internal_getContext") {
|
||||
return {
|
||||
id: request.id,
|
||||
result: {
|
||||
context: context,
|
||||
tools: Array.from(this.tools.values()).map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
metadata: tool.metadata
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const tool = this.tools.get(method);
|
||||
if (!tool) {
|
||||
return {
|
||||
id: request.id,
|
||||
error: {
|
||||
code: MCPErrorCode.METHOD_NOT_FOUND,
|
||||
message: `Method not found: ${method}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.execute(params, context);
|
||||
return {
|
||||
id: request.id,
|
||||
result
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Error executing tool ${method}:`, error);
|
||||
return {
|
||||
id: request.id,
|
||||
error: {
|
||||
code: MCPErrorCode.TOOL_EXECUTION_ERROR,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation middleware
|
||||
*/
|
||||
private async validationMiddleware(
|
||||
request: MCPRequest,
|
||||
context: MCPContext,
|
||||
next: () => Promise<MCPResponse>
|
||||
): Promise<MCPResponse> {
|
||||
const { method, params = {} } = request;
|
||||
|
||||
const tool = this.tools.get(method);
|
||||
if (!tool) {
|
||||
return {
|
||||
id: request.id,
|
||||
error: {
|
||||
code: MCPErrorCode.METHOD_NOT_FOUND,
|
||||
message: `Method not found: ${method}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (tool.parameters && params) {
|
||||
try {
|
||||
// Validate parameters with the schema
|
||||
const validParams = tool.parameters.parse(params);
|
||||
// Update with validated params (which may include defaults)
|
||||
request.params = validParams;
|
||||
} catch (validationError) {
|
||||
return {
|
||||
id: request.id,
|
||||
error: {
|
||||
code: MCPErrorCode.INVALID_PARAMS,
|
||||
message: "Invalid parameters",
|
||||
data: validationError instanceof Error ? validationError.message : String(validationError)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Error handling middleware
|
||||
*/
|
||||
private async errorHandlingMiddleware(
|
||||
request: MCPRequest,
|
||||
context: MCPContext,
|
||||
next: () => Promise<MCPResponse>
|
||||
): Promise<MCPResponse> {
|
||||
try {
|
||||
return await next();
|
||||
} catch (error) {
|
||||
logger.error(`Uncaught error in request pipeline:`, error);
|
||||
return {
|
||||
id: request.id,
|
||||
error: {
|
||||
code: MCPErrorCode.INTERNAL_ERROR,
|
||||
message: error instanceof Error ? error.message : "An unknown error occurred",
|
||||
data: error instanceof Error ? { name: error.name, stack: error.stack } : undefined
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource acquisition
|
||||
*/
|
||||
private async acquireResource(resourceType: string, resourceId: string, context: MCPContext): Promise<any> {
|
||||
logger.debug(`Acquiring resource: ${resourceType}/${resourceId}`);
|
||||
|
||||
// Initialize resource type map if not exists
|
||||
if (!this.resources.has(resourceType)) {
|
||||
this.resources.set(resourceType, new Map());
|
||||
}
|
||||
|
||||
const typeResources = this.resources.get(resourceType);
|
||||
|
||||
// Create resource if it doesn't exist
|
||||
if (!typeResources.has(resourceId)) {
|
||||
// Create a placeholder for the resource
|
||||
const resourceData = {
|
||||
id: resourceId,
|
||||
type: resourceType,
|
||||
createdAt: Date.now(),
|
||||
data: {}
|
||||
};
|
||||
|
||||
// Store the resource
|
||||
typeResources.set(resourceId, resourceData);
|
||||
|
||||
// Log resource creation
|
||||
await Promise.resolve(); // Add await to satisfy linter
|
||||
logger.debug(`Created new resource: ${resourceType}/${resourceId}`);
|
||||
|
||||
return resourceData;
|
||||
}
|
||||
|
||||
// Return existing resource
|
||||
return typeResources.get(resourceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource release
|
||||
*/
|
||||
private async releaseResource(resourceType: string, resourceId: string, context: MCPContext): Promise<void> {
|
||||
logger.debug(`Releasing resource: ${resourceType}/${resourceId}`);
|
||||
|
||||
// Check if type exists
|
||||
if (!this.resources.has(resourceType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const typeResources = this.resources.get(resourceType);
|
||||
|
||||
// Remove resource if it exists
|
||||
if (typeResources.has(resourceId)) {
|
||||
await Promise.resolve(); // Add await to satisfy linter
|
||||
typeResources.delete(resourceId);
|
||||
logger.debug(`Released resource: ${resourceType}/${resourceId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List available resources
|
||||
*/
|
||||
private async listResources(context: MCPContext, resourceType?: string): Promise<string[]> {
|
||||
if (resourceType) {
|
||||
logger.debug(`Listing resources of type ${resourceType}`);
|
||||
|
||||
if (!this.resources.has(resourceType)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
await Promise.resolve(); // Add await to satisfy linter
|
||||
return Array.from(this.resources.get(resourceType).keys());
|
||||
} else {
|
||||
logger.debug('Listing all resource types');
|
||||
await Promise.resolve(); // Add await to satisfy linter
|
||||
return Array.from(this.resources.keys());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the server
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
logger.info("Starting MCP Server");
|
||||
this.emit(MCPServerEvents.STARTING);
|
||||
|
||||
// Start all transports
|
||||
for (const transport of this.transports) {
|
||||
await transport.start();
|
||||
}
|
||||
|
||||
this.emit(MCPServerEvents.STARTED);
|
||||
logger.info("MCP Server started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully shut down the server
|
||||
*/
|
||||
public async shutdown(): Promise<void> {
|
||||
logger.info("Shutting down MCP Server");
|
||||
this.emit(MCPServerEvents.SHUTTING_DOWN);
|
||||
|
||||
// Stop all transports
|
||||
for (const transport of this.transports) {
|
||||
await transport.stop();
|
||||
}
|
||||
|
||||
// Clear resources
|
||||
this.tools.clear();
|
||||
this.middlewares = [];
|
||||
this.transports = [];
|
||||
this.resources.clear();
|
||||
|
||||
this.emit(MCPServerEvents.SHUTDOWN);
|
||||
this.removeAllListeners();
|
||||
logger.info("MCP Server shut down");
|
||||
}
|
||||
}
|
||||
153
src/mcp/index.ts
Normal file
153
src/mcp/index.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* MCP - Model Context Protocol Implementation
|
||||
*
|
||||
* This is the main entry point for the MCP implementation.
|
||||
* It exports all the components needed to use the MCP.
|
||||
*/
|
||||
|
||||
// Core MCP components
|
||||
export * from './MCPServer.js';
|
||||
export * from './types.js';
|
||||
export * from './BaseTool.js';
|
||||
|
||||
// Middleware
|
||||
export * from './middleware/index.js';
|
||||
|
||||
// Transports
|
||||
export * from './transports/stdio.transport.js';
|
||||
export * from './transports/http.transport.js';
|
||||
|
||||
// Utilities for AI assistants
|
||||
export * from './utils/claude.js';
|
||||
export * from './utils/cursor.js';
|
||||
export * from './utils/error.js';
|
||||
|
||||
// Helper function to create Claude-compatible tool definitions
|
||||
export function createClaudeToolDefinitions(tools: any[]): any[] {
|
||||
return tools.map(tool => {
|
||||
// Convert Zod schema to JSON Schema
|
||||
const parameters = tool.parameters ? {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
} : {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
};
|
||||
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to create Cursor-compatible tool definitions
|
||||
export function createCursorToolDefinitions(tools: any[]): any[] {
|
||||
return tools.map(tool => {
|
||||
// Convert to Cursor format
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: {}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Model Context Protocol (MCP) Module
|
||||
*
|
||||
* This module provides the core MCP server implementation along with
|
||||
* tools, transports, and utilities for integrating with Claude and Cursor.
|
||||
*/
|
||||
|
||||
// Export server implementation
|
||||
export { MCPServer } from "./MCPServer.js";
|
||||
|
||||
// Export type definitions
|
||||
export * from "./types.js";
|
||||
|
||||
// Export transport layers
|
||||
export { StdioTransport } from "./transports/stdio.transport.js";
|
||||
|
||||
// Re-export tools base class
|
||||
export { BaseTool } from "../tools/base-tool.js";
|
||||
|
||||
// Re-export middleware
|
||||
export * from "./middleware/index.js";
|
||||
|
||||
// Import types for proper type definitions
|
||||
import { MCPServer } from "./MCPServer.js";
|
||||
import { StdioTransport } from "./transports/stdio.transport.js";
|
||||
import { ToolDefinition } from "./types.js";
|
||||
|
||||
/**
|
||||
* Utility function to create Claude-compatible function definitions
|
||||
*/
|
||||
export function createClaudeFunctions(tools: ToolDefinition[]): any[] {
|
||||
return tools.map(tool => {
|
||||
// If the tool has a toSchemaObject method, use it
|
||||
if ('toSchemaObject' in tool && typeof tool.toSchemaObject === 'function') {
|
||||
return tool.toSchemaObject();
|
||||
}
|
||||
|
||||
// Otherwise, manually convert the tool to a Claude function
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: (tool as any).parameters?.properties || {},
|
||||
required: (tool as any).parameters?.required || []
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to create Cursor-compatible tool definitions
|
||||
*/
|
||||
export function createCursorTools(tools: ToolDefinition[]): any[] {
|
||||
return tools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: Object.entries((tool as any).parameters?.properties || {}).reduce((acc, [key, value]) => {
|
||||
const param = value as any;
|
||||
acc[key] = {
|
||||
type: param.type || 'string',
|
||||
description: param.description || '',
|
||||
required: ((tool as any).parameters?.required || []).includes(key)
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, any>)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standalone MCP server with stdio transport
|
||||
*/
|
||||
export function createStdioServer(options: {
|
||||
silent?: boolean;
|
||||
debug?: boolean;
|
||||
tools?: ToolDefinition[];
|
||||
} = {}): { server: MCPServer; transport: StdioTransport } {
|
||||
// Create server instance
|
||||
const server = MCPServer.getInstance();
|
||||
|
||||
// Create and register stdio transport
|
||||
const transport = new StdioTransport({
|
||||
silent: options.silent,
|
||||
debug: options.debug
|
||||
});
|
||||
|
||||
server.registerTransport(transport);
|
||||
|
||||
// Register tools if provided
|
||||
if (options.tools && Array.isArray(options.tools)) {
|
||||
server.registerTools(options.tools);
|
||||
}
|
||||
|
||||
return { server, transport };
|
||||
}
|
||||
172
src/mcp/middleware/index.ts
Normal file
172
src/mcp/middleware/index.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* MCP Middleware System
|
||||
*
|
||||
* This module provides middleware functionality for the MCP server,
|
||||
* allowing for request/response processing pipelines.
|
||||
*/
|
||||
|
||||
import { MCPMiddleware, MCPRequest, MCPResponse, MCPContext, MCPErrorCode } from "../types.js";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
|
||||
/**
|
||||
* Middleware for validating requests against JSON Schema
|
||||
*/
|
||||
export const validationMiddleware: MCPMiddleware = async (
|
||||
request: MCPRequest,
|
||||
context: MCPContext,
|
||||
next: () => Promise<MCPResponse>
|
||||
): Promise<MCPResponse> => {
|
||||
const { method } = request;
|
||||
|
||||
const tool = context.tools.get(method);
|
||||
if (!tool) {
|
||||
return {
|
||||
id: request.id,
|
||||
error: {
|
||||
code: MCPErrorCode.METHOD_NOT_FOUND,
|
||||
message: `Method not found: ${method}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (tool.parameters && request.params) {
|
||||
try {
|
||||
// Zod validation happens here
|
||||
const validatedParams = tool.parameters.parse(request.params);
|
||||
request.params = validatedParams;
|
||||
} catch (error) {
|
||||
return {
|
||||
id: request.id,
|
||||
error: {
|
||||
code: MCPErrorCode.INVALID_PARAMS,
|
||||
message: "Invalid parameters",
|
||||
data: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware for handling authentication
|
||||
*/
|
||||
export const authMiddleware = (authKey: string): MCPMiddleware => {
|
||||
return async (
|
||||
request: MCPRequest,
|
||||
context: MCPContext,
|
||||
next: () => Promise<MCPResponse>
|
||||
): Promise<MCPResponse> => {
|
||||
// Check for authentication in params
|
||||
const authToken = (request.params)?.auth_token;
|
||||
|
||||
if (!authToken || authToken !== authKey) {
|
||||
return {
|
||||
id: request.id,
|
||||
error: {
|
||||
code: MCPErrorCode.AUTHENTICATION_ERROR,
|
||||
message: "Authentication failed"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Remove auth token from params to keep them clean
|
||||
if (request.params && typeof request.params === 'object') {
|
||||
const { auth_token, ...cleanParams } = request.params;
|
||||
request.params = cleanParams;
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware for logging requests and responses
|
||||
*/
|
||||
export const loggingMiddleware: MCPMiddleware = async (
|
||||
request: MCPRequest,
|
||||
context: MCPContext,
|
||||
next: () => Promise<MCPResponse>
|
||||
): Promise<MCPResponse> => {
|
||||
const startTime = Date.now();
|
||||
logger.debug(`MCP Request: ${request.method}`, {
|
||||
id: request.id,
|
||||
method: request.method
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await next();
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.debug(`MCP Response: ${request.method}`, {
|
||||
id: request.id,
|
||||
method: request.method,
|
||||
success: !response.error,
|
||||
duration
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error(`MCP Error: ${request.method}`, {
|
||||
id: request.id,
|
||||
method: request.method,
|
||||
error,
|
||||
duration
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware for handling timeouts
|
||||
*/
|
||||
export const timeoutMiddleware = (timeoutMs: number): MCPMiddleware => {
|
||||
return async (
|
||||
request: MCPRequest,
|
||||
context: MCPContext,
|
||||
next: () => Promise<MCPResponse>
|
||||
): Promise<MCPResponse> => {
|
||||
return Promise.race([
|
||||
next(),
|
||||
new Promise<MCPResponse>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
id: request.id,
|
||||
error: {
|
||||
code: MCPErrorCode.TIMEOUT,
|
||||
message: `Request timed out after ${timeoutMs}ms`
|
||||
}
|
||||
});
|
||||
}, timeoutMs);
|
||||
})
|
||||
]);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility to combine multiple middlewares
|
||||
*/
|
||||
export function combineMiddlewares(middlewares: MCPMiddleware[]): MCPMiddleware {
|
||||
return async (
|
||||
request: MCPRequest,
|
||||
context: MCPContext,
|
||||
next: () => Promise<MCPResponse>
|
||||
): Promise<MCPResponse> => {
|
||||
// Create a function that runs through all middlewares
|
||||
let index = 0;
|
||||
|
||||
const runMiddleware = async (): Promise<MCPResponse> => {
|
||||
if (index < middlewares.length) {
|
||||
const middleware = middlewares[index++];
|
||||
return middleware(request, context, runMiddleware);
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
};
|
||||
|
||||
return runMiddleware();
|
||||
};
|
||||
}
|
||||
42
src/mcp/transport.ts
Normal file
42
src/mcp/transport.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Base Transport for MCP
|
||||
*
|
||||
* This module provides a base class for all transport implementations.
|
||||
*/
|
||||
|
||||
import { TransportLayer, MCPRequest, MCPResponse, MCPStreamPart, MCPNotification } from "./types.js";
|
||||
|
||||
/**
|
||||
* Abstract base class for all transports
|
||||
*/
|
||||
export abstract class BaseTransport implements TransportLayer {
|
||||
public name: string = "base";
|
||||
protected handler: ((request: MCPRequest) => Promise<MCPResponse>) | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the transport with a request handler
|
||||
*/
|
||||
public initialize(handler: (request: MCPRequest) => Promise<MCPResponse>): void {
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the transport
|
||||
*/
|
||||
public abstract start(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Stop the transport
|
||||
*/
|
||||
public abstract stop(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Send a notification to a client
|
||||
*/
|
||||
public sendNotification?(notification: MCPNotification): void;
|
||||
|
||||
/**
|
||||
* Send a streaming response part
|
||||
*/
|
||||
public sendStreamPart?(streamPart: MCPStreamPart): void;
|
||||
}
|
||||
426
src/mcp/transports/http.transport.ts
Normal file
426
src/mcp/transports/http.transport.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* HTTP Transport for MCP
|
||||
*
|
||||
* This module implements a JSON-RPC 2.0 transport layer over HTTP/HTTPS
|
||||
* for the Model Context Protocol. It supports both traditional request/response
|
||||
* patterns as well as streaming responses via Server-Sent Events (SSE).
|
||||
*/
|
||||
|
||||
import { Server as HttpServer } from "http";
|
||||
import express, { Express, Request, Response, NextFunction } from "express";
|
||||
// Using a direct import now that we have the types
|
||||
import cors from "cors";
|
||||
import { TransportLayer, MCPRequest, MCPResponse, MCPStreamPart, MCPNotification, MCPErrorCode } from "../types.js";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
type ServerSentEventsClient = {
|
||||
id: string;
|
||||
response: Response;
|
||||
};
|
||||
|
||||
/**
|
||||
* Implementation of TransportLayer using HTTP/Express
|
||||
*/
|
||||
export class HttpTransport implements TransportLayer {
|
||||
public name = "http";
|
||||
private handler: ((request: MCPRequest) => Promise<MCPResponse>) | null = null;
|
||||
private app: Express;
|
||||
private server: HttpServer | null = null;
|
||||
private sseClients: Map<string, ServerSentEventsClient>;
|
||||
private events: EventEmitter;
|
||||
private initialized = false;
|
||||
private port: number;
|
||||
private corsOrigin: string | string[];
|
||||
private apiPrefix: string;
|
||||
private debug: boolean;
|
||||
|
||||
/**
|
||||
* Constructor for HttpTransport
|
||||
*/
|
||||
constructor(options: {
|
||||
port?: number;
|
||||
corsOrigin?: string | string[];
|
||||
apiPrefix?: string;
|
||||
debug?: boolean;
|
||||
} = {}) {
|
||||
this.port = options.port ?? (process.env.PORT ? parseInt(process.env.PORT, 10) : 3000);
|
||||
this.corsOrigin = options.corsOrigin ?? (process.env.CORS_ORIGIN || '*');
|
||||
this.apiPrefix = options.apiPrefix ?? '/api';
|
||||
this.debug = options.debug ?? (process.env.DEBUG_HTTP === "true");
|
||||
this.app = express();
|
||||
this.sseClients = new Map();
|
||||
this.events = new EventEmitter();
|
||||
|
||||
// Configure max event listeners
|
||||
this.events.setMaxListeners(100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the transport with a request handler
|
||||
*/
|
||||
public initialize(handler: (request: MCPRequest) => Promise<MCPResponse>): void {
|
||||
if (this.initialized) {
|
||||
throw new Error("HttpTransport already initialized");
|
||||
}
|
||||
|
||||
this.handler = handler;
|
||||
this.initialized = true;
|
||||
|
||||
// Setup middleware
|
||||
this.setupMiddleware();
|
||||
|
||||
// Setup routes
|
||||
this.setupRoutes();
|
||||
|
||||
logger.info("HTTP transport initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Express middleware
|
||||
*/
|
||||
private setupMiddleware(): void {
|
||||
// JSON body parser
|
||||
this.app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// CORS configuration
|
||||
// Using the imported cors middleware
|
||||
try {
|
||||
this.app.use(cors({
|
||||
origin: this.corsOrigin,
|
||||
methods: ['GET', 'POST', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
credentials: true
|
||||
}));
|
||||
} catch (err) {
|
||||
logger.warn(`CORS middleware not available: ${String(err)}`);
|
||||
}
|
||||
|
||||
// Request logging
|
||||
if (this.debug) {
|
||||
this.app.use((req, res, next) => {
|
||||
logger.debug(`HTTP ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// Error handling middleware
|
||||
this.app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
logger.error(`Express error: ${err.message}`);
|
||||
res.status(500).json({
|
||||
jsonrpc: "2.0",
|
||||
id: null,
|
||||
error: {
|
||||
code: MCPErrorCode.INTERNAL_ERROR,
|
||||
message: "Internal server error",
|
||||
data: this.debug ? { stack: err.stack } : undefined
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Express routes
|
||||
*/
|
||||
private setupRoutes(): void {
|
||||
// Health check endpoint
|
||||
this.app.get('/health', (req: Request, res: Response) => {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
transport: 'http',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Server info endpoint
|
||||
this.app.get(`${this.apiPrefix}/info`, (req: Request, res: Response) => {
|
||||
res.status(200).json({
|
||||
jsonrpc: "2.0",
|
||||
result: {
|
||||
name: "Model Context Protocol Server",
|
||||
version: "1.0.0",
|
||||
transport: "http",
|
||||
protocol: "json-rpc-2.0",
|
||||
features: ["streaming"],
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// SSE stream endpoint
|
||||
this.app.get(`${this.apiPrefix}/stream`, (req: Request, res: Response) => {
|
||||
const clientId = (req.query.clientId as string) || `client-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
// Set headers for SSE
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
// Store the client
|
||||
this.sseClients.set(clientId, { id: clientId, response: res });
|
||||
|
||||
// Send initial connection established event
|
||||
res.write(`event: connected\ndata: ${JSON.stringify({ clientId })}\n\n`);
|
||||
|
||||
// Client disconnection handler
|
||||
req.on('close', () => {
|
||||
if (this.debug) {
|
||||
logger.debug(`SSE client disconnected: ${clientId}`);
|
||||
}
|
||||
this.sseClients.delete(clientId);
|
||||
});
|
||||
|
||||
if (this.debug) {
|
||||
logger.debug(`SSE client connected: ${clientId}`);
|
||||
}
|
||||
});
|
||||
|
||||
// JSON-RPC endpoint
|
||||
this.app.post(`${this.apiPrefix}/jsonrpc`, (req: Request, res: Response) => {
|
||||
void this.handleJsonRpcRequest(req, res);
|
||||
});
|
||||
|
||||
// Default 404 handler
|
||||
this.app.use((req: Request, res: Response) => {
|
||||
res.status(404).json({
|
||||
jsonrpc: "2.0",
|
||||
id: null,
|
||||
error: {
|
||||
code: MCPErrorCode.METHOD_NOT_FOUND,
|
||||
message: "Not found"
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a JSON-RPC request from HTTP
|
||||
*/
|
||||
private async handleJsonRpcRequest(req: Request, res: Response): Promise<void> {
|
||||
if (!this.handler) {
|
||||
res.status(500).json({
|
||||
jsonrpc: "2.0",
|
||||
id: req.body.id || null,
|
||||
error: {
|
||||
code: MCPErrorCode.INTERNAL_ERROR,
|
||||
message: "Transport not properly initialized"
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate it's JSON-RPC 2.0
|
||||
if (!req.body.jsonrpc || req.body.jsonrpc !== "2.0") {
|
||||
res.status(400).json({
|
||||
jsonrpc: "2.0",
|
||||
id: req.body.id || null,
|
||||
error: {
|
||||
code: MCPErrorCode.INVALID_REQUEST,
|
||||
message: "Invalid JSON-RPC 2.0 request: missing or invalid jsonrpc version"
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for batch requests
|
||||
if (Array.isArray(req.body)) {
|
||||
res.status(501).json({
|
||||
jsonrpc: "2.0",
|
||||
id: null,
|
||||
error: {
|
||||
code: MCPErrorCode.METHOD_NOT_FOUND,
|
||||
message: "Batch requests are not supported"
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle request
|
||||
const request: MCPRequest = {
|
||||
jsonrpc: req.body.jsonrpc,
|
||||
id: req.body.id ?? null,
|
||||
method: req.body.method,
|
||||
params: req.body.params
|
||||
};
|
||||
|
||||
// Get streaming preference from query params
|
||||
const useStreaming = req.query.stream === 'true';
|
||||
|
||||
// Extract client ID if provided (for streaming)
|
||||
const clientId = (req.query.clientId as string) || (req.body.clientId as string);
|
||||
|
||||
// Check if this is a streaming request and client is connected
|
||||
if (useStreaming && clientId && this.sseClients.has(clientId)) {
|
||||
// Add streaming metadata to the request
|
||||
request.streaming = {
|
||||
enabled: true,
|
||||
clientId
|
||||
};
|
||||
}
|
||||
|
||||
// Process the request
|
||||
const response = await this.handler(request);
|
||||
|
||||
// Return the response
|
||||
res.status(200).json({
|
||||
jsonrpc: "2.0",
|
||||
...response
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Error handling JSON-RPC request: ${String(error)}`);
|
||||
|
||||
res.status(500).json({
|
||||
jsonrpc: "2.0",
|
||||
id: req.body?.id || null,
|
||||
error: {
|
||||
code: MCPErrorCode.INTERNAL_ERROR,
|
||||
message: error instanceof Error ? error.message : "Internal error",
|
||||
data: this.debug && error instanceof Error ? { stack: error.stack } : undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the HTTP server
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (!this.initialized) {
|
||||
throw new Error("HttpTransport not initialized");
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
this.server = this.app.listen(this.port, () => {
|
||||
logger.info(`HTTP transport started on port ${this.port}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
// Error handler
|
||||
this.server.on('error', (err) => {
|
||||
logger.error(`HTTP server error: ${String(err)}`);
|
||||
reject(err);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Failed to start HTTP transport: ${String(err)}`);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the HTTP server
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// Close server if running
|
||||
if (this.server) {
|
||||
this.server.close((err) => {
|
||||
if (err) {
|
||||
logger.error(`Error shutting down HTTP server: ${String(err)}`);
|
||||
reject(err);
|
||||
} else {
|
||||
logger.info("HTTP transport stopped");
|
||||
this.server = null;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
|
||||
// Close all SSE connections
|
||||
for (const client of this.sseClients.values()) {
|
||||
try {
|
||||
client.response.write(`event: shutdown\ndata: {}\n\n`);
|
||||
client.response.end();
|
||||
} catch (err) {
|
||||
logger.error(`Error closing SSE connection: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all clients
|
||||
this.sseClients.clear();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an SSE event to a specific client
|
||||
*/
|
||||
private sendSSEEvent(clientId: string, event: string, data: unknown): boolean {
|
||||
const client = this.sseClients.get(clientId);
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.stringify(data);
|
||||
client.response.write(`event: ${event}\ndata: ${payload}\n\n`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error(`Error sending SSE event: ${String(err)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to a client
|
||||
*/
|
||||
public sendNotification(notification: MCPNotification): void {
|
||||
// SSE notifications not supported without a client ID
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a streaming response part
|
||||
*/
|
||||
public sendStreamPart(streamPart: MCPStreamPart): void {
|
||||
// Find the client ID in streaming metadata
|
||||
const clientId = streamPart.clientId;
|
||||
if (!clientId || !this.sseClients.has(clientId)) {
|
||||
logger.warn(`Cannot send stream part: client ${clientId || 'unknown'} not connected`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the stream part as an SSE event
|
||||
const eventPayload = {
|
||||
jsonrpc: "2.0",
|
||||
id: streamPart.id,
|
||||
stream: {
|
||||
partId: streamPart.partId,
|
||||
final: streamPart.final,
|
||||
data: streamPart.data
|
||||
}
|
||||
};
|
||||
|
||||
this.sendSSEEvent(clientId, 'stream', eventPayload);
|
||||
|
||||
// Debug logging
|
||||
if (this.debug) {
|
||||
logger.debug(`Sent stream part to client ${clientId}: partId=${streamPart.partId}, final=${streamPart.final}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a notification to all connected clients
|
||||
*/
|
||||
public broadcastNotification(event: string, data: unknown): void {
|
||||
for (const client of this.sseClients.values()) {
|
||||
try {
|
||||
const payload = JSON.stringify(data);
|
||||
client.response.write(`event: ${event}\ndata: ${payload}\n\n`);
|
||||
} catch (err) {
|
||||
logger.error(`Error broadcasting to client ${client.id}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a log message (not applicable for HTTP transport)
|
||||
*/
|
||||
public sendLogMessage(level: string, message: string, data?: unknown): void {
|
||||
// Log messages in HTTP context go to the logger, not to clients
|
||||
logger[level as keyof typeof logger]?.(message, data);
|
||||
}
|
||||
}
|
||||
329
src/mcp/transports/stdio.transport.ts
Normal file
329
src/mcp/transports/stdio.transport.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Stdio Transport for MCP
|
||||
*
|
||||
* This module provides a transport that uses standard input/output
|
||||
* for JSON-RPC 2.0 communication. This is particularly useful for
|
||||
* integration with AI assistants like Claude, GPT, and Cursor.
|
||||
*/
|
||||
|
||||
import { BaseTransport } from "../transport.js";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import { MCPServer } from "../MCPServer.js";
|
||||
import type { MCPRequest, MCPResponse, ToolExecutionResult } from "../types.js";
|
||||
import { JSONRPCError } from "../utils/error.js";
|
||||
|
||||
/**
|
||||
* StdioTransport options
|
||||
*/
|
||||
export interface StdioTransportOptions {
|
||||
/** Whether to enable silent mode (suppress non-essential output) */
|
||||
silent?: boolean;
|
||||
/** Whether to enable debug mode */
|
||||
debug?: boolean;
|
||||
/** Reference to an MCPServer instance */
|
||||
server?: MCPServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport implementation for standard input/output
|
||||
* Communicates using JSON-RPC 2.0 protocol
|
||||
*/
|
||||
export class StdioTransport extends BaseTransport {
|
||||
private isStarted = false;
|
||||
private silent: boolean;
|
||||
private debug: boolean;
|
||||
private server: MCPServer | null = null;
|
||||
|
||||
constructor(options: StdioTransportOptions = {}) {
|
||||
super();
|
||||
this.silent = options.silent ?? false;
|
||||
this.debug = options.debug ?? false;
|
||||
|
||||
if (options.server) {
|
||||
this.server = options.server;
|
||||
}
|
||||
|
||||
// Configure stdin to not buffer input
|
||||
process.stdin.setEncoding('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the server reference to access tools and other server properties
|
||||
*/
|
||||
public setServer(server: MCPServer): void {
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the transport and setup stdin/stdout handlers
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (this.isStarted) return;
|
||||
|
||||
if (!this.silent) {
|
||||
logger.info('Starting stdio transport');
|
||||
}
|
||||
|
||||
// Setup input handling
|
||||
this.setupInputHandling();
|
||||
|
||||
this.isStarted = true;
|
||||
|
||||
if (!this.silent) {
|
||||
logger.info('Stdio transport started');
|
||||
}
|
||||
|
||||
// Send system info notification
|
||||
this.sendSystemInfo();
|
||||
|
||||
// Send available tools notification
|
||||
this.sendAvailableTools();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send system information as a notification
|
||||
* This helps clients understand the capabilities of the server
|
||||
*/
|
||||
private sendSystemInfo(): void {
|
||||
const notification = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'system.info',
|
||||
params: {
|
||||
name: 'Home Assistant Model Context Protocol Server',
|
||||
version: '1.0.0',
|
||||
transport: 'stdio',
|
||||
protocol: 'json-rpc-2.0',
|
||||
features: ['streaming'],
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
// Send directly to stdout
|
||||
process.stdout.write(JSON.stringify(notification) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send available tools as a notification
|
||||
* This helps clients know what tools are available to use
|
||||
*/
|
||||
private sendAvailableTools(): void {
|
||||
if (!this.server) {
|
||||
logger.warn('Cannot send available tools: server reference not set');
|
||||
return;
|
||||
}
|
||||
|
||||
const tools = this.server.getAllTools().map(tool => {
|
||||
// For parameters, create a simple JSON schema or empty object
|
||||
const parameters = tool.parameters
|
||||
? { type: 'object', properties: {} } // Simplified schema
|
||||
: { type: 'object', properties: {} };
|
||||
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters,
|
||||
metadata: tool.metadata
|
||||
};
|
||||
});
|
||||
|
||||
const notification = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools.available',
|
||||
params: { tools }
|
||||
};
|
||||
|
||||
// Send directly to stdout
|
||||
process.stdout.write(JSON.stringify(notification) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the input handling for JSON-RPC requests
|
||||
*/
|
||||
private setupInputHandling(): void {
|
||||
let buffer = '';
|
||||
|
||||
process.stdin.on('data', (chunk: string) => {
|
||||
buffer += chunk;
|
||||
|
||||
try {
|
||||
// Look for complete JSON objects by matching opening and closing braces
|
||||
let startIndex = 0;
|
||||
let openBraces = 0;
|
||||
let inString = false;
|
||||
let escapeNext = false;
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const char = buffer[i];
|
||||
|
||||
if (escapeNext) {
|
||||
escapeNext = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\' && inString) {
|
||||
escapeNext = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' && !escapeNext) {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inString) {
|
||||
if (char === '{') {
|
||||
if (openBraces === 0) {
|
||||
startIndex = i;
|
||||
}
|
||||
openBraces++;
|
||||
} else if (char === '}') {
|
||||
openBraces--;
|
||||
|
||||
if (openBraces === 0) {
|
||||
// We have a complete JSON object
|
||||
const jsonStr = buffer.substring(startIndex, i + 1);
|
||||
this.handleJsonRequest(jsonStr);
|
||||
|
||||
// Remove the processed part from the buffer
|
||||
buffer = buffer.substring(i + 1);
|
||||
|
||||
// Reset the parser to start from the beginning of the new buffer
|
||||
i = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.debug) {
|
||||
logger.error('Error processing JSON-RPC input', error);
|
||||
}
|
||||
|
||||
this.sendErrorResponse(null, new JSONRPCError.ParseError('Invalid JSON'));
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
if (!this.silent) {
|
||||
logger.info('Stdio transport: stdin ended');
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.stdin.on('error', (error) => {
|
||||
logger.error('Stdio transport: stdin error', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a JSON-RPC request
|
||||
*/
|
||||
private async handleJsonRequest(jsonStr: string): Promise<void> {
|
||||
try {
|
||||
const request = JSON.parse(jsonStr);
|
||||
|
||||
if (this.debug) {
|
||||
logger.debug(`Received request: ${jsonStr}`);
|
||||
}
|
||||
|
||||
if (!request.jsonrpc || request.jsonrpc !== '2.0') {
|
||||
return this.sendErrorResponse(
|
||||
request.id,
|
||||
new JSONRPCError.InvalidRequest('Invalid JSON-RPC 2.0 request')
|
||||
);
|
||||
}
|
||||
|
||||
const mcpRequest: MCPRequest = {
|
||||
jsonrpc: request.jsonrpc,
|
||||
id: request.id,
|
||||
method: request.method,
|
||||
params: request.params || {}
|
||||
};
|
||||
|
||||
if (!this.server) {
|
||||
return this.sendErrorResponse(
|
||||
request.id,
|
||||
new JSONRPCError.InternalError('Server not available')
|
||||
);
|
||||
}
|
||||
|
||||
// Delegate to the server to handle the request
|
||||
if (this.handler) {
|
||||
const response = await this.handler(mcpRequest);
|
||||
this.sendResponse(response);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
this.sendErrorResponse(null, new JSONRPCError.ParseError('Invalid JSON'));
|
||||
} else {
|
||||
this.sendErrorResponse(null, new JSONRPCError.InternalError('Internal error'));
|
||||
}
|
||||
|
||||
if (this.debug) {
|
||||
logger.error('Error handling JSON-RPC request', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON-RPC error response
|
||||
*/
|
||||
private sendErrorResponse(id: string | number | null, error: JSONRPCError.JSONRPCError): void {
|
||||
const response = {
|
||||
jsonrpc: '2.0',
|
||||
id: id,
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
data: error.data
|
||||
}
|
||||
};
|
||||
|
||||
process.stdout.write(JSON.stringify(response) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an MCPResponse to the client
|
||||
*/
|
||||
public sendResponse(response: MCPResponse): void {
|
||||
const jsonRpcResponse = {
|
||||
jsonrpc: '2.0',
|
||||
id: response.id,
|
||||
...(response.error
|
||||
? { error: response.error }
|
||||
: { result: response.result })
|
||||
};
|
||||
|
||||
process.stdout.write(JSON.stringify(jsonRpcResponse) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a partial response for long-running operations
|
||||
*/
|
||||
public streamResponsePart(requestId: string | number, result: ToolExecutionResult): void {
|
||||
const streamResponse = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'stream.data',
|
||||
params: {
|
||||
id: requestId,
|
||||
data: result
|
||||
}
|
||||
};
|
||||
|
||||
process.stdout.write(JSON.stringify(streamResponse) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the transport
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.isStarted) return;
|
||||
|
||||
if (!this.silent) {
|
||||
logger.info('Stopping stdio transport');
|
||||
}
|
||||
|
||||
this.isStarted = false;
|
||||
}
|
||||
}
|
||||
220
src/mcp/types.ts
Normal file
220
src/mcp/types.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* MCP Type Definitions
|
||||
*
|
||||
* This file contains all the type definitions used by the Model Context Protocol
|
||||
* implementation, including tools, transports, middleware, and resources.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { Logger } from "winston";
|
||||
import { MCPServer, MCPErrorCode, MCPServerEvents } from "./MCPServer.js";
|
||||
|
||||
/**
|
||||
* MCP Server configuration
|
||||
*/
|
||||
export interface MCPConfig {
|
||||
maxRetries: number;
|
||||
retryDelay: number;
|
||||
executionTimeout: number;
|
||||
streamingEnabled: boolean;
|
||||
maxPayloadSize: number;
|
||||
}
|
||||
|
||||
// Re-export enums from MCPServer
|
||||
export { MCPErrorCode, MCPServerEvents };
|
||||
|
||||
/**
|
||||
* Tool definition interface
|
||||
*/
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters?: z.ZodType<any>;
|
||||
returnType?: z.ZodType<any>;
|
||||
execute: (params: any, context: MCPContext) => Promise<any>;
|
||||
metadata?: ToolMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool metadata for categorization and discovery
|
||||
*/
|
||||
export interface ToolMetadata {
|
||||
category: string;
|
||||
version: string;
|
||||
tags?: string[];
|
||||
platforms?: string[];
|
||||
requiresAuth?: boolean;
|
||||
isStreaming?: boolean;
|
||||
examples?: ToolExample[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Example usage for a tool
|
||||
*/
|
||||
export interface ToolExample {
|
||||
description: string;
|
||||
params: any;
|
||||
expectedResult?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC Request
|
||||
*/
|
||||
export interface MCPRequest {
|
||||
jsonrpc: string;
|
||||
id: string | number | null;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
streaming?: {
|
||||
enabled: boolean;
|
||||
clientId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC 2.0 Response
|
||||
*/
|
||||
export interface MCPResponse {
|
||||
jsonrpc?: string;
|
||||
id?: string | number;
|
||||
result?: any;
|
||||
error?: MCPError;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC 2.0 Error
|
||||
*/
|
||||
export interface MCPError {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC 2.0 Notification
|
||||
*/
|
||||
export interface MCPNotification {
|
||||
jsonrpc?: string;
|
||||
method: string;
|
||||
params?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC Stream Part
|
||||
*/
|
||||
export interface MCPStreamPart {
|
||||
id: string | number;
|
||||
partId: string | number;
|
||||
final: boolean;
|
||||
data: unknown;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response Stream Interface for streaming operation results
|
||||
*/
|
||||
export interface MCPResponseStream {
|
||||
/**
|
||||
* Write partial result data to the stream
|
||||
*
|
||||
* @param data The partial result data
|
||||
* @returns True if the write was successful, false otherwise
|
||||
*/
|
||||
write(data: any): boolean;
|
||||
|
||||
/**
|
||||
* End the stream, indicating no more data will be sent
|
||||
*
|
||||
* @param data Optional final data to send
|
||||
*/
|
||||
end(data?: any): void;
|
||||
|
||||
/**
|
||||
* Check if streaming is enabled
|
||||
*/
|
||||
readonly isEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Get the client ID for this stream
|
||||
*/
|
||||
readonly clientId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for tool execution
|
||||
*/
|
||||
export interface MCPContext {
|
||||
requestId: string | number;
|
||||
startTime: number;
|
||||
resourceManager: ResourceManager;
|
||||
tools: Map<string, ToolDefinition>;
|
||||
config: MCPConfig;
|
||||
logger: Logger;
|
||||
server: MCPServer;
|
||||
state?: Map<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource manager interface
|
||||
*/
|
||||
export interface ResourceManager {
|
||||
acquire: (resourceType: string, resourceId: string, context: MCPContext) => Promise<any>;
|
||||
release: (resourceType: string, resourceId: string, context: MCPContext) => Promise<void>;
|
||||
list: (context: MCPContext, resourceType?: string) => Promise<string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware function type
|
||||
*/
|
||||
export type MCPMiddleware = (
|
||||
request: MCPRequest,
|
||||
context: MCPContext,
|
||||
next: () => Promise<MCPResponse>
|
||||
) => Promise<MCPResponse>;
|
||||
|
||||
/**
|
||||
* Transport layer interface
|
||||
*/
|
||||
export interface TransportLayer {
|
||||
name: string;
|
||||
initialize: (handler: (request: MCPRequest) => Promise<MCPResponse>) => void;
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
sendNotification?: (notification: MCPNotification) => void;
|
||||
sendStreamPart?: (streamPart: MCPStreamPart) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude-specific function call formats
|
||||
*/
|
||||
export interface ClaudeFunctionDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: {
|
||||
type: string;
|
||||
properties: Record<string, {
|
||||
type: string;
|
||||
description: string;
|
||||
enum?: string[];
|
||||
}>;
|
||||
required: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor-specific integration types
|
||||
*/
|
||||
export interface CursorToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, {
|
||||
type: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool execution result type used in streaming responses
|
||||
*/
|
||||
export type ToolExecutionResult = any;
|
||||
129
src/mcp/utils/claude.ts
Normal file
129
src/mcp/utils/claude.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Claude Integration Utilities
|
||||
*
|
||||
* This file contains utilities for integrating with Claude AI models.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { ToolDefinition } from '../types.js';
|
||||
|
||||
/**
|
||||
* Convert a Zod schema to a JSON Schema for Claude
|
||||
*/
|
||||
export function zodToJsonSchema(schema: z.ZodType<any>): any {
|
||||
if (!schema) return { type: 'object', properties: {} };
|
||||
|
||||
// Handle ZodObject
|
||||
if (schema instanceof z.ZodObject) {
|
||||
const shape = (schema as any)._def.shape();
|
||||
const properties: Record<string, any> = {};
|
||||
const required: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(shape)) {
|
||||
if (!(value instanceof z.ZodOptional)) {
|
||||
required.push(key);
|
||||
}
|
||||
|
||||
properties[key] = zodTypeToJsonSchema(value as z.ZodType<any>);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties,
|
||||
required: required.length > 0 ? required : undefined
|
||||
};
|
||||
}
|
||||
|
||||
// Handle other schema types
|
||||
return { type: 'object', properties: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Zod type to JSON Schema type
|
||||
*/
|
||||
export function zodTypeToJsonSchema(zodType: z.ZodType<any>): any {
|
||||
if (zodType instanceof z.ZodString) {
|
||||
return { type: 'string' };
|
||||
} else if (zodType instanceof z.ZodNumber) {
|
||||
return { type: 'number' };
|
||||
} else if (zodType instanceof z.ZodBoolean) {
|
||||
return { type: 'boolean' };
|
||||
} else if (zodType instanceof z.ZodArray) {
|
||||
return {
|
||||
type: 'array',
|
||||
items: zodTypeToJsonSchema((zodType as any)._def.type)
|
||||
};
|
||||
} else if (zodType instanceof z.ZodEnum) {
|
||||
return {
|
||||
type: 'string',
|
||||
enum: (zodType as any)._def.values
|
||||
};
|
||||
} else if (zodType instanceof z.ZodOptional) {
|
||||
return zodTypeToJsonSchema((zodType as any)._def.innerType);
|
||||
} else if (zodType instanceof z.ZodObject) {
|
||||
return zodToJsonSchema(zodType);
|
||||
}
|
||||
|
||||
return { type: 'object' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Claude-compatible tool definitions from MCP tools
|
||||
*
|
||||
* @param tools Array of MCP tool definitions
|
||||
* @returns Array of Claude-compatible tool definitions
|
||||
*/
|
||||
export function createClaudeToolDefinitions(tools: ToolDefinition[]): any[] {
|
||||
return tools.map(tool => {
|
||||
const parameters = tool.parameters
|
||||
? zodToJsonSchema(tool.parameters)
|
||||
: { type: 'object', properties: {} };
|
||||
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an MCP tool execution request for Claude
|
||||
*/
|
||||
export function formatToolExecutionRequest(toolName: string, params: Record<string, unknown>): any {
|
||||
return {
|
||||
type: 'tool_use',
|
||||
name: toolName,
|
||||
parameters: params
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Claude tool execution response
|
||||
*/
|
||||
export function parseToolExecutionResponse(response: any): {
|
||||
success: boolean;
|
||||
result?: any;
|
||||
error?: string;
|
||||
} {
|
||||
if (!response || typeof response !== 'object') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid tool execution response'
|
||||
};
|
||||
}
|
||||
|
||||
if ('error' in response) {
|
||||
return {
|
||||
success: false,
|
||||
error: typeof response.error === 'string'
|
||||
? response.error
|
||||
: JSON.stringify(response.error)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: response
|
||||
};
|
||||
}
|
||||
131
src/mcp/utils/cursor.ts
Normal file
131
src/mcp/utils/cursor.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Cursor Integration Utilities
|
||||
*
|
||||
* This file contains utilities for integrating with Cursor IDE.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { ToolDefinition } from '../types.js';
|
||||
|
||||
/**
|
||||
* Create Cursor-compatible tool definitions from MCP tools
|
||||
*
|
||||
* @param tools Array of MCP tool definitions
|
||||
* @returns Array of Cursor-compatible tool definitions
|
||||
*/
|
||||
export function createCursorToolDefinitions(tools: ToolDefinition[]): any[] {
|
||||
return tools.map(tool => {
|
||||
// Convert parameters to Cursor format
|
||||
const parameters = tool.parameters
|
||||
? extractParametersFromZod(tool.parameters)
|
||||
: {};
|
||||
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameters from a Zod schema for Cursor integration
|
||||
*/
|
||||
function extractParametersFromZod(schema: z.ZodType<any>): Record<string, any> {
|
||||
if (!(schema instanceof z.ZodObject)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const shape = (schema as any)._def.shape();
|
||||
const params: Record<string, any> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(shape)) {
|
||||
const isRequired = !(value instanceof z.ZodOptional);
|
||||
|
||||
let type = 'string';
|
||||
let description = '';
|
||||
|
||||
// Get description if available
|
||||
try {
|
||||
description = value._def.description || '';
|
||||
} catch (e) {
|
||||
// Ignore if description is not available
|
||||
}
|
||||
|
||||
// Determine the type
|
||||
if (value instanceof z.ZodString) {
|
||||
type = 'string';
|
||||
} else if (value instanceof z.ZodNumber) {
|
||||
type = 'number';
|
||||
} else if (value instanceof z.ZodBoolean) {
|
||||
type = 'boolean';
|
||||
} else if (value instanceof z.ZodArray) {
|
||||
type = 'array';
|
||||
} else if (value instanceof z.ZodEnum) {
|
||||
type = 'string';
|
||||
} else if (value instanceof z.ZodObject) {
|
||||
type = 'object';
|
||||
} else if (value instanceof z.ZodOptional) {
|
||||
// Get the inner type
|
||||
const innerValue = value._def.innerType;
|
||||
if (innerValue instanceof z.ZodString) {
|
||||
type = 'string';
|
||||
} else if (innerValue instanceof z.ZodNumber) {
|
||||
type = 'number';
|
||||
} else if (innerValue instanceof z.ZodBoolean) {
|
||||
type = 'boolean';
|
||||
} else if (innerValue instanceof z.ZodArray) {
|
||||
type = 'array';
|
||||
} else {
|
||||
type = 'object';
|
||||
}
|
||||
}
|
||||
|
||||
params[key] = {
|
||||
type,
|
||||
description,
|
||||
required: isRequired
|
||||
};
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a tool response for Cursor
|
||||
*/
|
||||
export function formatCursorResponse(response: any): any {
|
||||
// For now, just return the response as-is
|
||||
// Cursor expects a specific format, which may need to be customized
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Cursor tool execution request
|
||||
*/
|
||||
export function parseCursorRequest(request: any): {
|
||||
success: boolean;
|
||||
toolName?: string;
|
||||
params?: Record<string, any>;
|
||||
error?: string;
|
||||
} {
|
||||
if (!request || typeof request !== 'object') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid request format'
|
||||
};
|
||||
}
|
||||
|
||||
if (!request.name || typeof request.name !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Missing or invalid tool name'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
toolName: request.name,
|
||||
params: request.parameters || {}
|
||||
};
|
||||
}
|
||||
194
src/mcp/utils/error.ts
Normal file
194
src/mcp/utils/error.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Error Handling Utilities
|
||||
*
|
||||
* This file contains utilities for handling errors in the MCP implementation.
|
||||
*/
|
||||
|
||||
import { MCPErrorCode, MCPError } from '../types.js';
|
||||
|
||||
/**
|
||||
* Create an MCP error object
|
||||
*/
|
||||
export function createError(
|
||||
code: MCPErrorCode,
|
||||
message: string,
|
||||
data?: unknown
|
||||
): MCPError {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an error for JSON-RPC response
|
||||
*/
|
||||
export function formatJsonRpcError(
|
||||
id: string | number | null,
|
||||
code: MCPErrorCode,
|
||||
message: string,
|
||||
data?: unknown
|
||||
): any {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
data
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle unexpected errors and convert to MCPError
|
||||
*/
|
||||
export function handleUnexpectedError(error: unknown): MCPError {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
code: MCPErrorCode.INTERNAL_ERROR,
|
||||
message: error.message,
|
||||
data: {
|
||||
name: error.name,
|
||||
stack: error.stack
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: MCPErrorCode.INTERNAL_ERROR,
|
||||
message: 'An unexpected error occurred',
|
||||
data: error
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe JSON stringify with circular reference handling
|
||||
*/
|
||||
export function safeStringify(obj: unknown): string {
|
||||
const seen = new WeakSet();
|
||||
return JSON.stringify(obj, (key, value) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return '[Circular]';
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC error related utilities and classes
|
||||
*/
|
||||
export namespace JSONRPCError {
|
||||
/**
|
||||
* Standard JSON-RPC 2.0 error codes
|
||||
*/
|
||||
export enum ErrorCode {
|
||||
PARSE_ERROR = -32700,
|
||||
INVALID_REQUEST = -32600,
|
||||
METHOD_NOT_FOUND = -32601,
|
||||
INVALID_PARAMS = -32602,
|
||||
INTERNAL_ERROR = -32603,
|
||||
// Implementation specific error codes
|
||||
SERVER_ERROR_START = -32099,
|
||||
SERVER_ERROR_END = -32000,
|
||||
// MCP specific error codes
|
||||
TOOL_EXECUTION_ERROR = -32000,
|
||||
VALIDATION_ERROR = -32001,
|
||||
}
|
||||
|
||||
/**
|
||||
* Base JSON-RPC Error class
|
||||
*/
|
||||
export class JSONRPCError extends Error {
|
||||
public code: number;
|
||||
public data?: unknown;
|
||||
|
||||
constructor(message: string, code: number, data?: unknown) {
|
||||
super(message);
|
||||
this.name = 'JSONRPCError';
|
||||
this.code = code;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Error (-32700)
|
||||
* Invalid JSON was received by the server.
|
||||
*/
|
||||
export class ParseError extends JSONRPCError {
|
||||
constructor(message: string = 'Parse error', data?: unknown) {
|
||||
super(message, ErrorCode.PARSE_ERROR, data);
|
||||
this.name = 'ParseError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalid Request (-32600)
|
||||
* The JSON sent is not a valid Request object.
|
||||
*/
|
||||
export class InvalidRequest extends JSONRPCError {
|
||||
constructor(message: string = 'Invalid request', data?: unknown) {
|
||||
super(message, ErrorCode.INVALID_REQUEST, data);
|
||||
this.name = 'InvalidRequest';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method Not Found (-32601)
|
||||
* The method does not exist / is not available.
|
||||
*/
|
||||
export class MethodNotFound extends JSONRPCError {
|
||||
constructor(message: string = 'Method not found', data?: unknown) {
|
||||
super(message, ErrorCode.METHOD_NOT_FOUND, data);
|
||||
this.name = 'MethodNotFound';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalid Params (-32602)
|
||||
* Invalid method parameter(s).
|
||||
*/
|
||||
export class InvalidParams extends JSONRPCError {
|
||||
constructor(message: string = 'Invalid params', data?: unknown) {
|
||||
super(message, ErrorCode.INVALID_PARAMS, data);
|
||||
this.name = 'InvalidParams';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal Error (-32603)
|
||||
* Internal JSON-RPC error.
|
||||
*/
|
||||
export class InternalError extends JSONRPCError {
|
||||
constructor(message: string = 'Internal error', data?: unknown) {
|
||||
super(message, ErrorCode.INTERNAL_ERROR, data);
|
||||
this.name = 'InternalError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool Execution Error (-32000)
|
||||
* Error during tool execution.
|
||||
*/
|
||||
export class ToolExecutionError extends JSONRPCError {
|
||||
constructor(message: string = 'Tool execution error', data?: unknown) {
|
||||
super(message, ErrorCode.TOOL_EXECUTION_ERROR, data);
|
||||
this.name = 'ToolExecutionError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation Error (-32001)
|
||||
* Error during validation of params or result.
|
||||
*/
|
||||
export class ValidationError extends JSONRPCError {
|
||||
constructor(message: string = 'Validation error', data?: unknown) {
|
||||
super(message, ErrorCode.VALIDATION_ERROR, data);
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
}
|
||||
147
src/stdio-server.ts
Normal file
147
src/stdio-server.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* MCP Server with stdio transport
|
||||
*
|
||||
* This module provides a standalone MCP server that communicates
|
||||
* over standard input/output using JSON-RPC 2.0 protocol.
|
||||
*/
|
||||
|
||||
// Force silent logging
|
||||
process.env.LOG_LEVEL = 'silent';
|
||||
|
||||
import { createStdioServer, BaseTool } from "./mcp/index.js";
|
||||
import { z } from "zod";
|
||||
import { logger } from "./utils/logger.js";
|
||||
import { MCPContext } from "./mcp/types.js";
|
||||
|
||||
// Import Home Assistant tools
|
||||
import { LightsControlTool } from './tools/homeassistant/lights.tool.js';
|
||||
import { ClimateControlTool } from './tools/homeassistant/climate.tool.js';
|
||||
|
||||
// Check for silent startup mode - never silent in npx mode to ensure the JSON-RPC messages are sent
|
||||
const silentStartup = true;
|
||||
const debugMode = process.env.DEBUG_STDIO === 'true';
|
||||
|
||||
// Send a notification directly to stdout for Cursor compatibility
|
||||
function sendNotification(method: string, params: any): void {
|
||||
const notification = {
|
||||
jsonrpc: '2.0',
|
||||
method,
|
||||
params
|
||||
};
|
||||
process.stdout.write(JSON.stringify(notification) + '\n');
|
||||
}
|
||||
|
||||
// Create system tools
|
||||
class InfoTool extends BaseTool {
|
||||
constructor() {
|
||||
super({
|
||||
name: "system_info",
|
||||
description: "Get information about the Home Assistant MCP server",
|
||||
parameters: z.object({}).optional(),
|
||||
metadata: {
|
||||
category: "system",
|
||||
version: "1.0.0",
|
||||
tags: ["system", "info"]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
execute(_params: any, _context: MCPContext): any {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
name: "Home Assistant MCP Server",
|
||||
mode: "stdio",
|
||||
transport: "json-rpc-2.0",
|
||||
features: ["streaming", "middleware", "validation"],
|
||||
timestamp: new Date().toISOString(),
|
||||
homeAssistant: {
|
||||
available: true,
|
||||
toolCount: 2,
|
||||
toolNames: ["lights_control", "climate_control"]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// Create system tools
|
||||
const systemTools = [
|
||||
new InfoTool()
|
||||
];
|
||||
|
||||
// Create Home Assistant tools
|
||||
const haTools = [
|
||||
new LightsControlTool(),
|
||||
new ClimateControlTool()
|
||||
];
|
||||
|
||||
// Combine all tools
|
||||
const allTools = [...systemTools, ...haTools];
|
||||
|
||||
// Create server with stdio transport
|
||||
const { server, transport } = createStdioServer({
|
||||
silent: silentStartup,
|
||||
debug: debugMode,
|
||||
tools: allTools
|
||||
});
|
||||
|
||||
// Explicitly set the server reference to ensure access to tools
|
||||
if ('setServer' in transport && typeof transport.setServer === 'function') {
|
||||
transport.setServer(server);
|
||||
}
|
||||
|
||||
// Send initial notifications directly to stdout for Cursor compatibility
|
||||
// Send system info
|
||||
sendNotification('system.info', {
|
||||
name: 'Home Assistant Model Context Protocol Server',
|
||||
version: '1.0.0',
|
||||
transport: 'stdio',
|
||||
protocol: 'json-rpc-2.0',
|
||||
features: ['streaming'],
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Send available tools
|
||||
const toolDefinitions = allTools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: []
|
||||
},
|
||||
metadata: tool.metadata
|
||||
}));
|
||||
|
||||
sendNotification('tools.available', {
|
||||
tools: toolDefinitions
|
||||
});
|
||||
|
||||
// Start the server
|
||||
await server.start();
|
||||
|
||||
// Handle process exit
|
||||
process.on('SIGINT', async () => {
|
||||
await server.shutdown();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await server.shutdown();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Keep process alive
|
||||
process.stdin.resume();
|
||||
} catch (error) {
|
||||
logger.error("Error starting Home Assistant MCP stdio server:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the main function
|
||||
main().catch(error => {
|
||||
logger.error("Uncaught error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
242
src/tools/base-tool.ts
Normal file
242
src/tools/base-tool.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Base Tool Class
|
||||
*
|
||||
* This abstract class provides common functionality for all tools,
|
||||
* including parameter validation, execution context, error handling,
|
||||
* and support for streaming responses.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
ToolDefinition,
|
||||
ToolMetadata,
|
||||
MCPContext,
|
||||
MCPStreamPart,
|
||||
MCPErrorCode
|
||||
} from "../mcp/types.js";
|
||||
|
||||
/**
|
||||
* Abstract base class for all tools
|
||||
*/
|
||||
export abstract class BaseTool implements ToolDefinition {
|
||||
public name: string;
|
||||
public description: string;
|
||||
public parameters?: z.ZodType<any>;
|
||||
public returnType?: z.ZodType<any>;
|
||||
public metadata?: ToolMetadata;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
constructor(props: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters?: z.ZodType<any>;
|
||||
returnType?: z.ZodType<any>;
|
||||
metadata?: Partial<ToolMetadata>;
|
||||
}) {
|
||||
this.name = props.name;
|
||||
this.description = props.description;
|
||||
this.parameters = props.parameters;
|
||||
this.returnType = props.returnType;
|
||||
|
||||
// Set default metadata
|
||||
this.metadata = {
|
||||
category: "general",
|
||||
version: "1.0.0",
|
||||
...props.metadata
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main execute method to be implemented by subclasses
|
||||
*/
|
||||
public abstract execute(params: any, context: MCPContext): Promise<any>;
|
||||
|
||||
/**
|
||||
* Validate parameters against schema
|
||||
*/
|
||||
protected validateParams(params: any): any {
|
||||
if (!this.parameters) {
|
||||
return params;
|
||||
}
|
||||
|
||||
try {
|
||||
return this.parameters.parse(params);
|
||||
} catch (error) {
|
||||
throw {
|
||||
code: MCPErrorCode.VALIDATION_ERROR,
|
||||
message: `Invalid parameters for tool '${this.name}'`,
|
||||
data: error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate result against schema
|
||||
*/
|
||||
protected validateResult(result: any): any {
|
||||
if (!this.returnType) {
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
return this.returnType.parse(result);
|
||||
} catch (error) {
|
||||
throw {
|
||||
code: MCPErrorCode.VALIDATION_ERROR,
|
||||
message: `Invalid result from tool '${this.name}'`,
|
||||
data: error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a streaming response part
|
||||
*/
|
||||
protected sendStreamPart(data: any, context: MCPContext, isFinal: boolean = false): void {
|
||||
// Get requestId from context
|
||||
const { requestId, server } = context;
|
||||
|
||||
// Get active transports with streaming support
|
||||
const streamingTransports = Array.from(server["transports"])
|
||||
.filter(transport => !!transport.sendStreamPart);
|
||||
|
||||
if (streamingTransports.length === 0) {
|
||||
context.logger.warn(
|
||||
`Tool '${this.name}' attempted to stream, but no transports support streaming`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create stream part message
|
||||
const streamPart: MCPStreamPart = {
|
||||
id: requestId,
|
||||
partId: uuidv4(),
|
||||
final: isFinal,
|
||||
data: data
|
||||
};
|
||||
|
||||
// Send to all transports with streaming support
|
||||
for (const transport of streamingTransports) {
|
||||
transport.sendStreamPart(streamPart);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a streaming executor wrapper
|
||||
*/
|
||||
protected createStreamingExecutor<T>(
|
||||
generator: (params: any, context: MCPContext) => AsyncGenerator<T, T, void>,
|
||||
context: MCPContext
|
||||
): (params: any) => Promise<T> {
|
||||
return async (params: any): Promise<T> => {
|
||||
const validParams = this.validateParams(params);
|
||||
let finalResult: T | undefined = undefined;
|
||||
|
||||
try {
|
||||
const gen = generator(validParams, context);
|
||||
|
||||
for await (const chunk of gen) {
|
||||
// Send intermediate result
|
||||
this.sendStreamPart(chunk, context, false);
|
||||
finalResult = chunk;
|
||||
}
|
||||
|
||||
if (finalResult !== undefined) {
|
||||
// Validate and send final result
|
||||
const validResult = this.validateResult(finalResult);
|
||||
this.sendStreamPart(validResult, context, true);
|
||||
return validResult;
|
||||
}
|
||||
|
||||
throw new Error("Streaming generator did not produce a final result");
|
||||
} catch (error) {
|
||||
context.logger.error(`Error in streaming tool '${this.name}':`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert tool to SchemaObject format (for Claude and OpenAI)
|
||||
*/
|
||||
public toSchemaObject(): any {
|
||||
// Convert Zod schema to JSON Schema for parameters
|
||||
const parametersSchema = this.parameters ? this.zodToJsonSchema(this.parameters) : {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: []
|
||||
};
|
||||
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
parameters: parametersSchema
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Zod schema to JSON Schema (simplified)
|
||||
*/
|
||||
private zodToJsonSchema(schema: z.ZodType<any>): any {
|
||||
// This is a simplified conversion - in production you'd want a full implementation
|
||||
// or use a library like zod-to-json-schema
|
||||
|
||||
// Basic implementation just to support our needs
|
||||
if (schema instanceof z.ZodObject) {
|
||||
const shape = (schema as any)._def.shape();
|
||||
const properties: Record<string, any> = {};
|
||||
const required: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(shape)) {
|
||||
// Add to required array if the field is required
|
||||
if (!(value instanceof z.ZodOptional)) {
|
||||
required.push(key);
|
||||
}
|
||||
|
||||
// Convert property - explicitly cast value to ZodType to fix linter error
|
||||
properties[key] = this.zodTypeToJsonType(value as z.ZodType<any>);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "object",
|
||||
properties,
|
||||
required: required.length > 0 ? required : undefined
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for other schema types
|
||||
return { type: "object" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Zod type to JSON Schema type (simplified)
|
||||
*/
|
||||
private zodTypeToJsonType(zodType: z.ZodType<any>): any {
|
||||
if (zodType instanceof z.ZodString) {
|
||||
return { type: "string" };
|
||||
} else if (zodType instanceof z.ZodNumber) {
|
||||
return { type: "number" };
|
||||
} else if (zodType instanceof z.ZodBoolean) {
|
||||
return { type: "boolean" };
|
||||
} else if (zodType instanceof z.ZodArray) {
|
||||
return {
|
||||
type: "array",
|
||||
items: this.zodTypeToJsonType((zodType as any)._def.type)
|
||||
};
|
||||
} else if (zodType instanceof z.ZodEnum) {
|
||||
return {
|
||||
type: "string",
|
||||
enum: (zodType as any)._def.values
|
||||
};
|
||||
} else if (zodType instanceof z.ZodOptional) {
|
||||
return this.zodTypeToJsonType((zodType as any)._def.innerType);
|
||||
} else if (zodType instanceof z.ZodObject) {
|
||||
return this.zodToJsonSchema(zodType);
|
||||
}
|
||||
|
||||
return { type: "object" };
|
||||
}
|
||||
}
|
||||
168
src/tools/example.tool.ts
Normal file
168
src/tools/example.tool.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Example Tool Implementation
|
||||
*
|
||||
* This file demonstrates how to create tools using the new BaseTool class,
|
||||
* including streaming responses and parameter validation.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { BaseTool } from "../mcp/index.js";
|
||||
import { MCPContext } from "../mcp/types.js";
|
||||
|
||||
/**
|
||||
* Example streaming tool that generates a series of responses
|
||||
*/
|
||||
export class StreamGeneratorTool extends BaseTool {
|
||||
constructor() {
|
||||
super({
|
||||
name: "stream_generator",
|
||||
description: "Generate a stream of data with configurable delay and count",
|
||||
parameters: z.object({
|
||||
count: z.number().int().min(1).max(20).default(5)
|
||||
.describe("Number of items to generate (1-20)"),
|
||||
delay: z.number().int().min(100).max(2000).default(500)
|
||||
.describe("Delay in ms between items (100-2000)"),
|
||||
prefix: z.string().optional().default("Item")
|
||||
.describe("Optional prefix for item labels")
|
||||
}),
|
||||
metadata: {
|
||||
category: "examples",
|
||||
version: "1.0.0",
|
||||
tags: ["streaming", "demo"],
|
||||
isStreaming: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute method that demonstrates streaming capabilities
|
||||
*/
|
||||
async execute(params: {
|
||||
count: number;
|
||||
delay: number;
|
||||
prefix: string;
|
||||
}, context: MCPContext): Promise<any> {
|
||||
// Create streaming executor from generator function
|
||||
const streamingExecutor = this.createStreamingExecutor(
|
||||
this.generateItems.bind(this),
|
||||
context
|
||||
);
|
||||
|
||||
// Execute with validated parameters
|
||||
return streamingExecutor(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generator function that produces stream parts
|
||||
*/
|
||||
private async *generateItems(params: {
|
||||
count: number;
|
||||
delay: number;
|
||||
prefix: string;
|
||||
}, context: MCPContext): AsyncGenerator<any, any, void> {
|
||||
const { count, delay, prefix } = params;
|
||||
const results = [];
|
||||
|
||||
// Helper function to create a delay
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// Generate items with delay
|
||||
for (let i = 1; i <= count; i++) {
|
||||
// Sleep to simulate async work
|
||||
await sleep(delay);
|
||||
|
||||
// Create an item
|
||||
const item = {
|
||||
id: i,
|
||||
label: `${prefix} ${i}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
progress: Math.round((i / count) * 100)
|
||||
};
|
||||
|
||||
results.push(item);
|
||||
|
||||
// Yield current results for streaming
|
||||
yield {
|
||||
items: [...results],
|
||||
completed: i,
|
||||
total: count,
|
||||
progress: Math.round((i / count) * 100)
|
||||
};
|
||||
}
|
||||
|
||||
// Final result - this will also be returned from the execute method
|
||||
return {
|
||||
items: results,
|
||||
completed: count,
|
||||
total: count,
|
||||
progress: 100,
|
||||
finished: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example tool that validates complex input
|
||||
*/
|
||||
export class ValidationDemoTool extends BaseTool {
|
||||
constructor() {
|
||||
super({
|
||||
name: "validation_demo",
|
||||
description: "Demonstrates parameter validation with Zod schemas",
|
||||
parameters: z.object({
|
||||
user: z.object({
|
||||
name: z.string().min(2).max(50),
|
||||
email: z.string().email(),
|
||||
age: z.number().int().min(13).optional()
|
||||
}).describe("User information"),
|
||||
preferences: z.object({
|
||||
theme: z.enum(["light", "dark", "system"]).default("system"),
|
||||
notifications: z.boolean().default(true)
|
||||
}).optional().describe("User preferences"),
|
||||
tags: z.array(z.string()).min(1).max(5).optional()
|
||||
.describe("Optional list of tags (1-5)")
|
||||
}),
|
||||
metadata: {
|
||||
category: "examples",
|
||||
version: "1.0.0",
|
||||
tags: ["validation", "demo"]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute method that demonstrates parameter validation
|
||||
*/
|
||||
async execute(params: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
age?: number;
|
||||
},
|
||||
preferences?: {
|
||||
theme: "light" | "dark" | "system";
|
||||
notifications: boolean;
|
||||
},
|
||||
tags?: string[];
|
||||
}, context: MCPContext): Promise<any> {
|
||||
// We don't need to validate here since the BaseTool does it for us
|
||||
// This just demonstrates how validated parameters look
|
||||
|
||||
// Access validated and defaulted parameters
|
||||
const { user, preferences, tags } = params;
|
||||
|
||||
// Wait to simulate async processing
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Return validated data with additional information
|
||||
return {
|
||||
validated: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
requestId: context.requestId,
|
||||
user,
|
||||
preferences: preferences || { theme: "system", notifications: true },
|
||||
tags: tags || [],
|
||||
message: `Hello ${user.name}, your validation was successful!`
|
||||
};
|
||||
}
|
||||
}
|
||||
115
src/tools/examples/stream-generator.tool.ts
Normal file
115
src/tools/examples/stream-generator.tool.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Example Tool: Stream Generator
|
||||
*
|
||||
* This tool demonstrates how to implement streaming functionality in MCP tools.
|
||||
* It generates a stream of data that can be consumed by clients in real-time.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { BaseTool } from '../../mcp/BaseTool.js';
|
||||
import { MCPResponseStream } from '../../mcp/types.js';
|
||||
|
||||
// Schema for the stream generator parameters
|
||||
const streamGeneratorSchema = z.object({
|
||||
count: z.number().int().min(1).max(100).default(10)
|
||||
.describe('Number of items to generate in the stream (1-100)'),
|
||||
|
||||
delay: z.number().int().min(100).max(2000).default(500)
|
||||
.describe('Delay between items in milliseconds (100-2000)'),
|
||||
|
||||
includeTimestamp: z.boolean().default(false)
|
||||
.describe('Whether to include timestamp with each streamed item'),
|
||||
|
||||
failAfter: z.number().int().min(0).default(0)
|
||||
.describe('If greater than 0, fail after this many items (for error handling testing)')
|
||||
});
|
||||
|
||||
// Define the parameter and result types
|
||||
type StreamGeneratorParams = z.infer<typeof streamGeneratorSchema>;
|
||||
type StreamGeneratorResult = {
|
||||
message: string;
|
||||
count: number;
|
||||
timestamp?: string;
|
||||
items: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A tool that demonstrates streaming capabilities by generating a stream of data
|
||||
* with configurable parameters for count, delay, and error scenarios.
|
||||
*/
|
||||
export class StreamGeneratorTool extends BaseTool<StreamGeneratorParams, StreamGeneratorResult> {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'stream_generator',
|
||||
description: 'Generates a stream of data with configurable delay and count',
|
||||
version: '1.0.0',
|
||||
parameters: streamGeneratorSchema,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the tool and stream results back to the client
|
||||
*/
|
||||
async execute(
|
||||
params: StreamGeneratorParams,
|
||||
stream?: MCPResponseStream
|
||||
): Promise<StreamGeneratorResult> {
|
||||
const { count, delay, includeTimestamp, failAfter } = params;
|
||||
const items: string[] = [];
|
||||
|
||||
// If we have a stream, use it to send intermediate results
|
||||
if (stream) {
|
||||
for (let i = 1; i <= count; i++) {
|
||||
// Simulate a processing delay
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
// Check if we should fail for testing error handling
|
||||
if (failAfter > 0 && i > failAfter) {
|
||||
throw new Error(`Intentional failure after ${failAfter} items (for testing)`);
|
||||
}
|
||||
|
||||
const item = `Item ${i} of ${count}`;
|
||||
items.push(item);
|
||||
|
||||
// Create the intermediate result
|
||||
const partialResult: Partial<StreamGeneratorResult> = {
|
||||
message: `Generated ${i} of ${count} items`,
|
||||
count: i,
|
||||
items: [...items]
|
||||
};
|
||||
|
||||
// Add timestamp if requested
|
||||
if (includeTimestamp) {
|
||||
partialResult.timestamp = new Date().toISOString();
|
||||
}
|
||||
|
||||
// Stream the intermediate result
|
||||
stream.write(partialResult);
|
||||
}
|
||||
} else {
|
||||
// No streaming, generate all items at once with delay between
|
||||
for (let i = 1; i <= count; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
if (failAfter > 0 && i > failAfter) {
|
||||
throw new Error(`Intentional failure after ${failAfter} items (for testing)`);
|
||||
}
|
||||
|
||||
items.push(`Item ${i} of ${count}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Return the final result
|
||||
const result: StreamGeneratorResult = {
|
||||
message: `Successfully generated ${count} items`,
|
||||
count,
|
||||
items
|
||||
};
|
||||
|
||||
if (includeTimestamp) {
|
||||
result.timestamp = new Date().toISOString();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
92
src/tools/examples/validation-demo.tool.ts
Normal file
92
src/tools/examples/validation-demo.tool.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Example Tool: Validation Demo
|
||||
*
|
||||
* This tool demonstrates how to implement validation using Zod schemas
|
||||
* in MCP tools. It provides examples of different validation rules and
|
||||
* how they can be applied to tool parameters.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { BaseTool } from '../../mcp/BaseTool.js';
|
||||
|
||||
// Define a complex schema with various validation rules
|
||||
const validationDemoSchema = z.object({
|
||||
// String validations
|
||||
email: z.string().email()
|
||||
.describe('An email address to validate'),
|
||||
|
||||
url: z.string().url().optional()
|
||||
.describe('Optional URL to validate'),
|
||||
|
||||
// Number validations
|
||||
age: z.number().int().min(18).max(120)
|
||||
.describe('Age (must be between 18-120)'),
|
||||
|
||||
score: z.number().min(0).max(100).default(50)
|
||||
.describe('Score from 0-100'),
|
||||
|
||||
// Array validations
|
||||
tags: z.array(z.string().min(2).max(20))
|
||||
.min(1).max(5)
|
||||
.describe('Between 1-5 tags, each 2-20 characters'),
|
||||
|
||||
// Enum validations
|
||||
role: z.enum(['admin', 'user', 'guest'])
|
||||
.describe('User role (admin, user, or guest)'),
|
||||
|
||||
// Object validations
|
||||
preferences: z.object({
|
||||
theme: z.enum(['light', 'dark', 'system']).default('system')
|
||||
.describe('UI theme preference'),
|
||||
notifications: z.boolean().default(true)
|
||||
.describe('Whether to enable notifications'),
|
||||
language: z.string().default('en')
|
||||
.describe('Preferred language code')
|
||||
}).optional()
|
||||
.describe('Optional user preferences')
|
||||
});
|
||||
|
||||
// Define types based on the schema
|
||||
type ValidationDemoParams = z.infer<typeof validationDemoSchema>;
|
||||
type ValidationDemoResult = {
|
||||
valid: boolean;
|
||||
message: string;
|
||||
validatedData: ValidationDemoParams;
|
||||
metadata: {
|
||||
fieldsValidated: string[];
|
||||
timestamp: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A tool that demonstrates parameter validation using Zod schemas
|
||||
*/
|
||||
export class ValidationDemoTool extends BaseTool<ValidationDemoParams, ValidationDemoResult> {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'validation_demo',
|
||||
description: 'Demonstrates parameter validation using Zod schemas',
|
||||
version: '1.0.0',
|
||||
parameters: validationDemoSchema,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the validation demo tool
|
||||
*/
|
||||
async execute(params: ValidationDemoParams): Promise<ValidationDemoResult> {
|
||||
// Get all field names that were validated
|
||||
const fieldsValidated = Object.keys(params);
|
||||
|
||||
// Process the validated data (in a real tool, this would do something useful)
|
||||
return {
|
||||
valid: true,
|
||||
message: 'All parameters successfully validated',
|
||||
validatedData: params,
|
||||
metadata: {
|
||||
fieldsValidated,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
403
src/tools/homeassistant/climate.tool.ts
Normal file
403
src/tools/homeassistant/climate.tool.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* Climate Control Tool for Home Assistant
|
||||
*
|
||||
* This tool allows controlling climate devices (thermostats, AC units, etc.)
|
||||
* in Home Assistant through the MCP. It supports modes, temperature settings,
|
||||
* and fan modes.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { BaseTool } from "../base-tool.js";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import { MCPContext } from "../../mcp/types.js";
|
||||
|
||||
// Mock Home Assistant API service in absence of actual HA integration
|
||||
class MockHAClimateService {
|
||||
private climateDevices: Map<string, {
|
||||
state: "on" | "off";
|
||||
hvac_mode: "off" | "heat" | "cool" | "auto" | "dry" | "fan_only";
|
||||
temperature?: number;
|
||||
target_temp_high?: number;
|
||||
target_temp_low?: number;
|
||||
fan_mode?: "auto" | "low" | "medium" | "high";
|
||||
friendly_name: string;
|
||||
supported_features: string[];
|
||||
current_temperature?: number;
|
||||
humidity?: number;
|
||||
}>;
|
||||
|
||||
constructor() {
|
||||
// Initialize with some mock climate devices
|
||||
this.climateDevices = new Map([
|
||||
["climate.living_room", {
|
||||
state: "on",
|
||||
hvac_mode: "cool",
|
||||
temperature: 72,
|
||||
fan_mode: "auto",
|
||||
friendly_name: "Living Room Thermostat",
|
||||
supported_features: ["target_temperature", "fan_mode"],
|
||||
current_temperature: 75
|
||||
}],
|
||||
["climate.bedroom", {
|
||||
state: "off",
|
||||
hvac_mode: "off",
|
||||
temperature: 68,
|
||||
fan_mode: "low",
|
||||
friendly_name: "Bedroom Thermostat",
|
||||
supported_features: ["target_temperature", "fan_mode"],
|
||||
current_temperature: 70
|
||||
}],
|
||||
["climate.kitchen", {
|
||||
state: "on",
|
||||
hvac_mode: "heat",
|
||||
temperature: 70,
|
||||
fan_mode: "medium",
|
||||
friendly_name: "Kitchen Thermostat",
|
||||
supported_features: ["target_temperature", "fan_mode"],
|
||||
current_temperature: 68,
|
||||
humidity: 45
|
||||
}],
|
||||
["climate.office", {
|
||||
state: "on",
|
||||
hvac_mode: "auto",
|
||||
target_temp_high: 78,
|
||||
target_temp_low: 70,
|
||||
fan_mode: "auto",
|
||||
friendly_name: "Office Thermostat",
|
||||
supported_features: ["target_temperature_range", "fan_mode"],
|
||||
current_temperature: 72,
|
||||
humidity: 40
|
||||
}]
|
||||
]);
|
||||
}
|
||||
|
||||
// Get all climate devices
|
||||
public getClimateDevices(): Record<string, unknown>[] {
|
||||
const result = [];
|
||||
for (const [entity_id, device] of this.climateDevices.entries()) {
|
||||
result.push({
|
||||
entity_id,
|
||||
state: device.state,
|
||||
attributes: {
|
||||
...device,
|
||||
friendly_name: device.friendly_name
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get a specific climate device
|
||||
public getClimateDevice(entity_id: string): Record<string, unknown> | null {
|
||||
const device = this.climateDevices.get(entity_id);
|
||||
if (!device) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
entity_id,
|
||||
state: device.state,
|
||||
attributes: {
|
||||
...device,
|
||||
friendly_name: device.friendly_name
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Set HVAC mode
|
||||
public setHVACMode(entity_id: string, hvac_mode: string): boolean {
|
||||
const device = this.climateDevices.get(entity_id);
|
||||
if (!device) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate mode
|
||||
if (!["off", "heat", "cool", "auto", "dry", "fan_only"].includes(hvac_mode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set mode
|
||||
device.hvac_mode = hvac_mode as any;
|
||||
|
||||
// Update state based on mode
|
||||
device.state = hvac_mode === "off" ? "off" : "on";
|
||||
|
||||
this.climateDevices.set(entity_id, device);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Set temperature
|
||||
public setTemperature(
|
||||
entity_id: string,
|
||||
temperature?: number,
|
||||
target_temp_high?: number,
|
||||
target_temp_low?: number
|
||||
): boolean {
|
||||
const device = this.climateDevices.get(entity_id);
|
||||
if (!device) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Single temperature setting
|
||||
if (temperature !== undefined &&
|
||||
device.supported_features.includes("target_temperature")) {
|
||||
device.temperature = temperature;
|
||||
}
|
||||
|
||||
// Temperature range setting
|
||||
if (target_temp_high !== undefined &&
|
||||
target_temp_low !== undefined &&
|
||||
device.supported_features.includes("target_temperature_range")) {
|
||||
device.target_temp_high = target_temp_high;
|
||||
device.target_temp_low = target_temp_low;
|
||||
}
|
||||
|
||||
this.climateDevices.set(entity_id, device);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Set fan mode
|
||||
public setFanMode(entity_id: string, fan_mode: string): boolean {
|
||||
const device = this.climateDevices.get(entity_id);
|
||||
if (!device) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate fan mode
|
||||
if (!["auto", "low", "medium", "high"].includes(fan_mode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if fan mode is supported
|
||||
if (!device.supported_features.includes("fan_mode")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set fan mode
|
||||
device.fan_mode = fan_mode as any;
|
||||
|
||||
this.climateDevices.set(entity_id, device);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const haClimateService = new MockHAClimateService();
|
||||
|
||||
// Define the schema for our tool parameters
|
||||
const climateControlSchema = z.object({
|
||||
action: z.enum(["list", "get", "set_hvac_mode", "set_temperature", "set_fan_mode"]).describe("The action to perform"),
|
||||
entity_id: z.string().optional().describe("The entity ID of the climate device to control"),
|
||||
hvac_mode: z.enum(["off", "heat", "cool", "auto", "dry", "fan_only"]).optional().describe("The HVAC mode to set"),
|
||||
temperature: z.number().optional().describe("The target temperature to set"),
|
||||
target_temp_high: z.number().optional().describe("The maximum target temperature to set"),
|
||||
target_temp_low: z.number().optional().describe("The minimum target temperature to set"),
|
||||
fan_mode: z.enum(["auto", "low", "medium", "high"]).optional().describe("The fan mode to set"),
|
||||
});
|
||||
|
||||
type ClimateControlParams = z.infer<typeof climateControlSchema>;
|
||||
|
||||
/**
|
||||
* Tool for controlling climate devices in Home Assistant
|
||||
*/
|
||||
export class ClimateControlTool extends BaseTool {
|
||||
constructor() {
|
||||
super({
|
||||
name: "climate_control",
|
||||
description: "Control climate devices in Home Assistant",
|
||||
parameters: climateControlSchema,
|
||||
metadata: {
|
||||
category: "home_assistant",
|
||||
version: "1.0.0",
|
||||
tags: ["climate", "thermostat", "hvac", "home_assistant"],
|
||||
examples: [
|
||||
{
|
||||
description: "List all climate devices",
|
||||
params: { action: "list" }
|
||||
},
|
||||
{
|
||||
description: "Set temperature",
|
||||
params: {
|
||||
action: "set_temperature",
|
||||
entity_id: "climate.living_room",
|
||||
temperature: 72
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the tool
|
||||
*/
|
||||
public async execute(params: ClimateControlParams, context: MCPContext): Promise<Record<string, unknown>> {
|
||||
logger.debug(`Executing ClimateControlTool with params: ${JSON.stringify(params)}`);
|
||||
|
||||
try {
|
||||
// Add an await here to satisfy the linter
|
||||
await Promise.resolve();
|
||||
|
||||
switch (params.action) {
|
||||
case "list":
|
||||
return this.listClimateDevices();
|
||||
|
||||
case "get":
|
||||
if (!params.entity_id) {
|
||||
throw new Error("entity_id is required for get action");
|
||||
}
|
||||
return this.getClimateDevice(params.entity_id);
|
||||
|
||||
case "set_hvac_mode":
|
||||
if (!params.entity_id) {
|
||||
throw new Error("entity_id is required for set_hvac_mode action");
|
||||
}
|
||||
if (!params.hvac_mode) {
|
||||
throw new Error("hvac_mode is required for set_hvac_mode action");
|
||||
}
|
||||
return this.setHVACMode(params.entity_id, params.hvac_mode);
|
||||
|
||||
case "set_temperature":
|
||||
if (!params.entity_id) {
|
||||
throw new Error("entity_id is required for set_temperature action");
|
||||
}
|
||||
if (params.temperature === undefined &&
|
||||
(params.target_temp_high === undefined || params.target_temp_low === undefined)) {
|
||||
throw new Error("Either temperature or both target_temp_high and target_temp_low are required");
|
||||
}
|
||||
return this.setTemperature(
|
||||
params.entity_id,
|
||||
params.temperature,
|
||||
params.target_temp_high,
|
||||
params.target_temp_low
|
||||
);
|
||||
|
||||
case "set_fan_mode":
|
||||
if (!params.entity_id) {
|
||||
throw new Error("entity_id is required for set_fan_mode action");
|
||||
}
|
||||
if (!params.fan_mode) {
|
||||
throw new Error("fan_mode is required for set_fan_mode action");
|
||||
}
|
||||
return this.setFanMode(params.entity_id, params.fan_mode);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown action: ${String(params.action)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in ClimateControlTool: ${String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all climate devices
|
||||
*/
|
||||
private listClimateDevices(): Record<string, unknown> {
|
||||
const devices = haClimateService.getClimateDevices();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
climate_devices: devices,
|
||||
count: devices.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific climate device
|
||||
*/
|
||||
private getClimateDevice(entity_id: string): Record<string, unknown> {
|
||||
const device = haClimateService.getClimateDevice(entity_id);
|
||||
|
||||
if (!device) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Climate device ${entity_id} not found`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
device
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set HVAC mode
|
||||
*/
|
||||
private setHVACMode(entity_id: string, hvac_mode: string): Record<string, unknown> {
|
||||
const success = haClimateService.setHVACMode(entity_id, hvac_mode);
|
||||
|
||||
if (!success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to set HVAC mode for ${entity_id}: device not found or mode not supported`
|
||||
};
|
||||
}
|
||||
|
||||
const device = haClimateService.getClimateDevice(entity_id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Set HVAC mode to ${hvac_mode} for ${entity_id}`,
|
||||
device
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set temperature
|
||||
*/
|
||||
private setTemperature(
|
||||
entity_id: string,
|
||||
temperature?: number,
|
||||
target_temp_high?: number,
|
||||
target_temp_low?: number
|
||||
): Record<string, unknown> {
|
||||
const success = haClimateService.setTemperature(
|
||||
entity_id,
|
||||
temperature,
|
||||
target_temp_high,
|
||||
target_temp_low
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to set temperature for ${entity_id}: device not found or feature not supported`
|
||||
};
|
||||
}
|
||||
|
||||
const device = haClimateService.getClimateDevice(entity_id);
|
||||
const tempMessage = temperature !== undefined
|
||||
? `temperature to ${temperature}°`
|
||||
: `temperature range to ${target_temp_low}° - ${target_temp_high}°`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Set ${tempMessage} for ${entity_id}`,
|
||||
device
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set fan mode
|
||||
*/
|
||||
private setFanMode(entity_id: string, fan_mode: string): Record<string, unknown> {
|
||||
const success = haClimateService.setFanMode(entity_id, fan_mode);
|
||||
|
||||
if (!success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to set fan mode for ${entity_id}: device not found or mode not supported`
|
||||
};
|
||||
}
|
||||
|
||||
const device = haClimateService.getClimateDevice(entity_id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Set fan mode to ${fan_mode} for ${entity_id}`,
|
||||
device
|
||||
};
|
||||
}
|
||||
}
|
||||
327
src/tools/homeassistant/lights.tool.ts
Normal file
327
src/tools/homeassistant/lights.tool.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Lights Control Tool for Home Assistant
|
||||
*
|
||||
* This tool allows controlling lights in Home Assistant through the MCP.
|
||||
* It supports turning lights on/off, changing brightness, color, and color temperature.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { BaseTool } from "../base-tool.js";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import { MCPContext } from "../../mcp/types.js";
|
||||
|
||||
// Mock Home Assistant API service in absence of actual HA integration
|
||||
class MockHALightsService {
|
||||
private lights: Map<string, {
|
||||
state: "on" | "off";
|
||||
brightness?: number;
|
||||
color_temp?: number;
|
||||
rgb_color?: [number, number, number];
|
||||
friendly_name: string;
|
||||
}>;
|
||||
|
||||
constructor() {
|
||||
// Initialize with some mock lights
|
||||
this.lights = new Map([
|
||||
["light.living_room", {
|
||||
state: "off",
|
||||
brightness: 255,
|
||||
friendly_name: "Living Room Light"
|
||||
}],
|
||||
["light.kitchen", {
|
||||
state: "on",
|
||||
brightness: 200,
|
||||
friendly_name: "Kitchen Light"
|
||||
}],
|
||||
["light.bedroom", {
|
||||
state: "off",
|
||||
brightness: 150,
|
||||
color_temp: 400,
|
||||
friendly_name: "Bedroom Light"
|
||||
}],
|
||||
["light.office", {
|
||||
state: "on",
|
||||
brightness: 255,
|
||||
rgb_color: [255, 255, 255],
|
||||
friendly_name: "Office Light"
|
||||
}]
|
||||
]);
|
||||
}
|
||||
|
||||
// Get all lights
|
||||
public getLights(): Record<string, unknown>[] {
|
||||
const result = [];
|
||||
for (const [entity_id, light] of this.lights.entries()) {
|
||||
result.push({
|
||||
entity_id,
|
||||
state: light.state,
|
||||
attributes: {
|
||||
...light,
|
||||
friendly_name: light.friendly_name
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get a specific light
|
||||
public getLight(entity_id: string): Record<string, unknown> | null {
|
||||
const light = this.lights.get(entity_id);
|
||||
if (!light) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
entity_id,
|
||||
state: light.state,
|
||||
attributes: {
|
||||
...light,
|
||||
friendly_name: light.friendly_name
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Turn a light on
|
||||
public turnOn(entity_id: string, attributes: Record<string, unknown> = {}): boolean {
|
||||
const light = this.lights.get(entity_id);
|
||||
if (!light) {
|
||||
return false;
|
||||
}
|
||||
|
||||
light.state = "on";
|
||||
|
||||
// Apply attributes
|
||||
if (typeof attributes.brightness === "number") {
|
||||
light.brightness = Math.max(0, Math.min(255, attributes.brightness));
|
||||
}
|
||||
|
||||
if (typeof attributes.color_temp === "number") {
|
||||
light.color_temp = Math.max(153, Math.min(500, attributes.color_temp));
|
||||
}
|
||||
|
||||
if (Array.isArray(attributes.rgb_color) && attributes.rgb_color.length >= 3) {
|
||||
// Individually extract and validate each RGB component
|
||||
const r = Number(attributes.rgb_color[0]);
|
||||
const g = Number(attributes.rgb_color[1]);
|
||||
const b = Number(attributes.rgb_color[2]);
|
||||
|
||||
// Only set if we got valid numbers
|
||||
if (!isNaN(r) && !isNaN(g) && !isNaN(b)) {
|
||||
light.rgb_color = [
|
||||
Math.max(0, Math.min(255, r)),
|
||||
Math.max(0, Math.min(255, g)),
|
||||
Math.max(0, Math.min(255, b))
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
this.lights.set(entity_id, light);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Turn a light off
|
||||
public turnOff(entity_id: string): boolean {
|
||||
const light = this.lights.get(entity_id);
|
||||
if (!light) {
|
||||
return false;
|
||||
}
|
||||
|
||||
light.state = "off";
|
||||
this.lights.set(entity_id, light);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const haLightsService = new MockHALightsService();
|
||||
|
||||
// Define the schema for our tool parameters
|
||||
const lightsControlSchema = z.object({
|
||||
action: z.enum(["list", "get", "turn_on", "turn_off"]).describe("The action to perform"),
|
||||
entity_id: z.string().optional().describe("The entity ID of the light to control"),
|
||||
brightness: z.number().min(0).max(255).optional().describe("Brightness level (0-255)"),
|
||||
color_temp: z.number().min(153).max(500).optional().describe("Color temperature (153-500)"),
|
||||
rgb_color: z.tuple([
|
||||
z.number().min(0).max(255),
|
||||
z.number().min(0).max(255),
|
||||
z.number().min(0).max(255)
|
||||
]).optional().describe("RGB color as [r, g, b]"),
|
||||
});
|
||||
|
||||
type LightsControlParams = z.infer<typeof lightsControlSchema>;
|
||||
|
||||
/**
|
||||
* Tool for controlling lights in Home Assistant
|
||||
*/
|
||||
export class LightsControlTool extends BaseTool {
|
||||
constructor() {
|
||||
super({
|
||||
name: "lights_control",
|
||||
description: "Control lights in Home Assistant",
|
||||
parameters: lightsControlSchema,
|
||||
metadata: {
|
||||
category: "home_assistant",
|
||||
version: "1.0.0",
|
||||
tags: ["lights", "home_assistant", "control"],
|
||||
examples: [
|
||||
{
|
||||
description: "List all lights",
|
||||
params: { action: "list" }
|
||||
},
|
||||
{
|
||||
description: "Turn on a light with brightness",
|
||||
params: {
|
||||
action: "turn_on",
|
||||
entity_id: "light.living_room",
|
||||
brightness: 200
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the tool
|
||||
*/
|
||||
public async execute(params: LightsControlParams, context: MCPContext): Promise<Record<string, unknown>> {
|
||||
logger.debug(`Executing LightsControlTool with params: ${JSON.stringify(params)}`);
|
||||
|
||||
try {
|
||||
// Add an await here to satisfy the linter
|
||||
await Promise.resolve();
|
||||
|
||||
// Pre-declare variables that will be used in the switch statement
|
||||
let attributes: Record<string, unknown>;
|
||||
|
||||
switch (params.action) {
|
||||
case "list":
|
||||
return this.listLights();
|
||||
|
||||
case "get":
|
||||
if (!params.entity_id) {
|
||||
throw new Error("entity_id is required for get action");
|
||||
}
|
||||
return this.getLight(params.entity_id);
|
||||
|
||||
case "turn_on":
|
||||
if (!params.entity_id) {
|
||||
throw new Error("entity_id is required for turn_on action");
|
||||
}
|
||||
|
||||
// Initialize attributes outside the case block
|
||||
attributes = {};
|
||||
|
||||
if (params.brightness !== undefined) {
|
||||
attributes.brightness = params.brightness;
|
||||
}
|
||||
|
||||
if (params.color_temp !== undefined) {
|
||||
attributes.color_temp = params.color_temp;
|
||||
}
|
||||
|
||||
if (params.rgb_color !== undefined) {
|
||||
// Ensure the rgb_color is passed correctly
|
||||
attributes.rgb_color = [
|
||||
params.rgb_color[0],
|
||||
params.rgb_color[1],
|
||||
params.rgb_color[2]
|
||||
];
|
||||
}
|
||||
|
||||
return this.turnOnLight(params.entity_id, attributes);
|
||||
|
||||
case "turn_off":
|
||||
if (!params.entity_id) {
|
||||
throw new Error("entity_id is required for turn_off action");
|
||||
}
|
||||
return this.turnOffLight(params.entity_id);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown action: ${String(params.action)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in LightsControlTool: ${String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available lights
|
||||
*/
|
||||
private listLights(): Record<string, unknown> {
|
||||
const lights = haLightsService.getLights();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
lights,
|
||||
count: lights.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific light
|
||||
*/
|
||||
private getLight(entity_id: string): Record<string, unknown> {
|
||||
const light = haLightsService.getLight(entity_id);
|
||||
|
||||
if (!light) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Light ${entity_id} not found`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
light
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn on a light
|
||||
*/
|
||||
private turnOnLight(
|
||||
entity_id: string,
|
||||
attributes: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const success = haLightsService.turnOn(entity_id, attributes);
|
||||
|
||||
if (!success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to turn on ${entity_id}: light not found`
|
||||
};
|
||||
}
|
||||
|
||||
const light = haLightsService.getLight(entity_id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Turned on ${entity_id}`,
|
||||
light
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn off a light
|
||||
*/
|
||||
private turnOffLight(entity_id: string): Record<string, unknown> {
|
||||
const success = haLightsService.turnOff(entity_id);
|
||||
|
||||
if (!success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to turn off ${entity_id}: light not found`
|
||||
};
|
||||
}
|
||||
|
||||
const light = haLightsService.getLight(entity_id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Turned off ${entity_id}`,
|
||||
light
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,112 +1,77 @@
|
||||
/**
|
||||
* Logging Module
|
||||
*
|
||||
* This module provides logging functionality with rotation support.
|
||||
* It uses winston for logging and winston-daily-rotate-file for rotation.
|
||||
*
|
||||
* @module logger
|
||||
* Logger Module
|
||||
*
|
||||
* This module provides a consistent logging interface for all MCP components.
|
||||
* It handles log formatting, error handling, and ensures log output is directed
|
||||
* to the appropriate destination based on the runtime environment.
|
||||
*/
|
||||
|
||||
import winston from "winston";
|
||||
import DailyRotateFile from "winston-daily-rotate-file";
|
||||
import { APP_CONFIG } from "../config/app.config.js";
|
||||
import winston from 'winston';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
/**
|
||||
* Log levels configuration
|
||||
* Defines the severity levels for logging
|
||||
*/
|
||||
const levels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
http: 3,
|
||||
debug: 4,
|
||||
};
|
||||
// Ensure logs directory exists
|
||||
const logsDir = path.join(process.cwd(), 'logs');
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Log level colors configuration
|
||||
* Defines colors for different log levels
|
||||
*/
|
||||
const colors = {
|
||||
error: "red",
|
||||
warn: "yellow",
|
||||
info: "green",
|
||||
http: "magenta",
|
||||
debug: "white",
|
||||
};
|
||||
// Special handling for stdio mode to ensure stdout stays clean for JSON-RPC
|
||||
const isStdioMode = process.env.USE_STDIO_TRANSPORT === 'true';
|
||||
const isDebugStdio = process.env.DEBUG_STDIO === 'true';
|
||||
|
||||
/**
|
||||
* Add colors to winston
|
||||
*/
|
||||
winston.addColors(colors);
|
||||
|
||||
/**
|
||||
* Log format configuration
|
||||
* Defines how log messages are formatted
|
||||
*/
|
||||
const format = winston.format.combine(
|
||||
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss:ms" }),
|
||||
winston.format.colorize({ all: true }),
|
||||
winston.format.printf(
|
||||
(info) => `${info.timestamp} ${info.level}: ${info.message}`,
|
||||
),
|
||||
// Create base format that works with TypeScript
|
||||
const baseFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
/**
|
||||
* Transport for daily rotating file
|
||||
* Configures how logs are rotated and stored
|
||||
*/
|
||||
const dailyRotateFileTransport = new DailyRotateFile({
|
||||
filename: "logs/%DATE%.log",
|
||||
datePattern: "YYYY-MM-DD",
|
||||
zippedArchive: true,
|
||||
maxSize: "20m",
|
||||
maxFiles: "14d",
|
||||
format: winston.format.combine(
|
||||
winston.format.uncolorize(),
|
||||
winston.format.timestamp(),
|
||||
winston.format.json(),
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Transport for error logs
|
||||
* Stores error logs in a separate file
|
||||
*/
|
||||
const errorFileTransport = new DailyRotateFile({
|
||||
filename: "logs/error-%DATE%.log",
|
||||
datePattern: "YYYY-MM-DD",
|
||||
level: "error",
|
||||
zippedArchive: true,
|
||||
maxSize: "20m",
|
||||
maxFiles: "14d",
|
||||
format: winston.format.combine(
|
||||
winston.format.uncolorize(),
|
||||
winston.format.timestamp(),
|
||||
winston.format.json(),
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create the logger instance
|
||||
*/
|
||||
// Create logger with appropriate transports
|
||||
const logger = winston.createLogger({
|
||||
level: APP_CONFIG.NODE_ENV === "development" ? "debug" : "info",
|
||||
levels,
|
||||
format,
|
||||
level: process.env.LOG_LEVEL || 'error',
|
||||
format: baseFormat,
|
||||
defaultMeta: { service: 'mcp-server' },
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
// Always log to files
|
||||
new winston.transports.File({ filename: path.join(logsDir, 'error.log'), level: 'error' }),
|
||||
new winston.transports.File({ filename: path.join(logsDir, 'combined.log') })
|
||||
]
|
||||
});
|
||||
|
||||
// Handle console output based on environment
|
||||
if (process.env.NODE_ENV !== 'production' || process.env.CONSOLE_LOGGING === 'true') {
|
||||
// In stdio mode with debug enabled, ensure logs only go to stderr to keep stdout clean for JSON-RPC
|
||||
if (isStdioMode && isDebugStdio) {
|
||||
// Use stderr stream transport in stdio debug mode
|
||||
logger.add(new winston.transports.Stream({
|
||||
stream: process.stderr,
|
||||
format: winston.format.combine(
|
||||
winston.format.simple()
|
||||
)
|
||||
}));
|
||||
} else {
|
||||
// Use console transport in normal mode
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple(),
|
||||
),
|
||||
}),
|
||||
dailyRotateFileTransport,
|
||||
errorFileTransport,
|
||||
],
|
||||
});
|
||||
winston.format.simple()
|
||||
)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the logger instance
|
||||
*/
|
||||
// Custom logger interface
|
||||
export interface MCPLogger {
|
||||
debug: (message: string, meta?: Record<string, any>) => void;
|
||||
info: (message: string, meta?: Record<string, any>) => void;
|
||||
warn: (message: string, meta?: Record<string, any>) => void;
|
||||
error: (message: string, meta?: Record<string, any>) => void;
|
||||
child: (options: Record<string, any>) => MCPLogger;
|
||||
}
|
||||
|
||||
// Export the winston logger with MCPLogger interface
|
||||
export { logger };
|
||||
|
||||
// Export default logger for convenience
|
||||
export default logger;
|
||||
|
||||
339
src/utils/stdio-transport.ts
Normal file
339
src/utils/stdio-transport.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Stdio Transport Module
|
||||
*
|
||||
* This module implements communication via standard input/output streams
|
||||
* using JSON-RPC 2.0 format for sending and receiving messages.
|
||||
*
|
||||
* @module stdio-transport
|
||||
*/
|
||||
|
||||
import { createInterface } from "readline";
|
||||
import { logger } from "./logger.js";
|
||||
import { z } from "zod";
|
||||
|
||||
// JSON-RPC 2.0 error codes
|
||||
export enum JsonRpcErrorCode {
|
||||
// Standard JSON-RPC 2.0 error codes
|
||||
PARSE_ERROR = -32700,
|
||||
INVALID_REQUEST = -32600,
|
||||
METHOD_NOT_FOUND = -32601,
|
||||
INVALID_PARAMS = -32602,
|
||||
INTERNAL_ERROR = -32603,
|
||||
// MCP specific error codes
|
||||
TOOL_EXECUTION_ERROR = -32000,
|
||||
VALIDATION_ERROR = -32001,
|
||||
}
|
||||
|
||||
// Type definitions for JSON-RPC 2.0 messages
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: "2.0";
|
||||
id: string | number;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
jsonrpc: "2.0";
|
||||
id: string | number;
|
||||
result?: unknown;
|
||||
error?: JsonRpcError;
|
||||
}
|
||||
|
||||
export interface JsonRpcError {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcNotification {
|
||||
jsonrpc: "2.0";
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Setup readline interface for stdin
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
terminal: false
|
||||
});
|
||||
|
||||
// Message handlers map
|
||||
const messageHandlers: Map<string, {
|
||||
execute: (params: Record<string, unknown>) => Promise<unknown>;
|
||||
paramsSchema?: z.ZodType<any>;
|
||||
}> = new Map();
|
||||
|
||||
/**
|
||||
* Initialize stdio transport
|
||||
* Sets up event listeners and message processing
|
||||
*/
|
||||
export function initStdioTransport(): void {
|
||||
// Check for silent startup mode
|
||||
const silentStartup = process.env.SILENT_STARTUP === 'true';
|
||||
|
||||
// Handle line events (incoming JSON)
|
||||
rl.on('line', async (line) => {
|
||||
try {
|
||||
// Parse incoming JSON
|
||||
const request = JSON.parse(line);
|
||||
|
||||
// Validate it's a proper JSON-RPC 2.0 request
|
||||
if (!request.jsonrpc || request.jsonrpc !== "2.0") {
|
||||
sendErrorResponse({
|
||||
id: request.id || null,
|
||||
code: JsonRpcErrorCode.INVALID_REQUEST,
|
||||
message: "Invalid JSON-RPC 2.0 request: missing or invalid jsonrpc version"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle request with ID (requires response)
|
||||
if (request.id !== undefined) {
|
||||
await handleJsonRpcRequest(request as JsonRpcRequest).catch(err => {
|
||||
if (!silentStartup) {
|
||||
logger.error(`Error handling request: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Handle notification (no response expected)
|
||||
else if (request.method) {
|
||||
void handleJsonRpcNotification(request as JsonRpcNotification);
|
||||
}
|
||||
// Invalid request format
|
||||
else {
|
||||
sendErrorResponse({
|
||||
id: null,
|
||||
code: JsonRpcErrorCode.INVALID_REQUEST,
|
||||
message: "Invalid JSON-RPC 2.0 message format"
|
||||
});
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Handle JSON parsing errors
|
||||
if (!silentStartup) {
|
||||
logger.error(`Failed to parse JSON input: ${String(parseError)}`);
|
||||
}
|
||||
sendErrorResponse({
|
||||
id: null,
|
||||
code: JsonRpcErrorCode.PARSE_ERROR,
|
||||
message: "Parse error: invalid JSON",
|
||||
data: parseError instanceof Error ? parseError.message : String(parseError)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stdin close
|
||||
rl.on('close', () => {
|
||||
if (!silentStartup) {
|
||||
logger.info('Stdin closed, shutting down');
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Log initialization only if not in silent mode
|
||||
if (!silentStartup) {
|
||||
logger.info("JSON-RPC 2.0 stdio transport initialized");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a JSON-RPC request that requires a response
|
||||
*/
|
||||
async function handleJsonRpcRequest(request: JsonRpcRequest): Promise<void> {
|
||||
const { id, method, params = {} } = request;
|
||||
|
||||
// Log to file but not console
|
||||
logger.debug(`Received request: ${id} - ${method}`);
|
||||
|
||||
// Look up handler
|
||||
const handler = messageHandlers.get(method);
|
||||
if (!handler) {
|
||||
sendErrorResponse({
|
||||
id,
|
||||
code: JsonRpcErrorCode.METHOD_NOT_FOUND,
|
||||
message: `Method not found: ${method}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate parameters if schema exists
|
||||
if (handler.paramsSchema) {
|
||||
try {
|
||||
const validationResult = handler.paramsSchema.parse(params);
|
||||
// If validation changes values (e.g. default values), use the validated result
|
||||
Object.assign(params, validationResult);
|
||||
} catch (validationError) {
|
||||
sendErrorResponse({
|
||||
id,
|
||||
code: JsonRpcErrorCode.INVALID_PARAMS,
|
||||
message: "Invalid parameters",
|
||||
data: validationError instanceof Error ? validationError.message : String(validationError)
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute handler
|
||||
const result = await handler.execute(params);
|
||||
|
||||
// Send successful response
|
||||
sendResponse({
|
||||
id,
|
||||
result
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle execution errors
|
||||
sendErrorResponse({
|
||||
id,
|
||||
code: JsonRpcErrorCode.TOOL_EXECUTION_ERROR,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
data: error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a JSON-RPC notification (no response required)
|
||||
*/
|
||||
async function handleJsonRpcNotification(notification: JsonRpcNotification): Promise<void> {
|
||||
const { method, params = {} } = notification;
|
||||
|
||||
// Log to file but not console
|
||||
logger.debug(`Received notification: ${method}`);
|
||||
|
||||
// Look up handler
|
||||
const handler = messageHandlers.get(method);
|
||||
if (!handler) {
|
||||
// No response for notifications even if method not found
|
||||
logger.warn(`Method not found for notification: ${method}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate parameters if schema exists
|
||||
if (handler.paramsSchema) {
|
||||
try {
|
||||
handler.paramsSchema.parse(params);
|
||||
} catch (validationError) {
|
||||
logger.error(`Invalid parameters for notification ${method}: ${String(validationError)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute handler (fire and forget)
|
||||
await handler.execute(params);
|
||||
} catch (error) {
|
||||
// Log execution errors but don't send response
|
||||
logger.error(`Error handling notification ${method}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a message handler for a specific method
|
||||
*
|
||||
* @param method - The method name to handle
|
||||
* @param handler - The function to handle the method
|
||||
* @param paramsSchema - Optional Zod schema for parameter validation
|
||||
*/
|
||||
export function registerHandler(
|
||||
method: string,
|
||||
handler: (params: Record<string, unknown>) => Promise<unknown>,
|
||||
paramsSchema?: z.ZodType<any>
|
||||
): void {
|
||||
messageHandlers.set(method, {
|
||||
execute: handler,
|
||||
paramsSchema
|
||||
});
|
||||
logger.debug(`Registered handler for method: ${method}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a successful response to stdout
|
||||
*
|
||||
* @param options - The response options
|
||||
*/
|
||||
export function sendResponse({ id, result }: { id: string | number; result?: unknown }): void {
|
||||
const response: JsonRpcResponse = {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
result
|
||||
};
|
||||
|
||||
const jsonResponse = JSON.stringify(response);
|
||||
process.stdout.write(jsonResponse + '\n');
|
||||
logger.debug(`Sent response: ${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error response to stdout
|
||||
*
|
||||
* @param error - The error details
|
||||
*/
|
||||
export function sendErrorResponse({
|
||||
id,
|
||||
code,
|
||||
message,
|
||||
data
|
||||
}: {
|
||||
id: string | number | null;
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}): void {
|
||||
const response: JsonRpcResponse = {
|
||||
jsonrpc: "2.0",
|
||||
id: id ?? null,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
data
|
||||
}
|
||||
};
|
||||
|
||||
const jsonResponse = JSON.stringify(response);
|
||||
process.stdout.write(jsonResponse + '\n');
|
||||
logger.error(`Sent error response: ${id} - [${code}] ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to the client (no response expected)
|
||||
*
|
||||
* @param method - The notification method name
|
||||
* @param params - The notification parameters
|
||||
*/
|
||||
export function sendNotification(method: string, params?: Record<string, unknown>): void {
|
||||
const notification: JsonRpcNotification = {
|
||||
jsonrpc: "2.0",
|
||||
method,
|
||||
params
|
||||
};
|
||||
|
||||
const jsonNotification = JSON.stringify(notification);
|
||||
process.stdout.write(jsonNotification + '\n');
|
||||
logger.debug(`Sent notification: ${method}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a log message to the client
|
||||
*
|
||||
* @param level - The log level (info, warn, error, debug)
|
||||
* @param message - The log message
|
||||
* @param data - Optional additional data
|
||||
*/
|
||||
export function sendLogMessage(level: string, message: string, data?: unknown): void {
|
||||
sendNotification("log", {
|
||||
level,
|
||||
message,
|
||||
data,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable debug mode for the transport
|
||||
* Increases logging verbosity
|
||||
*/
|
||||
export function enableDebugMode(): void {
|
||||
logger.level = "debug";
|
||||
logger.info("Debug mode enabled for stdio transport");
|
||||
}
|
||||
Reference in New Issue
Block a user