Merge remote-tracking branch 'origin/incident-ai'

This commit is contained in:
Nawaz Dhandala
2025-12-16 13:33:18 +00:00
65 changed files with 6934 additions and 11692 deletions

View File

@@ -13,6 +13,7 @@ import LlmProvider from "Common/Models/DatabaseModels/LlmProvider";
import LlmType from "Common/Types/LLM/LlmType";
import React, { FunctionComponent, ReactElement } from "react";
import DropdownUtil from "Common/UI/Utils/Dropdown";
import { BILLING_ENABLED } from "Common/UI/Config";
const Settings: FunctionComponent = (): ReactElement => {
return (
@@ -83,6 +84,14 @@ const Settings: FunctionComponent = (): ReactElement => {
title: "Provider Settings",
id: "provider-settings",
},
...(BILLING_ENABLED
? [
{
title: "Cost Settings",
id: "cost-settings",
},
]
: []),
]}
formFields={[
{
@@ -156,6 +165,22 @@ const Settings: FunctionComponent = (): ReactElement => {
description:
"Required for Ollama. Optional for others to use custom endpoints.",
},
...(BILLING_ENABLED
? [
{
field: {
costPerMillionTokensInUSDCents: true,
},
title: "Cost Per Million Tokens (USD Cents)",
stepId: "cost-settings",
fieldType: FormFieldSchemaType.Number,
required: false,
placeholder: "0",
description:
"Cost per million tokens in USD cents. For example, if the cost is $0.01 per 1M tokens, enter 1.",
},
]
: []),
]}
selectMoreFields={{
apiKey: true,
@@ -206,6 +231,18 @@ const Settings: FunctionComponent = (): ReactElement => {
type: FieldType.Text,
noValueMessage: "-",
},
...(BILLING_ENABLED
? [
{
field: {
costPerMillionTokensInUSDCents: true,
},
title: "Cost (cents/1M)",
type: FieldType.Number,
noValueMessage: "0",
},
]
: []),
]}
/>
</Page>

View File

@@ -358,6 +358,10 @@ import ProbeOwnerUserService, {
Service as ProbeOwnerUserServiceType,
} from "Common/Server/Services/ProbeOwnerUserService";
import LlmLogService, {
Service as LlmLogServiceType,
} from "Common/Server/Services/LlmLogService";
import TelemetryExceptionService, {
Service as TelemetryExceptionServiceType,
} from "Common/Server/Services/TelemetryExceptionService";
@@ -467,6 +471,7 @@ import WorkflowLog from "Common/Models/DatabaseModels/WorkflowLog";
import WorkflowVariable from "Common/Models/DatabaseModels/WorkflowVariable";
import ProbeOwnerTeam from "Common/Models/DatabaseModels/ProbeOwnerTeam";
import ProbeOwnerUser from "Common/Models/DatabaseModels/ProbeOwnerUser";
import LlmLog from "Common/Models/DatabaseModels/LlmLog";
import ServiceCatalogDependency from "Common/Models/DatabaseModels/ServiceCatalogDependency";
import ExceptionInstance from "Common/Models/AnalyticsModels/ExceptionInstance";
import TelemetyException from "Common/Models/DatabaseModels/TelemetryException";
@@ -1682,6 +1687,11 @@ const BaseAPIFeatureSet: FeatureSet = {
new LlmProviderAPI().getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<LlmLog, LlmLogServiceType>(LlmLog, LlmLogService).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new StatusPageSubscriberAPI().getRouter(),

View File

@@ -73,6 +73,7 @@ import Probe from "./Probe";
import ProbeOwnerTeam from "./ProbeOwnerTeam";
import ProbeOwnerUser from "./ProbeOwnerUser";
import LlmProvider from "./LlmProvider";
import LlmLog from "./LlmLog";
import Project from "./Project";
import ProjectCallSMSConfig from "./ProjectCallSMSConfig";
// Project SMTP Config.
@@ -379,6 +380,7 @@ const AllModelTypes: Array<{
ProbeOwnerUser,
LlmProvider,
LlmLog,
UserSession,
UserTotpAuth,

View File

@@ -0,0 +1,818 @@
import Project from "./Project";
import Incident from "./Incident";
import Alert from "./Alert";
import ScheduledMaintenance from "./ScheduledMaintenance";
import User from "./User";
import LlmProvider from "./LlmProvider";
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
import Route from "../../Types/API/Route";
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
import ColumnLength from "../../Types/Database/ColumnLength";
import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
import TableMetadata from "../../Types/Database/TableMetadata";
import TenantColumn from "../../Types/Database/TenantColumn";
import IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import LlmLogStatus from "../../Types/LlmLogStatus";
import LlmType from "../../Types/LLM/LlmType";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
@EnableDocumentation()
@TenantColumn("projectId")
@TableAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
delete: [],
update: [],
})
@CrudApiEndpoint(new Route("/llm-log"))
@Entity({
name: "LlmLog",
})
@TableMetadata({
tableName: "LlmLog",
singularName: "LLM Log",
pluralName: "LLM Logs",
icon: IconProp.Bolt,
tableDescription:
"Logs of all the LLM API calls for AI features in this project.",
})
export default class LlmLog extends BaseModel {
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "projectId",
type: TableColumnType.Entity,
modelType: Project,
title: "Project",
description: "Relation to Project Resource in which this object belongs",
})
@ManyToOne(
() => {
return Project;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "projectId" })
public project?: Project = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "Project ID",
description: "ID of your OneUptime Project in which this object belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public projectId?: ObjectID = undefined;
// LLM Provider Relation
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "llmProviderId",
type: TableColumnType.Entity,
modelType: LlmProvider,
title: "LLM Provider",
description: "LLM Provider used for this API call",
})
@ManyToOne(
() => {
return LlmProvider;
},
{
eager: false,
nullable: true,
onDelete: "SET NULL",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "llmProviderId" })
public llmProvider?: LlmProvider = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "LLM Provider ID",
description: "ID of LLM Provider used for this API call",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public llmProviderId?: ObjectID = undefined;
// Denormalized provider info for historical reference
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.ShortText,
title: "LLM Provider Name",
description: "Name of the LLM Provider at time of call",
canReadOnRelationQuery: false,
})
@Column({
nullable: true,
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
})
public llmProviderName?: string = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.ShortText,
title: "LLM Type",
description: "Type of LLM (OpenAI, Anthropic, Ollama)",
canReadOnRelationQuery: false,
})
@Column({
nullable: true,
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
})
public llmType?: LlmType = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.ShortText,
title: "Model Name",
description: "Name of the model used (e.g., gpt-4, claude-3-opus)",
canReadOnRelationQuery: false,
})
@Column({
nullable: true,
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
})
public modelName?: string = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
required: true,
type: TableColumnType.Boolean,
title: "Is Global Provider",
description: "Was a global LLM provider used for this call?",
canReadOnRelationQuery: false,
isDefaultValueColumn: true,
defaultValue: false,
})
@Column({
nullable: false,
type: ColumnType.Boolean,
default: false,
})
public isGlobalProvider?: boolean = undefined;
// Token usage
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
required: true,
type: TableColumnType.Number,
title: "Tokens Used",
description: "Total tokens used (input + output)",
canReadOnRelationQuery: false,
isDefaultValueColumn: true,
defaultValue: 0,
})
@Column({
nullable: false,
default: 0,
type: ColumnType.Number,
})
public totalTokens?: number = undefined;
// Cost tracking
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
required: true,
type: TableColumnType.Number,
title: "Cost (USD Cents)",
description: "Total cost in USD cents",
canReadOnRelationQuery: false,
isDefaultValueColumn: true,
defaultValue: 0,
})
@Column({
nullable: false,
default: 0,
type: ColumnType.Number,
})
public costInUSDCents?: number = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
required: true,
type: TableColumnType.Boolean,
title: "Was Billed",
description: "Was the project charged for this API call?",
canReadOnRelationQuery: false,
isDefaultValueColumn: true,
defaultValue: false,
})
@Column({
nullable: false,
type: ColumnType.Boolean,
default: false,
})
public wasBilled?: boolean = undefined;
// Status
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
required: true,
type: TableColumnType.ShortText,
title: "Status",
description: "Status of the LLM API call",
canReadOnRelationQuery: false,
})
@Column({
nullable: false,
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
})
public status?: LlmLogStatus = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.LongText,
title: "Status Message",
description: "Status Message (error details if failed)",
canReadOnRelationQuery: false,
})
@Column({
nullable: true,
type: ColumnType.LongText,
length: ColumnLength.LongText,
})
public statusMessage?: string = undefined;
// Feature identification
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.ShortText,
title: "Feature",
description:
"The feature that triggered this API call (e.g., IncidentPostmortem)",
canReadOnRelationQuery: false,
})
@Column({
nullable: true,
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
})
public feature?: string = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.VeryLongText,
title: "Request Prompt",
description: "The prompt sent to the LLM (truncated)",
canReadOnRelationQuery: false,
})
@Column({
nullable: true,
type: ColumnType.VeryLongText,
})
public requestPrompt?: string = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.VeryLongText,
title: "Response Preview",
description: "Preview of the LLM response (truncated)",
canReadOnRelationQuery: false,
})
@Column({
nullable: true,
type: ColumnType.VeryLongText,
})
public responsePreview?: string = undefined;
// Related resources
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "incidentId",
type: TableColumnType.Entity,
modelType: Incident,
title: "Incident",
description: "Incident associated with this LLM call (if any)",
})
@ManyToOne(
() => {
return Incident;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "incidentId" })
public incident?: Incident = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "Incident ID",
description: "ID of Incident associated with this LLM call (if any)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public incidentId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "alertId",
type: TableColumnType.Entity,
modelType: Alert,
title: "Alert",
description: "Alert associated with this LLM call (if any)",
})
@ManyToOne(
() => {
return Alert;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "alertId" })
public alert?: Alert = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "Alert ID",
description: "ID of Alert associated with this LLM call (if any)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public alertId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "scheduledMaintenanceId",
type: TableColumnType.Entity,
modelType: ScheduledMaintenance,
title: "Scheduled Maintenance",
description: "Scheduled Maintenance associated with this LLM call (if any)",
})
@ManyToOne(
() => {
return ScheduledMaintenance;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "scheduledMaintenanceId" })
public scheduledMaintenance?: ScheduledMaintenance = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "Scheduled Maintenance ID",
description:
"ID of Scheduled Maintenance associated with this LLM call (if any)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public scheduledMaintenanceId?: ObjectID = undefined;
// User who triggered the request
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "userId",
type: TableColumnType.Entity,
modelType: User,
title: "User",
description: "User who triggered this LLM call (if any)",
})
@ManyToOne(
() => {
return User;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "userId" })
public user?: User = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "User ID",
description: "ID of User who triggered this LLM call (if any)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public userId?: ObjectID = undefined;
// Timestamps and duration
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.Date,
title: "Request Started At",
description: "When the LLM request started",
canReadOnRelationQuery: false,
})
@Column({
nullable: true,
type: ColumnType.Date,
})
public requestStartedAt?: Date = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.Date,
title: "Request Completed At",
description: "When the LLM request completed",
canReadOnRelationQuery: false,
})
@Column({
nullable: true,
type: ColumnType.Date,
})
public requestCompletedAt?: Date = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadLlmLog,
],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.Number,
title: "Duration (ms)",
description: "Request duration in milliseconds",
canReadOnRelationQuery: false,
})
@Column({
nullable: true,
type: ColumnType.Number,
})
public durationMs?: number = undefined;
// Deleted by user
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "deletedByUserId",
type: TableColumnType.Entity,
title: "Deleted by User",
modelType: User,
description:
"Relation to User who deleted this object (if this object was deleted by a User)",
})
@ManyToOne(
() => {
return User;
},
{
cascade: false,
eager: false,
nullable: true,
onDelete: "SET NULL",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "deletedByUserId" })
public deletedByUser?: User = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ObjectID,
title: "Deleted by User ID",
description:
"User ID who deleted this object (if this object was deleted by a User)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public deletedByUserId?: ObjectID = undefined;
}

View File

@@ -462,4 +462,25 @@ export default class LlmProvider extends BaseModel {
default: false,
})
public isDefault?: boolean = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.Public],
update: [],
})
@TableColumn({
isDefaultValueColumn: true,
required: true,
type: TableColumnType.Number,
title: "Cost Per Million Tokens (USD Cents)",
description:
"Cost per million tokens in USD cents. Used for billing when using global LLM providers.",
defaultValue: 0,
})
@Column({
type: ColumnType.Number,
nullable: false,
default: 0,
})
public costPerMillionTokensInUSDCents?: number = undefined;
}

View File

@@ -1,6 +1,7 @@
import Incident from "../../Models/DatabaseModels/Incident";
import File from "../../Models/DatabaseModels/File";
import NotFoundException from "../../Types/Exception/NotFoundException";
import BadDataException from "../../Types/Exception/BadDataException";
import ObjectID from "../../Types/ObjectID";
import IncidentService, {
Service as IncidentServiceType,
@@ -15,6 +16,13 @@ import {
} from "../Utils/Express";
import CommonAPI from "./CommonAPI";
import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
import AIService, { AILogRequest, AILogResponse } from "../Services/AIService";
import IncidentAIContextBuilder, {
AIGenerationContext,
IncidentContextData,
} from "../Utils/AI/IncidentAIContextBuilder";
import JSONFunctions from "../../Types/JSONFunctions";
import Permission from "../../Types/Permission";
export default class IncidentAPI extends BaseAPI<
Incident,
@@ -36,6 +44,21 @@ export default class IncidentAPI extends BaseAPI<
}
},
);
// Generate postmortem from AI
this.router.post(
`${new this.entityType()
.getCrudApiPath()
?.toString()}/generate-postmortem-from-ai/:incidentId`,
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
await this.generatePostmortemFromAI(req, res);
} catch (err) {
next(err);
}
},
);
}
private async getPostmortemAttachment(
@@ -103,4 +126,107 @@ export default class IncidentAPI extends BaseAPI<
Response.setNoCacheHeaders(res);
return Response.sendFileResponse(req, res, attachment);
}
private async generatePostmortemFromAI(
req: ExpressRequest,
res: ExpressResponse,
): Promise<void> {
const incidentIdParam: string | undefined = req.params["incidentId"];
if (!incidentIdParam) {
throw new BadDataException("Incident ID is required");
}
let incidentId: ObjectID;
try {
incidentId = new ObjectID(incidentIdParam);
} catch {
throw new BadDataException("Invalid Incident ID");
}
const props: DatabaseCommonInteractionProps =
await CommonAPI.getDatabaseCommonInteractionProps(req);
// Verify user has permission to edit the incident
const permissions: Array<Permission> | undefined = props
.userTenantAccessPermission?.["permissions"] as
| Array<Permission>
| undefined;
const hasPermission: boolean = permissions
? permissions.some((p: Permission) => {
return (
p === Permission.ProjectOwner ||
p === Permission.ProjectAdmin ||
p === Permission.EditProjectIncident
);
})
: false;
if (!hasPermission && !props.isMasterAdmin) {
throw new BadDataException(
"You do not have permission to generate postmortem for this incident. You need to have one of these permissions: Project Owner, Project Admin, Edit Project Incident.",
);
}
// Get the template from request body if provided
const template: string | undefined = JSONFunctions.getJSONValueInPath(
req.body,
"template",
) as string | undefined;
// Always include workspace messages for comprehensive context
const includeWorkspaceMessages: boolean = true;
// Get the incident to verify it exists and get the project ID
const incident: Incident | null = await this.service.findOneById({
id: incidentId,
select: {
_id: true,
projectId: true,
},
props,
});
if (!incident || !incident.projectId) {
throw new NotFoundException("Incident not found");
}
// Build incident context
const contextData: IncidentContextData =
await IncidentAIContextBuilder.buildIncidentContext({
incidentId,
includeWorkspaceMessages,
workspaceMessageLimit: 500,
});
// Format context for postmortem generation
const aiContext: AIGenerationContext =
IncidentAIContextBuilder.formatIncidentContextForPostmortem(
contextData,
template,
);
// Generate postmortem using AIService (handles billing and logging)
const aiLogRequest: AILogRequest = {
projectId: incident.projectId,
feature: "Incident Postmortem",
incidentId: incidentId,
messages: aiContext.messages,
maxTokens: 8192,
temperature: 0.7,
};
if (props.userId) {
aiLogRequest.userId = props.userId;
}
const response: AILogResponse =
await AIService.executeWithLogging(aiLogRequest);
return Response.sendJsonObjectResponse(req, res, {
postmortemNote: response.content,
});
}
}

View File

