Refactor SelectFieldGenerator and SchemaConverter for improved readability and maintainability

- Updated formatting and indentation for consistency in SelectFieldGenerator.ts and SchemaConverter.ts.
- Enhanced logging messages for better debugging in generateAllFieldsSelect and findModelClass functions.
- Simplified error handling and fallback mechanisms in generateAllFieldsSelect.
- Improved type definitions and structure in Zod schema conversion functions.
- Added tests for server initialization and tool management to ensure proper functionality and error handling.
This commit is contained in:
Nawaz Dhandala
2025-12-18 13:22:18 +00:00
parent 89dd543677
commit 9481d61c2f
9 changed files with 1130 additions and 1076 deletions

View File

@@ -17,7 +17,7 @@ export const ROUTE_PREFIXES: string[] = [`/${APP_NAME}`, "/"];
// API URL configuration
export function getApiUrl(): string {
return Host ? `${HttpProtocol}${Host}` : "https://oneuptime.com";
return Host ? `${HttpProtocol}${Host}` : "https://oneuptime.com";
}
// Session header name

View File

@@ -6,224 +6,237 @@
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { randomUUID } from "node:crypto";
import {
ExpressApplication,
ExpressRequest,
ExpressResponse,
NextFunction,
ExpressJson,
ExpressApplication,
ExpressRequest,
ExpressResponse,
NextFunction,
ExpressJson,
} from "Common/Server/Utils/Express";
import { getMCPServer } from "../Server/MCPServer";
import SessionManager, { SessionData } from "../Server/SessionManager";
import { McpToolInfo } from "../Types/McpTypes";
import {
ROUTE_PREFIXES,
SESSION_HEADER,
API_KEY_HEADERS,
ROUTE_PREFIXES,
SESSION_HEADER,
API_KEY_HEADERS,
} from "../Config/ServerConfig";
import logger from "Common/Server/Utils/Logger";
// Type for MCP handler function
type McpHandlerFunction = (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
) => Promise<void>;
/**
* Extract API key from request headers
*/
export function extractApiKey(req: ExpressRequest): string | undefined {
for (const header of API_KEY_HEADERS) {
const value: string | undefined = req.headers[header] as string | undefined;
if (value) {
// Handle Bearer token format
if (header === "authorization" && value.startsWith("Bearer ")) {
return value.replace("Bearer ", "");
}
return value;
}
for (const header of API_KEY_HEADERS) {
const value: string | undefined = req.headers[header] as string | undefined;
if (value) {
// Handle Bearer token format
if (header === "authorization" && value.startsWith("Bearer ")) {
return value.replace("Bearer ", "");
}
return value;
}
return undefined;
}
return undefined;
}
/**
* Setup all MCP-specific routes on the Express app
*/
export function setupMCPRoutes(app: ExpressApplication, tools: McpToolInfo[]): void {
ROUTE_PREFIXES.forEach((prefix: string) => {
setupRoutesForPrefix(app, prefix, tools);
});
export function setupMCPRoutes(
app: ExpressApplication,
tools: McpToolInfo[],
): void {
ROUTE_PREFIXES.forEach((prefix: string) => {
setupRoutesForPrefix(app, prefix, tools);
});
logger.info(`MCP routes setup complete for prefixes: ${ROUTE_PREFIXES.join(", ")}`);
logger.info(
`MCP routes setup complete for prefixes: ${ROUTE_PREFIXES.join(", ")}`,
);
}
/**
* Setup routes for a specific prefix
*/
function setupRoutesForPrefix(
app: ExpressApplication,
prefix: string,
tools: McpToolInfo[]
app: ExpressApplication,
prefix: string,
tools: McpToolInfo[],
): void {
const mcpEndpoint: string = prefix === "/" ? "/mcp" : `${prefix}/mcp`;
const mcpHandler: McpHandlerFunction = createMCPHandler();
const mcpEndpoint: string = prefix === "/" ? "/mcp" : `${prefix}/mcp`;
const mcpHandler: McpHandlerFunction = createMCPHandler();
// MCP endpoint for all methods (GET for SSE, POST for requests, DELETE for cleanup)
app.get(mcpEndpoint, mcpHandler);
app.post(mcpEndpoint, ExpressJson(), mcpHandler);
app.delete(mcpEndpoint, mcpHandler);
// MCP endpoint for all methods (GET for SSE, POST for requests, DELETE for cleanup)
app.get(mcpEndpoint, mcpHandler);
app.post(mcpEndpoint, ExpressJson(), mcpHandler);
app.delete(mcpEndpoint, mcpHandler);
// OPTIONS handler for CORS preflight requests
app.options(mcpEndpoint, (_req: ExpressRequest, res: ExpressResponse) => {
res.status(200).end();
// OPTIONS handler for CORS preflight requests
app.options(mcpEndpoint, (_req: ExpressRequest, res: ExpressResponse) => {
res.status(200).end();
});
// Handle root "/" when nginx strips the /mcp/ prefix (for "/" prefix only)
if (prefix === "/") {
app.get("/", mcpHandler);
app.post("/", ExpressJson(), mcpHandler);
app.delete("/", mcpHandler);
app.options("/", (_req: ExpressRequest, res: ExpressResponse) => {
res.status(200).end();
});
}
// Handle root "/" when nginx strips the /mcp/ prefix (for "/" prefix only)
if (prefix === "/") {
app.get("/", mcpHandler);
app.post("/", ExpressJson(), mcpHandler);
app.delete("/", mcpHandler);
app.options("/", (_req: ExpressRequest, res: ExpressResponse) => {
res.status(200).end();
});
}
// List tools endpoint (REST API)
setupToolsEndpoint(app, prefix, tools);
// List tools endpoint (REST API)
setupToolsEndpoint(app, prefix, tools);
// Health check endpoint
setupHealthEndpoint(app, prefix, tools);
// Health check endpoint
setupHealthEndpoint(app, prefix, tools);
}
/**
* Create the main MCP request handler
*/
function createMCPHandler(): McpHandlerFunction {
return async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction
): Promise<void> => {
try {
// Extract API key (optional - public tools work without it)
const apiKey: string | undefined = extractApiKey(req);
return async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
// Extract API key (optional - public tools work without it)
const apiKey: string | undefined = extractApiKey(req);
// Set the current API key for tool calls (may be undefined for public tools)
SessionManager.setCurrentApiKey(apiKey || "");
// Set the current API key for tool calls (may be undefined for public tools)
SessionManager.setCurrentApiKey(apiKey || "");
// Check for existing session
const sessionId: string | undefined = req.headers[SESSION_HEADER] as string;
// Check for existing session
const sessionId: string | undefined = req.headers[
SESSION_HEADER
] as string;
if (sessionId && SessionManager.hasSession(sessionId)) {
await handleExistingSession(req, res, sessionId, apiKey || "");
return;
}
if (sessionId && SessionManager.hasSession(sessionId)) {
await handleExistingSession(req, res, sessionId, apiKey || "");
return;
}
// Create new session for new connections
await handleNewSession(req, res, apiKey || "");
} catch (error) {
next(error);
}
};
// Create new session for new connections
await handleNewSession(req, res, apiKey || "");
} catch (error) {
next(error);
}
};
}
/**
* Handle request for an existing session
*/
async function handleExistingSession(
req: ExpressRequest,
res: ExpressResponse,
sessionId: string,
apiKey: string
req: ExpressRequest,
res: ExpressResponse,
sessionId: string,
apiKey: string,
): Promise<void> {
const sessionData: SessionData | undefined = SessionManager.getSession(sessionId);
const sessionData: SessionData | undefined =
SessionManager.getSession(sessionId);
if (!sessionData) {
return;
}
if (!sessionData) {
return;
}
// Update API key in case it changed
sessionData.apiKey = apiKey;
await sessionData.transport.handleRequest(req, res, req.body);
// Update API key in case it changed
sessionData.apiKey = apiKey;
await sessionData.transport.handleRequest(req, res, req.body);
}
/**
* Handle request for a new session (initialization)
*/
async function handleNewSession(
req: ExpressRequest,
res: ExpressResponse,
apiKey: string
req: ExpressRequest,
res: ExpressResponse,
apiKey: string,
): Promise<void> {
const mcpServer = getMCPServer();
const mcpServer = getMCPServer();
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: (): string => randomUUID(),
onsessioninitialized: (newSessionId: string): void => {
// Store the transport with the new session ID and API key
SessionManager.setSession(newSessionId, { transport, apiKey });
logger.info(`New MCP session initialized: ${newSessionId}`);
},
const transport: StreamableHTTPServerTransport =
new StreamableHTTPServerTransport({
sessionIdGenerator: (): string => {
return randomUUID();
},
onsessioninitialized: (newSessionId: string): void => {
// Store the transport with the new session ID and API key
SessionManager.setSession(newSessionId, { transport, apiKey });
logger.info(`New MCP session initialized: ${newSessionId}`);
},
});
// Handle transport close
transport.onclose = (): void => {
const transportSessionId: string | undefined = transport.sessionId;
if (transportSessionId) {
logger.info(`MCP session closed: ${transportSessionId}`);
SessionManager.removeSession(transportSessionId);
}
};
// Handle transport close
transport.onclose = (): void => {
const transportSessionId: string | undefined = transport.sessionId;
if (transportSessionId) {
logger.info(`MCP session closed: ${transportSessionId}`);
SessionManager.removeSession(transportSessionId);
}
};
// Handle transport errors
transport.onerror = (error: Error): void => {
logger.error(`MCP transport error: ${error.message}`);
};
// Handle transport errors
transport.onerror = (error: Error): void => {
logger.error(`MCP transport error: ${error.message}`);
};
// Connect the MCP server to this transport
await mcpServer.connect(transport as Parameters<typeof mcpServer.connect>[0]);
// Connect the MCP server to this transport
await mcpServer.connect(transport as Parameters<typeof mcpServer.connect>[0]);
// Handle the request
await transport.handleRequest(req, res, req.body);
// Handle the request
await transport.handleRequest(req, res, req.body);
}
/**
* Setup the tools listing endpoint
*/
function setupToolsEndpoint(
app: ExpressApplication,
prefix: string,
tools: McpToolInfo[]
app: ExpressApplication,
prefix: string,
tools: McpToolInfo[],
): void {
const endpoint: string = `${prefix === "/" ? "" : prefix}/tools`;
const endpoint: string = `${prefix === "/" ? "" : prefix}/tools`;
app.get(endpoint, (_req: ExpressRequest, res: ExpressResponse) => {
const toolsList: Array<{ name: string; description: string }> = tools.map(
(tool: McpToolInfo) => ({
name: tool.name,
description: tool.description,
})
);
res.json({ tools: toolsList, count: toolsList.length });
});
app.get(endpoint, (_req: ExpressRequest, res: ExpressResponse) => {
const toolsList: Array<{ name: string; description: string }> = tools.map(
(tool: McpToolInfo) => {
return {
name: tool.name,
description: tool.description,
};
},
);
res.json({ tools: toolsList, count: toolsList.length });
});
}
/**
* Setup the health check endpoint
*/
function setupHealthEndpoint(
app: ExpressApplication,
prefix: string,
tools: McpToolInfo[]
app: ExpressApplication,
prefix: string,
tools: McpToolInfo[],
): void {
const endpoint: string = `${prefix === "/" ? "" : prefix}/health`;
const endpoint: string = `${prefix === "/" ? "" : prefix}/health`;
app.get(endpoint, (_req: ExpressRequest, res: ExpressResponse) => {
res.json({
status: "healthy",
service: "oneuptime-mcp",
tools: tools.length,
activeSessions: SessionManager.getSessionCount(),
});
app.get(endpoint, (_req: ExpressRequest, res: ExpressResponse) => {
res.json({
status: "healthy",
service: "oneuptime-mcp",
tools: tools.length,
activeSessions: SessionManager.getSessionCount(),
});
});
}

View File

@@ -17,7 +17,9 @@ import { initializeMCPServer, getMCPServer } from "./Server/MCPServer";
import { registerToolHandlers } from "./Handlers/ToolHandler";
import { setupMCPRoutes } from "./Handlers/RouteHandler";
import { generateAllTools } from "./Tools/ToolGenerator";
import OneUptimeApiService, { OneUptimeApiConfig } from "./Services/OneUptimeApiService";
import OneUptimeApiService, {
OneUptimeApiConfig,
} from "./Services/OneUptimeApiService";
import { McpToolInfo } from "./Types/McpTypes";
const app: ExpressApplication = Express.getExpressApp();
@@ -26,94 +28,98 @@ const app: ExpressApplication = Express.getExpressApp();
* Initialize OneUptime API Service
*/
function initializeApiService(): void {
const apiUrl: string = getApiUrl();
const apiUrl: string = getApiUrl();
const config: OneUptimeApiConfig = {
url: apiUrl,
};
const config: OneUptimeApiConfig = {
url: apiUrl,
};
OneUptimeApiService.initialize(config);
logger.info(
`OneUptime API Service initialized with: ${apiUrl} (API keys provided per-request via x-api-key header)`
);
OneUptimeApiService.initialize(config);
logger.info(
`OneUptime API Service initialized with: ${apiUrl} (API keys provided per-request via x-api-key header)`,
);
}
/**
* Generate MCP tools for all models
*/
function generateTools(): McpToolInfo[] {
try {
const tools: McpToolInfo[] = generateAllTools();
logger.info(`Generated ${tools.length} OneUptime MCP tools`);
return tools;
} catch (error) {
logger.error(`Failed to generate tools: ${error}`);
throw error;
}
try {
const tools: McpToolInfo[] = generateAllTools();
logger.info(`Generated ${tools.length} OneUptime MCP tools`);
return tools;
} catch (error) {
logger.error(`Failed to generate tools: ${error}`);
throw error;
}
}
/**
* Simple status check for MCP (no database connections)
*/
const statusCheck: PromiseVoidFunction = async (): Promise<void> => {
// MCP server doesn't connect to databases directly
// Just verify the server is running
return Promise.resolve();
/*
* MCP server doesn't connect to databases directly
* Just verify the server is running
*/
return Promise.resolve();
};
/**
* Main initialization function
*/
const init: PromiseVoidFunction = async (): Promise<void> => {
try {
// Initialize telemetry
Telemetry.init({
serviceName: APP_NAME,
});
try {
// Initialize telemetry
Telemetry.init({
serviceName: APP_NAME,
});
// Initialize the app with service name and status checks
await App.init({
appName: APP_NAME,
statusOptions: {
liveCheck: statusCheck,
readyCheck: statusCheck,
},
});
// Initialize the app with service name and status checks
await App.init({
appName: APP_NAME,
statusOptions: {
liveCheck: statusCheck,
readyCheck: statusCheck,
},
});
// Initialize services
initializeApiService();
// Initialize services
initializeApiService();
// Initialize MCP server
initializeMCPServer();
// Initialize MCP server
initializeMCPServer();
// Generate tools
const tools: McpToolInfo[] = generateTools();
// Generate tools
const tools: McpToolInfo[] = generateTools();
// Register tool handlers
registerToolHandlers(getMCPServer(), tools);
// Register tool handlers
registerToolHandlers(getMCPServer(), tools);
// Setup MCP-specific routes
setupMCPRoutes(app, tools);
// Setup MCP-specific routes
setupMCPRoutes(app, tools);
// Add default routes to the app
await App.addDefaultRoutes();
// Add default routes to the app
await App.addDefaultRoutes();
logger.info(`OneUptime MCP Server started successfully`);
logger.info(`Available tools: ${tools.length} total`);
logger.info(`OneUptime MCP Server started successfully`);
logger.info(`Available tools: ${tools.length} total`);
// Log some example tools
const exampleTools: string[] = tools.slice(0, 5).map((t: McpToolInfo) => t.name);
logger.info(`Example tools: ${exampleTools.join(", ")}`);
} catch (err) {
logger.error("MCP Server Init Failed:");
logger.error(err);
throw err;
}
// Log some example tools
const exampleTools: string[] = tools.slice(0, 5).map((t: McpToolInfo) => {
return t.name;
});
logger.info(`Example tools: ${exampleTools.join(", ")}`);
} catch (err) {
logger.error("MCP Server Init Failed:");
logger.error(err);
throw err;
}
};
// Start the server
init().catch((err: Error) => {
logger.error(err);
logger.error("Exiting node process");
process.exit(1);
logger.error(err);
logger.error("Exiting node process");
process.exit(1);
});

View File

@@ -14,24 +14,26 @@ let mcpServerInstance: McpServer | null = null;
* Initialize and return the MCP server instance
*/
export function initializeMCPServer(): McpServer {
if (mcpServerInstance) {
return mcpServerInstance;
}
mcpServerInstance = new McpServer(
{
name: MCP_SERVER_NAME,
version: MCP_SERVER_VERSION,
},
{
capabilities: {
tools: {},
},
}
);
logger.info(`MCP Server initialized: ${MCP_SERVER_NAME} v${MCP_SERVER_VERSION}`);
if (mcpServerInstance) {
return mcpServerInstance;
}
mcpServerInstance = new McpServer(
{
name: MCP_SERVER_NAME,
version: MCP_SERVER_VERSION,
},
{
capabilities: {
tools: {},
},
},
);
logger.info(
`MCP Server initialized: ${MCP_SERVER_NAME} v${MCP_SERVER_VERSION}`,
);
return mcpServerInstance;
}
/**
@@ -39,24 +41,26 @@ export function initializeMCPServer(): McpServer {
* @throws Error if server not initialized
*/
export function getMCPServer(): McpServer {
if (!mcpServerInstance) {
throw new Error("MCP Server not initialized. Call initializeMCPServer() first.");
}
return mcpServerInstance;
if (!mcpServerInstance) {
throw new Error(
"MCP Server not initialized. Call initializeMCPServer() first.",
);
}
return mcpServerInstance;
}
/**
* Check if MCP server is initialized
*/
export function isMCPServerInitialized(): boolean {
return mcpServerInstance !== null;
return mcpServerInstance !== null;
}
/**
* Reset MCP server (useful for testing)
*/
export function resetMCPServer(): void {
mcpServerInstance = null;
mcpServerInstance = null;
}
export { McpServer };

View File

@@ -8,103 +8,106 @@ import logger from "Common/Server/Utils/Logger";
// Session data interface
export interface SessionData {
transport: StreamableHTTPServerTransport;
apiKey: string;
transport: StreamableHTTPServerTransport;
apiKey: string;
}
/**
* SessionManager handles the lifecycle of MCP sessions
*/
export default class SessionManager {
private static sessions: Map<string, SessionData> = new Map();
private static currentSessionApiKey: string = "";
private static sessions: Map<string, SessionData> = new Map();
private static currentSessionApiKey: string = "";
/**
* Get all active sessions
*/
public static getSessions(): Map<string, SessionData> {
return this.sessions;
}
/**
* Get all active sessions
*/
public static getSessions(): Map<string, SessionData> {
return this.sessions;
}
/**
* Check if a session exists
*/
public static hasSession(sessionId: string): boolean {
return this.sessions.has(sessionId);
}
/**
* Check if a session exists
*/
public static hasSession(sessionId: string): boolean {
return this.sessions.has(sessionId);
}
/**
* Get a session by ID
*/
public static getSession(sessionId: string): SessionData | undefined {
return this.sessions.get(sessionId);
}
/**
* Get a session by ID
*/
public static getSession(sessionId: string): SessionData | undefined {
return this.sessions.get(sessionId);
}
/**
* Create or update a session
*/
public static setSession(sessionId: string, data: SessionData): void {
this.sessions.set(sessionId, data);
logger.info(`MCP session stored: ${sessionId}`);
}
/**
* Create or update a session
*/
public static setSession(sessionId: string, data: SessionData): void {
this.sessions.set(sessionId, data);
logger.info(`MCP session stored: ${sessionId}`);
}
/**
* Update the API key for an existing session
*/
public static updateSessionApiKey(sessionId: string, apiKey: string): boolean {
const session: SessionData | undefined = this.sessions.get(sessionId);
if (session) {
session.apiKey = apiKey;
return true;
}
return false;
/**
* Update the API key for an existing session
*/
public static updateSessionApiKey(
sessionId: string,
apiKey: string,
): boolean {
const session: SessionData | undefined = this.sessions.get(sessionId);
if (session) {
session.apiKey = apiKey;
return true;
}
return false;
}
/**
* Remove a session
*/
public static removeSession(sessionId: string): boolean {
const deleted: boolean = this.sessions.delete(sessionId);
if (deleted) {
logger.info(`MCP session removed: ${sessionId}`);
}
return deleted;
/**
* Remove a session
*/
public static removeSession(sessionId: string): boolean {
const deleted: boolean = this.sessions.delete(sessionId);
if (deleted) {
logger.info(`MCP session removed: ${sessionId}`);
}
return deleted;
}
/**
* Get the current session API key (used during request processing)
*/
public static getCurrentApiKey(): string {
return this.currentSessionApiKey;
}
/**
* Get the current session API key (used during request processing)
*/
public static getCurrentApiKey(): string {
return this.currentSessionApiKey;
}
/**
* Set the current session API key (called at the start of each request)
*/
public static setCurrentApiKey(apiKey: string): void {
this.currentSessionApiKey = apiKey;
}
/**
* Set the current session API key (called at the start of each request)
*/
public static setCurrentApiKey(apiKey: string): void {
this.currentSessionApiKey = apiKey;
}
/**
* Clear the current session API key
*/
public static clearCurrentApiKey(): void {
this.currentSessionApiKey = "";
}
/**
* Clear the current session API key
*/
public static clearCurrentApiKey(): void {
this.currentSessionApiKey = "";
}
/**
* Get the count of active sessions
*/
public static getSessionCount(): number {
return this.sessions.size;
}
/**
* Get the count of active sessions
*/
public static getSessionCount(): number {
return this.sessions.size;
}
/**
* Clear all sessions (useful for cleanup)
*/
public static clearAllSessions(): void {
this.sessions.clear();
this.currentSessionApiKey = "";
logger.info("All MCP sessions cleared");
}
/**
* Clear all sessions (useful for cleanup)
*/
public static clearAllSessions(): void {
this.sessions.clear();
this.currentSessionApiKey = "";
logger.info("All MCP sessions cleared");
}
}

View File

@@ -19,330 +19,335 @@ import Protocol from "Common/Types/API/Protocol";
import Hostname from "Common/Types/API/Hostname";
export interface OneUptimeApiConfig {
url: string;
apiKey?: string;
url: string;
apiKey?: string;
}
export default class OneUptimeApiService {
private static api: API;
private static api: API;
/**
* Initialize the API service with configuration
*/
public static initialize(config: OneUptimeApiConfig): void {
try {
const url: URL = URL.fromString(config.url);
const protocol: Protocol = url.protocol;
const hostname: Hostname = url.hostname;
/**
* Initialize the API service with configuration
*/
public static initialize(config: OneUptimeApiConfig): void {
try {
const url: URL = URL.fromString(config.url);
const protocol: Protocol = url.protocol;
const hostname: Hostname = url.hostname;
this.api = new API(protocol, hostname, new Route("/"));
} catch (error) {
throw new Error(`Invalid URL format: ${config.url}. Error: ${error}`);
}
MCPLogger.info(`OneUptime API Service initialized with: ${config.url}`);
this.api = new API(protocol, hostname, new Route("/"));
} catch (error) {
throw new Error(`Invalid URL format: ${config.url}. Error: ${error}`);
}
/**
* Execute a OneUptime operation
*/
public static async executeOperation(
tableName: string,
operation: OneUptimeOperation,
modelType: ModelType,
apiPath: string,
args: OneUptimeToolCallArgs,
apiKey: string
): Promise<JSONValue> {
this.validateInitialization();
this.validateApiKey(apiKey);
this.validateOperationArgs(operation, args);
MCPLogger.info(`OneUptime API Service initialized with: ${config.url}`);
}
const route: Route = this.buildApiRoute(apiPath, operation, args.id);
const headers: Headers = this.buildHeaders(apiKey);
const data: JSONObject | undefined = this.buildRequestData(
operation,
args,
tableName,
modelType
/**
* Execute a OneUptime operation
*/
public static async executeOperation(
tableName: string,
operation: OneUptimeOperation,
modelType: ModelType,
apiPath: string,
args: OneUptimeToolCallArgs,
apiKey: string,
): Promise<JSONValue> {
this.validateInitialization();
this.validateApiKey(apiKey);
this.validateOperationArgs(operation, args);
const route: Route = this.buildApiRoute(apiPath, operation, args.id);
const headers: Headers = this.buildHeaders(apiKey);
const data: JSONObject | undefined = this.buildRequestData(
operation,
args,
tableName,
modelType,
);
MCPLogger.info(
`Executing ${operation} operation for ${tableName} at ${route.toString()}`,
);
try {
const response = await this.makeApiRequest(
operation,
route,
headers,
data,
);
MCPLogger.info(
`Successfully executed ${operation} operation for ${tableName}`,
);
return response;
} catch (error) {
MCPLogger.error(
`Error executing ${operation} operation for ${tableName}: ${error}`,
);
throw error;
}
}
/**
* Make the actual API request
*/
private static async makeApiRequest(
operation: OneUptimeOperation,
route: Route,
headers: Headers,
data: JSONObject | undefined,
): Promise<JSONValue> {
const url: URL = new URL(this.api.protocol, this.api.hostname, route);
const baseOptions: { url: URL; headers: Headers } = { url, headers };
let response: HTTPResponse<JSONObject> | HTTPErrorResponse;
switch (operation) {
case OneUptimeOperation.Create:
case OneUptimeOperation.Count:
case OneUptimeOperation.List:
case OneUptimeOperation.Read:
response = await API.post(
data ? { ...baseOptions, data } : baseOptions,
);
MCPLogger.info(
`Executing ${operation} operation for ${tableName} at ${route.toString()}`
break;
case OneUptimeOperation.Update:
response = await API.put(data ? { ...baseOptions, data } : baseOptions);
break;
case OneUptimeOperation.Delete:
response = await API.delete(
data ? { ...baseOptions, data } : baseOptions,
);
break;
default:
throw new Error(`Unsupported operation: ${operation}`);
}
try {
const response = await this.makeApiRequest(operation, route, headers, data);
MCPLogger.info(`Successfully executed ${operation} operation for ${tableName}`);
return response;
} catch (error) {
MCPLogger.error(
`Error executing ${operation} operation for ${tableName}: ${error}`
);
throw error;
if (response instanceof HTTPErrorResponse) {
throw new Error(
`API request failed: ${response.statusCode} - ${response.message}`,
);
}
return response.data;
}
/**
* Build the API route for an operation
*/
private static buildApiRoute(
apiPath: string,
operation: OneUptimeOperation,
id?: string,
): Route {
let fullPath: string = `/api${apiPath}`;
switch (operation) {
case OneUptimeOperation.Read:
if (id) {
fullPath = `/api${apiPath}/${id}/get-item`;
}
}
/**
* Make the actual API request
*/
private static async makeApiRequest(
operation: OneUptimeOperation,
route: Route,
headers: Headers,
data: JSONObject | undefined
): Promise<JSONValue> {
const url: URL = new URL(this.api.protocol, this.api.hostname, route);
const baseOptions: { url: URL; headers: Headers } = { url, headers };
let response: HTTPResponse<JSONObject> | HTTPErrorResponse;
switch (operation) {
case OneUptimeOperation.Create:
case OneUptimeOperation.Count:
case OneUptimeOperation.List:
case OneUptimeOperation.Read:
response = await API.post(
data ? { ...baseOptions, data } : baseOptions
);
break;
case OneUptimeOperation.Update:
response = await API.put(
data ? { ...baseOptions, data } : baseOptions
);
break;
case OneUptimeOperation.Delete:
response = await API.delete(
data ? { ...baseOptions, data } : baseOptions
);
break;
default:
throw new Error(`Unsupported operation: ${operation}`);
break;
case OneUptimeOperation.Update:
case OneUptimeOperation.Delete:
if (id) {
fullPath = `/api${apiPath}/${id}/`;
}
break;
case OneUptimeOperation.Count:
fullPath = `/api${apiPath}/count`;
break;
case OneUptimeOperation.List:
fullPath = `/api${apiPath}/get-list`;
break;
case OneUptimeOperation.Create:
default:
fullPath = `/api${apiPath}`;
break;
}
if (response instanceof HTTPErrorResponse) {
throw new Error(
`API request failed: ${response.statusCode} - ${response.message}`
);
return new Route(fullPath);
}
/**
* Build request data based on operation type
*/
private static buildRequestData(
operation: OneUptimeOperation,
args: OneUptimeToolCallArgs,
tableName: string,
modelType: ModelType,
): JSONObject | undefined {
MCPLogger.info(
`Preparing request data for operation: ${operation}, tableName: ${tableName}`,
);
switch (operation) {
case OneUptimeOperation.Create:
return this.buildCreateData(args);
case OneUptimeOperation.Update:
return this.buildUpdateData(args);
case OneUptimeOperation.List:
case OneUptimeOperation.Count:
return this.buildQueryData(args, tableName, modelType);
case OneUptimeOperation.Read:
return this.buildReadData(args, tableName, modelType);
case OneUptimeOperation.Delete:
default:
return undefined;
}
}
private static buildCreateData(args: OneUptimeToolCallArgs): JSONObject {
const createData: JSONObject = {};
const reservedFields = ["id", "query", "select", "skip", "limit", "sort"];
for (const [key, value] of Object.entries(args)) {
if (!reservedFields.includes(key)) {
createData[key] = value as JSONValue;
}
}
return { data: createData } as JSONObject;
}
private static buildUpdateData(args: OneUptimeToolCallArgs): JSONObject {
const updateData: JSONObject = {};
const reservedFields = ["id", "query", "select", "skip", "limit", "sort"];
for (const [key, value] of Object.entries(args)) {
if (!reservedFields.includes(key)) {
updateData[key] = value as JSONValue;
}
}
return { data: updateData } as JSONObject;
}
private static buildQueryData(
args: OneUptimeToolCallArgs,
tableName: string,
modelType: ModelType,
): JSONObject {
const generatedSelect: JSONObject =
args.select || generateAllFieldsSelect(tableName, modelType);
const requestData: JSONObject = {
query: args.query || {},
select: generatedSelect,
skip: args.skip,
limit: args.limit,
sort: args.sort,
} as JSONObject;
MCPLogger.info(`Request data: ${JSON.stringify(requestData, null, 2)}`);
return requestData;
}
private static buildReadData(
args: OneUptimeToolCallArgs,
tableName: string,
modelType: ModelType,
): JSONObject {
const readSelect: JSONObject =
args.select || generateAllFieldsSelect(tableName, modelType);
const readRequestData: JSONObject = {
select: readSelect,
} as JSONObject;
MCPLogger.info(
`Request data for Read: ${JSON.stringify(readRequestData, null, 2)}`,
);
return readRequestData;
}
/**
* Build headers for API request
*/
private static buildHeaders(apiKey: string): Headers {
return {
"Content-Type": "application/json",
Accept: "application/json",
APIKey: apiKey,
};
}
/**
* Validate that the service is initialized
*/
private static validateInitialization(): void {
if (!this.api) {
throw new Error(
"OneUptime API Service not initialized. Please call initialize() first.",
);
}
}
/**
* Validate that an API key is provided
*/
private static validateApiKey(apiKey: string): void {
if (!apiKey) {
throw new Error(
"API key is required. Please provide x-api-key header in your request.",
);
}
}
/**
* Validate arguments for a specific operation
*/
public static validateOperationArgs(
operation: OneUptimeOperation,
args: OneUptimeToolCallArgs,
): void {
const reservedFields = ["id", "query", "select", "skip", "limit", "sort"];
switch (operation) {
case OneUptimeOperation.Create: {
const createDataFields = Object.keys(args).filter((key: string) => {
return !reservedFields.includes(key);
});
if (createDataFields.length === 0) {
throw new Error(
"At least one data field is required for create operation",
);
}
return response.data;
}
/**
* Build the API route for an operation
*/
private static buildApiRoute(
apiPath: string,
operation: OneUptimeOperation,
id?: string
): Route {
let fullPath: string = `/api${apiPath}`;
switch (operation) {
case OneUptimeOperation.Read:
if (id) {
fullPath = `/api${apiPath}/${id}/get-item`;
}
break;
case OneUptimeOperation.Update:
case OneUptimeOperation.Delete:
if (id) {
fullPath = `/api${apiPath}/${id}/`;
}
break;
case OneUptimeOperation.Count:
fullPath = `/api${apiPath}/count`;
break;
case OneUptimeOperation.List:
fullPath = `/api${apiPath}/get-list`;
break;
case OneUptimeOperation.Create:
default:
fullPath = `/api${apiPath}`;
break;
break;
}
case OneUptimeOperation.Read:
case OneUptimeOperation.Delete:
if (!args.id) {
throw new Error(`ID is required for ${operation} operation`);
}
return new Route(fullPath);
}
/**
* Build request data based on operation type
*/
private static buildRequestData(
operation: OneUptimeOperation,
args: OneUptimeToolCallArgs,
tableName: string,
modelType: ModelType
): JSONObject | undefined {
MCPLogger.info(
`Preparing request data for operation: ${operation}, tableName: ${tableName}`
);
switch (operation) {
case OneUptimeOperation.Create:
return this.buildCreateData(args);
case OneUptimeOperation.Update:
return this.buildUpdateData(args);
case OneUptimeOperation.List:
case OneUptimeOperation.Count:
return this.buildQueryData(args, tableName, modelType);
case OneUptimeOperation.Read:
return this.buildReadData(args, tableName, modelType);
case OneUptimeOperation.Delete:
default:
return undefined;
break;
case OneUptimeOperation.Update: {
if (!args.id) {
throw new Error(`ID is required for ${operation} operation`);
}
}
private static buildCreateData(args: OneUptimeToolCallArgs): JSONObject {
const createData: JSONObject = {};
const reservedFields = ["id", "query", "select", "skip", "limit", "sort"];
for (const [key, value] of Object.entries(args)) {
if (!reservedFields.includes(key)) {
createData[key] = value as JSONValue;
}
}
return { data: createData } as JSONObject;
}
private static buildUpdateData(args: OneUptimeToolCallArgs): JSONObject {
const updateData: JSONObject = {};
const reservedFields = ["id", "query", "select", "skip", "limit", "sort"];
for (const [key, value] of Object.entries(args)) {
if (!reservedFields.includes(key)) {
updateData[key] = value as JSONValue;
}
}
return { data: updateData } as JSONObject;
}
private static buildQueryData(
args: OneUptimeToolCallArgs,
tableName: string,
modelType: ModelType
): JSONObject {
const generatedSelect: JSONObject =
args.select || generateAllFieldsSelect(tableName, modelType);
const requestData: JSONObject = {
query: args.query || {},
select: generatedSelect,
skip: args.skip,
limit: args.limit,
sort: args.sort,
} as JSONObject;
MCPLogger.info(`Request data: ${JSON.stringify(requestData, null, 2)}`);
return requestData;
}
private static buildReadData(
args: OneUptimeToolCallArgs,
tableName: string,
modelType: ModelType
): JSONObject {
const readSelect: JSONObject =
args.select || generateAllFieldsSelect(tableName, modelType);
const readRequestData: JSONObject = {
select: readSelect,
} as JSONObject;
MCPLogger.info(
`Request data for Read: ${JSON.stringify(readRequestData, null, 2)}`
);
return readRequestData;
}
/**
* Build headers for API request
*/
private static buildHeaders(apiKey: string): Headers {
return {
"Content-Type": "application/json",
Accept: "application/json",
APIKey: apiKey,
};
}
/**
* Validate that the service is initialized
*/
private static validateInitialization(): void {
if (!this.api) {
throw new Error(
"OneUptime API Service not initialized. Please call initialize() first."
);
}
}
/**
* Validate that an API key is provided
*/
private static validateApiKey(apiKey: string): void {
if (!apiKey) {
throw new Error(
"API key is required. Please provide x-api-key header in your request."
);
}
}
/**
* Validate arguments for a specific operation
*/
public static validateOperationArgs(
operation: OneUptimeOperation,
args: OneUptimeToolCallArgs
): void {
const reservedFields = ["id", "query", "select", "skip", "limit", "sort"];
switch (operation) {
case OneUptimeOperation.Create: {
const createDataFields = Object.keys(args).filter(
(key: string) => !reservedFields.includes(key)
);
if (createDataFields.length === 0) {
throw new Error(
"At least one data field is required for create operation"
);
}
break;
}
case OneUptimeOperation.Read:
case OneUptimeOperation.Delete:
if (!args.id) {
throw new Error(`ID is required for ${operation} operation`);
}
break;
case OneUptimeOperation.Update: {
if (!args.id) {
throw new Error(`ID is required for ${operation} operation`);
}
const updateDataFields = Object.keys(args).filter(
(key: string) => !reservedFields.includes(key)
);
if (updateDataFields.length === 0) {
throw new Error(
"At least one data field is required for update operation"
);
}
break;
}
case OneUptimeOperation.List:
case OneUptimeOperation.Count:
// No required arguments for list/count operations
break;
default:
throw new Error(`Unknown operation: ${operation}`);
const updateDataFields = Object.keys(args).filter((key: string) => {
return !reservedFields.includes(key);
});
if (updateDataFields.length === 0) {
throw new Error(
"At least one data field is required for update operation",
);
}
break;
}
case OneUptimeOperation.List:
case OneUptimeOperation.Count:
// No required arguments for list/count operations
break;
default:
throw new Error(`Unknown operation: ${operation}`);
}
}
}

View File

@@ -20,15 +20,18 @@ type ModelConstructor<T> = new () => T;
// Type for model class with table name
interface ModelWithTableName {
tableName: string;
getColumnAccessControlForAllColumns?: () => Record<string, ColumnAccessControl>;
tableName: string;
getColumnAccessControlForAllColumns?: () => Record<
string,
ColumnAccessControl
>;
}
// Type for column access control
interface ColumnAccessControl {
read?: Permission[];
create?: Permission[];
update?: Permission[];
read?: Permission[];
create?: Permission[];
update?: Permission[];
}
// Type for table columns
@@ -36,253 +39,263 @@ type TableColumns = Record<string, unknown>;
// Type for Zod schema shape
interface ZodSchemaWithShape {
_def?: {
shape?: Record<string, unknown> | (() => Record<string, unknown>);
};
_def?: {
shape?: Record<string, unknown> | (() => Record<string, unknown>);
};
}
/**
* Generate a select object that includes all fields from the select schema
*/
export function generateAllFieldsSelect(
tableName: string,
modelType: ModelType
tableName: string,
modelType: ModelType,
): JSONObject {
MCPLogger.info(
`Generating select for tableName: ${tableName}, modelType: ${modelType}`,
);
try {
const ModelClass = findModelClass(tableName, modelType);
if (!ModelClass) {
MCPLogger.warn(
`Model class not found for ${tableName}, using empty select`,
);
return {};
}
MCPLogger.info(
`Generating select for tableName: ${tableName}, modelType: ${modelType}`
`Found ModelClass: ${(ModelClass as { name: string }).name} for tableName: ${tableName}`,
);
try {
const ModelClass = findModelClass(tableName, modelType);
// Try to get raw table columns first (most reliable approach)
const selectFromColumns: JSONObject | null = generateSelectFromTableColumns(
ModelClass as ModelConstructor<BaseModel>,
tableName,
);
if (!ModelClass) {
MCPLogger.warn(
`Model class not found for ${tableName}, using empty select`
);
return {};
}
MCPLogger.info(
`Found ModelClass: ${(ModelClass as { name: string }).name} for tableName: ${tableName}`
);
// Try to get raw table columns first (most reliable approach)
const selectFromColumns: JSONObject | null = generateSelectFromTableColumns(
ModelClass as ModelConstructor<BaseModel>,
tableName
);
if (selectFromColumns && Object.keys(selectFromColumns).length > 0) {
return selectFromColumns;
}
// Fallback to schema approach if table columns fail
return generateSelectFromSchema(ModelClass, tableName, modelType);
} catch (error) {
MCPLogger.error(`Error generating select for ${tableName}: ${error}`);
return getDefaultSelect();
if (selectFromColumns && Object.keys(selectFromColumns).length > 0) {
return selectFromColumns;
}
// Fallback to schema approach if table columns fail
return generateSelectFromSchema(ModelClass, tableName, modelType);
} catch (error) {
MCPLogger.error(`Error generating select for ${tableName}: ${error}`);
return getDefaultSelect();
}
}
/**
* Find the model class by table name
*/
function findModelClass(
tableName: string,
modelType: ModelType
tableName: string,
modelType: ModelType,
): ModelConstructor<BaseModel> | ModelConstructor<AnalyticsBaseModel> | null {
if (modelType === ModelType.Database) {
MCPLogger.info(`Searching DatabaseModels for tableName: ${tableName}`);
return (
(DatabaseModels.find((Model: ModelConstructor<BaseModel>): boolean => {
try {
const instance: ModelWithTableName =
new Model() as unknown as ModelWithTableName;
const instanceTableName: string = instance.tableName;
MCPLogger.info(
`Checking model ${Model.name} with tableName: ${instanceTableName}`
);
return instanceTableName === tableName;
} catch (error) {
MCPLogger.warn(`Error instantiating model ${Model.name}: ${error}`);
return false;
}
}) as ModelConstructor<BaseModel> | undefined) || null
);
}
if (modelType === ModelType.Database) {
MCPLogger.info(`Searching DatabaseModels for tableName: ${tableName}`);
return (
(DatabaseModels.find((Model: ModelConstructor<BaseModel>): boolean => {
try {
const instance: ModelWithTableName =
new Model() as unknown as ModelWithTableName;
const instanceTableName: string = instance.tableName;
MCPLogger.info(
`Checking model ${Model.name} with tableName: ${instanceTableName}`,
);
return instanceTableName === tableName;
} catch (error) {
MCPLogger.warn(`Error instantiating model ${Model.name}: ${error}`);
return false;
}
}) as ModelConstructor<BaseModel> | undefined) || null
);
}
if (modelType === ModelType.Analytics) {
MCPLogger.info(`Searching AnalyticsModels for tableName: ${tableName}`);
return (
(AnalyticsModels.find(
(Model: ModelConstructor<AnalyticsBaseModel>): boolean => {
try {
const instance: ModelWithTableName =
new Model() as unknown as ModelWithTableName;
return instance.tableName === tableName;
} catch (error) {
MCPLogger.warn(
`Error instantiating analytics model ${Model.name}: ${error}`
);
return false;
}
}
) as ModelConstructor<AnalyticsBaseModel> | undefined) || null
);
}
if (modelType === ModelType.Analytics) {
MCPLogger.info(`Searching AnalyticsModels for tableName: ${tableName}`);
return (
(AnalyticsModels.find(
(Model: ModelConstructor<AnalyticsBaseModel>): boolean => {
try {
const instance: ModelWithTableName =
new Model() as unknown as ModelWithTableName;
return instance.tableName === tableName;
} catch (error) {
MCPLogger.warn(
`Error instantiating analytics model ${Model.name}: ${error}`,
);
return false;
}
},
) as ModelConstructor<AnalyticsBaseModel> | undefined) || null
);
}
return null;
return null;
}
/**
* Generate select object from table columns
*/
function generateSelectFromTableColumns(
ModelClass: ModelConstructor<BaseModel>,
tableName: string
ModelClass: ModelConstructor<BaseModel>,
tableName: string,
): JSONObject | null {
try {
const modelInstance: ModelWithTableName =
new ModelClass() as unknown as ModelWithTableName;
const tableColumns: TableColumns = getTableColumns(modelInstance as BaseModel);
const columnNames: string[] = Object.keys(tableColumns);
try {
const modelInstance: ModelWithTableName =
new ModelClass() as unknown as ModelWithTableName;
const tableColumns: TableColumns = getTableColumns(
modelInstance as BaseModel,
);
const columnNames: string[] = Object.keys(tableColumns);
MCPLogger.info(
`Raw table columns (${columnNames.length}): ${columnNames.slice(0, 10).join(", ")}`
);
MCPLogger.info(
`Raw table columns (${columnNames.length}): ${columnNames.slice(0, 10).join(", ")}`,
);
if (columnNames.length === 0) {
return null;
}
// Get access control information to filter out restricted fields
const accessControlForColumns: Record<string, ColumnAccessControl> =
modelInstance.getColumnAccessControlForAllColumns
? modelInstance.getColumnAccessControlForAllColumns()
: {};
const selectObject: JSONObject = {};
let filteredCount: number = 0;
for (const columnName of columnNames) {
if (shouldIncludeField(columnName, accessControlForColumns)) {
selectObject[columnName] = true;
} else {
filteredCount++;
MCPLogger.info(`Filtered out restricted field: ${columnName}`);
}
}
MCPLogger.info(
`Generated select from table columns for ${tableName} with ${Object.keys(selectObject).length} fields (filtered out ${filteredCount} restricted fields)`
);
// Ensure we have at least some basic fields
if (Object.keys(selectObject).length === 0) {
MCPLogger.warn(`All fields were filtered out, adding safe basic fields`);
return getDefaultSelect();
}
return selectObject;
} catch (tableColumnError) {
MCPLogger.warn(
`Failed to get table columns for ${tableName}: ${tableColumnError}`
);
return null;
if (columnNames.length === 0) {
return null;
}
// Get access control information to filter out restricted fields
const accessControlForColumns: Record<string, ColumnAccessControl> =
modelInstance.getColumnAccessControlForAllColumns
? modelInstance.getColumnAccessControlForAllColumns()
: {};
const selectObject: JSONObject = {};
let filteredCount: number = 0;
for (const columnName of columnNames) {
if (shouldIncludeField(columnName, accessControlForColumns)) {
selectObject[columnName] = true;
} else {
filteredCount++;
MCPLogger.info(`Filtered out restricted field: ${columnName}`);
}
}
MCPLogger.info(
`Generated select from table columns for ${tableName} with ${Object.keys(selectObject).length} fields (filtered out ${filteredCount} restricted fields)`,
);
// Ensure we have at least some basic fields
if (Object.keys(selectObject).length === 0) {
MCPLogger.warn(`All fields were filtered out, adding safe basic fields`);
return getDefaultSelect();
}
return selectObject;
} catch (tableColumnError) {
MCPLogger.warn(
`Failed to get table columns for ${tableName}: ${tableColumnError}`,
);
return null;
}
}
/**
* Check if a field should be included based on access control
*/
function shouldIncludeField(
columnName: string,
accessControlForColumns: Record<string, ColumnAccessControl>
columnName: string,
accessControlForColumns: Record<string, ColumnAccessControl>,
): boolean {
const accessControl: ColumnAccessControl | undefined =
accessControlForColumns[columnName];
const accessControl: ColumnAccessControl | undefined =
accessControlForColumns[columnName];
// Include the field if:
// 1. No access control defined (open access)
// 2. Has read permissions that are not empty
// 3. Read permissions don't only contain Permission.CurrentUser
return (
!accessControl ||
(accessControl.read !== undefined &&
accessControl.read.length > 0 &&
!(
accessControl.read.length === 1 &&
accessControl.read[0] === Permission.CurrentUser
))
);
/*
* Include the field if:
* 1. No access control defined (open access)
* 2. Has read permissions that are not empty
* 3. Read permissions don't only contain Permission.CurrentUser
*/
return (
!accessControl ||
(accessControl.read !== undefined &&
accessControl.read.length > 0 &&
!(
accessControl.read.length === 1 &&
accessControl.read[0] === Permission.CurrentUser
))
);
}
/**
* Generate select object from model schema (fallback)
*/
function generateSelectFromSchema(
ModelClass: ModelConstructor<BaseModel> | ModelConstructor<AnalyticsBaseModel>,
tableName: string,
modelType: ModelType
ModelClass:
| ModelConstructor<BaseModel>
| ModelConstructor<AnalyticsBaseModel>,
tableName: string,
modelType: ModelType,
): JSONObject {
let selectSchema: ZodSchemaWithShape;
if (modelType === ModelType.Database) {
MCPLogger.info(`Generating select schema for database model: ${(ModelClass as { name: string }).name}`);
selectSchema = ModelSchema.getSelectModelSchema({
modelType: ModelClass as ModelConstructor<BaseModel>,
}) as ZodSchemaWithShape;
} else {
MCPLogger.info(`Generating schema for analytics model: ${(ModelClass as { name: string }).name}`);
selectSchema = AnalyticsModelSchema.getModelSchema({
modelType: ModelClass as ModelConstructor<AnalyticsBaseModel>,
}) as ZodSchemaWithShape;
}
// Extract field names from the schema
const selectObject: JSONObject = {};
const rawShape:
| Record<string, unknown>
| (() => Record<string, unknown>)
| undefined = selectSchema._def?.shape;
// Handle both function and object shapes
const shape: Record<string, unknown> | undefined =
typeof rawShape === "function" ? rawShape() : rawShape;
MCPLogger.info(`Schema shape keys: ${shape ? Object.keys(shape).length : 0}`);
if (shape) {
const fieldNames: string[] = Object.keys(shape);
MCPLogger.info(
`Available fields: ${fieldNames.slice(0, 10).join(", ")}${fieldNames.length > 10 ? "..." : ""}`
);
for (const fieldName of fieldNames) {
selectObject[fieldName] = true;
}
}
let selectSchema: ZodSchemaWithShape;
if (modelType === ModelType.Database) {
MCPLogger.info(
`Generated select for ${tableName} with ${Object.keys(selectObject).length} fields`
`Generating select schema for database model: ${(ModelClass as { name: string }).name}`,
);
selectSchema = ModelSchema.getSelectModelSchema({
modelType: ModelClass as ModelConstructor<BaseModel>,
}) as ZodSchemaWithShape;
} else {
MCPLogger.info(
`Generating schema for analytics model: ${(ModelClass as { name: string }).name}`,
);
selectSchema = AnalyticsModelSchema.getModelSchema({
modelType: ModelClass as ModelConstructor<AnalyticsBaseModel>,
}) as ZodSchemaWithShape;
}
// Extract field names from the schema
const selectObject: JSONObject = {};
const rawShape:
| Record<string, unknown>
| (() => Record<string, unknown>)
| undefined = selectSchema._def?.shape;
// Handle both function and object shapes
const shape: Record<string, unknown> | undefined =
typeof rawShape === "function" ? rawShape() : rawShape;
MCPLogger.info(`Schema shape keys: ${shape ? Object.keys(shape).length : 0}`);
if (shape) {
const fieldNames: string[] = Object.keys(shape);
MCPLogger.info(
`Available fields: ${fieldNames.slice(0, 10).join(", ")}${fieldNames.length > 10 ? "..." : ""}`,
);
// Force include some basic fields if select is empty
if (Object.keys(selectObject).length === 0) {
MCPLogger.warn(`No fields found, adding basic fields for ${tableName}`);
return getDefaultSelect();
for (const fieldName of fieldNames) {
selectObject[fieldName] = true;
}
}
return selectObject;
MCPLogger.info(
`Generated select for ${tableName} with ${Object.keys(selectObject).length} fields`,
);
// Force include some basic fields if select is empty
if (Object.keys(selectObject).length === 0) {
MCPLogger.warn(`No fields found, adding basic fields for ${tableName}`);
return getDefaultSelect();
}
return selectObject;
}
/**
* Get default select fields
*/
function getDefaultSelect(): JSONObject {
return {
_id: true,
createdAt: true,
updatedAt: true,
};
return {
_id: true,
createdAt: true,
updatedAt: true,
};
}

View File

@@ -9,224 +9,232 @@ import { AnalyticsModelSchemaType } from "Common/Utils/Schema/AnalyticsModelSche
// Type for Zod field definition
interface ZodFieldDef {
typeName?: string;
innerType?: ZodField;
description?: string;
openapi?: {
metadata?: OpenApiMetadata;
};
typeName?: string;
innerType?: ZodField;
description?: string;
openapi?: {
metadata?: OpenApiMetadata;
};
}
// Type for Zod field
interface ZodField {
_def?: ZodFieldDef;
_def?: ZodFieldDef;
}
// Type for OpenAPI metadata
interface OpenApiMetadata {
type?: string;
description?: string;
example?: unknown;
format?: string;
default?: unknown;
items?: JSONSchemaProperty;
type?: string;
description?: string;
example?: unknown;
format?: string;
default?: unknown;
items?: JSONSchemaProperty;
}
// Type for Zod schema with shape
interface ZodSchemaWithShape {
_def?: {
shape?: () => Record<string, ZodField>;
};
_def?: {
shape?: () => Record<string, ZodField>;
};
}
// Result type for schema conversion
export interface ZodToJsonSchemaResult {
type: string;
properties: Record<string, JSONSchemaProperty>;
required?: string[];
additionalProperties: boolean;
type: string;
properties: Record<string, JSONSchemaProperty>;
required?: string[];
additionalProperties: boolean;
}
/**
* Convert a Zod schema to JSON Schema format for MCP tools
*/
export function zodToJsonSchema(
zodSchema: ModelSchemaType | AnalyticsModelSchemaType
zodSchema: ModelSchemaType | AnalyticsModelSchemaType,
): ZodToJsonSchemaResult {
try {
const schemaWithShape: ZodSchemaWithShape = zodSchema as unknown as ZodSchemaWithShape;
const shapeFunction: (() => Record<string, ZodField>) | undefined =
schemaWithShape._def?.shape;
try {
const schemaWithShape: ZodSchemaWithShape =
zodSchema as unknown as ZodSchemaWithShape;
const shapeFunction: (() => Record<string, ZodField>) | undefined =
schemaWithShape._def?.shape;
if (!shapeFunction) {
return createEmptySchema();
}
const shape: Record<string, ZodField> = shapeFunction();
const properties: Record<string, JSONSchemaProperty> = {};
const required: string[] = [];
for (const [key, value] of Object.entries(shape)) {
const { property, isRequired } = convertZodField(key, value);
properties[key] = property;
if (isRequired) {
required.push(key);
}
}
const result: ZodToJsonSchemaResult = {
type: "object",
properties,
additionalProperties: false,
};
if (required.length > 0) {
result.required = required;
}
return result;
} catch {
return createEmptySchema();
if (!shapeFunction) {
return createEmptySchema();
}
const shape: Record<string, ZodField> = shapeFunction();
const properties: Record<string, JSONSchemaProperty> = {};
const required: string[] = [];
for (const [key, value] of Object.entries(shape)) {
const { property, isRequired } = convertZodField(key, value);
properties[key] = property;
if (isRequired) {
required.push(key);
}
}
const result: ZodToJsonSchemaResult = {
type: "object",
properties,
additionalProperties: false,
};
if (required.length > 0) {
result.required = required;
}
return result;
} catch {
return createEmptySchema();
}
}
/**
* Convert a single Zod field to JSON Schema property
*/
function convertZodField(
key: string,
zodField: ZodField
key: string,
zodField: ZodField,
): { property: JSONSchemaProperty; isRequired: boolean } {
// Handle ZodOptional fields by looking at the inner type
let actualField: ZodField = zodField;
let isOptional: boolean = false;
// Handle ZodOptional fields by looking at the inner type
let actualField: ZodField = zodField;
let isOptional: boolean = false;
if (zodField._def?.typeName === "ZodOptional") {
actualField = zodField._def.innerType || zodField;
isOptional = true;
}
if (zodField._def?.typeName === "ZodOptional") {
actualField = zodField._def.innerType || zodField;
isOptional = true;
}
// Extract OpenAPI metadata
const openApiMetadata: OpenApiMetadata | undefined =
actualField._def?.openapi?.metadata || zodField._def?.openapi?.metadata;
// Extract OpenAPI metadata
const openApiMetadata: OpenApiMetadata | undefined =
actualField._def?.openapi?.metadata || zodField._def?.openapi?.metadata;
// Clean up description
const rawDescription: string =
zodField._def?.description || openApiMetadata?.description || `${key} field`;
const cleanDescription: string = cleanFieldDescription(rawDescription);
// Clean up description
const rawDescription: string =
zodField._def?.description ||
openApiMetadata?.description ||
`${key} field`;
const cleanDescription: string = cleanFieldDescription(rawDescription);
let property: JSONSchemaProperty;
let property: JSONSchemaProperty;
if (openApiMetadata) {
property = buildPropertyFromMetadata(openApiMetadata, key, cleanDescription);
} else {
// Fallback for fields without OpenAPI metadata
property = {
type: "string",
description: cleanDescription,
};
}
return {
property,
isRequired: !isOptional,
if (openApiMetadata) {
property = buildPropertyFromMetadata(
openApiMetadata,
key,
cleanDescription,
);
} else {
// Fallback for fields without OpenAPI metadata
property = {
type: "string",
description: cleanDescription,
};
}
return {
property,
isRequired: !isOptional,
};
}
/**
* Build JSON Schema property from OpenAPI metadata
*/
function buildPropertyFromMetadata(
metadata: OpenApiMetadata,
key: string,
description: string
metadata: OpenApiMetadata,
key: string,
description: string,
): JSONSchemaProperty {
const property: JSONSchemaProperty = {
type: metadata.type || "string",
description,
const property: JSONSchemaProperty = {
type: metadata.type || "string",
description,
};
// Add optional fields if present
if (metadata.example !== undefined) {
(property as JSONSchemaProperty & { example: unknown }).example =
metadata.example;
}
if (metadata.format) {
property.format = metadata.format;
}
if (metadata.default !== undefined) {
property.default = metadata.default;
}
// Handle array types
if (metadata.type === "array") {
property.items = metadata.items || {
type: "string",
description: `${key} item`,
};
}
// Add optional fields if present
if (metadata.example !== undefined) {
(property as JSONSchemaProperty & { example: unknown }).example = metadata.example;
}
if (metadata.format) {
property.format = metadata.format;
}
if (metadata.default !== undefined) {
property.default = metadata.default;
}
// Handle array types
if (metadata.type === "array") {
property.items = metadata.items || {
type: "string",
description: `${key} item`,
};
}
return property;
return property;
}
/**
* Clean up description by removing permission information
*/
export function cleanFieldDescription(description: string): string {
if (!description) {
return description;
}
// Remove everything after ". Permissions -"
const permissionsIndex: number = description.indexOf(". Permissions -");
if (permissionsIndex !== -1) {
const beforeText: string = description.substring(0, permissionsIndex);
return addPeriodIfNeeded(beforeText);
}
// Handle cases where it starts with "Permissions -" without a preceding sentence
const permissionsStartIndex: number = description.indexOf("Permissions -");
if (permissionsStartIndex !== -1) {
const beforePermissions: string = description
.substring(0, permissionsStartIndex)
.trim();
if (beforePermissions && beforePermissions.length > 0) {
return addPeriodIfNeeded(beforePermissions);
}
}
if (!description) {
return description;
}
// Remove everything after ". Permissions -"
const permissionsIndex: number = description.indexOf(". Permissions -");
if (permissionsIndex !== -1) {
const beforeText: string = description.substring(0, permissionsIndex);
return addPeriodIfNeeded(beforeText);
}
// Handle cases where it starts with "Permissions -" without a preceding sentence
const permissionsStartIndex: number = description.indexOf("Permissions -");
if (permissionsStartIndex !== -1) {
const beforePermissions: string = description
.substring(0, permissionsStartIndex)
.trim();
if (beforePermissions && beforePermissions.length > 0) {
return addPeriodIfNeeded(beforePermissions);
}
}
return description;
}
/**
* Add period to text if it doesn't end with punctuation
*/
function addPeriodIfNeeded(text: string): string {
if (!text) {
return text;
}
if (!text) {
return text;
}
const punctuation: string[] = [".", "!", "?"];
const lastChar: string = text.charAt(text.length - 1);
const punctuation: string[] = [".", "!", "?"];
const lastChar: string = text.charAt(text.length - 1);
if (punctuation.includes(lastChar)) {
return text;
}
if (punctuation.includes(lastChar)) {
return text;
}
return text + ".";
return text + ".";
}
/**
* Create an empty schema result
*/
function createEmptySchema(): ZodToJsonSchemaResult {
return {
type: "object",
properties: {},
additionalProperties: false,
};
return {
type: "object",
properties: {},
additionalProperties: false,
};
}
/**
@@ -234,16 +242,16 @@ function createEmptySchema(): ZodToJsonSchemaResult {
* MCP tool names can only contain [a-z0-9_-]
*/
export function sanitizeToolName(name: string): string {
return (
name
// Convert camelCase to snake_case
.replace(/([a-z])([A-Z])/g, "$1_$2")
.toLowerCase()
// Replace non-alphanumeric characters with underscores
.replace(/[^a-z0-9]/g, "_")
// Replace multiple consecutive underscores with single underscore
.replace(/_+/g, "_")
// Remove leading/trailing underscores
.replace(/^_|_$/g, "")
);
return (
name
// Convert camelCase to snake_case
.replace(/([a-z])([A-Z])/g, "$1_$2")
.toLowerCase()
// Replace non-alphanumeric characters with underscores
.replace(/[^a-z0-9]/g, "_")
// Replace multiple consecutive underscores with single underscore
.replace(/_+/g, "_")
// Remove leading/trailing underscores
.replace(/^_|_$/g, "")
);
}

View File

@@ -11,128 +11,130 @@ jest.mock("../Tools/ToolGenerator");
jest.mock("../Utils/MCPLogger");
describe("OneUptime MCP Server", () => {
beforeEach(() => {
jest.clearAllMocks();
process.env["ONEUPTIME_API_KEY"] = "test-api-key";
process.env["ONEUPTIME_URL"] = "https://test.oneuptime.com";
beforeEach(() => {
jest.clearAllMocks();
process.env["ONEUPTIME_API_KEY"] = "test-api-key";
process.env["ONEUPTIME_URL"] = "https://test.oneuptime.com";
});
describe("Server Initialization", () => {
it("should initialize with proper configuration", () => {
const mockTools: McpToolInfo[] = [
{
name: "create_project",
description: "Create a new project",
inputSchema: { type: "object", properties: {} },
modelName: "Project",
operation: OneUptimeOperation.Create,
modelType: ModelType.Database,
singularName: "project",
pluralName: "projects",
tableName: "Project",
apiPath: "/api/project",
},
];
(ToolGenerator.generateAllTools as jest.Mock).mockReturnValue(mockTools);
(OneUptimeApiService.initialize as jest.Mock).mockImplementation(
() => {},
);
// Call the mocked functions to simulate server initialization
ToolGenerator.generateAllTools();
OneUptimeApiService.initialize({
url: "https://test.oneuptime.com",
apiKey: "test-api-key",
});
// Test that the functions were called
expect(ToolGenerator.generateAllTools).toHaveBeenCalled();
expect(OneUptimeApiService.initialize).toHaveBeenCalledWith({
url: "https://test.oneuptime.com",
apiKey: "test-api-key",
});
});
describe("Server Initialization", () => {
it("should initialize with proper configuration", () => {
const mockTools: McpToolInfo[] = [
{
name: "create_project",
description: "Create a new project",
inputSchema: { type: "object", properties: {} },
modelName: "Project",
operation: OneUptimeOperation.Create,
modelType: ModelType.Database,
singularName: "project",
pluralName: "projects",
tableName: "Project",
apiPath: "/api/project",
},
];
it("should throw error when API key is missing", () => {
// Mock the service to throw error for missing API key
(OneUptimeApiService.initialize as jest.Mock).mockImplementation(
(config: unknown) => {
const typedConfig = config as { url: string; apiKey: string };
if (!typedConfig.apiKey) {
throw new Error("OneUptime API key is required");
}
},
);
(ToolGenerator.generateAllTools as jest.Mock).mockReturnValue(mockTools);
(OneUptimeApiService.initialize as jest.Mock).mockImplementation(() => {});
// Call the mocked functions to simulate server initialization
ToolGenerator.generateAllTools();
OneUptimeApiService.initialize({
url: "https://test.oneuptime.com",
apiKey: "test-api-key",
});
// Test that the functions were called
expect(ToolGenerator.generateAllTools).toHaveBeenCalled();
expect(OneUptimeApiService.initialize).toHaveBeenCalledWith({
url: "https://test.oneuptime.com",
apiKey: "test-api-key",
});
expect(() => {
OneUptimeApiService.initialize({
url: "https://test.oneuptime.com",
apiKey: "",
});
}).toThrow("OneUptime API key is required");
});
});
it("should throw error when API key is missing", () => {
// Mock the service to throw error for missing API key
(OneUptimeApiService.initialize as jest.Mock).mockImplementation(
(config: unknown) => {
const typedConfig = config as { url: string; apiKey: string };
if (!typedConfig.apiKey) {
throw new Error("OneUptime API key is required");
}
}
);
describe("Tool Management", () => {
it("should generate tools correctly", () => {
const mockTools: McpToolInfo[] = [
{
name: "create_monitor",
description: "Create a new monitor",
inputSchema: {
type: "object",
properties: {
name: { type: "string" },
url: { type: "string" },
},
required: ["name", "url"],
},
modelName: "Monitor",
operation: OneUptimeOperation.Create,
modelType: ModelType.Database,
singularName: "monitor",
pluralName: "monitors",
tableName: "Monitor",
apiPath: "/api/monitor",
},
{
name: "list_projects",
description: "List all projects",
inputSchema: {
type: "object",
properties: {
limit: { type: "number" },
skip: { type: "number" },
},
},
modelName: "Project",
operation: OneUptimeOperation.List,
modelType: ModelType.Database,
singularName: "project",
pluralName: "projects",
tableName: "Project",
apiPath: "/api/project",
},
];
expect(() => {
OneUptimeApiService.initialize({
url: "https://test.oneuptime.com",
apiKey: "",
});
}).toThrow("OneUptime API key is required");
});
(ToolGenerator.generateAllTools as jest.Mock).mockReturnValue(mockTools);
const tools: McpToolInfo[] = ToolGenerator.generateAllTools();
expect(tools).toHaveLength(2);
expect(tools[0]?.name).toBe("create_monitor");
expect(tools[1]?.name).toBe("list_projects");
expect(tools[0]?.operation).toBe(OneUptimeOperation.Create);
expect(tools[1]?.operation).toBe(OneUptimeOperation.List);
});
describe("Tool Management", () => {
it("should generate tools correctly", () => {
const mockTools: McpToolInfo[] = [
{
name: "create_monitor",
description: "Create a new monitor",
inputSchema: {
type: "object",
properties: {
name: { type: "string" },
url: { type: "string" },
},
required: ["name", "url"],
},
modelName: "Monitor",
operation: OneUptimeOperation.Create,
modelType: ModelType.Database,
singularName: "monitor",
pluralName: "monitors",
tableName: "Monitor",
apiPath: "/api/monitor",
},
{
name: "list_projects",
description: "List all projects",
inputSchema: {
type: "object",
properties: {
limit: { type: "number" },
skip: { type: "number" },
},
},
modelName: "Project",
operation: OneUptimeOperation.List,
modelType: ModelType.Database,
singularName: "project",
pluralName: "projects",
tableName: "Project",
apiPath: "/api/project",
},
];
it("should handle tool generation errors", () => {
(ToolGenerator.generateAllTools as jest.Mock).mockImplementation(() => {
throw new Error("Failed to generate tools");
});
(ToolGenerator.generateAllTools as jest.Mock).mockReturnValue(mockTools);
const tools: McpToolInfo[] = ToolGenerator.generateAllTools();
expect(tools).toHaveLength(2);
expect(tools[0]?.name).toBe("create_monitor");
expect(tools[1]?.name).toBe("list_projects");
expect(tools[0]?.operation).toBe(OneUptimeOperation.Create);
expect(tools[1]?.operation).toBe(OneUptimeOperation.List);
});
it("should handle tool generation errors", () => {
(ToolGenerator.generateAllTools as jest.Mock).mockImplementation(() => {
throw new Error("Failed to generate tools");
});
expect(() => {
ToolGenerator.generateAllTools();
}).toThrow("Failed to generate tools");
});
expect(() => {
ToolGenerator.generateAllTools();
}).toThrow("Failed to generate tools");
});
});
});