mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
refactor: Improve type definitions and enhance JSON schema handling in MCP services
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
Reference in New Issue
Block a user