@@ -0,0 +1,111 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1765830758857 implements MigrationInterface {
public name = "MigrationName1765830758857";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "LlmLog" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "llmProviderId" uuid, "llmProviderName" character varying(100), "llmType" character varying(100), "modelName" character varying(100), "isGlobalProvider" boolean NOT NULL DEFAULT false, "inputTokens" integer NOT NULL DEFAULT '0', "outputTokens" integer NOT NULL DEFAULT '0', "totalTokens" integer NOT NULL DEFAULT '0', "costInUSDCents" integer NOT NULL DEFAULT '0', "wasBilled" boolean NOT NULL DEFAULT false, "status" character varying(100) NOT NULL, "statusMessage" character varying(500), "feature" character varying(100), "requestPrompt" text, "responsePreview" text, "incidentId" uuid, "alertId" uuid, "scheduledMaintenanceId" uuid, "userId" uuid, "requestStartedAt" TIMESTAMP WITH TIME ZONE, "requestCompletedAt" TIMESTAMP WITH TIME ZONE, "durationMs" integer, "deletedByUserId" uuid, CONSTRAINT "PK_807b7f4578f9dcbb1f7aeeb94f8" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_c3c061a924f368e2cc68a23308" ON "LlmLog" ("projectId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_bfd15354697dc30fedf7a96976" ON "LlmLog" ("llmProviderId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_159d2b07c02788dcac8575bf4a" ON "LlmLog" ("incidentId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_83696b0732c0a3601a9d5d7afe" ON "LlmLog" ("alertId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_19d277440e9b9e3ed4fa46c227" ON "LlmLog" ("scheduledMaintenanceId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_5c6c985581a3f85d84a2987daa" ON "LlmLog" ("userId") `,
);
await queryRunner.query(
`ALTER TABLE "LlmProvider" ADD "costPerMillionTokensInUSDCents" integer NOT NULL DEFAULT '0'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
);
await queryRunner.query(
`ALTER TABLE "LlmLog" ADD CONSTRAINT "FK_c3c061a924f368e2cc68a233083" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "LlmLog" ADD CONSTRAINT "FK_bfd15354697dc30fedf7a96976e" FOREIGN KEY ("llmProviderId") REFERENCES "LlmProvider"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "LlmLog" ADD CONSTRAINT "FK_159d2b07c02788dcac8575bf4a6" FOREIGN KEY ("incidentId") REFERENCES "Incident"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "LlmLog" ADD CONSTRAINT "FK_83696b0732c0a3601a9d5d7afe1" FOREIGN KEY ("alertId") REFERENCES "Alert"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "LlmLog" ADD CONSTRAINT "FK_19d277440e9b9e3ed4fa46c227a" FOREIGN KEY ("scheduledMaintenanceId") REFERENCES "ScheduledMaintenance"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "LlmLog" ADD CONSTRAINT "FK_5c6c985581a3f85d84a2987daae" FOREIGN KEY ("userId") REFERENCES "User"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "LlmLog" ADD CONSTRAINT "FK_bbe2bdcf251d6ef1ea43b666370" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "LlmLog" DROP CONSTRAINT "FK_bbe2bdcf251d6ef1ea43b666370"`,
);
await queryRunner.query(
`ALTER TABLE "LlmLog" DROP CONSTRAINT "FK_5c6c985581a3f85d84a2987daae"`,
);
await queryRunner.query(
`ALTER TABLE "LlmLog" DROP CONSTRAINT "FK_19d277440e9b9e3ed4fa46c227a"`,
);
await queryRunner.query(
`ALTER TABLE "LlmLog" DROP CONSTRAINT "FK_83696b0732c0a3601a9d5d7afe1"`,
);
await queryRunner.query(
`ALTER TABLE "LlmLog" DROP CONSTRAINT "FK_159d2b07c02788dcac8575bf4a6"`,
);
await queryRunner.query(
`ALTER TABLE "LlmLog" DROP CONSTRAINT "FK_bfd15354697dc30fedf7a96976e"`,
);
await queryRunner.query(
`ALTER TABLE "LlmLog" DROP CONSTRAINT "FK_c3c061a924f368e2cc68a233083"`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
);
await queryRunner.query(
`ALTER TABLE "LlmProvider" DROP COLUMN "costPerMillionTokensInUSDCents"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_5c6c985581a3f85d84a2987daa"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_19d277440e9b9e3ed4fa46c227"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_83696b0732c0a3601a9d5d7afe"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_159d2b07c02788dcac8575bf4a"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_bfd15354697dc30fedf7a96976"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_c3c061a924f368e2cc68a23308"`,
);
await queryRunner.query(`DROP TABLE "LlmLog"`);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1765834537501 implements MigrationInterface {
public name = "MigrationName1765834537501";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "LlmLog" DROP COLUMN "inputTokens"`);
await queryRunner.query(`ALTER TABLE "LlmLog" DROP COLUMN "outputTokens"`);
await queryRunner.query(`ALTER TABLE "LlmLog" DROP COLUMN "totalTokens"`);
await queryRunner.query(
`ALTER TABLE "LlmLog" ADD "totalTokens" integer NOT NULL DEFAULT '0'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
);
await queryRunner.query(`ALTER TABLE "LlmLog" DROP COLUMN "totalTokens"`);
await queryRunner.query(
`ALTER TABLE "LlmLog" ADD "totalTokens" integer NOT NULL DEFAULT '0'`,
);
await queryRunner.query(
`ALTER TABLE "LlmLog" ADD "outputTokens" integer NOT NULL DEFAULT '0'`,
);
await queryRunner.query(
`ALTER TABLE "LlmLog" ADD "inputTokens" integer NOT NULL DEFAULT '0'`,
);
}
}

View File

@@ -200,6 +200,8 @@ import { MigrationName1765580181582 } from "./1765580181582-MigrationName";
import { MigrationName1765633554715 } from "./1765633554715-MigrationName";
import { MigrationName1765801357168 } from "./1765801357168-MigrationName";
import { MigrationName1765810218488 } from "./1765810218488-MigrationName";
import { MigrationName1765830758857 } from "./1765830758857-MigrationName";
import { MigrationName1765834537501 } from "./1765834537501-MigrationName";
export default [
InitialMigration,
@@ -404,4 +406,6 @@ export default [
MigrationName1765633554715,
MigrationName1765801357168,
MigrationName1765810218488,
MigrationName1765830758857,
MigrationName1765834537501,
];

View File

@@ -0,0 +1,239 @@
import { IsBillingEnabled } from "../EnvironmentConfig";
import BaseService from "./BaseService";
import LlmProviderService from "./LlmProviderService";
import LlmLogService from "./LlmLogService";
import ProjectService from "./ProjectService";
import Project from "../../Models/DatabaseModels/Project";
import AIBillingService from "./AIBillingService";
import LLMService, {
LLMProviderConfig,
LLMCompletionResponse,
LLMMessage,
} from "../Utils/LLM/LLMService";
import LlmProvider from "../../Models/DatabaseModels/LlmProvider";
import LlmLog from "../../Models/DatabaseModels/LlmLog";
import LlmLogStatus from "../../Types/LlmLogStatus";
import ObjectID from "../../Types/ObjectID";
import BadDataException from "../../Types/Exception/BadDataException";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import logger from "../Utils/Logger";
export interface AILogRequest {
projectId: ObjectID;
userId?: ObjectID;
feature: string; // e.g., "IncidentPostmortem", "IncidentNote"
incidentId?: ObjectID;
alertId?: ObjectID;
scheduledMaintenanceId?: ObjectID;
messages: Array<LLMMessage>;
maxTokens?: number;
temperature?: number;
}
export interface AILogResponse {
content: string;
llmLog: LlmLog;
}
export class Service extends BaseService {
public constructor() {
super();
}
@CaptureSpan()
public async executeWithLogging(
request: AILogRequest,
): Promise<AILogResponse> {
const startTime: Date = new Date();
// Get LLM provider for the project
const llmProvider: LlmProvider | null =
await LlmProviderService.getLLMProviderForProject(request.projectId);
if (!llmProvider) {
throw new BadDataException(
"No LLM provider configured for this project. Please configure an LLM provider in Settings > AI > LLM Providers.",
);
}
if (!llmProvider.llmType) {
throw new BadDataException(
"LLM provider type is not configured properly.",
);
}
// Create log entry (will be updated after completion)
const logEntry: LlmLog = new LlmLog();
logEntry.projectId = request.projectId;
logEntry.isGlobalProvider = llmProvider.isGlobalLlm || false;
logEntry.feature = request.feature;
logEntry.requestPrompt = request.messages
.map((m: LLMMessage) => {
return m.content;
})
.join("\n")
.substring(0, 5000); // Store first 5000 chars
logEntry.requestStartedAt = startTime;
// Set optional fields only if they have values
if (llmProvider.id) {
logEntry.llmProviderId = llmProvider.id;
}
if (llmProvider.name) {
logEntry.llmProviderName = llmProvider.name;
}
if (llmProvider.llmType) {
logEntry.llmType = llmProvider.llmType;
}
if (llmProvider.modelName) {
logEntry.modelName = llmProvider.modelName;
}
if (request.userId) {
logEntry.userId = request.userId;
}
if (request.incidentId) {
logEntry.incidentId = request.incidentId;
}
if (request.alertId) {
logEntry.alertId = request.alertId;
}
if (request.scheduledMaintenanceId) {
logEntry.scheduledMaintenanceId = request.scheduledMaintenanceId;
}
// Check if billing should apply
const shouldBill: boolean =
IsBillingEnabled && (llmProvider.isGlobalLlm || false);
// Check balance if billing enabled and using global provider
if (shouldBill) {
const project: Project | null = await ProjectService.findOneById({
id: request.projectId,
select: { aiCurrentBalanceInUSDCents: true },
props: { isRoot: true },
});
if (!project || (project.aiCurrentBalanceInUSDCents || 0) <= 0) {
logEntry.status = LlmLogStatus.InsufficientBalance;
logEntry.statusMessage = "Insufficient AI balance";
logEntry.requestCompletedAt = new Date();
logEntry.durationMs = new Date().getTime() - startTime.getTime();
await LlmLogService.create({
data: logEntry,
props: { isRoot: true },
});
throw new BadDataException(
"Insufficient AI balance. Please recharge your AI balance in Project Settings > Billing.",
);
}
}
try {
// Build LLM config
const llmConfig: LLMProviderConfig = {
llmType: llmProvider.llmType,
};
if (llmProvider.apiKey) {
llmConfig.apiKey = llmProvider.apiKey;
}
if (llmProvider.baseUrl) {
llmConfig.baseUrl = llmProvider.baseUrl.toString();
}
if (llmProvider.modelName) {
llmConfig.modelName = llmProvider.modelName;
}
// Execute LLM call
const response: LLMCompletionResponse = await LLMService.getCompletion({
llmProviderConfig: llmConfig,
messages: request.messages,
maxTokens: request.maxTokens || 4096,
temperature: request.temperature ?? 0.7,
});
const endTime: Date = new Date();
// Update log with success info
logEntry.status = LlmLogStatus.Success;
logEntry.totalTokens = response.usage?.totalTokens || 0;
logEntry.responsePreview = response.content.substring(0, 2000); // Store first 2000 chars
logEntry.requestCompletedAt = endTime;
logEntry.durationMs = endTime.getTime() - startTime.getTime();
// Calculate and apply costs if using global provider with billing enabled
if (shouldBill && response.usage) {
const totalCost: number = Math.ceil(
(response.usage.totalTokens / 1_000_000) *
(llmProvider.costPerMillionTokensInUSDCents || 0),
);
logEntry.costInUSDCents = totalCost;
logEntry.wasBilled = true;
// Deduct from project balance
if (totalCost > 0) {
const project: Project | null = await ProjectService.findOneById({
id: request.projectId,
select: { aiCurrentBalanceInUSDCents: true },
props: { isRoot: true },
});
if (project) {
const newBalance: number = Math.max(
0,
(project.aiCurrentBalanceInUSDCents || 0) - totalCost,
);
await ProjectService.updateOneById({
id: request.projectId,
data: {
aiCurrentBalanceInUSDCents: newBalance,
},
props: { isRoot: true },
});
}
// Check if auto-recharge is needed (do this async, don't wait)
AIBillingService.rechargeIfBalanceIsLow(request.projectId).catch(
(err: Error) => {
logger.error("Error during AI balance auto-recharge check:");
logger.error(err);
},
);
}
}
// Save log entry
const savedLog: LlmLog = await LlmLogService.create({
data: logEntry,
props: { isRoot: true },
});
return {
content: response.content,
llmLog: savedLog,
};
} catch (error) {
// Log the error
logEntry.status = LlmLogStatus.Error;
logEntry.statusMessage =
error instanceof Error ? error.message : String(error);
logEntry.requestCompletedAt = new Date();
logEntry.durationMs = new Date().getTime() - startTime.getTime();
await LlmLogService.create({
data: logEntry,
props: { isRoot: true },
});
throw error;
}
}
}
export default new Service();

View File

@@ -65,6 +65,16 @@ import OnCallDutyPolicy from "../../Models/DatabaseModels/OnCallDutyPolicy";
import Dictionary from "../../Types/Dictionary";
import IncidentTemplateService from "./IncidentTemplateService";
import IncidentTemplate from "../../Models/DatabaseModels/IncidentTemplate";
import LLMService, {
LLMProviderConfig,
LLMCompletionResponse,
} from "../Utils/LLM/LLMService";
import LlmProviderService from "./LlmProviderService";
import LlmProvider from "../../Models/DatabaseModels/LlmProvider";
import IncidentAIContextBuilder, {
AIGenerationContext,
IncidentContextData,
} from "../Utils/AI/IncidentAIContextBuilder";
// key is incidentId for this dictionary.
type UpdateCarryForward = Dictionary<{
@@ -2388,6 +2398,85 @@ ${incidentSeverity.name}
);
}
}
@CaptureSpan()
public async generatePostmortemFromAI(data: {
incidentId: ObjectID;
template?: string;
}): Promise<string> {
// Get the incident to verify it exists and get the project ID
const incident: Model | null = await this.findOneById({
id: data.incidentId,
select: {
_id: true,
projectId: true,
},
props: {
isRoot: true,
},
});
if (!incident || !incident.projectId) {
throw new BadDataException("Incident not found");
}
// Get LLM provider for the project
const llmProvider: LlmProvider | null =
await LlmProviderService.getLLMProviderForProject(incident.projectId);
if (!llmProvider) {
throw new BadDataException(
"No LLM provider configured for this project. Please configure an LLM provider in Settings > AI > LLM Providers.",
);
}
if (!llmProvider.llmType) {
throw new BadDataException(
"LLM provider type is not configured properly.",
);
}
// Build incident context - always include workspace messages
const contextData: IncidentContextData =
await IncidentAIContextBuilder.buildIncidentContext({
incidentId: data.incidentId,
includeWorkspaceMessages: true,
workspaceMessageLimit: 500,
});
// Format context for postmortem generation
const aiContext: AIGenerationContext =
IncidentAIContextBuilder.formatIncidentContextForPostmortem(
contextData,
data.template,
);
// Generate postmortem using LLM
const llmConfig: LLMProviderConfig = {
llmType: llmProvider.llmType,
};
if (llmProvider.apiKey) {
llmConfig.apiKey = llmProvider.apiKey;
}
if (llmProvider.baseUrl) {
llmConfig.baseUrl = llmProvider.baseUrl.toString();
}
if (llmProvider.modelName) {
llmConfig.modelName = llmProvider.modelName;
}
const response: LLMCompletionResponse = await LLMService.getCompletion({
llmProviderConfig: llmConfig,
messages: aiContext.messages,
maxTokens: 8192,
temperature: 0.7,
});
return response.content;
}
}
export default new Service();

View File

@@ -0,0 +1,14 @@
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/LlmLog";
import { IsBillingEnabled } from "../EnvironmentConfig";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
if (IsBillingEnabled) {
this.hardDeleteItemsOlderThanInDays("createdAt", 3);
}
}
}
export default new Service();

View File

@@ -6,6 +6,7 @@ import ObjectID from "../../Types/ObjectID";
import UpdateBy from "../Types/Database/UpdateBy";
import QueryHelper from "../Types/Database/QueryHelper";
import LIMIT_MAX from "../../Types/Database/LimitMax";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -95,6 +96,63 @@ export class Service extends DatabaseService<Model> {
return { updateBy, carryForward: null };
}
@CaptureSpan()
public async getLLMProviderForProject(
projectId: ObjectID,
): Promise<Model | null> {
// First try to get the default provider for the project
let provider: Model | null = await this.findOneBy({
query: {
projectId: projectId,
isDefault: true,
},
select: {
_id: true,
name: true,
llmType: true,
apiKey: true,
baseUrl: true,
modelName: true,
isGlobalLlm: true,
costPerMillionTokensInUSDCents: true,
},
props: {
isRoot: true,
},
});
if (provider) {
return provider;
}
// If no default provider, get any global provider for the project.
provider = await this.findOneBy({
query: {
projectId: QueryHelper.isNull(),
isGlobalLlm: true,
},
select: {
_id: true,
name: true,
llmType: true,
apiKey: true,
baseUrl: true,
modelName: true,
isGlobalLlm: true,
costPerMillionTokensInUSDCents: true,
},
props: {
isRoot: true,
},
});
if (provider) {
return provider;
}
return null;
}
}
export default new Service();

View File

@@ -0,0 +1,498 @@
import ObjectID from "../../../Types/ObjectID";
import Incident from "../../../Models/DatabaseModels/Incident";
import IncidentStateTimeline from "../../../Models/DatabaseModels/IncidentStateTimeline";
import IncidentInternalNote from "../../../Models/DatabaseModels/IncidentInternalNote";
import IncidentPublicNote from "../../../Models/DatabaseModels/IncidentPublicNote";
import IncidentService from "../../Services/IncidentService";
import IncidentStateTimelineService from "../../Services/IncidentStateTimelineService";
import IncidentInternalNoteService from "../../Services/IncidentInternalNoteService";
import IncidentPublicNoteService from "../../Services/IncidentPublicNoteService";
import WorkspaceUtil, { WorkspaceChannelMessage } from "../Workspace/Workspace";
import WorkspaceProjectAuthTokenService from "../../Services/WorkspaceProjectAuthTokenService";
import WorkspaceProjectAuthToken from "../../../Models/DatabaseModels/WorkspaceProjectAuthToken";
import logger from "../Logger";
import CaptureSpan from "../Telemetry/CaptureSpan";
import OneUptimeDate from "../../../Types/Date";
import SortOrder from "../../../Types/BaseDatabase/SortOrder";
import { LLMMessage } from "../LLM/LLMService";
import NotificationRuleWorkspaceChannel from "../../../Types/Workspace/NotificationRules/NotificationRuleWorkspaceChannel";
import WorkspaceType from "../../../Types/Workspace/WorkspaceType";
export interface IncidentContextData {
incident: Incident;
stateTimeline: Array<IncidentStateTimeline>;
internalNotes: Array<IncidentInternalNote>;
publicNotes: Array<IncidentPublicNote>;
workspaceMessages: Array<WorkspaceChannelMessage>;
}
export interface AIGenerationContext {
contextText: string;
systemPrompt: string;
messages: Array<LLMMessage>;
}
export default class IncidentAIContextBuilder {
@CaptureSpan()
public static async buildIncidentContext(data: {
incidentId: ObjectID;
includeWorkspaceMessages?: boolean;
workspaceMessageLimit?: number;
}): Promise<IncidentContextData> {
const incident: Incident | null = await IncidentService.findOneById({
id: data.incidentId,
select: {
_id: true,
title: true,
description: true,
createdAt: true,
postmortemNote: true,
remediationNotes: true,
rootCause: true,
customFields: true,
projectId: true,
incidentSeverity: {
name: true,
color: true,
},
currentIncidentState: {
name: true,
color: true,
},
monitors: {
name: true,
},
labels: {
name: true,
color: true,
},
postUpdatesToWorkspaceChannels: true,
},
props: {
isRoot: true,
},
});
if (!incident) {
throw new Error("Incident not found");
}
// Fetch state timeline
const stateTimeline: Array<IncidentStateTimeline> =
await IncidentStateTimelineService.findBy({
query: {
incidentId: data.incidentId,
},
select: {
_id: true,
createdAt: true,
startsAt: true,
endsAt: true,
rootCause: true,
incidentState: {
name: true,
color: true,
},
createdByUser: {
name: true,
email: true,
},
},
sort: {
startsAt: SortOrder.Ascending,
},
limit: 100,
skip: 0,
props: {
isRoot: true,
},
});
// Fetch internal notes
const internalNotes: Array<IncidentInternalNote> =
await IncidentInternalNoteService.findBy({
query: {
incidentId: data.incidentId,
},
select: {
_id: true,
note: true,
createdAt: true,
createdByUser: {
name: true,
email: true,
},
},
sort: {
createdAt: SortOrder.Ascending,
},
limit: 100,
skip: 0,
props: {
isRoot: true,
},
});
// Fetch public notes
const publicNotes: Array<IncidentPublicNote> =
await IncidentPublicNoteService.findBy({
query: {
incidentId: data.incidentId,
},
select: {
_id: true,
note: true,
createdAt: true,
postedAt: true,
createdByUser: {
name: true,
email: true,
},
},
sort: {
createdAt: SortOrder.Ascending,
},
limit: 100,
skip: 0,
props: {
isRoot: true,
},
});
// Fetch workspace messages if requested and channels exist
let workspaceMessages: Array<WorkspaceChannelMessage> = [];
const workspaceChannels:
| Array<NotificationRuleWorkspaceChannel>
| undefined = incident.postUpdatesToWorkspaceChannels as
| Array<NotificationRuleWorkspaceChannel>
| undefined;
if (
data.includeWorkspaceMessages &&
workspaceChannels &&
workspaceChannels.length > 0 &&
incident.projectId
) {
try {
const fetchParams: {
projectId: ObjectID;
workspaceChannels: Array<NotificationRuleWorkspaceChannel>;
limit?: number;
oldestTimestamp?: Date;
} = {
projectId: incident.projectId,
workspaceChannels: workspaceChannels,
limit: data.workspaceMessageLimit || 500,
};
if (incident.createdAt) {
fetchParams.oldestTimestamp = incident.createdAt;
}
workspaceMessages =
await this.getWorkspaceMessagesForIncident(fetchParams);
} catch (error) {
logger.error(`Error fetching workspace messages: ${error}`);
// Continue without workspace messages
}
}
return {
incident,
stateTimeline,
internalNotes,
publicNotes,
workspaceMessages,
};
}
@CaptureSpan()
public static formatIncidentContextForPostmortem(
contextData: IncidentContextData,
template?: string,
): AIGenerationContext {
const {
incident,
stateTimeline,
internalNotes,
publicNotes,
workspaceMessages,
} = contextData;
let contextText: string = "";
// Basic incident information
contextText += "# Incident Information\n\n";
contextText += `**Title:** ${incident.title || "N/A"}\n\n`;
contextText += `**Description:** ${incident.description || "N/A"}\n\n`;
contextText += `**Severity:** ${incident.incidentSeverity?.name || "N/A"}\n\n`;
contextText += `**Current State:** ${incident.currentIncidentState?.name || "N/A"}\n\n`;
contextText += `**Created At:** ${incident.createdAt ? OneUptimeDate.getDateAsFormattedString(incident.createdAt) : "N/A"}\n\n`;
// Affected monitors
if (incident.monitors && incident.monitors.length > 0) {
contextText += "**Affected Monitors:** ";
contextText += incident.monitors
.map((m: { name?: string }) => {
return m.name;
})
.join(", ");
contextText += "\n\n";
}
// Labels
if (incident.labels && incident.labels.length > 0) {
contextText += "**Labels:** ";
contextText += incident.labels
.map((l: { name?: string }) => {
return l.name;
})
.join(", ");
contextText += "\n\n";
}
// Root cause if available
if (incident.rootCause) {
contextText += `**Root Cause:** ${incident.rootCause}\n\n`;
}
// Remediation notes if available
if (incident.remediationNotes) {
contextText += `**Remediation Notes:** ${incident.remediationNotes}\n\n`;
}
// State timeline
if (stateTimeline.length > 0) {
contextText += "# State Timeline\n\n";
for (const timeline of stateTimeline) {
const startTime: string = timeline.startsAt
? OneUptimeDate.getDateAsFormattedString(timeline.startsAt)
: "N/A";
const stateName: string =
timeline.incidentState?.name?.toString() || "Unknown";
const createdBy: string =
timeline.createdByUser?.name?.toString() ||
timeline.createdByUser?.email?.toString() ||
"System";
contextText += `- **${startTime}**: State changed to **${stateName}** by ${createdBy}\n`;
if (timeline.rootCause) {
contextText += ` - Root cause noted: ${timeline.rootCause}\n`;
}
}
contextText += "\n";
}
// Internal notes
if (internalNotes.length > 0) {
contextText += "# Internal Notes (Private)\n\n";
for (const note of internalNotes) {
const noteTime: string = note.createdAt
? OneUptimeDate.getDateAsFormattedString(note.createdAt)
: "N/A";
const createdBy: string =
note.createdByUser?.name?.toString() ||
note.createdByUser?.email?.toString() ||
"Unknown";
contextText += `**[${noteTime}] ${createdBy}:**\n`;
contextText += `${note.note || "N/A"}\n\n`;
}
}
// Public notes
if (publicNotes.length > 0) {
contextText += "# Public Notes\n\n";
for (const note of publicNotes) {
const noteTime: string = note.postedAt
? OneUptimeDate.getDateAsFormattedString(note.postedAt)
: note.createdAt
? OneUptimeDate.getDateAsFormattedString(note.createdAt)
: "N/A";
const createdBy: string =
note.createdByUser?.name?.toString() ||
note.createdByUser?.email?.toString() ||
"Unknown";
contextText += `**[${noteTime}] ${createdBy}:**\n`;
contextText += `${note.note || "N/A"}\n\n`;
}
}
// Workspace messages (Slack/Teams)
if (workspaceMessages.length > 0) {
contextText += "# Discussion from Incident Channel\n\n";
contextText += WorkspaceUtil.formatMessagesAsContext(workspaceMessages, {
includeTimestamp: true,
includeUsername: true,
maxLength: 30000,
});
contextText += "\n\n";
}
// System prompt for postmortem generation
let systemPrompt: string;
if (template) {
// When a template is provided, strictly fill only the template
systemPrompt = `You are an expert Site Reliability Engineer (SRE) and incident response specialist. Your task is to fill in an incident postmortem template based on the provided incident data.
CRITICAL INSTRUCTIONS:
- You MUST use ONLY the exact template structure provided below
- Fill in each section of the template with relevant information from the incident data
- Do NOT add any new sections, headers, or content that is not part of the template
- Do NOT add introductions, conclusions, or any text outside the template structure
- If a section in the template has no relevant data, write "No data available" or leave the placeholder text
- Be blameless - focus on systemic improvements rather than individual blame
- Write in a professional, clear, and concise manner
TEMPLATE TO FILL (use this exact structure):
${template}`;
} else {
// When no template is provided, use standard format
systemPrompt = `You are an expert Site Reliability Engineer (SRE) and incident response specialist. Your task is to generate a comprehensive, well-structured incident postmortem based on the provided incident data.
The postmortem should:
1. Be written in a blameless manner, focusing on systemic improvements rather than individual blame
2. Include a clear executive summary
3. Provide a detailed timeline of events
4. Identify the root cause(s) and contributing factors
5. Outline the impact on users and systems
6. List actionable items to prevent recurrence
7. Include lessons learned
Use a standard incident postmortem format with sections for: Executive Summary, Timeline, Root Cause Analysis, Impact, Action Items, and Lessons Learned.
Write in a professional, clear, and concise manner. Use markdown formatting for better readability.`;
}
// Build user message based on whether template is provided
const userMessage: string = template
? `Fill in the template above using ONLY the following incident data. Output only the filled template, nothing else:\n\n${contextText}`
: `Based on the following incident data, please generate a comprehensive incident postmortem:\n\n${contextText}`;
// Build messages array
const messages: Array<LLMMessage> = [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: userMessage,
},
];
return {
contextText,
systemPrompt,
messages,
};
}
@CaptureSpan()
public static buildGenericAIContext(data: {
systemPrompt: string;
userPrompt: string;
context?: string;
}): AIGenerationContext {
let userContent: string = data.userPrompt;
if (data.context) {
userContent = `${data.userPrompt}\n\nContext:\n${data.context}`;
}
const messages: Array<LLMMessage> = [
{
role: "system",
content: data.systemPrompt,
},
{
role: "user",
content: userContent,
},
];
return {
contextText: data.context || "",
systemPrompt: data.systemPrompt,
messages,
};
}
@CaptureSpan()
private static async getWorkspaceMessagesForIncident(data: {
projectId: ObjectID;
workspaceChannels: Array<NotificationRuleWorkspaceChannel>;
limit?: number;
oldestTimestamp?: Date;
}): Promise<Array<WorkspaceChannelMessage>> {
const allMessages: Array<WorkspaceChannelMessage> = [];
for (const channel of data.workspaceChannels) {
try {
// Get auth token for this workspace type
const projectAuth: WorkspaceProjectAuthToken | null =
await WorkspaceProjectAuthTokenService.getProjectAuth({
projectId: data.projectId,
workspaceType: channel.workspaceType,
});
if (!projectAuth || !projectAuth.authToken) {
logger.debug(
`No auth token found for workspace type: ${channel.workspaceType}`,
);
continue;
}
const messagesParams: {
channelId: string;
authToken: string;
projectId: ObjectID;
workspaceType: WorkspaceType;
teamId?: string;
limit?: number;
oldestTimestamp?: Date;
} = {
channelId: channel.id,
authToken: projectAuth.authToken,
projectId: data.projectId,
workspaceType: channel.workspaceType,
};
if (channel.teamId) {
messagesParams.teamId = channel.teamId;
}
if (data.limit !== undefined) {
messagesParams.limit = data.limit;
}
if (data.oldestTimestamp) {
messagesParams.oldestTimestamp = data.oldestTimestamp;
}
const messages: Array<WorkspaceChannelMessage> =
await WorkspaceUtil.getChannelMessages(messagesParams);
allMessages.push(...messages);
} catch (error) {
logger.error(
`Error fetching messages from channel ${channel.id}: ${error}`,
);
// Continue with other channels even if one fails
}
}
// Sort all messages by timestamp
allMessages.sort(
(a: WorkspaceChannelMessage, b: WorkspaceChannelMessage) => {
return a.timestamp.getTime() - b.timestamp.getTime();
},
);
return allMessages;
}
}

View File

@@ -0,0 +1,276 @@
import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
import HTTPResponse from "../../../Types/API/HTTPResponse";
import URL from "../../../Types/API/URL";
import { JSONObject } from "../../../Types/JSON";
import API from "../../../Utils/API";
import LlmType from "../../../Types/LLM/LlmType";
import BadDataException from "../../../Types/Exception/BadDataException";
import logger from "../Logger";
import CaptureSpan from "../Telemetry/CaptureSpan";
export interface LLMMessage {
role: "system" | "user" | "assistant";
content: string;
}
export interface LLMCompletionRequest {
messages: Array<LLMMessage>;
temperature?: number;
llmProviderConfig: LLMProviderConfig;
}
export interface LLMUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
export interface LLMCompletionResponse {
content: string;
usage: LLMUsage | undefined;
}
export interface LLMProviderConfig {
llmType: LlmType;
apiKey?: string;
baseUrl?: string;
modelName?: string;
}
export default class LLMService {
@CaptureSpan()
public static async getCompletion(
request: LLMCompletionRequest,
): Promise<LLMCompletionResponse> {
const config: LLMProviderConfig = request.llmProviderConfig;
switch (config.llmType) {
case LlmType.OpenAI:
return await this.getOpenAICompletion(config, request);
case LlmType.Anthropic:
return await this.getAnthropicCompletion(config, request);
case LlmType.Ollama:
return await this.getOllamaCompletion(config, request);
default:
throw new BadDataException(`Unsupported LLM type: ${config.llmType}`);
}
}
@CaptureSpan()
private static async getOpenAICompletion(
config: LLMProviderConfig,
request: LLMCompletionRequest,
): Promise<LLMCompletionResponse> {
if (!config.apiKey) {
throw new BadDataException("OpenAI API key is required");
}
const baseUrl: string = config.baseUrl || "https://api.openai.com/v1";
const modelName: string = config.modelName || "gpt-4o";
const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
await API.post<JSONObject>({
url: URL.fromString(`${baseUrl}/chat/completions`),
data: {
model: modelName,
messages: request.messages.map((msg: LLMMessage) => {
return {
role: msg.role,
content: msg.content,
};
}),
temperature: request.temperature ?? 0.7,
},
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
},
options: {
retries: 2,
exponentialBackoff: true,
timeout: 120000, // 2 minutes timeout for LLM calls
},
});
if (response instanceof HTTPErrorResponse) {
logger.error("Error from OpenAI API:");
logger.error(response);
throw new BadDataException(
`OpenAI API error: ${JSON.stringify(response.jsonData)}`,
);
}
const jsonData: JSONObject = response.jsonData as JSONObject;
const choices: Array<JSONObject> = jsonData["choices"] as Array<JSONObject>;
if (!choices || choices.length === 0) {
throw new BadDataException("No response from OpenAI");
}
const message: JSONObject = choices[0]!["message"] as JSONObject;
const usage: JSONObject = jsonData["usage"] as JSONObject;
return {
content: message["content"] as string,
usage: usage
? {
promptTokens: usage["prompt_tokens"] as number,
completionTokens: usage["completion_tokens"] as number,
totalTokens: usage["total_tokens"] as number,
}
: undefined,
};
}
@CaptureSpan()
private static async getAnthropicCompletion(
config: LLMProviderConfig,
request: LLMCompletionRequest,
): Promise<LLMCompletionResponse> {
if (!config.apiKey) {
throw new BadDataException("Anthropic API key is required");
}
const baseUrl: string = config.baseUrl || "https://api.anthropic.com/v1";
const modelName: string = config.modelName || "claude-sonnet-4-20250514";
// Anthropic requires system message to be separate
let systemMessage: string = "";
const userMessages: Array<{ role: string; content: string }> = [];
for (const msg of request.messages) {
if (msg.role === "system") {
systemMessage = msg.content;
} else {
userMessages.push({
role: msg.role,
content: msg.content,
});
}
}
const requestData: JSONObject = {
model: modelName,
messages: userMessages,
temperature: request.temperature ?? 0.7,
};
if (systemMessage) {
requestData["system"] = systemMessage;
}
const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
await API.post<JSONObject>({
url: URL.fromString(`${baseUrl}/messages`),
data: requestData,
headers: {
"x-api-key": config.apiKey,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
options: {
retries: 2,
exponentialBackoff: true,
timeout: 120000,
},
});
if (response instanceof HTTPErrorResponse) {
logger.error("Error from Anthropic API:");
logger.error(response);
throw new BadDataException(
`Anthropic API error: ${JSON.stringify(response.jsonData)}`,
);
}
const jsonData: JSONObject = response.jsonData as JSONObject;
const content: Array<JSONObject> = jsonData["content"] as Array<JSONObject>;
if (!content || content.length === 0) {
throw new BadDataException("No response from Anthropic");
}
const textContent: JSONObject | undefined = content.find(
(c: JSONObject) => {
return c["type"] === "text";
},
);
if (!textContent) {
throw new BadDataException("No text content in Anthropic response");
}
const usage: JSONObject = jsonData["usage"] as JSONObject;
return {
content: textContent["text"] as string,
usage: usage
? {
promptTokens: usage["input_tokens"] as number,
completionTokens: usage["output_tokens"] as number,
totalTokens:
((usage["input_tokens"] as number) || 0) +
((usage["output_tokens"] as number) || 0),
}
: undefined,
};
}
@CaptureSpan()
private static async getOllamaCompletion(
config: LLMProviderConfig,
request: LLMCompletionRequest,
): Promise<LLMCompletionResponse> {
if (!config.baseUrl) {
throw new BadDataException("Ollama base URL is required");
}
const modelName: string = config.modelName || "llama2";
const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
await API.post<JSONObject>({
url: URL.fromString(`${config.baseUrl}/api/chat`),
data: {
model: modelName,
messages: request.messages.map((msg: LLMMessage) => {
return {
role: msg.role,
content: msg.content,
};
}),
stream: false,
options: {
temperature: request.temperature ?? 0.7,
},
},
headers: {
"Content-Type": "application/json",
},
options: {
retries: 2,
exponentialBackoff: true,
timeout: 300000, // 5 minutes for Ollama as it may be slower
},
});
if (response instanceof HTTPErrorResponse) {
logger.error("Error from Ollama API:");
logger.error(response);
throw new BadDataException(
`Ollama API error: ${JSON.stringify(response.jsonData)}`,
);
}
const jsonData: JSONObject = response.jsonData as JSONObject;
const message: JSONObject = jsonData["message"] as JSONObject;
if (!message) {
throw new BadDataException("No response from Ollama");
}
return {
content: message["content"] as string,
usage: undefined, // Ollama doesn't provide token usage in the same way
};
}
}

View File

@@ -1,3 +1,4 @@
import { WorkspaceChannelMessage } from "../Workspace";
import HTTPErrorResponse from "../../../../Types/API/HTTPErrorResponse";
import HTTPResponse from "../../../../Types/API/HTTPResponse";
import URL from "../../../../Types/API/URL";
@@ -3062,4 +3063,169 @@ All monitoring checks are passing normally.`;
throw error;
}
}
@CaptureSpan()
public static async getChannelMessages(params: {
channelId: string;
teamId: string;
projectId: ObjectID;
limit?: number;
oldestTimestamp?: Date;
}): Promise<
Array<{
messageId: string;
text: string;
userId?: string;
username?: string;
timestamp: Date;
isBot: boolean;
}>
> {
const messages: Array<{
messageId: string;
text: string;
userId?: string;
username?: string;
timestamp: Date;
isBot: boolean;
}> = [];
try {
// Get valid access token
const projectAuth: WorkspaceProjectAuthToken | null =
await WorkspaceProjectAuthTokenService.getProjectAuth({
projectId: params.projectId,
workspaceType: WorkspaceType.MicrosoftTeams,
});
if (!projectAuth || !projectAuth.miscData) {
logger.error("Microsoft Teams integration not found for this project");
return messages;
}
const miscData: JSONObject = projectAuth.miscData as JSONObject;
const accessToken: string = miscData["appAccessToken"] as string;
const tokenExpiresAt: string = miscData[
"appAccessTokenExpiresAt"
] as string;
// Check if token is expired
if (
!accessToken ||
(tokenExpiresAt &&
OneUptimeDate.isInThePast(OneUptimeDate.fromString(tokenExpiresAt)))
) {
logger.debug(
"Microsoft Teams access token expired or missing, skipping message fetch",
);
return messages;
}
// Fetch messages from Microsoft Teams channel
let nextLink: string | undefined = undefined;
const maxMessages: number = params.limit || 1000;
const maxPages: number = 10;
let pageCount: number = 0;
do {
let requestUrl: string;
if (nextLink) {
requestUrl = nextLink;
} else {
requestUrl = `https://graph.microsoft.com/v1.0/teams/${params.teamId}/channels/${params.channelId}/messages`;
requestUrl += `?$top=${Math.min(50, maxMessages - messages.length)}`;
}
const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
await API.get<JSONObject>({
url: URL.fromString(requestUrl),
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
options: {
retries: 2,
exponentialBackoff: true,
},
});
if (response instanceof HTTPErrorResponse) {
logger.error(
"Error response from Microsoft Teams API for channel messages:",
);
logger.error(response);
break;
}
const jsonData: JSONObject = response.jsonData as JSONObject;
const teamsMessages: Array<JSONObject> =
(jsonData["value"] as Array<JSONObject>) || [];
for (const msg of teamsMessages) {
// Skip system messages
if (msg["messageType"] !== "message") {
continue;
}
const body: JSONObject = msg["body"] as JSONObject;
let text: string = (body?.["content"] as string) || "";
// Remove HTML tags if present (Teams uses HTML)
text = text.replace(/<[^>]*>/g, "");
text = text.trim();
// Skip empty messages
if (!text) {
continue;
}
const from: JSONObject = msg["from"] as JSONObject;
const user: JSONObject = from?.["user"] as JSONObject;
const isBot: boolean = Boolean(from?.["application"]);
const createdDateTime: string = msg["createdDateTime"] as string;
const timestamp: Date = createdDateTime
? new Date(createdDateTime)
: new Date();
// Check if message is older than the oldest timestamp filter
if (params.oldestTimestamp && timestamp < params.oldestTimestamp) {
continue;
}
messages.push({
messageId: msg["id"] as string,
text: text,
userId: user?.["id"] as string,
username: user?.["displayName"] as string,
timestamp: timestamp,
isBot: isBot,
});
}
nextLink = jsonData["@odata.nextLink"] as string;
pageCount++;
} while (
nextLink &&
messages.length < maxMessages &&
pageCount < maxPages
);
logger.debug(
`Retrieved ${messages.length} messages from Microsoft Teams channel ${params.channelId}`,
);
// Sort by timestamp (oldest first)
messages.sort(
(a: WorkspaceChannelMessage, b: WorkspaceChannelMessage) => {
return a.timestamp.getTime() - b.timestamp.getTime();
},
);
} catch (error) {
logger.error(`Error fetching Microsoft Teams channel messages: ${error}`);
}
return messages;
}
}

