refactor: Improve type definitions and enhance JSON schema handling in MCP services

This commit is contained in:
Nawaz Dhandala
2025-12-16 11:42:56 +00:00
parent 2fb8239fe9
commit 9b714bbe29
4 changed files with 226 additions and 84 deletions

View File

@@ -4,6 +4,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import {
CallToolRequestSchema,
CallToolRequest,
ErrorCode,
ListToolsRequestSchema,
McpError,
@@ -106,8 +107,7 @@ function setupHandlers(): void {
// Handle tool calls
mcpServer.setRequestHandler(
CallToolRequestSchema,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async (request: any) => {
async (request: CallToolRequest) => {
const { name, arguments: args } = request.params;
try {

View File

@@ -8,7 +8,7 @@ import Route from "Common/Types/API/Route";
import Headers from "Common/Types/API/Headers";
import HTTPResponse from "Common/Types/API/HTTPResponse";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import { JSONObject } from "Common/Types/JSON";
import { JSONObject, JSONValue } from "Common/Types/JSON";
import DatabaseModels from "Common/Models/DatabaseModels/Index";
import AnalyticsModels from "Common/Models/AnalyticsModels/Index";
import { ModelSchema } from "Common/Utils/Schema/ModelSchema";
@@ -17,12 +17,43 @@ import { getTableColumns } from "Common/Types/Database/TableColumn";
import Permission from "Common/Types/Permission";
import Protocol from "Common/Types/API/Protocol";
import Hostname from "Common/Types/API/Hostname";
import BaseModel from "Common/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
import AnalyticsBaseModel from "Common/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel";
export interface OneUptimeApiConfig {
url: string;
apiKey: string;
}
// Type for model constructor
type ModelConstructor<T> = new () => T;
// Type for model class with table name
interface ModelWithTableName {
tableName: string;
getColumnAccessControlForAllColumns?: () => Record<
string,
ColumnAccessControl
>;
}
// Type for column access control
interface ColumnAccessControl {
read?: Permission[];
create?: Permission[];
update?: Permission[];
}
// Type for table columns
type TableColumns = Record<string, unknown>;
// Type for Zod schema shape (shape can be an object or a function returning an object)
interface ZodSchemaWithShape {
_def?: {
shape?: Record<string, unknown> | (() => Record<string, unknown>);
};
}
export default class OneUptimeApiService {
private static api: API;
private static config: OneUptimeApiConfig;
@@ -60,7 +91,7 @@ export default class OneUptimeApiService {
modelType: ModelType,
apiPath: string,
args: OneUptimeToolCallArgs,
): Promise<any> {
): Promise<JSONValue> {
if (!this.api) {
throw new Error(
"OneUptime API Service not initialized. Please call initialize() first.",
@@ -69,9 +100,9 @@ export default class OneUptimeApiService {
this.validateOperationArgs(operation, args);
const route: any = this.buildApiRoute(apiPath, operation, args.id);
const headers: any = this.getHeaders();
const data: any = this.getRequestData(
const route: Route = this.buildApiRoute(apiPath, operation, args.id);
const headers: Headers = this.getHeaders();
const data: JSONObject | undefined = this.getRequestData(
operation,
args,
tableName,
@@ -83,35 +114,35 @@ export default class OneUptimeApiService {
);
try {
let response: HTTPResponse<any> | HTTPErrorResponse;
let response: HTTPResponse<JSONObject> | HTTPErrorResponse;
// Create a direct URL to avoid base route accumulation
const url: URL = new URL(this.api.protocol, this.api.hostname, route);
// Build request options, only including data if it's defined
const baseOptions: { url: URL; headers: Headers } = {
url: url,
headers: headers,
};
switch (operation) {
case OneUptimeOperation.Create:
case OneUptimeOperation.Count:
case OneUptimeOperation.List:
case OneUptimeOperation.Read:
response = await API.post({
url: url,
data: data,
headers: headers,
});
response = await API.post(
data ? { ...baseOptions, data: data } : baseOptions,
);
break;
case OneUptimeOperation.Update:
response = await API.put({
url: url,
data: data,
headers: headers,
});
response = await API.put(
data ? { ...baseOptions, data: data } : baseOptions,
);
break;
case OneUptimeOperation.Delete:
response = await API.delete({
url: url,
data: data,
headers: headers,
});
response = await API.delete(
data ? { ...baseOptions, data: data } : baseOptions,
);
break;
default:
throw new Error(`Unsupported operation: ${operation}`);
@@ -190,7 +221,7 @@ export default class OneUptimeApiService {
if (
!["id", "query", "select", "skip", "limit", "sort"].includes(key)
) {
createData[key] = value;
createData[key] = value as JSONValue;
}
}
return { data: createData } as JSONObject;
@@ -202,14 +233,14 @@ export default class OneUptimeApiService {
if (
!["id", "query", "select", "skip", "limit", "sort"].includes(key)
) {
updateData[key] = value;
updateData[key] = value as JSONValue;
}
}
return { data: updateData } as JSONObject;
}
case OneUptimeOperation.List:
case OneUptimeOperation.Count: {
const generatedSelect: any =
const generatedSelect: JSONObject =
args.select || this.generateAllFieldsSelect(tableName, modelType);
const requestData: JSONObject = {
query: args.query || {},
@@ -225,7 +256,7 @@ export default class OneUptimeApiService {
return requestData;
}
case OneUptimeOperation.Read: {
const readSelect: any =
const readSelect: JSONObject =
args.select || this.generateAllFieldsSelect(tableName, modelType);
const readRequestData: JSONObject = {
select: readSelect,
@@ -254,37 +285,50 @@ export default class OneUptimeApiService {
);
try {
let ModelClass: any = null;
let ModelClass:
| ModelConstructor<BaseModel>
| ModelConstructor<AnalyticsBaseModel>
| null = null;
// Find the model class by table name
if (modelType === ModelType.Database) {
MCPLogger.info(`Searching DatabaseModels for tableName: ${tableName}`);
ModelClass = DatabaseModels.find((Model: any) => {
try {
const instance: any = new Model();
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;
}
});
ModelClass =
(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;
} else if (modelType === ModelType.Analytics) {
MCPLogger.info(`Searching AnalyticsModels for tableName: ${tableName}`);
ModelClass = AnalyticsModels.find((Model: any) => {
try {
const instance: any = new Model();
return instance.tableName === tableName;
} catch (error) {
MCPLogger.warn(
`Error instantiating analytics model ${Model.name}: ${error}`,
);
return false;
}
});
ModelClass =
(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 (!ModelClass) {
@@ -300,8 +344,11 @@ export default class OneUptimeApiService {
// Try to get raw table columns first (most reliable approach)
try {
const modelInstance: any = new ModelClass();
const tableColumns: any = getTableColumns(modelInstance);
const modelInstance: ModelWithTableName =
new ModelClass() as unknown as ModelWithTableName;
const tableColumns: TableColumns = getTableColumns(
modelInstance as BaseModel,
);
const columnNames: string[] = Object.keys(tableColumns);
MCPLogger.info(
@@ -310,13 +357,16 @@ export default class OneUptimeApiService {
if (columnNames.length > 0) {
// Get access control information to filter out restricted fields
const accessControlForColumns: any =
modelInstance.getColumnAccessControlForAllColumns();
const accessControlForColumns: Record<string, ColumnAccessControl> =
modelInstance.getColumnAccessControlForAllColumns
? modelInstance.getColumnAccessControlForAllColumns()
: {};
const selectObject: JSONObject = {};
let filteredCount: number = 0;
for (const columnName of columnNames) {
const accessControl: any = accessControlForColumns[columnName];
const accessControl: ColumnAccessControl | undefined =
accessControlForColumns[columnName];
/*
* Include the field if:
@@ -363,27 +413,32 @@ export default class OneUptimeApiService {
}
// Fallback to schema approach if table columns fail
let selectSchema: any;
let selectSchema: ZodSchemaWithShape;
if (modelType === ModelType.Database) {
MCPLogger.info(
`Generating select schema for database model: ${ModelClass.name}`,
);
selectSchema = ModelSchema.getSelectModelSchema({
modelType: ModelClass,
});
modelType: ModelClass as ModelConstructor<BaseModel>,
}) as ZodSchemaWithShape;
} else {
MCPLogger.info(
`Generating schema for analytics model: ${ModelClass.name}`,
);
// For analytics models, use the general model schema
selectSchema = AnalyticsModelSchema.getModelSchema({
modelType: ModelClass,
});
modelType: ModelClass as ModelConstructor<AnalyticsBaseModel>,
}) as ZodSchemaWithShape;
}
// Extract field names from the schema
const selectObject: JSONObject = {};
const shape: any = selectSchema._def?.shape;
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}`,

View File

@@ -1,10 +1,36 @@
import OneUptimeOperation from "./OneUptimeOperation";
import ModelType from "./ModelType";
import { JSONObject } from "Common/Types/JSON";
// JSON Schema type for MCP tool input schemas
export interface JSONSchemaProperty {
type: string;
description?: string;
enum?: Array<string | number | boolean>;
items?: JSONSchemaProperty;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
default?: unknown;
format?: string;
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
}
export interface JSONSchema {
type: string;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
additionalProperties?: boolean;
description?: string;
}
export interface McpToolInfo {
name: string;
description: string;
inputSchema: any;
inputSchema: JSONSchema;
modelName: string;
operation: OneUptimeOperation;
modelType: ModelType;
@@ -25,12 +51,18 @@ export interface ModelToolsResult {
};
}
// Sort direction type
export type SortDirection = 1 | -1;
// Sort object type
export type SortObject = Record<string, SortDirection>;
export interface OneUptimeToolCallArgs {
id?: string;
data?: any;
query?: any;
select?: any;
data?: JSONObject;
query?: JSONObject;
select?: JSONObject;
skip?: number;
limit?: number;
sort?: any;
sort?: SortObject;
}

View File

@@ -4,7 +4,11 @@ import DatabaseBaseModel from "Common/Models/DatabaseModels/DatabaseBaseModel/Da
import AnalyticsBaseModel from "Common/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel";
import OneUptimeOperation from "../Types/OneUptimeOperation";
import ModelType from "../Types/ModelType";
import { McpToolInfo, ModelToolsResult } from "../Types/McpTypes";
import {
McpToolInfo,
ModelToolsResult,
JSONSchemaProperty,
} from "../Types/McpTypes";
import {
ModelSchema,
ModelSchemaType,
@@ -15,6 +19,46 @@ import {
} from "Common/Utils/Schema/AnalyticsModelSchema";
import MCPLogger from "./MCPLogger";
// Type for Zod field definition
interface ZodFieldDef {
typeName?: string;
innerType?: ZodField;
description?: string;
openapi?: {
metadata?: OpenApiMetadata;
};
}
// Type for Zod field
interface ZodField {
_def?: ZodFieldDef;
}
// Type for OpenAPI metadata
interface OpenApiMetadata {
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>;
};
}
// Type for zodToJsonSchema return value
interface ZodToJsonSchemaResult {
type: string;
properties: Record<string, JSONSchemaProperty>;
required?: string[];
additionalProperties: boolean;
}
export default class DynamicToolGenerator {
/**
* Sanitize a name to be valid for MCP tool names
@@ -41,15 +85,18 @@ export default class DynamicToolGenerator {
*/
private static zodToJsonSchema(
zodSchema: ModelSchemaType | AnalyticsModelSchemaType,
): any {
): ZodToJsonSchemaResult {
try {
/*
* The Zod schemas in this project are extended with OpenAPI metadata
* We can extract the shape and create a basic JSON schema
*/
const shape: any = (zodSchema as any)._def?.shape;
const schemaWithShape: ZodSchemaWithShape =
zodSchema as unknown as ZodSchemaWithShape;
const shapeFunction: (() => Record<string, ZodField>) | undefined =
schemaWithShape._def?.shape;
if (!shape) {
if (!shapeFunction) {
return {
type: "object",
properties: {},
@@ -57,23 +104,24 @@ export default class DynamicToolGenerator {
};
}
const properties: any = {};
const shape: Record<string, ZodField> = shapeFunction();
const properties: Record<string, JSONSchemaProperty> = {};
const required: string[] = [];
for (const [key, value] of Object.entries(shape())) {
const zodField: any = value as any;
for (const [key, value] of Object.entries(shape)) {
const zodField: ZodField = value;
// Handle ZodOptional fields by looking at the inner type
let actualField: any = zodField;
let actualField: ZodField = zodField;
let isOptional: boolean = false;
if (zodField._def?.typeName === "ZodOptional") {
actualField = zodField._def.innerType;
actualField = zodField._def.innerType || zodField;
isOptional = true;
}
// Extract OpenAPI metadata - it's stored in _def.openapi.metadata
const openApiMetadata: any =
const openApiMetadata: OpenApiMetadata | undefined =
actualField._def?.openapi?.metadata ||
zodField._def?.openapi?.metadata;
@@ -85,7 +133,7 @@ export default class DynamicToolGenerator {
const cleanDescription: string = this.cleanDescription(rawDescription);
if (openApiMetadata) {
const fieldSchema: any = {
const fieldSchema: JSONSchemaProperty = {
type: openApiMetadata.type || "string",
description: cleanDescription,
...(openApiMetadata.example !== undefined && {
@@ -120,12 +168,17 @@ export default class DynamicToolGenerator {
}
}
return {
const result: ZodToJsonSchemaResult = {
type: "object",
properties,
required: required.length > 0 ? required : undefined,
additionalProperties: false,
};
if (required.length > 0) {
result.required = required;
}
return result;
} catch {
return {
type: "object",
@@ -217,7 +270,8 @@ export default class DynamicToolGenerator {
});
// CREATE Tool
const createSchemaProperties: any = this.zodToJsonSchema(createSchema);
const createSchemaProperties: ZodToJsonSchemaResult =
this.zodToJsonSchema(createSchema);
tools.push({
name: `create_${this.sanitizeToolName(singularName)}`,
description: `Create a new ${singularName} in OneUptime`,
@@ -292,7 +346,8 @@ export default class DynamicToolGenerator {
});
// UPDATE Tool
const updateSchemaProperties: any = this.zodToJsonSchema(updateSchema);
const updateSchemaProperties: ZodToJsonSchemaResult =
this.zodToJsonSchema(updateSchema);
tools.push({
name: `update_${this.sanitizeToolName(singularName)}`,
description: `Update an existing ${singularName} in OneUptime`,
@@ -420,7 +475,7 @@ export default class DynamicToolGenerator {
});
// CREATE Tool for Analytics
const analyticsCreateSchemaProperties: any =
const analyticsCreateSchemaProperties: ZodToJsonSchemaResult =
this.zodToJsonSchema(createSchema);
tools.push({
name: `create_${this.sanitizeToolName(singularName)}`,