View File

@@ -1896,4 +1896,138 @@ export default class SlackUtil extends WorkspaceBase {
public static convertMarkdownToSlackRichText(markdown: string): string {
return SlackifyMarkdown(markdown);
}
@CaptureSpan()
public static async getChannelMessages(params: {
channelId: string;
authToken: string;
limit?: number;
oldestTimestamp?: Date;
}): Promise<
Array<{
messageId: string;
text: string;
userId?: string;
username?: string;
timestamp: Date;
isBot: boolean;
}>
> {
const messages: Array<{
messageId: string;
text: string;
userId?: string;
username?: string;
timestamp: Date;
isBot: boolean;
}> = [];
let cursor: string | undefined = undefined;
const maxMessages: number = params.limit || 1000;
const maxPages: number = 10;
let pageCount: number = 0;
do {
const requestData: JSONObject = {
channel: params.channelId,
limit: Math.min(200, maxMessages - messages.length),
};
if (cursor) {
requestData["cursor"] = cursor;
}
if (params.oldestTimestamp) {
requestData["oldest"] = (
params.oldestTimestamp.getTime() / 1000
).toString();
}
const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
await API.post<JSONObject>({
url: URL.fromString("https://slack.com/api/conversations.history"),
data: requestData,
headers: {
Authorization: `Bearer ${params.authToken}`,
"Content-Type": "application/x-www-form-urlencoded",
},
options: {
retries: 3,
exponentialBackoff: true,
},
});
if (response instanceof HTTPErrorResponse) {
logger.error("Error response from Slack API for channel history:");
logger.error(response);
break;
}
const jsonData: JSONObject = response.jsonData as JSONObject;
if (jsonData["ok"] !== true) {
logger.error("Invalid response from Slack API for channel history:");
logger.error(jsonData);
break;
}
const slackMessages: Array<JSONObject> =
(jsonData["messages"] as Array<JSONObject>) || [];
for (const msg of slackMessages) {
// Skip bot messages if they're from the OneUptime bot (app messages)
const isBot: boolean =
Boolean(msg["bot_id"]) || msg["subtype"] === "bot_message";
// Extract text, handling attachments and blocks
let text: string = (msg["text"] as string) || "";
// If there are attachments, append their text
const attachments: Array<JSONObject> | undefined = msg[
"attachments"
] as Array<JSONObject> | undefined;
if (attachments && Array.isArray(attachments)) {
for (const attachment of attachments) {
if (attachment && attachment["text"]) {
text += "\n" + (attachment["text"] as string);
}
if (attachment && attachment["fallback"]) {
text += "\n" + (attachment["fallback"] as string);
}
}
}
// Skip empty messages
if (!text.trim()) {
continue;
}
const timestamp: Date = msg["ts"]
? new Date(parseFloat(msg["ts"] as string) * 1000)
: new Date();
messages.push({
messageId: msg["ts"] as string,
text: text,
userId: msg["user"] as string,
username: msg["username"] as string,
timestamp: timestamp,
isBot: isBot,
});
}
cursor = (jsonData["response_metadata"] as JSONObject)?.[
"next_cursor"
] as string;
pageCount++;
} while (cursor && messages.length < maxMessages && pageCount < maxPages);
logger.debug(
`Retrieved ${messages.length} messages from Slack channel ${params.channelId}`,
);
// Reverse to get chronological order (Slack returns newest first)
messages.reverse();
return messages;
}
}

View File

@@ -17,6 +17,16 @@ import WorkspaceUserAuthToken from "../../../Models/DatabaseModels/WorkspaceUser
import WorkspaceUserAuthTokenService from "../../Services/WorkspaceUserAuthTokenService";
import UserService from "../../Services/UserService";
import CaptureSpan from "../Telemetry/CaptureSpan";
import OneUptimeDate from "../../../Types/Date";
export interface WorkspaceChannelMessage {
messageId: string;
text: string;
userId?: string;
username?: string;
timestamp: Date;
isBot: boolean;
}
export default class WorkspaceUtil {
@CaptureSpan()
@@ -236,4 +246,120 @@ export default class WorkspaceUtil {
return result;
}
@CaptureSpan()
public static async getChannelMessages(params: {
channelId: string;
authToken: string;
projectId: ObjectID;
workspaceType: WorkspaceType;
teamId?: string;
limit?: number;
oldestTimestamp?: Date;
}): Promise<Array<WorkspaceChannelMessage>> {
switch (params.workspaceType) {
case WorkspaceType.Slack: {
const slackParams: {
channelId: string;
authToken: string;
limit?: number;
oldestTimestamp?: Date;
} = {
channelId: params.channelId,
authToken: params.authToken,
};
if (params.limit !== undefined) {
slackParams.limit = params.limit;
}
if (params.oldestTimestamp) {
slackParams.oldestTimestamp = params.oldestTimestamp;
}
return await SlackWorkspace.getChannelMessages(slackParams);
}
case WorkspaceType.MicrosoftTeams: {
if (!params.teamId) {
logger.error(
"Team ID is required for Microsoft Teams channel messages",
);
return [];
}
const teamsParams: {
channelId: string;
teamId: string;
projectId: ObjectID;
limit?: number;
oldestTimestamp?: Date;
} = {
channelId: params.channelId,
teamId: params.teamId,
projectId: params.projectId,
};
if (params.limit !== undefined) {
teamsParams.limit = params.limit;
}
if (params.oldestTimestamp) {
teamsParams.oldestTimestamp = params.oldestTimestamp;
}
return await MicrosoftTeamsUtil.getChannelMessages(teamsParams);
}
default:
logger.debug(
`Unsupported workspace type for channel messages: ${params.workspaceType}`,
);
return [];
}
}
@CaptureSpan()
public static formatMessagesAsContext(
messages: Array<WorkspaceChannelMessage>,
options?: {
includeTimestamp?: boolean;
includeUsername?: boolean;
maxLength?: number;
},
): string {
const includeTimestamp: boolean = options?.includeTimestamp ?? true;
const includeUsername: boolean = options?.includeUsername ?? true;
const maxLength: number = options?.maxLength || 50000;
let context: string = "";
for (const msg of messages) {
let line: string = "";
if (includeTimestamp) {
const dateStr: string = OneUptimeDate.getDateAsFormattedString(
msg.timestamp,
);
line += `[${dateStr}] `;
}
if (includeUsername && msg.username) {
line += `${msg.username}: `;
} else if (includeUsername && msg.userId) {
line += `User ${msg.userId}: `;
}
line += msg.text;
line += "\n";
// Check if adding this line would exceed max length
if (context.length + line.length > maxLength) {
context += "\n... (messages truncated due to length)";
break;
}
context += line;
}
return context.trim();
}
}

View File

@@ -0,0 +1,7 @@
enum LlmLogStatus {
Success = "Success",
Error = "Error",
InsufficientBalance = "Insufficient Balance",
}
export default LlmLogStatus;

View File

@@ -147,6 +147,7 @@ enum Permission {
ReadCallLog = "ReadCallLog",
ReadPushLog = "ReadPushLog",
ReadWorkspaceNotificationLog = "ReadWorkspaceNotificationLog",
ReadLlmLog = "ReadLlmLog",
CreateIncidentOwnerTeam = "CreateIncidentOwnerTeam",
DeleteIncidentOwnerTeam = "DeleteIncidentOwnerTeam",
@@ -3143,6 +3144,14 @@ export class PermissionHelper {
isAccessControlPermission: false,
},
{
permission: Permission.ReadLlmLog,
title: "Read LLM Log",
description: "This permission can read LLM Logs of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.CreateMonitorProbe,
title: "Create Monitor Probe",

View File

@@ -0,0 +1,96 @@
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
export interface AILoaderProps {
/** Optional title to display */
title?: string | undefined;
/** Data sources that AI is analyzing */
dataSourceItems?: Array<string> | undefined;
}
const loadingMessages: Array<string> = [
"Gathering context",
"Analyzing data",
"Generating content",
];
const AILoader: FunctionComponent<AILoaderProps> = (
props: AILoaderProps,
): ReactElement => {
const [currentMessageIndex, setCurrentMessageIndex] = useState<number>(0);
// Cycle through messages
useEffect(() => {
const interval: NodeJS.Timeout = setInterval(() => {
setCurrentMessageIndex((prev: number) => {
return (prev + 1) % loadingMessages.length;
});
}, 3000);
return () => {
clearInterval(interval);
};
}, []);
return (
<div className="py-12 px-4">
<div className="flex flex-col items-center justify-center">
{/* Minimal animated bars */}
<div className="flex items-end gap-1 h-8 mb-8">
{[0, 1, 2, 3, 4].map((index: number) => {
return (
<div
key={index}
className="w-1 bg-gray-800 rounded-full"
style={{
animation: "aiBarPulse 1.2s ease-in-out infinite",
animationDelay: `${index * 0.1}s`,
}}
/>
);
})}
</div>
{/* Title */}
<p className="text-sm font-medium text-gray-900 mb-1 tracking-wide">
{props.title || "Generating"}
</p>
{/* Current stage with fade transition */}
<p className="text-xs text-gray-500 h-4 transition-opacity duration-500">
{loadingMessages[currentMessageIndex]}
</p>
{/* Subtle data sources indicator */}
{props.dataSourceItems && props.dataSourceItems.length > 0 && (
<p className="text-xs text-gray-400 mt-6">
Analyzing {props.dataSourceItems.length} data source
{props.dataSourceItems.length > 1 ? "s" : ""}
</p>
)}
</div>
{/* CSS for animation */}
<style>
{`
@keyframes aiBarPulse {
0%, 100% {
height: 8px;
opacity: 0.4;
}
50% {
height: 24px;
opacity: 1;
}
}
`}
</style>
</div>
);
};
export default AILoader;

View File

@@ -0,0 +1,328 @@
import React, {
FunctionComponent,
ReactElement,
useState,
useEffect,
} from "react";
import Modal, { ModalWidth } from "../Modal/Modal";
import AILoader from "./AILoader";
import ErrorMessage from "../ErrorMessage/ErrorMessage";
import ButtonType from "../Button/ButtonTypes";
import { ButtonStyleType } from "../Button/Button";
import IconProp from "../../../Types/Icon/IconProp";
import Dropdown, { DropdownOption, DropdownValue } from "../Dropdown/Dropdown";
import MarkdownEditor from "../Markdown.tsx/MarkdownEditor";
export interface GenerateFromAIModalProps {
title: string;
description?: string;
onClose: () => void;
onGenerate: (data: GenerateAIRequestData) => Promise<string>;
onSuccess: (generatedContent: string) => void;
templates?: Array<{ id: string; name: string; content?: string }>;
/** Optional list of data sources that will be used for generation (shown when no templates) */
dataSourceItems?: Array<string>;
}
export interface GenerateAIRequestData {
template?: string;
templateId?: string;
}
// Default hardcoded templates for incident postmortem
const DEFAULT_TEMPLATES: Array<{ id: string; name: string; content: string }> =
[
{
id: "default-standard",
name: "Standard Postmortem",
content: `## Executive Summary
[Brief overview of the incident, its impact, and resolution]
## Incident Timeline
| Time | Event |
|------|-------|
| [Time] | [Event description] |
## Root Cause Analysis
[Detailed analysis of what caused the incident]
## Impact Assessment
- **Duration**: [How long the incident lasted]
- **Users Affected**: [Number or percentage of affected users]
- **Services Affected**: [List of affected services]
## Resolution
[Steps taken to resolve the incident]
## Action Items
- [ ] [Action item 1]
- [ ] [Action item 2]
- [ ] [Action item 3]
## Lessons Learned
[Key takeaways and improvements identified]`,
},
{
id: "default-detailed",
name: "Detailed Technical Postmortem",
content: `## Incident Overview
**Incident Title**: [Title]
**Severity**: [P1/P2/P3/P4]
**Duration**: [Start time] - [End time]
**Authors**: [Names]
## Summary
[2-3 sentence summary of the incident]
## Detection
- **How was the incident detected?** [Monitoring alert / Customer report / etc.]
- **Time to detection**: [Duration from start to detection]
## Timeline
| Timestamp | Action | Owner |
|-----------|--------|-------|
| [Time] | [What happened] | [Who did it] |
## Root Cause
### Primary Cause
[Detailed explanation of the root cause]
### Contributing Factors
1. [Factor 1]
2. [Factor 2]
## Impact
### Customer Impact
[Description of how customers were affected]
### Business Impact
[Description of business consequences]
### Technical Impact
[Systems and services affected]
## Mitigation & Resolution
### Immediate Actions
[Steps taken to stop the bleeding]
### Permanent Fix
[Long-term solution implemented]
## Prevention
### What Went Well
- [Item 1]
- [Item 2]
### What Went Wrong
- [Item 1]
- [Item 2]
### Where We Got Lucky
- [Item 1]
## Action Items
| Action | Owner | Priority | Due Date |
|--------|-------|----------|----------|
| [Action] | [Name] | [High/Medium/Low] | [Date] |
## Appendix
[Any additional technical details, logs, or graphs]`,
},
{
id: "default-brief",
name: "Brief Postmortem",
content: `## What Happened
[Concise description of the incident]
## Why It Happened
[Root cause explanation]
## How We Fixed It
[Resolution steps]
## How We Prevent It
- [ ] [Prevention action 1]
- [ ] [Prevention action 2]`,
},
];
const GenerateFromAIModal: FunctionComponent<GenerateFromAIModalProps> = (
props: GenerateFromAIModalProps,
): ReactElement => {
const [isGenerating, setIsGenerating] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [templateContent, setTemplateContent] = useState<string>("");
// Combine default templates with custom templates
const allTemplates: Array<{ id: string; name: string; content?: string }> = [
...DEFAULT_TEMPLATES,
...(props.templates || []),
];
// Build dropdown options
const templateOptions: Array<DropdownOption> = [
{
label: "No template (AI will use default format)",
value: "",
},
...allTemplates.map(
(template: { id: string; name: string; content?: string }) => {
return {
label: template.name,
value: template.id,
};
},
),
];
// Update template content when selection changes
useEffect(() => {
if (selectedTemplateId) {
const selectedTemplate:
| { id: string; name: string; content?: string }
| undefined = allTemplates.find(
(t: { id: string; name: string; content?: string }) => {
return t.id === selectedTemplateId;
},
);
setTemplateContent(selectedTemplate?.content || "");
} else {
setTemplateContent("");
}
}, [selectedTemplateId]);
const handleGenerate: () => Promise<void> = async (): Promise<void> => {
setIsGenerating(true);
setError("");
try {
const requestData: GenerateAIRequestData = {};
// Use the edited template content if a template was selected
if (selectedTemplateId && templateContent) {
requestData.template = templateContent;
requestData.templateId = selectedTemplateId;
}
const generatedContent: string = await props.onGenerate(requestData);
props.onSuccess(generatedContent);
} catch (err) {
if (err instanceof Error) {
setError(err.message);
} else {
setError("An error occurred while generating content.");
}
} finally {
setIsGenerating(false);
}
};
return (
<Modal
title={props.title}
description={
props.description ||
"Generate content using AI based on the available data."
}
onClose={() => {
if (!isGenerating) {
props.onClose();
}
}}
submitButtonText={isGenerating ? "Generating..." : "Generate with AI"}
submitButtonStyleType={ButtonStyleType.SUCCESS}
submitButtonType={ButtonType.Button}
isLoading={isGenerating}
disableSubmitButton={isGenerating}
onSubmit={handleGenerate}
modalWidth={ModalWidth.Large}
icon={IconProp.Bolt}
>
<>
{error && <ErrorMessage message={error} />}
{isGenerating && (
<AILoader
title="Generating with AI"
dataSourceItems={props.dataSourceItems || undefined}
/>
)}
{!isGenerating && (
<div className="space-y-4">
{/* Template Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Select Template (Optional)
</label>
<Dropdown
options={templateOptions}
value={templateOptions.find((opt: DropdownOption) => {
return opt.value === selectedTemplateId;
})}
onChange={(
value: DropdownValue | Array<DropdownValue> | null,
) => {
setSelectedTemplateId((value as string) || "");
}}
placeholder="Select a template..."
/>
<p className="mt-1 text-xs text-gray-500">
Choose a template to guide the AI generation. You can edit it
below before generating.
</p>
</div>
{/* Template Preview/Editor */}
{selectedTemplateId && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Template Preview
</label>
<p className="text-xs text-gray-500 mb-2">
Edit the template below. AI will fill in the sections with
incident data.
</p>
<div className="border border-gray-200 rounded-md">
<MarkdownEditor
key={selectedTemplateId}
initialValue={templateContent}
onChange={(value: string) => {
setTemplateContent(value);
}}
placeholder="Template content..."
/>
</div>
</div>
)}
{/* Data Sources Info */}
{!selectedTemplateId &&
props.dataSourceItems &&
props.dataSourceItems.length > 0 && (
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-2">
AI will analyze the following data sources:
</p>
<ul className="list-disc ml-5 space-y-1">
{props.dataSourceItems.map(
(item: string, index: number): ReactElement => {
return (
<li key={index} className="text-sm text-gray-600">
{item}
</li>
);
},
)}
</ul>
</div>
)}
</div>
)}
</>
</Modal>
);
};
export default GenerateFromAIModal;

View File

@@ -0,0 +1,204 @@
import React, { FunctionComponent, ReactElement, useState } from "react";
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
import LlmLog from "Common/Models/DatabaseModels/LlmLog";
import FieldType from "Common/UI/Components/Types/FieldType";
import Columns from "Common/UI/Components/ModelTable/Columns";
import Pill from "Common/UI/Components/Pill/Pill";
import Color from "Common/Types/Color";
import { Green, Red, Yellow } from "Common/Types/BrandColors";
import LlmLogStatus from "Common/Types/LlmLogStatus";
import ProjectUtil from "Common/UI/Utils/Project";
import Filter from "Common/UI/Components/ModelFilter/Filter";
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
import IconProp from "Common/Types/Icon/IconProp";
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
import Query from "Common/Types/BaseDatabase/Query";
import UserElement from "../User/User";
import User from "Common/Models/DatabaseModels/User";
import { BILLING_ENABLED } from "Common/UI/Config";
export interface LlmLogsTableProps {
query?: Query<LlmLog>;
singularName?: string;
}
const LlmLogsTable: FunctionComponent<LlmLogsTableProps> = (
props: LlmLogsTableProps,
): ReactElement => {
const [showModal, setShowModal] = useState<boolean>(false);
const [modalText, setModalText] = useState<string>("");
const [modalTitle, setModalTitle] = useState<string>("");
const defaultColumns: Columns<LlmLog> = [
{
field: { llmProviderName: true },
title: "Provider",
type: FieldType.Text,
noValueMessage: "-",
},
{
field: { feature: true },
title: "Feature",
type: FieldType.Text,
noValueMessage: "-",
},
{
field: {
user: {
name: true,
email: true,
profilePictureId: true,
},
},
title: "User",
type: FieldType.Text,
noValueMessage: "-",
getElement: (item: LlmLog): ReactElement => {
if (!item["user"]) {
return <p>-</p>;
}
return <UserElement user={item["user"] as User} />;
},
},
{
field: { totalTokens: true },
title: "Tokens Used",
type: FieldType.Number,
},
// Only show cost column if billing is enabled
...(BILLING_ENABLED
? [
{
field: { costInUSDCents: true },
title: "Cost (USD)",
type: FieldType.Text,
getElement: (item: LlmLog): ReactElement => {
const cents: number = item["costInUSDCents"] as number;
if (cents === undefined || cents === null) {
return <p>-</p>;
}
const usd: number = cents / 100;
return <p>${usd.toFixed(4)}</p>;
},
},
]
: []),
{
field: { createdAt: true },
title: "Time",
type: FieldType.DateTime,
},
{
field: { status: true },
title: "Status",
type: FieldType.Text,
getElement: (item: LlmLog): ReactElement => {
if (item["status"]) {
let color: Color = Green;
if (item["status"] === LlmLogStatus.Error) {
color = Red;
}
if (item["status"] === LlmLogStatus.InsufficientBalance) {
color = Yellow;
}
return (
<Pill
isMinimal={false}
color={color}
text={item["status"] as string}
/>
);
}
return <></>;
},
},
];
const defaultFilters: Array<Filter<LlmLog>> = [
{ field: { createdAt: true }, title: "Time", type: FieldType.Date },
{ field: { status: true }, title: "Status", type: FieldType.Text },
{ field: { llmType: true }, title: "Provider Type", type: FieldType.Text },
{ field: { feature: true }, title: "Feature", type: FieldType.Text },
];
return (
<>
<ModelTable<LlmLog>
modelType={LlmLog}
id={
props.singularName
? `${props.singularName.replace(/\s+/g, "-").toLowerCase()}-llm-logs-table`
: "llm-logs-table"
}
userPreferencesKey={
props.singularName
? `${props.singularName.replace(/\s+/g, "-").toLowerCase()}-llm-logs-table`
: "llm-logs-table"
}
name="AI Logs"
isDeleteable={false}
isEditable={false}
isCreateable={false}
showViewIdButton={true}
isViewable={false}
query={{
projectId: ProjectUtil.getCurrentProjectId()!,
...(props.query || {}),
}}
selectMoreFields={{
statusMessage: true,
}}
cardProps={{
title: "AI Logs",
description: props.singularName
? `AI usage logs for this ${props.singularName}.`
: "AI usage logs for this project.",
}}
noItemsMessage={
props.singularName
? `No AI logs for this ${props.singularName}.`
: "No AI logs."
}
showRefreshButton={true}
columns={defaultColumns}
filters={defaultFilters}
actionButtons={[
{
title: "View Error",
buttonStyleType: ButtonStyleType.NORMAL,
icon: IconProp.Error,
isVisible: (item: LlmLog): boolean => {
return (
item["status"] === LlmLogStatus.Error ||
item["status"] === LlmLogStatus.InsufficientBalance
);
},
onClick: async (item: LlmLog, onCompleteAction: VoidFunction) => {
setModalText(
(item["statusMessage"] as string) || "No error message",
);
setModalTitle("Status Message");
setShowModal(true);
onCompleteAction();
},
},
]}
/>
{showModal && (
<ConfirmModal
title={modalTitle}
description={modalText}
onSubmit={() => {
return setShowModal(false);
}}
submitButtonText="Close"
submitButtonType={ButtonStyleType.NORMAL}
/>
)}
</>
);
};
export default LlmLogsTable;

View File

@@ -0,0 +1,13 @@
import React, { FunctionComponent, ReactElement } from "react";
import PageComponentProps from "../../PageComponentProps";
import LlmLogsTable from "../../../Components/AILogs/LlmLogsTable";
import Navigation from "Common/UI/Utils/Navigation";
import ObjectID from "Common/Types/ObjectID";
const AlertAILogs: FunctionComponent<PageComponentProps> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
return <LlmLogsTable singularName="alert" query={{ alertId: modelId }} />;
};
export default AlertAILogs;

View File

@@ -100,7 +100,7 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
/>
</SideMenuSection>
<SideMenuSection title="Notification Logs">
<SideMenuSection title="Logs">
<SideMenuItem
link={{
title: "Notification Logs",
@@ -111,6 +111,16 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
}}
icon={IconProp.Bell}
/>
<SideMenuItem
link={{
title: "AI Logs",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.ALERT_VIEW_AI_LOGS] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Bolt}
/>
</SideMenuSection>
<SideMenuSection title="Alert Notes">

View File

@@ -0,0 +1,17 @@
import React, { FunctionComponent, ReactElement } from "react";
import PageComponentProps from "../../PageComponentProps";
import LlmLogsTable from "../../../Components/AILogs/LlmLogsTable";
import Navigation from "Common/UI/Utils/Navigation";
import ObjectID from "Common/Types/ObjectID";
const IncidentAILogs: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
return (
<LlmLogsTable singularName="incident" query={{ incidentId: modelId }} />
);
};
export default IncidentAILogs;

View File

@@ -32,6 +32,13 @@ import AttachmentList from "../../../Components/Attachment/AttachmentList";
import { getModelIdString } from "../../../Utils/ModelId";
import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus";
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
import GenerateFromAIModal, {
GenerateAIRequestData,
} from "Common/UI/Components/AI/GenerateFromAIModal";
import { APP_API_URL } from "Common/UI/Config";
import URL from "Common/Types/API/URL";
import HTTPResponse from "Common/Types/API/HTTPResponse";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
const POSTMORTEM_FORM_FIELDS: Fields<Incident> = [
{
@@ -110,6 +117,11 @@ const IncidentPostmortem: FunctionComponent<
useState<boolean>(false);
const [templateInitialValues, setTemplateInitialValues] =
useState<FormValues<Incident> | null>(null);
const [showAIGenerateModal, setShowAIGenerateModal] =
useState<boolean>(false);
const [aiTemplates, setAiTemplates] = useState<
Array<{ id: string; name: string; content?: string }>
>([]);
const handleResendPostmortemNotification: () => Promise<void> =
async (): Promise<void> => {
@@ -151,13 +163,29 @@ const IncidentPostmortem: FunctionComponent<
limit: LIMIT_PER_PROJECT,
skip: 0,
select: {
templateName: true,
_id: true,
templateName: true,
postmortemNote: true,
},
sort: {},
});
setIncidentPostmortemTemplates(listResult.data);
// Also set AI templates format
const templates: Array<{ id: string; name: string; content?: string }> =
listResult.data.map((template: IncidentPostmortemTemplate) => {
const templateItem: { id: string; name: string; content?: string } = {
id: template._id?.toString() || "",
name: template.templateName || "Unnamed Template",
};
if (template.postmortemNote) {
templateItem.content = template.postmortemNote;
}
return templateItem;
});
setAiTemplates(templates);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
@@ -200,14 +228,57 @@ const IncidentPostmortem: FunctionComponent<
};
useEffect(() => {
if (!showTemplateModal) {
if (!showTemplateModal && !showAIGenerateModal) {
return;
}
fetchTemplates().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, [showTemplateModal]);
}, [showTemplateModal, showAIGenerateModal]);
const handleGeneratePostmortemFromAI: (
data: GenerateAIRequestData,
) => Promise<string> = async (
data: GenerateAIRequestData,
): Promise<string> => {
const apiUrl: URL = URL.fromString(
APP_API_URL.toString() +
`/incident/generate-postmortem-from-ai/${modelId.toString()}`,
);
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.post<JSONObject>({
url: apiUrl,
data: {
template: data.template,
},
});
if (response instanceof HTTPErrorResponse) {
throw new Error(API.getFriendlyMessage(response));
}
const postmortemNote: string = (response.data as JSONObject)[
"postmortemNote"
] as string;
if (!postmortemNote) {
throw new Error("Failed to generate postmortem note from AI.");
}
return postmortemNote;
};
const handleAIGenerationSuccess: (generatedContent: string) => void = (
generatedContent: string,
): void => {
setShowAIGenerateModal(false);
setTemplateInitialValues({
postmortemNote: generatedContent,
});
setShowTemplateEditModal(true);
};
return (
<>
@@ -218,6 +289,14 @@ const IncidentPostmortem: FunctionComponent<
description:
"Document the summary, learnings, and follow-ups for this incident.",
buttons: [
{
title: "Generate from AI",
icon: IconProp.Bolt,
buttonStyle: ButtonStyleType.SUCCESS,
onClick: () => {
setShowAIGenerateModal(true);
},
},
{
title: "Apply Template",
icon: IconProp.Template,
@@ -446,6 +525,27 @@ const IncidentPostmortem: FunctionComponent<
) : (
<></>
)}
{showAIGenerateModal ? (
<GenerateFromAIModal
title="Generate Postmortem with AI"
description="AI will analyze the incident data, timeline, notes, and channel discussions to generate a comprehensive postmortem."
onClose={() => {
setShowAIGenerateModal(false);
}}
onGenerate={handleGeneratePostmortemFromAI}
onSuccess={handleAIGenerationSuccess}
templates={aiTemplates}
dataSourceItems={[
"Incident details and description",
"State timeline and history",
"Internal and public notes",
"Slack/Teams channel discussions",
]}
/>
) : (
<></>
)}
</>
);
};

View File

@@ -111,7 +111,7 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
/>
</SideMenuSection>
<SideMenuSection title="Notification Logs">
<SideMenuSection title="Logs">
<SideMenuItem
link={{
title: "Notification Logs",
@@ -122,6 +122,16 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
}}
icon={IconProp.Bell}
/>
<SideMenuItem
link={{
title: "AI Logs",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.INCIDENT_VIEW_AI_LOGS] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Bolt}
/>
</SideMenuSection>
<SideMenuSection title="Incident Notes">

View File

@@ -0,0 +1,20 @@
import React, { FunctionComponent, ReactElement } from "react";
import PageComponentProps from "../../PageComponentProps";
import LlmLogsTable from "../../../Components/AILogs/LlmLogsTable";
import Navigation from "Common/UI/Utils/Navigation";
import ObjectID from "Common/Types/ObjectID";
const ScheduledMaintenanceAILogs: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
return (
<LlmLogsTable
singularName="scheduled maintenance event"
query={{ scheduledMaintenanceId: modelId }}
/>
);
};
export default ScheduledMaintenanceAILogs;

View File

@@ -63,7 +63,7 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
/>
</SideMenuSection>
<SideMenuSection title="Notification Logs">
<SideMenuSection title="Logs">
<SideMenuItem
link={{
title: "Notification Logs",
@@ -76,6 +76,16 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
}}
icon={IconProp.Bell}
/>
<SideMenuItem
link={{
title: "AI Logs",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.SCHEDULED_MAINTENANCE_VIEW_AI_LOGS] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Bolt}
/>
</SideMenuSection>
<SideMenuSection title="Scheduled Maintenance Notes">

View File

@@ -0,0 +1,11 @@
import React, { FunctionComponent, ReactElement } from "react";
import PageComponentProps from "../PageComponentProps";
import LlmLogsTable from "../../Components/AILogs/LlmLogsTable";
const SettingsAILogs: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
return <LlmLogsTable />;
};
export default SettingsAILogs;

View File

@@ -113,7 +113,9 @@ const Settings: FunctionComponent<ComponentProps> = (
});
if (!paymentMethodsResult || paymentMethodsResult.data.length === 0) {
setError("Payment methods not found. Please try again later");
setError(
"Payment methods not found. Please add one in Project Settings -> Billing.",
);
return;
}

View File

@@ -373,6 +373,15 @@ const DashboardSideMenu: () => JSX.Element = (): ReactElement => {
},
]
: []),
{
link: {
title: "AI Logs",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.SETTINGS_AI_LOGS] as Route,
),
},
icon: IconProp.Bolt,
},
],
},
{

View File

@@ -31,6 +31,11 @@ const AlertViewNotificationLogs: LazyExoticComponent<
return import("../Pages/Alerts/View/NotificationLogs");
});
const AlertViewAILogs: LazyExoticComponent<FunctionComponent<ComponentProps>> =
lazy(() => {
return import("../Pages/Alerts/View/AILogs");
});
const AlertsWorkspaceConnectionSlack: LazyExoticComponent<
FunctionComponent<ComponentProps>
> = lazy(() => {
@@ -194,6 +199,18 @@ const AlertsRoutes: FunctionComponent<ComponentProps> = (
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(PageMap.ALERT_VIEW_AI_LOGS)}
element={
<Suspense fallback={Loader}>
<AlertViewAILogs
{...props}
pageRoute={RouteMap[PageMap.ALERT_VIEW_AI_LOGS] as Route}
/>
</Suspense>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(PageMap.ALERT_VIEW_DESCRIPTION)}
element={

View File

@@ -44,6 +44,12 @@ const IncidentViewNotificationLogs: LazyExoticComponent<
return import("../Pages/Incidents/View/NotificationLogs");
});
const IncidentViewAILogs: LazyExoticComponent<
FunctionComponent<ComponentProps>
> = lazy(() => {
return import("../Pages/Incidents/View/AILogs");
});
const IncidentViewDelete: LazyExoticComponent<
FunctionComponent<ComponentProps>
> = lazy(() => {
@@ -405,6 +411,18 @@ const IncidentsRoutes: FunctionComponent<ComponentProps> = (
</Suspense>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(PageMap.INCIDENT_VIEW_AI_LOGS)}
element={
<Suspense fallback={Loader}>
<IncidentViewAILogs
{...props}
pageRoute={RouteMap[PageMap.INCIDENT_VIEW_AI_LOGS] as Route}
/>
</Suspense>
}
/>
</PageRoute>
</Routes>
);

View File

@@ -96,6 +96,12 @@ const ScheduledMaintenanceEventViewNotificationLogs: LazyExoticComponent<
return import("../Pages/ScheduledMaintenanceEvents/View/NotificationLogs");
});
const ScheduledMaintenanceEventViewAILogs: LazyExoticComponent<
FunctionComponent<ComponentProps>
> = lazy(() => {
return import("../Pages/ScheduledMaintenanceEvents/View/AILogs");
});
const ScheduledMaintenanceEventViewDescription: LazyExoticComponent<
FunctionComponent<ComponentProps>
> = lazy(() => {
@@ -279,6 +285,21 @@ const ScheduledMaintenanceEventsRoutes: FunctionComponent<ComponentProps> = (
</Suspense>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.SCHEDULED_MAINTENANCE_VIEW_AI_LOGS,
)}
element={
<Suspense fallback={Loader}>
<ScheduledMaintenanceEventViewAILogs
{...props}
pageRoute={
RouteMap[PageMap.SCHEDULED_MAINTENANCE_VIEW_AI_LOGS] as Route
}
/>
</Suspense>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.SCHEDULED_MAINTENANCE_VIEW_SETTINGS,

View File

@@ -249,6 +249,10 @@ const SettingsNotificationLogs: LazyExoticComponent<
> = lazy(() => {
return import("../Pages/Settings/NotificationLogs");
});
const SettingsAILogs: LazyExoticComponent<FunctionComponent<ComponentProps>> =
lazy(() => {
return import("../Pages/Settings/AILogs");
});
const SettingsNotifications: LazyExoticComponent<
FunctionComponent<ComponentProps>
> = lazy(() => {
@@ -348,6 +352,17 @@ const SettingsRoutes: FunctionComponent<ComponentProps> = (
</Suspense>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(PageMap.SETTINGS_AI_LOGS)}
element={
<Suspense fallback={Loader}>
<SettingsAILogs
{...props}
pageRoute={RouteMap[PageMap.SETTINGS_AI_LOGS] as Route}
/>
</Suspense>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(

View File

@@ -89,6 +89,7 @@ enum PageMap {
INCIDENT_VIEW_ON_CALL_POLICY_EXECUTION_LOGS = "INCIDENT_VIEW_ON_CALL_POLICY_EXECUTION_LOGS",
INCIDENT_VIEW_NOTIFICATION_LOGS = "INCIDENT_VIEW_NOTIFICATION_LOGS",
INCIDENT_VIEW_AI_LOGS = "INCIDENT_VIEW_AI_LOGS",
ALERTS_ROOT = "ALERTS_ROOT",
ALERTS = "ALERTS",
@@ -107,6 +108,7 @@ enum PageMap {
ALERT_VIEW_ON_CALL_POLICY_EXECUTION_LOGS = "ALERT_VIEW_ON_CALL_POLICY_EXECUTION_LOGS",
ALERT_VIEW_NOTIFICATION_LOGS = "ALERT_VIEW_NOTIFICATION_LOGS",
ALERT_VIEW_AI_LOGS = "ALERT_VIEW_AI_LOGS",
SCHEDULED_MAINTENANCE_EVENTS_ROOT = "SCHEDULED_MAINTENANCE_EVENTS_ROOT",
SCHEDULED_MAINTENANCE_EVENTS = "SCHEDULED_MAINTENANCE_EVENTS",
@@ -124,6 +126,7 @@ enum PageMap {
SCHEDULED_MAINTENANCE_VIEW_OWNERS = "SCHEDULED_MAINTENANCE_VIEW_OWNERS",
SCHEDULED_MAINTENANCE_VIEW_SETTINGS = "SCHEDULED_MAINTENANCE_VIEW_SETTINGS",
SCHEDULED_MAINTENANCE_VIEW_NOTIFICATION_LOGS = "SCHEDULED_MAINTENANCE_VIEW_NOTIFICATION_LOGS",
SCHEDULED_MAINTENANCE_VIEW_AI_LOGS = "SCHEDULED_MAINTENANCE_VIEW_AI_LOGS",
MONITORS = "MONITORS",
MONITORS_ROOT = "MONITORS_ROOT",
@@ -412,6 +415,9 @@ enum PageMap {
SETTINGS_NOTIFICATION_LOGS = "SETTINGS_NOTIFICATION_LOGS",
// AI Logs
SETTINGS_AI_LOGS = "SETTINGS_AI_LOGS",
// Push Logs in resource views
}

View File

@@ -165,6 +165,7 @@ export const IncidentsRoutePath: Dictionary<string> = {
[PageMap.INCIDENT_VIEW_OWNERS]: `${RouteParams.ModelID}/owners`,
[PageMap.INCIDENT_VIEW_ON_CALL_POLICY_EXECUTION_LOGS]: `${RouteParams.ModelID}/on-call-policy-execution-logs`,
[PageMap.INCIDENT_VIEW_NOTIFICATION_LOGS]: `${RouteParams.ModelID}/notification-logs`,
[PageMap.INCIDENT_VIEW_AI_LOGS]: `${RouteParams.ModelID}/ai-logs`,
[PageMap.INCIDENT_VIEW_DELETE]: `${RouteParams.ModelID}/delete`,
[PageMap.INCIDENT_VIEW_SETTINGS]: `${RouteParams.ModelID}/settings`,
[PageMap.INCIDENT_VIEW_CUSTOM_FIELDS]: `${RouteParams.ModelID}/custom-fields`,
@@ -182,6 +183,7 @@ export const AlertsRoutePath: Dictionary<string> = {
[PageMap.ALERT_VIEW_OWNERS]: `${RouteParams.ModelID}/owners`,
[PageMap.ALERT_VIEW_ON_CALL_POLICY_EXECUTION_LOGS]: `${RouteParams.ModelID}/on-call-policy-execution-logs`,
[PageMap.ALERT_VIEW_NOTIFICATION_LOGS]: `${RouteParams.ModelID}/notification-logs`,
[PageMap.ALERT_VIEW_AI_LOGS]: `${RouteParams.ModelID}/ai-logs`,
[PageMap.ALERT_VIEW_DELETE]: `${RouteParams.ModelID}/delete`,
[PageMap.ALERT_VIEW_DESCRIPTION]: `${RouteParams.ModelID}/description`,
[PageMap.ALERT_VIEW_ROOT_CAUSE]: `${RouteParams.ModelID}/root-cause`,
@@ -207,12 +209,14 @@ export const ScheduledMaintenanceEventsRoutePath: Dictionary<string> = {
[PageMap.SCHEDULED_MAINTENANCE_VIEW_CUSTOM_FIELDS]: `${RouteParams.ModelID}/custom-fields`,
[PageMap.SCHEDULED_MAINTENANCE_VIEW_SETTINGS]: `${RouteParams.ModelID}/settings`,
[PageMap.SCHEDULED_MAINTENANCE_VIEW_NOTIFICATION_LOGS]: `${RouteParams.ModelID}/notification-logs`,
[PageMap.SCHEDULED_MAINTENANCE_VIEW_AI_LOGS]: `${RouteParams.ModelID}/ai-logs`,
};
export const SettingsRoutePath: Dictionary<string> = {
[PageMap.SETTINGS_DANGERZONE]: "danger-zone",
[PageMap.SETTINGS_NOTIFICATION_SETTINGS]: "notification-settings",
[PageMap.SETTINGS_NOTIFICATION_LOGS]: "notification-logs",
[PageMap.SETTINGS_AI_LOGS]: "ai-logs",
[PageMap.SETTINGS_APIKEYS]: `api-keys`,
[PageMap.SETTINGS_APIKEY_VIEW]: `api-keys/${RouteParams.ModelID}`,
[PageMap.SETTINGS_TELEMETRY_INGESTION_KEYS]: `telemetry-ingestion-keys`,
@@ -541,6 +545,12 @@ const RouteMap: Dictionary<Route> = {
}`,
),
[PageMap.ALERT_VIEW_AI_LOGS]: new Route(
`/dashboard/${RouteParams.ProjectID}/alerts/${
AlertsRoutePath[PageMap.ALERT_VIEW_AI_LOGS]
}`,
),
[PageMap.ALERT_VIEW_DELETE]: new Route(
`/dashboard/${RouteParams.ProjectID}/alerts/${
AlertsRoutePath[PageMap.ALERT_VIEW_DELETE]
@@ -682,6 +692,12 @@ const RouteMap: Dictionary<Route> = {
}`,
),
[PageMap.INCIDENT_VIEW_AI_LOGS]: new Route(
`/dashboard/${RouteParams.ProjectID}/incidents/${
IncidentsRoutePath[PageMap.INCIDENT_VIEW_AI_LOGS]
}`,
),
[PageMap.INCIDENT_VIEW_DELETE]: new Route(
`/dashboard/${RouteParams.ProjectID}/incidents/${
IncidentsRoutePath[PageMap.INCIDENT_VIEW_DELETE]
@@ -824,6 +840,14 @@ const RouteMap: Dictionary<Route> = {
}`,
),
[PageMap.SCHEDULED_MAINTENANCE_VIEW_AI_LOGS]: new Route(
`/dashboard/${RouteParams.ProjectID}/scheduled-maintenance-events/${
ScheduledMaintenanceEventsRoutePath[
PageMap.SCHEDULED_MAINTENANCE_VIEW_AI_LOGS
]
}`,
),
[PageMap.SCHEDULED_MAINTENANCE_PUBLIC_NOTE]: new Route(
`/dashboard/${RouteParams.ProjectID}/scheduled-maintenance-events/${
ScheduledMaintenanceEventsRoutePath[
@@ -1635,6 +1659,12 @@ const RouteMap: Dictionary<Route> = {
}`,
),
[PageMap.SETTINGS_AI_LOGS]: new Route(
`/dashboard/${RouteParams.ProjectID}/settings/${
SettingsRoutePath[PageMap.SETTINGS_AI_LOGS]
}`,
),
[PageMap.SETTINGS_APIKEYS]: new Route(
`/dashboard/${RouteParams.ProjectID}/settings/${
SettingsRoutePath[PageMap.SETTINGS_APIKEYS]

View File

@@ -0,0 +1,250 @@
# MCP Server
The OneUptime Model Context Protocol (MCP) Server provides LLMs with direct access to your OneUptime instance, enabling AI-powered monitoring, incident management, and observability operations.
## What is the OneUptime MCP Server?
The OneUptime MCP Server is a bridge between Large Language Models (LLMs) and your OneUptime instance. It implements the Model Context Protocol (MCP), allowing AI assistants like Claude to interact directly with your monitoring infrastructure.
## How It Works
The MCP server is hosted alongside your OneUptime instance and accessible via Server-Sent Events (SSE). No local installation is required.
**Cloud Users**: `https://oneuptime.com/mcp`
**Self-Hosted Users**: `https://your-oneuptime-domain.com/mcp`
## Key Features
- **Complete API Coverage**: Access to 711 OneUptime API endpoints
- **126 Resource Types**: Manage all OneUptime resources including monitors, incidents, teams, probes, and more
- **Real-time Operations**: Create, read, update, and delete resources in real-time
- **Type-safe Interface**: Fully typed with comprehensive input validation
- **Secure Authentication**: API key-based authentication with proper error handling
- **Easy Integration**: Works with Claude Desktop and other MCP-compatible clients
## What You Can Do
With the OneUptime MCP Server, AI assistants can help you:
- **Monitor Management**: Create and configure monitors, check their status, and manage monitor groups
- **Incident Response**: Create incidents, add notes, assign team members, and track resolution
- **Team Operations**: Manage teams, permissions, and on-call schedules
- **Status Pages**: Update status pages, create announcements, and manage subscribers
- **Alerting**: Configure alert rules, manage escalation policies, and check notification logs
- **Probes**: Deploy and manage monitoring probes across different locations
- **Reports & Analytics**: Generate reports and analyze monitoring data
## Requirements
- OneUptime instance (cloud or self-hosted)
- Valid OneUptime API key
- MCP-compatible client (Claude Desktop, etc.)
## Getting Your API Key
1. Log in to your OneUptime instance
2. Navigate to **Settings****API Keys**
3. Click **Create API Key**
4. Provide a name (e.g., "MCP Server")
5. Select the appropriate permissions for your use case
6. Copy the generated API key
## Configuration
### Claude Desktop Configuration
Find your Claude Desktop configuration file:
**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
**Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
**Linux**: `~/.config/Claude/claude_desktop_config.json`
### For OneUptime Cloud
Add the following configuration:
```json
{
"mcpServers": {
"oneuptime": {
"transport": "sse",
"url": "https://oneuptime.com/mcp/sse",
"headers": {
"x-api-key": "your-api-key-here"
}
}
}
}
```
### For Self-Hosted OneUptime
Replace `oneuptime.com` with your OneUptime domain:
```json
{
"mcpServers": {
"oneuptime": {
"transport": "sse",
"url": "https://your-oneuptime-domain.com/mcp/sse",
"headers": {
"x-api-key": "your-api-key-here"
}
}
}
}
```
### Multiple Instances
You can configure multiple OneUptime instances:
```json
{
"mcpServers": {
"oneuptime-prod": {
"transport": "sse",
"url": "https://prod.oneuptime.com/mcp/sse",
"headers": {
"x-api-key": "prod-api-key"
}
},
"oneuptime-staging": {
"transport": "sse",
"url": "https://staging.oneuptime.com/mcp/sse",
"headers": {
"x-api-key": "staging-api-key"
}
}
}
}
```
## Available Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/mcp/sse` | GET | SSE endpoint for MCP connections |
| `/mcp/message` | POST | Message endpoint for client-to-server communication |
| `/mcp/health` | GET | Health check endpoint |
| `/mcp/tools` | GET | REST API to list available tools |
## Verification
Verify the MCP server is running:
```bash
# For OneUptime Cloud
curl https://oneuptime.com/mcp/health
# For Self-Hosted
curl https://your-oneuptime-domain.com/mcp/health
```
List available tools:
```bash
# For OneUptime Cloud
curl https://oneuptime.com/mcp/tools
# For Self-Hosted
curl https://your-oneuptime-domain.com/mcp/tools
```
## Usage Examples
### Basic Information Queries
```
"What's the current status of all my monitors?"
"Show me incidents from the last 24 hours"
"List my OneUptime projects"
```
### Monitor Management
```
"Create a new website monitor for https://example.com that checks every 5 minutes"
"Set up an API monitor for https://api.example.com/health with a 30-second timeout"
"Change the monitoring interval for my website monitor to every 2 minutes"
"Disable the monitor for staging.example.com while we're doing maintenance"
```
### Incident Management
```
"Create a high-priority incident for the database outage affecting user authentication"
"Add a note to incident #123 saying 'Database connection restored, monitoring for stability'"
"Mark incident #456 as resolved"
"Assign the current payment gateway incident to the infrastructure team"
```
### Team and On-Call
```
"Who are the members of the infrastructure team?"
"Who's currently on call for the infrastructure team?"
"Show me the on-call schedule for this week"
```
### Status Page Management
```
"Update our status page to show 'Investigating Payment Issues' for the payment service"
"Create a status page announcement about scheduled maintenance this weekend"
```
### Advanced Operations
```
"Create a scheduled maintenance window for Saturday 2-4 AM, disable all monitors for api.example.com during that time, and update the status page"
"Show me all monitors that have been down in the last hour, create incidents for any that don't already have one"
```
## API Key Permissions
### Read-Only Access
For viewing data only, add read permissions for your API key.
### Full Access
For full access to create, update, and delete resources, ensure your API key has Project Admin permissions.
### Best Practices
- Use Specific Permissions: Only grant the minimum permissions needed
- Rotate API Keys: Regularly rotate your API keys
- Monitor Usage: Keep track of API key usage in OneUptime
- Separate Keys: Use different API keys for different environments
## Troubleshooting
### Permission Errors
Ensure your API key has the necessary permissions:
- Read access for listing resources
- Write access for creating/updating resources
- Delete access if you want to remove resources
### Connection Issues
1. Verify your OneUptime URL is correct
2. Check that your API key is valid
3. Ensure your OneUptime instance is accessible
4. Test the health endpoint
### Invalid API Key
- Verify the API key in your OneUptime settings
- Check for extra spaces or characters
- Ensure the key hasn't expired
## Available Resources
The MCP server provides access to 126 resource types including:
**Monitoring**: Monitor, MonitorStatus, MonitorGroup, Probe
**Incidents**: Incident, IncidentState, IncidentNote, IncidentTemplate
**Alerts**: Alert, AlertState, AlertSeverity
**Status Pages**: StatusPage, StatusPageAnnouncement, StatusPageSubscriber
**On-Call**: On-CallPolicy, EscalationRule, On-CallSchedule
**Teams**: Team, TeamMember, TeamPermission
**Telemetry**: TelemetryService, Log, Span, Metric
**Workflows**: Workflow, WorkflowVariable, WorkflowLog
Each resource supports standard operations: List, Count, Get, Create, Update, and Delete.

View File

@@ -1,160 +0,0 @@
# Configuration
Learn how to configure the OneUptime MCP Server for your specific needs.
## Environment Variables
The MCP server uses environment variables for configuration:
| Variable | Description | Required | Default | Example |
|----------|-------------|----------|---------|---------|
| `ONEUPTIME_API_KEY` | Your OneUptime API key | **Yes** | - | `xxxxxxxx-xxxx-xxxx-xxxx` |
| `ONEUPTIME_URL` | Your OneUptime instance URL | No | `https://oneuptime.com` | `https://my-company.oneuptime.com` |
## Setting Environment Variables
### In Claude Desktop Configuration
The recommended way is to set environment variables in your Claude Desktop configuration:
```json
{
"mcpServers": {
"oneuptime": {
"command": "oneuptime-mcp",
"env": {
"ONEUPTIME_API_KEY": "your-api-key-here",
"ONEUPTIME_URL": "https://oneuptime.com" // Replace with your instance URL if you are self-hosting
}
}
}
}
```
### System Environment Variables
Alternatively, you can set system environment variables:
**macOS/Linux**:
```bash
export ONEUPTIME_API_KEY="your-api-key-here"
# Optional: Set custom OneUptime URL. Replace with your instance if self-hosted
export ONEUPTIME_URL="https://oneuptime.com"
```
**Windows**:
```cmd
set ONEUPTIME_API_KEY=your-api-key-here
# Optional: Set custom OneUptime URL. Replace with your instance if self-hosted
set ONEUPTIME_URL=https://oneuptime.com
```
### Using .env File
For development, you can create a `.env` file in your working directory:
```env
ONEUPTIME_API_KEY=your-api-key-here
# Optional: Set custom OneUptime URL. Replace with your instance if self-hosted
ONEUPTIME_URL=https://oneuptime.com
```
## API Key Permissions
Your API key needs appropriate permissions based on what operations you want to perform:
### Read-Only Access
For viewing data only, Please add read permissions for this API Key,
### Full Access
For full access to create, update, and delete resources, ensure your API key has the following permissions:
- Project Admin
### Minimal Permissions
It is recommended to have minimum set of permissions assigned to your API key for your use-case.
## Advanced Configuration
### Multiple Instances
You can configure multiple OneUptime instances by creating separate MCP server configurations:
```json
{
"mcpServers": {
"oneuptime-prod": {
"command": "oneuptime-mcp",
"env": {
"ONEUPTIME_API_KEY": "prod-api-key",
"ONEUPTIME_URL": "https://prod.oneuptime.com"
}
},
"oneuptime-staging": {
"command": "oneuptime-mcp",
"env": {
"ONEUPTIME_API_KEY": "staging-api-key",
"ONEUPTIME_URL": "https://staging.oneuptime.com"
}
}
}
}
```
### Custom Command Path
If you installed from source or want to use a specific version:
```json
{
"mcpServers": {
"oneuptime": {
"command": "/path/to/your/oneuptime-mcp",
"env": {
"ONEUPTIME_API_KEY": "your-api-key-here"
}
}
}
}
```
## Configuration Validation
To verify your configuration is working:
1. **Check API Key**: Ensure your API key is valid and has proper permissions
2. **Test Connection**: Ask Claude to list your projects or monitors
3. **Verify Permissions**: Try creating a simple resource to test write access
## Troubleshooting Configuration
### Invalid API Key
- Verify the API key in your OneUptime settings
- Check for extra spaces or characters
- Ensure the key hasn't expired
### Wrong URL
- Verify your OneUptime instance URL
- Ensure it includes the protocol (https://)
- Check for typos in the domain
### Permission Denied
- Review your API key permissions
- Contact your OneUptime administrator if needed
- Try with a more permissive API key for testing
## Security Best Practices
1. **Use Specific Permissions**: Only grant the minimum permissions needed
2. **Rotate API Keys**: Regularly rotate your API keys
3. **Monitor Usage**: Keep track of API key usage in OneUptime
4. **Separate Keys**: Use different API keys for different environments
5. **Store Securely**: Never commit API keys to version control
## Next Steps
- [Explore usage examples](/docs/mcp/examples)
- [View available resources](/docs/mcp/resources)
- [Learn about troubleshooting](/docs/mcp/troubleshooting)

View File

@@ -1,254 +0,0 @@
# Usage Examples
Learn how to use the OneUptime MCP Server with practical examples and common use cases.
## Getting Started Examples
### Basic Information Queries
**Check monitor status:**
```
"What's the current status of all my monitors?"
```
**View recent incidents:**
```
"Show me incidents from the last 24 hours"
```
## Monitor Management
### Creating Monitors
**Create a website monitor:**
```
"Create a new website monitor for https://example.com that checks every 5 minutes"
```
**Create an API monitor:**
```
"Set up an API monitor for https://api.example.com/health with a 30-second timeout"
```
**Create a ping monitor:**
```
"Create a ping monitor for server 192.168.1.100"
```
### Managing Existing Monitors
**Update monitor frequency:**
```
"Change the monitoring interval for my website monitor to every 2 minutes"
```
**Disable a monitor temporarily:**
```
"Disable the monitor for staging.example.com while we're doing maintenance"
```
**Add custom headers to HTTP monitor:**
```
"Add an Authorization header to my API monitor with value 'Bearer token123'"
```
## Incident Management
### Creating Incidents
**Create a new incident:**
```
"Create a high-priority incident for the database outage affecting user authentication"
```
**Create incident with details:**
```
"Create an incident titled 'Payment Gateway Down' with description 'Users cannot process payments' and assign it to the backend team"
```
### Managing Incidents
**Add notes to incident:**
```
"Add a note to incident #123 saying 'Database connection restored, monitoring for stability'"
```
**Update incident status:**
```
"Mark incident #456 as resolved"
```
**Assign incident to team:**
```
"Assign the current payment gateway incident to the infrastructure team"
```
## Team and User Management
### Team Operations
**List team members:**
```
"Who are the members of the infrastructure team?"
```
**Add user to team:**
```
"Add john@example.com to the backend development team"
```
**Check team permissions:**
```
"What permissions does the frontend team have?"
```
### On-Call Management
**Check who's on call:**
```
"Who's currently on call for the infrastructure team?"
```
**View on-call schedule:**
```
"Show me the on-call schedule for this week"
```
## Status Page Management
### Status Page Updates
**Update status page:**
```
"Update our status page to show 'Investigating Payment Issues' for the payment service"
```
**Create announcement:**
```
"Create a status page announcement about scheduled maintenance this weekend"
```
**Check current status:**
```
"What's the current status showing on our public status page?"
```
## Probe Management
### Managing Probes
**List all probes:**
```
"Show me all monitoring probes and their locations"
```
**Create a new probe:**
```
"Set up a new monitoring probe in the EU-West region"
```
**Check probe health:**
```
"Are all our monitoring probes healthy and reporting data?"
```
## Analytics and Reporting
### Performance Queries
**Monitor uptime stats:**
```
"What's the uptime percentage for all monitors this month?"
```
**Incident trends:**
```
"How many incidents did we have last week compared to this week?"
```
**Response time analysis:**
```
"What are the average response times for our API endpoints today?"
```
## Advanced Use Cases
### Automated Incident Response
**Create incident and assign team:**
```
"Create a critical incident for API timeout issues, assign to DevOps team, and add initial troubleshooting steps to the description"
```
**Bulk monitor updates:**
```
"Update all website monitors to use a 60-second timeout instead of 30 seconds"
```
### Maintenance Operations
**Prepare for maintenance:**
```
"Create a scheduled maintenance window for this Saturday 2-4 AM, disable all monitors for api.example.com during that time, and update the status page"
```
**Post-maintenance cleanup:**
```
"Re-enable all monitors that were disabled for maintenance and update status page to show all systems operational"
```
### Integration Workflows
**Monitor creation from incidents:**
```
"Based on the recent database timeout incident, create a new monitor to check database response time every minute"
```
**Team notification setup:**
```
"Set up escalation rules so that if any critical monitor fails, it immediately notifies the on-call engineer and escalates to the team lead after 15 minutes"
```
## Complex Queries
### Multi-step Operations
```
"Show me all monitors that have been down in the last hour, create incidents for any that don't already have one, and assign them to the appropriate teams based on the monitor tags"
```
```
"Find all incidents that have been open for more than 24 hours, add a note requesting status updates, and notify the assigned teams"
```
### Conditional Logic
```
"If any monitors in the 'production' group are currently failing, create a high-priority incident and immediately notify the on-call team"
```
```
"Check if our main website monitor has been down for more than 5 minutes, and if so, update the status page to show 'investigating connectivity issues'"
```
## Best Practices
### Effective Prompts
1. **Be Specific**: Include exact names, IDs, or criteria
2. **Provide Context**: Mention urgency, affected systems, or business impact
3. **Use Natural Language**: The AI understands conversational requests
4. **Combine Operations**: Ask for multiple related actions in one request
### Safety Considerations
1. **Review Before Executing**: Check what the AI plans to do
2. **Start Small**: Test with non-critical resources first
3. **Have Rollback Plans**: Know how to reverse changes
4. **Monitor Results**: Verify operations completed successfully
## Next Steps
- [Learn about available resources](/docs/mcp/resources)
- [Explore configuration options](/docs/mcp/configuration)
- [View troubleshooting guide](/docs/mcp/troubleshooting)

View File

@@ -1,59 +0,0 @@
# OneUptime MCP Server
The OneUptime Model Context Protocol (MCP) Server provides LLMs with direct access to your OneUptime instance, enabling AI-powered monitoring, incident management, and observability operations.
## What is the OneUptime MCP Server?
The OneUptime MCP Server is a bridge between Large Language Models (LLMs) and your OneUptime instance. It implements the Model Context Protocol (MCP), allowing AI assistants like Claude to interact directly with your monitoring infrastructure.
## Key Features
- **Complete API Coverage**: Access to 711 OneUptime API endpoints
- **126 Resource Types**: Manage all OneUptime resources including monitors, incidents, teams, probes, and more
- **Real-time Operations**: Create, read, update, and delete resources in real-time
- **Type-safe Interface**: Fully typed with comprehensive input validation
- **Secure Authentication**: API key-based authentication with proper error handling
- **Easy Integration**: Works with Claude Desktop and other MCP-compatible clients
## What You Can Do
With the OneUptime MCP Server, AI assistants can help you:
- **Monitor Management**: Create and configure monitors, check their status, and manage monitor groups
- **Incident Response**: Create incidents, add notes, assign team members, and track resolution
- **Team Operations**: Manage teams, permissions, and on-call schedules
- **Status Pages**: Update status pages, create announcements, and manage subscribers
- **Alerting**: Configure alert rules, manage escalation policies, and check notification logs
- **Probes**: Deploy and manage monitoring probes across different locations
- **Reports & Analytics**: Generate reports and analyze monitoring data
## Getting Started
1. [Installation Guide](/docs/mcp/installation) - Install and configure the MCP server
2. [Quick Start](/docs/mcp/quick-start) - Get up and running in minutes
3. [Configuration](/docs/mcp/configuration) - Detailed configuration options
4. [Usage Examples](/docs/mcp/examples) - Common use cases and examples
## Requirements
- OneUptime instance (cloud or self-hosted)
- Valid OneUptime API key
- Node.js 18+ (for development)
- MCP-compatible client (Claude Desktop, etc.)
## Architecture
The MCP server acts as a translation layer between the Model Context Protocol and OneUptime's REST API:
```
LLM Client (Claude) ↔ MCP Server ↔ OneUptime API
```
This architecture ensures secure, efficient access to your OneUptime data while maintaining proper authentication and authorization.
## Next Steps
- [Install the MCP Server](/docs/mcp/installation)
- [Learn about Configuration Options](/docs/mcp/configuration)
- [Explore Usage Examples](/docs/mcp/examples)
- [View Available Resources](/docs/mcp/resources)

View File

@@ -1,77 +0,0 @@
# Installation
This guide will walk you through installing and setting up the OneUptime MCP Server.
## Prerequisites
Before installing the MCP server, ensure you have:
- A OneUptime instance (cloud or self-hosted)
- A valid OneUptime API key
- Node.js 18 or later (for npm installation)
## Installation Methods
### Method 1: NPM Installation (Recommended)
Install the MCP server globally using npm:
```bash
npm install -g @oneuptime/mcp-server
```
This will install the `oneuptime-mcp` command globally on your system.
### Method 2: From Source
If you want to build from source or contribute to the project:
```bash
# Clone the OneUptime repository
git clone https://github.com/OneUptime/oneuptime.git
cd oneuptime
# Generate the MCP server
cd MCP
# Install dependencies
npm install && npm link
# This should now execute
oneuptime-mcp --version
```
## Getting Your API Key
### For OneUptime Cloud
1. Log in to [OneUptime Cloud](https://oneuptime.com)
2. Navigate to **Settings****API Keys**
3. Click **Create API Key**
4. Provide a name (e.g., "MCP Server")
5. Select the appropriate permissions for your use case
6. Copy the generated API key
### For Self-Hosted OneUptime
1. Access your OneUptime instance
2. Navigate to **Settings****API Keys**
3. Click **Create API Key**
4. Provide a name (e.g., "MCP Server")
5. Select the appropriate permissions
6. Copy the generated API key
## Verification
Verify your installation by checking the version:
```bash
oneuptime-mcp --version
```
## Next Steps
- [Configure the MCP Server](/docs/mcp/configuration)
- [Set up with Claude Desktop](/docs/mcp/quick-start)
- [Explore configuration options](/docs/mcp/configuration)

View File

@@ -1,122 +0,0 @@
# Quick Start
Get up and running with the OneUptime MCP Server in just a few minutes.
## Step 1: Install the MCP Server
If you haven't already, install the MCP server:
```bash
npm install -g @oneuptime/mcp-server
```
## Step 2: Get Your API Key
1. Log in to your OneUptime instance
2. Go to **Settings****API Keys**
3. Create a new API key with appropriate permissions
4. Copy the API key for use in configuration
## Step 3: Configure Claude Desktop
Add the OneUptime MCP server to your Claude Desktop configuration.
### Find Your Configuration File
**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
**Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
**Linux**: `~/.config/Claude/claude_desktop_config.json`
### Update Configuration
Add the following to your Claude Desktop configuration file:
```json
{
"mcpServers": {
"oneuptime": {
"command": "oneuptime-mcp",
"env": {
"ONEUPTIME_API_KEY": "your-api-key-here",
"ONEUPTIME_URL": "https://oneuptime.com"
}
}
}
}
```
**For self-hosted OneUptime**, replace `https://oneuptime.com` with your instance URL.
### Example Complete Configuration
```json
{
"mcpServers": {
"oneuptime": {
"command": "oneuptime-mcp",
"env": {
"ONEUPTIME_API_KEY": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"ONEUPTIME_URL": "https://my-company.oneuptime.com"
}
}
}
}
```
## Step 4: Restart Claude Desktop
Close and restart Claude Desktop for the configuration changes to take effect.
## Step 5: Test the Connection
Once Claude Desktop restarts, you can test the MCP server by asking Claude to:
- "List my OneUptime projects"
- "Show me recent incidents"
- "What monitors are currently down?"
- "Create a new monitor for my website"
## Example Conversation
Here's what you can do once everything is set up:
**You**: "Can you show me all my OneUptime projects?"
**Claude**: "I'll list your OneUptime projects for you."
*Claude will use the MCP server to fetch and display your projects*
**You**: "Create a new monitor for https://example.com"
**Claude**: "I'll create a website monitor for https://example.com."
*Claude will use the MCP server to create the monitor*
## Common Issues
### Permission Errors
If you see permission errors, ensure your API key has the necessary permissions:
- Read access for listing resources
- Write access for creating/updating resources
- Delete access if you want to remove resources
### Connection Issues
If Claude can't connect to your OneUptime instance:
1. Verify your `ONEUPTIME_URL` is correct
2. Check that your API key is valid
3. Ensure your OneUptime instance is accessible
### MCP Server Not Found
If you get "command not found" errors:
1. Verify the installation: `npm list -g @oneuptime/mcp-server`
2. Check your PATH includes npm global binaries
3. Try reinstalling: `npm install -g @oneuptime/mcp-server`
## Next Steps
- [Learn about configuration options](/docs/mcp/configuration)
- [Explore usage examples](/docs/mcp/examples)
- [View available resources](/docs/mcp/resources)

View File

@@ -1,233 +0,0 @@
# Available Resources
The OneUptime MCP Server provides access to 126 different resource types across your OneUptime instance. Here's a comprehensive overview of what you can manage.
## Core Resources
### User Management
- **User**: Manage user accounts and profiles
- **TeamMember**: Handle team membership relationships
- **TeamPermission**: Control access permissions within teams
- **TwoFactorAuth**: Manage two-factor authentication settings
### Project and Team Management
- **Project**: Create and manage monitoring projects
- **Team**: Organize users into teams with specific roles
- **TeamOwner**: Manage team ownership relationships
### Authentication and Security
- **APIKey**: Generate and manage API keys for integrations
- **APIKeyPermission**: Control what each API key can access
- **Label**: Create labels for organizing resources
## Monitoring and Observability
### Monitors
- **Monitor**: Configure and manage all types of monitors
- **MonitorSecret**: Store sensitive data for monitor configurations
- **MonitorStatus**: Track the current state of monitors
- **MonitorCustomField**: Add custom metadata to monitors
- **MonitorProbe**: Assign probes to monitors
- **MonitorTeamOwner**: Manage monitor ownership by teams
- **MonitorUserOwner**: Manage monitor ownership by users
- **MonitorGroup**: Organize monitors into logical groups
- **MonitorGroupTeamOwner**: Manage group ownership by teams
- **MonitorGroupUserOwner**: Manage group ownership by users
- **MonitorGroupResource**: Associate monitors with groups
- **MonitorStatusEvent**: Track monitor status change events
- **MonitorFeed**: Monitor activity feeds and notifications
- **MonitorLog**: Access monitor execution logs
### Probes
- **Probe**: Deploy and manage monitoring probes
- **ProbeOwnerTeam**: Manage probe ownership by teams
- **ProbeUserOwner**: Manage probe ownership by users
## Incident Management
### Incidents
- **Incident**: Create and manage incidents
- **IncidentState**: Define incident states (open, investigating, resolved)
- **IncidentFeed**: Incident activity feeds
- **IncidentCustomField**: Add custom fields to incidents
- **IncidentStateTimeline**: Track incident state changes over time
- **IncidentInternalNote**: Add internal notes for team communication
- **IncidentPublicNote**: Add public-facing notes for transparency
- **IncidentTemplate**: Create templates for common incident types
- **IncidentTemplateTeamOwner**: Manage template ownership by teams
- **IncidentTemplateUserOwner**: Manage template ownership by users
- **IncidentTeamOwner**: Assign incidents to teams
- **IncidentUserOwner**: Assign incidents to individual users
- **IncidentSeverity**: Define and manage incident severity levels
- **IncidentNoteTemplate**: Create templates for incident notes
### Alerts
- **Alert**: Manage alert notifications
- **AlertState**: Define alert states
- **AlertFeed**: Alert activity feeds
- **AlertCustomField**: Add custom fields to alerts
- **AlertStateTimeline**: Track alert state changes
- **AlertInternalNote**: Internal alert notes
- **AlertTeamOwner**: Assign alerts to teams
- **AlertUserOwner**: Assign alerts to users
- **AlertSeverity**: Define alert severity levels
- **AlertNoteTemplate**: Create templates for alert notes
## Status Pages
### Status Page Management
- **StatusPage**: Create and manage public status pages
- **StatusPageGroup**: Organize status page components
- **StatusPageDomain**: Configure custom domains for status pages
- **StatusPageCustomField**: Add custom fields to status pages
- **StatusPageResource**: Manage resources displayed on status pages
- **StatusPageAnnouncement**: Create announcements for status pages
- **StatusPageAnnouncementTemplate**: Templates for common announcements
- **StatusPageSubscriber**: Manage status page subscribers
- **StatusPageFooterLink**: Add custom footer links
- **StatusPageHeaderLink**: Add custom header links
- **StatusPagePrivateUser**: Manage private status page access
- **StatusPageHistoryChartBarColor**: Customize status page chart colors
- **StatusPageTeamOwner**: Manage status page ownership by teams
- **StatusPageUserOwner**: Manage status page ownership by users
- **StatusPageSSO**: Configure single sign-on for private status pages
## Scheduled Maintenance
### Maintenance Management
- **ScheduledMaintenanceState**: Define maintenance states
- **ScheduledMaintenanceEvent**: Create and manage maintenance windows
- **ScheduledMaintenanceStateTimeline**: Track maintenance state changes
- **ScheduledEventPublicNote**: Public notes for maintenance events
- **ScheduledMaintenanceCustomField**: Custom fields for maintenance
- **ScheduledMaintenanceFeed**: Maintenance activity feeds
- **ScheduledMaintenanceTeamOwner**: Team ownership of maintenance events
- **ScheduledMaintenanceUserOwner**: User ownership of maintenance events
- **ScheduledMaintenanceTemplate**: Templates for common maintenance
- **ScheduledMaintenanceTemplateTeamOwner**: Template team ownership
- **ScheduledMaintenanceTemplateUserOwner**: Template user ownership
- **ScheduledMaintenanceNoteTemplate**: Templates for maintenance notes
## On-Call Management
### On-Call Policies
- **On-CallPolicy**: Define on-call escalation policies
- **On-CallPolicyCustomField**: Custom fields for on-call policies
- **EscalationRule**: Configure escalation rules
- **TeamOn-CallDutyEscalationRule**: Team-based escalation rules
- **User'SOn-CallDutyEscalationRule**: User-based escalation rules
- **Schedule'SOn-CallDutyEscalationRule**: Schedule-based escalation rules
- **On-CallDutyExecutionLog**: Logs of on-call executions
- **On-CallDutyExecutionLogTimeline**: Timeline of on-call events
- **UserOverride**: Temporary on-call schedule overrides
- **OnCallDutyPolicyFeed**: On-call policy activity feeds
- **OnCallDutyPolicyTeamOwner**: Team ownership of on-call policies
- **OnCallDutyPolicyUserOwner**: User ownership of on-call policies
### Scheduling
- **On-CallPolicySchedule**: Define on-call schedules
- **On-CallScheduleLayer**: Create schedule layers for complex rotations
- **On-CallScheduleLayerUser**: Assign users to schedule layers
- **UserOn-CallLogTimeline**: Track user on-call activities
- **OnCallTimeLog**: Log on-call time for billing/reporting
## Service Catalog
### Service Management
- **ServiceInServiceCatalog**: Define services in your catalog
- **ServiceCatalogTeamOwner**: Team ownership of services
- **ServiceCatalogUserOwner**: User ownership of services
- **ServiceDependency**: Define dependencies between services
- **ServiceCatalogMonitor**: Associate monitors with services
- **ServiceCatalogTelemetryService**: Link telemetry to services
## Workflow Automation
### Workflows
- **Workflow**: Create automated workflows
- **WorkflowVariable**: Define variables for workflows
- **WorkflowLog**: Access workflow execution logs
## Telemetry and Observability
### Telemetry Services
- **TelemetryService**: Manage telemetry data sources
- **TelemetryIngestionKey**: Keys for telemetry data ingestion
- **Log**: Access and manage log data
- **Span**: Distributed tracing spans
- **Metric**: Application and infrastructure metrics
- **MetricType**: Define custom metric types
### Error Tracking
- **Exception**: Track application exceptions
- **ExceptionInstance**: Individual exception occurrences
### Code Repository Integration
- **CodeRepository**: Connect code repositories
## Communication and Notifications
### Notification Logs
- **SMSLog**: SMS notification delivery logs
- **CallLog**: Phone call notification logs
- **EmailLog**: Email notification delivery logs
- **UserNotificationLog**: User-specific notification history
### Workspace Communication
- **WorkspaceNotificationRule**: Define workspace-wide notification rules
## UI and Customization
### Dashboard and Views
- **Dashboard**: Create custom dashboards
- **TableView**: Customize table views for resources
- **File**: Manage uploaded files and assets
- **Domain**: Configure custom domains
## Resource Operations
For each resource type, the MCP server typically provides these operations:
### Standard Operations
- **List**: Retrieve multiple resources with filtering and pagination
- **Count**: Get the total count of resources matching criteria
- **Get**: Retrieve a single resource by ID
- **Create**: Create new resources
- **Update**: Modify existing resources
- **Delete**: Remove resources
### Example Usage Patterns
**Resource Listing:**
```
"Show me all monitors" → Uses listMonitor
"How many incidents are open?" → Uses countIncident with status filter
```
**Resource Creation:**
```
"Create a new team called 'DevOps'" → Uses createTeam
"Add a website monitor for example.com" → Uses createMonitor
```
**Resource Management:**
```
"Update the timeout for monitor #123" → Uses updateMonitor
"Delete the test probe" → Uses deleteProbe
```
## Resource Relationships
Many resources are interconnected:
- **Monitors** can be owned by **Teams** and **Users**
- **Incidents** can be assigned to **Teams** and have multiple **Notes**
- **Status Pages** display **Resources** and can have **Announcements**
- **On-Call Policies** include **Escalation Rules** and **Schedules**
- **Services** can have **Dependencies** and associated **Monitors**
## Next Steps
- [View usage examples](/docs/mcp/examples)
- [Learn about configuration](/docs/mcp/configuration)
- [Explore troubleshooting](/docs/mcp/troubleshooting)

View File

@@ -192,20 +192,12 @@ const DocsNav: NavGroup[] = [
{ title: "Syslog", url: "/docs/telemetry/syslog" },
],
},
{
title: "MCP Server",
links: [
{ title: "Overview", url: "/docs/mcp/index" },
{ title: "Installation", url: "/docs/mcp/installation" },
{ title: "Quick Start", url: "/docs/mcp/quick-start" },
{ title: "Configuration", url: "/docs/mcp/configuration" },
{ title: "Usage Examples", url: "/docs/mcp/examples" },
{ title: "Available Resources", url: "/docs/mcp/resources" },
],
},
{
title: "AI",
links: [{ title: "LLM Providers", url: "/docs/ai/llm-provider" }],
links: [
{ title: "LLM Providers", url: "/docs/ai/llm-provider" },
{ title: "MCP Server", url: "/docs/ai/mcp-server" },
],
},
{
title: "API Reference",

View File

@@ -131,6 +131,8 @@ Usage:
value: {{ $.Release.Name }}-admin-dashboard.{{ $.Release.Namespace }}.svc.{{ $.Values.global.clusterDomain }}
- name: SERVER_DOCS_HOSTNAME
value: {{ $.Release.Name }}-docs.{{ $.Release.Namespace }}.svc.{{ $.Values.global.clusterDomain }}
- name: SERVER_MCP_HOSTNAME
value: {{ $.Release.Name }}-mcp.{{ $.Release.Namespace }}.svc.{{ $.Values.global.clusterDomain }}
- name: APP_PORT
value: {{ $.Values.app.ports.http | squote }}
@@ -164,6 +166,8 @@ Usage:
value: {{ $.Values.apiReference.ports.http | squote }}
- name: DOCS_PORT
value: {{ $.Values.docs.ports.http | squote }}
- name: MCP_PORT
value: {{ $.Values.mcp.ports.http | squote }}
{{- end }}

View File

@@ -0,0 +1,19 @@
{{- if $.Values.mcp.enabled }}
# OneUptime MCP Deployment
{{- $mcpEnv := dict "PORT" $.Values.mcp.ports.http "DISABLE_TELEMETRY" $.Values.mcp.disableTelemetryCollection -}}
{{- $mcpPorts := $.Values.mcp.ports -}}
{{- $mcpDeploymentArgs := dict "ServiceName" "mcp" "Ports" $mcpPorts "Release" $.Release "Values" $.Values "Env" $mcpEnv "Resources" $.Values.mcp.resources "NodeSelector" $.Values.mcp.nodeSelector "PodSecurityContext" $.Values.mcp.podSecurityContext "ContainerSecurityContext" $.Values.mcp.containerSecurityContext "DisableAutoscaler" $.Values.mcp.disableAutoscaler "ReplicaCount" $.Values.mcp.replicaCount -}}
{{- include "oneuptime.deployment" $mcpDeploymentArgs }}
---
# OneUptime MCP Service
{{- $mcpPorts := $.Values.mcp.ports -}}
{{- $mcpServiceArgs := dict "ServiceName" "mcp" "Ports" $mcpPorts "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.service" $mcpServiceArgs }}
---
# OneUptime MCP autoscaler
{{- $mcpAutoScalerArgs := dict "ServiceName" "mcp" "Release" $.Release "Values" $.Values "DisableAutoscaler" $.Values.mcp.disableAutoscaler -}}
{{- include "oneuptime.autoscaler" $mcpAutoScalerArgs }}
---
{{- end }}

View File

@@ -1921,6 +1921,45 @@
},
"additionalProperties": false
},
"mcp": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"replicaCount": {
"type": "integer"
},
"disableTelemetryCollection": {
"type": "boolean"
},
"disableAutoscaler": {
"type": "boolean"
},
"ports": {
"type": "object",
"properties": {
"http": {
"type": "integer"
}
},
"additionalProperties": false
},
"resources": {
"type": ["object", "null"]
},
"nodeSelector": {
"type": "object"
},
"podSecurityContext": {
"type": "object"
},
"containerSecurityContext": {
"type": "object"
}
},
"additionalProperties": false
},
"serverMonitorIngest": {
"type": "object",
"properties": {

View File

@@ -765,6 +765,18 @@ isolatedVM:
podSecurityContext: {}
containerSecurityContext: {}
mcp:
enabled: true
replicaCount: 1
disableTelemetryCollection: false
disableAutoscaler: false
ports:
http: 3405
resources:
nodeSelector: {}
podSecurityContext: {}
containerSecurityContext: {}
serverMonitorIngest:
enabled: true
replicaCount: 1

View File

@@ -1,261 +1,377 @@
#!/usr/bin/env npx ts-node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import {
CallToolRequestSchema,
CallToolRequest,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from "@modelcontextprotocol/sdk/types.js";
import dotenv from "dotenv";
import Express, {
ExpressApplication,
ExpressRequest,
ExpressResponse,
NextFunction,
ExpressJson,
} from "Common/Server/Utils/Express";
import DynamicToolGenerator from "./Utils/DynamicToolGenerator";
import OneUptimeApiService, {
OneUptimeApiConfig,
} from "./Services/OneUptimeApiService";
import { McpToolInfo, OneUptimeToolCallArgs } from "./Types/McpTypes";
import {
McpToolInfo,
OneUptimeToolCallArgs,
JSONSchema,
} from "./Types/McpTypes";
import OneUptimeOperation from "./Types/OneUptimeOperation";
import MCPLogger from "./Utils/MCPLogger";
import logger from "Common/Server/Utils/Logger";
import App from "Common/Server/Utils/StartServer";
import Telemetry from "Common/Server/Utils/Telemetry";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import "ejs";
// Load environment variables
dotenv.config();
const APP_NAME: string = "mcp";
MCPLogger.info("OneUptime MCP Server is starting...");
const app: ExpressApplication = Express.getExpressApp();
class OneUptimeMCPServer {
private server: Server;
private tools: McpToolInfo[] = [];
// Store active SSE transports
const transports: Map<string, SSEServerTransport> = new Map();
public constructor() {
this.server = new Server(
{
name: "oneuptime-mcp",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
// MCP Server instance
let mcpServer: Server;
let tools: McpToolInfo[] = [];
function initializeMCPServer(): void {
mcpServer = new Server(
{
name: "oneuptime-mcp",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
},
);
initializeServices();
generateTools();
setupHandlers();
}
function initializeServices(): void {
// Initialize OneUptime API Service
const apiKey: string | undefined = process.env["ONEUPTIME_API_KEY"];
if (!apiKey) {
throw new Error(
"OneUptime API key is required. Please set ONEUPTIME_API_KEY environment variable.",
);
this.initializeServices();
this.generateTools();
this.setupHandlers();
}
private initializeServices(): void {
// Initialize OneUptime API Service
const apiKey: string | undefined = process.env["ONEUPTIME_API_KEY"];
if (!apiKey) {
throw new Error(
"OneUptime API key is required. Please set ONEUPTIME_API_KEY environment variable.",
);
}
const config: OneUptimeApiConfig = {
url: process.env["ONEUPTIME_URL"] || "https://oneuptime.com",
apiKey: apiKey,
};
const config: OneUptimeApiConfig = {
url: process.env["ONEUPTIME_URL"] || "https://oneuptime.com",
apiKey: apiKey,
};
OneUptimeApiService.initialize(config);
logger.info("OneUptime API Service initialized");
}
OneUptimeApiService.initialize(config);
MCPLogger.info("OneUptime API Service initialized");
function generateTools(): void {
try {
tools = DynamicToolGenerator.generateAllTools();
logger.info(`Generated ${tools.length} OneUptime MCP tools`);
} catch (error) {
logger.error(`Failed to generate tools: ${error}`);
throw error;
}
}
private generateTools(): void {
try {
this.tools = DynamicToolGenerator.generateAllTools();
MCPLogger.info(`Generated ${this.tools.length} OneUptime MCP tools`);
} catch (error) {
MCPLogger.error(`Failed to generate tools: ${error}`);
throw error;
}
}
private setupHandlers(): void {
// List available tools
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
const mcpTools: Array<{
name: string;
description: string;
inputSchema: any;
}> = this.tools.map((tool: McpToolInfo) => {
return {
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
};
});
MCPLogger.info(`Listing ${mcpTools.length} available tools`);
return { tools: mcpTools };
function setupHandlers(): void {
// List available tools
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => {
const mcpTools: Array<{
name: string;
description: string;
inputSchema: JSONSchema;
}> = tools.map((tool: McpToolInfo) => {
return {
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
};
});
// Handle tool calls
this.server.setRequestHandler(
CallToolRequestSchema,
async (request: any) => {
const { name, arguments: args } = request.params;
logger.info(`Listing ${mcpTools.length} available tools`);
return { tools: mcpTools };
});
try {
// Find the tool by name
const tool: McpToolInfo | undefined = this.tools.find(
(t: McpToolInfo) => {
return t.name === name;
// Handle tool calls
mcpServer.setRequestHandler(
CallToolRequestSchema,
async (request: CallToolRequest) => {
const { name, arguments: args } = request.params;
try {
// Find the tool by name
const tool: McpToolInfo | undefined = tools.find((t: McpToolInfo) => {
return t.name === name;
});
if (!tool) {
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
}
logger.info(`Executing tool: ${name} for model: ${tool.modelName}`);
// Execute the OneUptime operation
const result: unknown = await OneUptimeApiService.executeOperation(
tool.tableName,
tool.operation,
tool.modelType,
tool.apiPath || "",
args as OneUptimeToolCallArgs,
);
// Format the response
const responseText: string = formatToolResponse(
tool,
result,
args as OneUptimeToolCallArgs,
);
return {
content: [
{
type: "text",
text: responseText,
},
);
if (!tool) {
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${name}`,
);
],
};
} catch (error) {
logger.error(`Error executing tool ${name}: ${error}`);
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InternalError,
`Failed to execute ${name}: ${error}`,
);
}
},
);
}
function formatToolResponse(
tool: McpToolInfo,
result: unknown,
args: OneUptimeToolCallArgs,
): string {
const operation: OneUptimeOperation = tool.operation;
const modelName: string = tool.singularName;
const pluralName: string = tool.pluralName;
switch (operation) {
case OneUptimeOperation.Create:
return `Successfully created ${modelName}: ${JSON.stringify(result, null, 2)}`;
case OneUptimeOperation.Read:
if (result) {
return `Retrieved ${modelName} (ID: ${args.id}): ${JSON.stringify(result, null, 2)}`;
}
return `${modelName} not found with ID: ${args.id}`;
case OneUptimeOperation.List: {
const items: Array<unknown> = Array.isArray(result)
? result
: (result as { data?: Array<unknown> })?.data || [];
const count: number = items.length;
const summary: string = `Found ${count} ${count === 1 ? modelName : pluralName}`;
if (count === 0) {
return `${summary}. No items match the criteria.`;
}
const limitedItems: Array<unknown> = items.slice(0, 5); // Show first 5 items
const itemsText: string = limitedItems
.map((item: unknown, index: number) => {
return `${index + 1}. ${JSON.stringify(item, null, 2)}`;
})
.join("\n");
const hasMore: string =
count > 5 ? `\n... and ${count - 5} more items` : "";
return `${summary}:\n${itemsText}${hasMore}`;
}
case OneUptimeOperation.Update:
return `Successfully updated ${modelName} (ID: ${args.id}): ${JSON.stringify(result, null, 2)}`;
case OneUptimeOperation.Delete:
return `Successfully deleted ${modelName} (ID: ${args.id})`;
case OneUptimeOperation.Count: {
const totalCount: number =
(result as { count?: number })?.count || (result as number) || 0;
return `Total count of ${pluralName}: ${totalCount}`;
}
default:
return `Operation ${operation} completed successfully: ${JSON.stringify(result, null, 2)}`;
}
}
// Setup MCP-specific routes
function setupMCPRoutes(): void {
const ROUTE_PREFIXES: Array<string> = [`/${APP_NAME}`, "/"];
// Use forEach to create proper closures for each route prefix
ROUTE_PREFIXES.forEach((prefix: string) => {
// SSE endpoint for MCP connections
app.get(
`${prefix === "/" ? "" : prefix}/sse`,
async (req: ExpressRequest, res: ExpressResponse) => {
logger.info("New SSE connection established");
// Set SSE headers
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("Access-Control-Allow-Origin", "*");
// Create SSE transport
const messageEndpoint: string =
prefix === "/" ? "/message" : `${prefix}/message`;
const transport: SSEServerTransport = new SSEServerTransport(
messageEndpoint,
res,
);
// Store transport with session ID
const sessionId: string = `session-${Date.now()}-${Math.random().toString(36).substring(7)}`;
transports.set(sessionId, transport);
// Handle connection close
req.on("close", () => {
logger.info(`SSE connection closed: ${sessionId}`);
transports.delete(sessionId);
});
// Connect server to transport
await mcpServer.connect(transport);
},
);
// Message endpoint for client-to-server messages
app.post(
`${prefix === "/" ? "" : prefix}/message`,
ExpressJson(),
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
/*
* Find the transport for this session
* In a real implementation, you'd use session management
*/
const transport: SSEServerTransport | undefined = Array.from(
transports.values(),
)[0];
if (transport) {
await transport.handlePostMessage(req, res);
} else {
res.status(400).json({ error: "No active SSE connection" });
}
MCPLogger.info(
`Executing tool: ${name} for model: ${tool.modelName}`,
);
// Execute the OneUptime operation
const result: any = await OneUptimeApiService.executeOperation(
tool.tableName,
tool.operation,
tool.modelType,
tool.apiPath || "",
args as OneUptimeToolCallArgs,
);
// Format the response
const responseText: string = this.formatToolResponse(
tool,
result,
args as OneUptimeToolCallArgs,
);
return {
content: [
{
type: "text",
text: responseText,
},
],
};
} catch (error) {
MCPLogger.error(`Error executing tool ${name}: ${error}`);
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InternalError,
`Failed to execute ${name}: ${error}`,
);
next(error);
}
},
);
}
private formatToolResponse(
tool: McpToolInfo,
result: any,
args: OneUptimeToolCallArgs,
): string {
const operation: OneUptimeOperation = tool.operation;
const modelName: string = tool.singularName;
const pluralName: string = tool.pluralName;
// List tools endpoint (REST API)
app.get(
`${prefix === "/" ? "" : prefix}/tools`,
(_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 });
},
);
switch (operation) {
case OneUptimeOperation.Create:
return `✅ Successfully created ${modelName}: ${JSON.stringify(result, null, 2)}`;
// Health check endpoint (in addition to standard status endpoints)
app.get(
`${prefix === "/" ? "" : prefix}/health`,
(_req: ExpressRequest, res: ExpressResponse) => {
res.json({
status: "healthy",
service: "oneuptime-mcp",
tools: tools.length,
});
},
);
});
}
case OneUptimeOperation.Read:
if (result) {
return `📋 Retrieved ${modelName} (ID: ${args.id}): ${JSON.stringify(result, null, 2)}`;
}
return `${modelName} not found with ID: ${args.id}`;
const init: PromiseVoidFunction = async (): Promise<void> => {
try {
// Initialize telemetry
Telemetry.init({
serviceName: APP_NAME,
});
case OneUptimeOperation.List: {
const items: any[] = Array.isArray(result)
? result
: result?.data || [];
const count: number = items.length;
const summary: string = `📊 Found ${count} ${count === 1 ? modelName : pluralName}`;
// 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();
};
if (count === 0) {
return `${summary}. No items match the criteria.`;
}
// Initialize the app with service name and status checks
await App.init({
appName: APP_NAME,
statusOptions: {
liveCheck: statusCheck,
readyCheck: statusCheck,
},
});
const limitedItems: any[] = items.slice(0, 5); // Show first 5 items
const itemsText: string = limitedItems
.map((item: any, index: number) => {
return `${index + 1}. ${JSON.stringify(item, null, 2)}`;
})
.join("\n");
// Initialize MCP server
initializeMCPServer();
const hasMore: string =
count > 5 ? `\n... and ${count - 5} more items` : "";
return `${summary}:\n${itemsText}${hasMore}`;
}
// Setup MCP-specific routes
setupMCPRoutes();
case OneUptimeOperation.Update:
return `✅ Successfully updated ${modelName} (ID: ${args.id}): ${JSON.stringify(result, null, 2)}`;
// Add default routes to the app
await App.addDefaultRoutes();
case OneUptimeOperation.Delete:
return `🗑️ Successfully deleted ${modelName} (ID: ${args.id})`;
case OneUptimeOperation.Count: {
const totalCount: number = result?.count || result || 0;
return `📊 Total count of ${pluralName}: ${totalCount}`;
}
default:
return `✅ Operation ${operation} completed successfully: ${JSON.stringify(result, null, 2)}`;
}
}
public async run(): Promise<void> {
const transport: StdioServerTransport = new StdioServerTransport();
await this.server.connect(transport);
MCPLogger.info("OneUptime MCP Server is running!");
MCPLogger.info(`Available tools: ${this.tools.length} total`);
logger.info(`OneUptime MCP Server started successfully`);
logger.info(`Available tools: ${tools.length} total`);
// Log some example tools
const exampleTools: string[] = this.tools
.slice(0, 5)
.map((t: McpToolInfo) => {
return t.name;
});
MCPLogger.info(`Example tools: ${exampleTools.join(", ")}`);
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
async function main(): Promise<void> {
try {
const mcpServer: OneUptimeMCPServer = new OneUptimeMCPServer();
await mcpServer.run();
} catch (error) {
MCPLogger.error(`Failed to start MCP server: ${error}`);
process.exit(1);
}
}
// Handle graceful shutdown
process.on("SIGINT", () => {
MCPLogger.info("Received SIGINT, shutting down gracefully...");
process.exit(0);
});
process.on("SIGTERM", () => {
MCPLogger.info("Received SIGTERM, shutting down gracefully...");
process.exit(0);
});
// Start the server
main().catch((error: unknown) => {
const errorMessage: string =
error instanceof Error ? error.message : String(error);
MCPLogger.error(`Unhandled error: ${errorMessage}`);
// Call the initialization function and handle errors
init().catch((err: Error) => {
logger.error(err);
logger.error("Exiting node process");
process.exit(1);
});

View File

@@ -2,27 +2,58 @@ import OneUptimeOperation from "../Types/OneUptimeOperation";
import ModelType from "../Types/ModelType";
import { OneUptimeToolCallArgs } from "../Types/McpTypes";
import MCPLogger from "../Utils/MCPLogger";
import API from "@oneuptime/common/Utils/API";
import URL from "@oneuptime/common/Types/API/URL";
import Route from "@oneuptime/common/Types/API/Route";
import Headers from "@oneuptime/common/Types/API/Headers";
import HTTPResponse from "@oneuptime/common/Types/API/HTTPResponse";
import HTTPErrorResponse from "@oneuptime/common/Types/API/HTTPErrorResponse";
import { JSONObject } from "@oneuptime/common/Types/JSON";
import DatabaseModels from "@oneuptime/common/Models/DatabaseModels/Index";
import AnalyticsModels from "@oneuptime/common/Models/AnalyticsModels/Index";
import { ModelSchema } from "@oneuptime/common/Utils/Schema/ModelSchema";
import { AnalyticsModelSchema } from "@oneuptime/common/Utils/Schema/AnalyticsModelSchema";
import { getTableColumns } from "@oneuptime/common/Types/Database/TableColumn";
import Permission from "@oneuptime/common/Types/Permission";
import Protocol from "@oneuptime/common/Types/API/Protocol";
import Hostname from "@oneuptime/common/Types/API/Hostname";
import API from "Common/Utils/API";
import URL from "Common/Types/API/URL";
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, 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";
import { AnalyticsModelSchema } from "Common/Utils/Schema/AnalyticsModelSchema";
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,34 @@ 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

@@ -1,20 +1,61 @@
import DatabaseModels from "@oneuptime/common/Models/DatabaseModels/Index";
import AnalyticsModels from "@oneuptime/common/Models/AnalyticsModels/Index";
import DatabaseBaseModel from "@oneuptime/common/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
import AnalyticsBaseModel from "@oneuptime/common/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel";
import DatabaseModels from "Common/Models/DatabaseModels/Index";
import AnalyticsModels from "Common/Models/AnalyticsModels/Index";
import DatabaseBaseModel from "Common/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
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 {
ModelSchema,
ModelSchemaType,
} from "@oneuptime/common/Utils/Schema/ModelSchema";
McpToolInfo,
ModelToolsResult,
JSONSchemaProperty,
} from "../Types/McpTypes";
import { ModelSchema, ModelSchemaType } from "Common/Utils/Schema/ModelSchema";
import {
AnalyticsModelSchema,
AnalyticsModelSchemaType,
} from "@oneuptime/common/Utils/Schema/AnalyticsModelSchema";
} 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 +82,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 +101,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 +130,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 +165,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 +267,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 +343,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 +472,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)}`,

View File

@@ -3,10 +3,10 @@
* All logs are directed to stderr to avoid interfering with the JSON-RPC protocol on stdout
*/
import { LogLevel } from "@oneuptime/common/Server/EnvironmentConfig";
import ConfigLogLevel from "@oneuptime/common/Server/Types/ConfigLogLevel";
import { JSONObject } from "@oneuptime/common/Types/JSON";
import Exception from "@oneuptime/common/Types/Exception/Exception";
import { LogLevel } from "Common/Server/EnvironmentConfig";
import ConfigLogLevel from "Common/Server/Types/ConfigLogLevel";
import { JSONObject } from "Common/Types/JSON";
import Exception from "Common/Types/Exception/Exception";
export type LogBody = string | JSONObject | Exception | Error | unknown;

View File

@@ -21,13 +21,11 @@
"transform": {
"^.+\\.ts$": ["ts-jest", {
"tsconfig": {
"compilerOptions": {
"noUnusedLocals": false,
"noUnusedParameters": false,
"strict": false,
"noPropertyAccessFromIndexSignature": false,
"module": "commonjs"
}
"noUnusedLocals": false,
"noUnusedParameters": false,
"strict": false,
"noPropertyAccessFromIndexSignature": false,
"module": "commonjs"
}
}]
}

12590
MCP/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,17 +24,16 @@
"author": "OneUptime <hello@oneuptime.com> (https://oneuptime.com/)",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^0.6.0",
"@oneuptime/common": "*",
"dotenv": "^16.4.5",
"ts-node": "^10.9.1"
"@modelcontextprotocol/sdk": "^1.25.0",
"Common": "file:../Common",
"ts-node": "^10.9.2"
},
"devDependencies": {
"@types/jest": "^27.5.0",
"@types/node": "^17.0.31",
"jest": "^28.1.0",
"nodemon": "^2.0.20",
"ts-jest": "^28.0.2",
"typescript": "^5.8.3"
"@types/jest": "^29.5.14",
"@types/node": "^22.15.21",
"jest": "^29.7.0",
"nodemon": "^3.1.11",
"ts-jest": "^29.4.6",
"typescript": "^5.9.3"
}
}

View File

@@ -8,7 +8,6 @@
}
},
"compilerOptions": {
"experimentalDecorators": true,
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Projects */
@@ -20,9 +19,10 @@
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2017", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"target": "es2017" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
"jsx": "react", /* Specify what JSX code is generated. */ /* Enable experimental support for TC39 stage 2 draft decorators. */
"jsx": "react" /* Specify what JSX code is generated. */,
"experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
"emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
@@ -32,19 +32,16 @@
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
"module": "es2022" /* Specify what module code is generated. */,
// "rootDir": "./" /* Specify the root folder within your source files. */,
// "module": "es2022" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
"typeRoots": [
"./node_modules/@types"
] /* Specify multiple folders that act like `./node_modules/@types`. */,
"types": [
"node",
"jest"
] /* Specify type package names to be included without being referenced in a source file. */,
], /* Specify multiple folders that act like `./node_modules/@types`. */
"types": ["node", "jest"], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true, /* Enable importing .json files */
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
@@ -60,7 +57,7 @@
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
"outDir": "./build/dist" /* Specify an output folder for all emitted files. */,
"outDir": "./build/dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
@@ -88,22 +85,22 @@
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
"noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied `any` type.. */,
"strictNullChecks": true /* When type checking, take into account `null` and `undefined`. */,
"strictFunctionTypes": true /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */,
"strictBindCallApply": true /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */,
"strictPropertyInitialization": true /* Check for class properties that are declared but not set in the constructor. */,
"noImplicitThis": true /* Enable error reporting when `this` is given the type `any`. */,
"useUnknownInCatchVariables": true /* Type catch clause variables as 'unknown' instead of 'any'. */,
"alwaysStrict": true /* Ensure 'use strict' is always emitted. */,
"noUnusedLocals": true /* Enable error reporting when a local variables aren't read. */,
"noUnusedParameters": true /* Raise an error when a function parameter isn't read */,
"exactOptionalPropertyTypes": true /* Interpret optional property types as written, rather than adding 'undefined'. */,
"noImplicitReturns": true /* Enable error reporting for codepaths that do not explicitly return in a function. */,
"noFallthroughCasesInSwitch": true /* Enable error reporting for fallthrough cases in switch statements. */,
"noUncheckedIndexedAccess": true /* Include 'undefined' in index signature results */,
"noImplicitOverride": true /* Ensure overriding members in derived classes are marked with an override modifier. */,
"noPropertyAccessFromIndexSignature": true /* Enforces using indexed accessors for keys declared using an indexed type */,
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
"strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
"strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
"strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
"strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
"noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
"useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
"alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
"noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
"noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
"exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
"noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
"noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
"noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
"noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
"noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */

View File

@@ -72,6 +72,10 @@ upstream opentelemetry-collector-grpc {
server ${SERVER_OTEL_COLLECTOR_HOSTNAME}:4317;
}
upstream mcp {
server ${SERVER_MCP_HOSTNAME}:${MCP_PORT} weight=10 max_fails=3 fail_timeout=30s;
}
# Status Pages
server {
@@ -915,12 +919,12 @@ ${PROVISION_SSL_CERTIFICATE_KEY_DIRECTIVE}
}
location /workers {
# This is for nginx not to crash when service is not available.
# This is for nginx not to crash when service is not available.
resolver 127.0.0.1 valid=30s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
# enable WebSockets (for ws://sockjs not connected error in the accounts source: https://stackoverflow.com/questions/41381444/websocket-connection-failed-error-during-websocket-handshake-unexpected-respon)
proxy_http_version 1.1;
@@ -928,4 +932,31 @@ ${PROVISION_SSL_CERTIFICATE_KEY_DIRECTIVE}
proxy_set_header Connection "upgrade";
proxy_pass http://app/api/workers;
}
location /mcp/ {
# This is for nginx not to crash when service is not available.
resolver 127.0.0.1 valid=30s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# enable WebSockets and SSE (for MCP Server-Sent Events)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# SSE specific settings for long-lived connections
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
chunked_transfer_encoding on;
proxy_pass http://mcp/;
}
location = /mcp {
return 301 /mcp/;
}
}

View File

@@ -109,6 +109,7 @@ SERVER_OTEL_COLLECTOR_HOSTNAME=otel-collector
SERVER_API_REFERENCE_HOSTNAME=reference
SERVER_WORKER_HOSTNAME=worker
SERVER_DOCS_HOSTNAME=docs
SERVER_MCP_HOSTNAME=mcp
#Ports. Usually they don't need to change.
@@ -129,6 +130,7 @@ WORKER_PORT=1445
WORKFLOW_PORT=3099
API_REFERENCE_PORT=1446
DOCS_PORT=1447
MCP_PORT=3405
# Plans
# This is in the format of PlanName,PlanIdFromBillingProvider,MonthlySubscriptionPlanAmountInUSD,YearlySubscriptionPlanAmountInUSD,Order,TrialPeriodInDays
@@ -314,6 +316,7 @@ DISABLE_TELEMETRY_FOR_ISOLATED_VM=true
DISABLE_TELEMETRY_FOR_INGRESS=true
DISABLE_TELEMETRY_FOR_WORKER=true
DISABLE_TELEMETRY_FOR_SERVER_MONITOR_INGEST=true
DISABLE_TELEMETRY_FOR_MCP=true
# OPENTELEMETRY_COLLECTOR env vars

View File

@@ -46,6 +46,7 @@ x-common-variables: &common-variables
SERVER_API_REFERENCE_HOSTNAME: api-reference
SERVER_DOCS_HOSTNAME: docs
SERVER_SERVER_MONITOR_INGEST_HOSTNAME: server-monitor-ingest
SERVER_MCP_HOSTNAME: mcp
#Ports. Usually they don't need to change.
APP_PORT: ${APP_PORT}
@@ -64,6 +65,7 @@ x-common-variables: &common-variables
API_REFERENCE_PORT: ${API_REFERENCE_PORT}
DOCS_PORT: ${DOCS_PORT}
SERVER_MONITOR_INGEST_PORT: ${SERVER_MONITOR_INGEST_PORT}
MCP_PORT: ${MCP_PORT}
OPENTELEMETRY_EXPORTER_OTLP_ENDPOINT: ${OPENTELEMETRY_EXPORTER_OTLP_ENDPOINT}
OPENTELEMETRY_EXPORTER_OTLP_HEADERS: ${OPENTELEMETRY_EXPORTER_OTLP_HEADERS}
@@ -508,9 +510,22 @@ services:
options:
max-size: "1000m"
mcp:
networks:
- oneuptime
restart: always
environment:
<<: *common-runtime-variables
PORT: ${MCP_PORT}
DISABLE_TELEMETRY: ${DISABLE_TELEMETRY_FOR_MCP}
logging:
driver: "local"
options:
max-size: "1000m"
e2e:
restart: "no"
network_mode: host # This is needed to access the host network,
network_mode: host # This is needed to access the host network,
environment:
<<: *common-variables
E2E_TEST_IS_USER_REGISTERED: ${E2E_TEST_IS_USER_REGISTERED}

View File

@@ -394,7 +394,23 @@ services:
context: .
dockerfile: ./IncomingRequestIngest/Dockerfile
mcp:
volumes:
- ./MCP:/usr/src/app:cached
# Use node modules of the container and not host system.
# https://stackoverflow.com/questions/29181032/add-a-volume-to-docker-but-exclude-a-sub-folder
- /usr/src/app/node_modules/
- ./Common:/usr/src/Common:cached
- /usr/src/Common/node_modules/
ports:
- '9945:9229' # Debugging port.
extends:
file: ./docker-compose.base.yml
service: mcp
build:
network: host
context: .
dockerfile: ./MCP/Dockerfile
# Fluentd. Required only for development. In production its the responsibility of the customer to run fluentd and pipe logs to OneUptime.
# We run this container just for development, to see if logs are piped.

View File

@@ -132,6 +132,12 @@ services:
file: ./docker-compose.base.yml
service: isolated-vm
mcp:
image: oneuptime/mcp:${APP_TAG}
extends:
file: ./docker-compose.base.yml
service: mcp
ingress:
image: oneuptime/nginx:${APP_TAG}
extends: