diff --git a/apps/mesh/migrations/035-reports.ts b/apps/mesh/migrations/035-reports.ts new file mode 100644 index 0000000000..7534ee5463 --- /dev/null +++ b/apps/mesh/migrations/035-reports.ts @@ -0,0 +1,41 @@ +/** + * Reports Migration + * + * Creates the reports table for storing automated reports (performance audits, + * security scans, collection reorder rankings, etc.) served via REPORTS_BINDING. + */ + +import { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable("reports") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("organization_id", "text", (col) => + col.notNull().references("organization.id").onDelete("cascade"), + ) + .addColumn("title", "text", (col) => col.notNull()) + .addColumn("category", "text", (col) => col.notNull()) + .addColumn("status", "text", (col) => col.notNull()) + .addColumn("summary", "text", (col) => col.notNull()) + .addColumn("source", "text") + .addColumn("tags", "text") + .addColumn("lifecycle_status", "text", (col) => + col.notNull().defaultTo("unread"), + ) + .addColumn("sections", "text", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .addColumn("updated_at", "text", (col) => col.notNull()) + .execute(); + + await db.schema + .createIndex("reports_organization_id") + .on("reports") + .columns(["organization_id"]) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropIndex("reports_organization_id").execute(); + await db.schema.dropTable("reports").execute(); +} diff --git a/apps/mesh/migrations/index.ts b/apps/mesh/migrations/index.ts index a58844fe8d..396273f987 100644 --- a/apps/mesh/migrations/index.ts +++ b/apps/mesh/migrations/index.ts @@ -33,6 +33,7 @@ import * as migration031adddependencymode from "./031-add-dependency-mode.ts"; import * as migration032projects from "./032-projects.ts"; import * as migration033threadstatus from "./033-thread-status.ts"; import * as migration034monitoringdashboards from "./034-monitoring-dashboards.ts"; +import * as migration035reports from "./035-reports.ts"; /** * Core migrations for the Mesh application. @@ -78,6 +79,7 @@ const migrations: Record = { "032-projects": migration032projects, "033-thread-status": migration033threadstatus, "034-monitoring-dashboards": migration034monitoringdashboards, + "035-reports": migration035reports, }; export default migrations; diff --git a/apps/mesh/src/core/context-factory.ts b/apps/mesh/src/core/context-factory.ts index c0f04e548b..65d77f6016 100644 --- a/apps/mesh/src/core/context-factory.ts +++ b/apps/mesh/src/core/context-factory.ts @@ -21,6 +21,7 @@ import { SqlMonitoringDashboardStorage } from "../storage/monitoring-dashboards" import { OrganizationSettingsStorage } from "../storage/organization-settings"; import { ProjectsStorage } from "../storage/projects"; import { ProjectPluginConfigsStorage } from "../storage/project-plugin-configs"; +import { KyselyReportsStorage } from "../storage/reports"; import { TagStorage } from "../storage/tags"; import type { Database, Permission } from "../storage/types"; import { UserStorage } from "../storage/user"; @@ -747,6 +748,7 @@ export async function createMeshContextFactory( tags: new TagStorage(config.db), projects: new ProjectsStorage(config.db), projectPluginConfigs: new ProjectPluginConfigsStorage(config.db), + reports: new KyselyReportsStorage(config.db), // Note: Organizations, teams, members, roles managed by Better Auth organization plugin // Note: Policies handled by Better Auth permissions directly // Note: API keys (tokens) managed by Better Auth API Key plugin diff --git a/apps/mesh/src/core/define-tool.test.ts b/apps/mesh/src/core/define-tool.test.ts index 122af92c68..214cfe3ab0 100644 --- a/apps/mesh/src/core/define-tool.test.ts +++ b/apps/mesh/src/core/define-tool.test.ts @@ -53,6 +53,7 @@ const createMockContext = (): MeshContext => ({ projects: null as never, projectPluginConfigs: null as never, monitoringDashboards: null as never, + reports: null as never, }, vault: null as never, authInstance: null as never, diff --git a/apps/mesh/src/core/mesh-context.test.ts b/apps/mesh/src/core/mesh-context.test.ts index cdd6de9c4b..1c66c470f8 100644 --- a/apps/mesh/src/core/mesh-context.test.ts +++ b/apps/mesh/src/core/mesh-context.test.ts @@ -27,6 +27,7 @@ const createMockContext = (overrides?: Partial): MeshContext => ({ projects: null as never, projectPluginConfigs: null as never, monitoringDashboards: null as never, + reports: null as never, }, vault: null as never, authInstance: null as never, diff --git a/apps/mesh/src/core/mesh-context.ts b/apps/mesh/src/core/mesh-context.ts index b9644ce615..a8dafd16d3 100644 --- a/apps/mesh/src/core/mesh-context.ts +++ b/apps/mesh/src/core/mesh-context.ts @@ -233,6 +233,7 @@ import type { UserStorage } from "../storage/user"; import type { VirtualMCPStorage } from "../storage/virtual"; import type { ProjectsStorage } from "../storage/projects"; import type { ProjectPluginConfigsStorage } from "../storage/project-plugin-configs"; +import type { ReportsStorage } from "../storage/reports"; import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; @@ -260,6 +261,7 @@ export interface MeshStorage { tags: TagStorage; projects: ProjectsStorage; projectPluginConfigs: ProjectPluginConfigsStorage; + reports: ReportsStorage; } // ============================================================================ diff --git a/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.ts b/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.ts index 4320b1e23c..8cbea9965f 100644 --- a/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.ts +++ b/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.ts @@ -62,11 +62,16 @@ interface PromptCache extends Cache {} * actually needs it (e.g. `callTool`). * * This avoids the ~80-120ms MCP handshake per connection when tools are cached. + * + * @param requiredTools - Tool names that must be present in the cache for it to + * be considered valid. If any are missing the client fetches live to handle + * stale caches (e.g. after new management tools are deployed). */ function createLazyClient( connection: ConnectionEntity, ctx: MeshContext, superUser: boolean, + requiredTools?: string[], ): Client { // Placeholder client — never connects to anything const placeholder = new Client( @@ -105,15 +110,27 @@ function createLazyClient( return realClientPromise; } - const hasCachedTools = + const cachedTools = connection.connection_type !== "VIRTUAL" && Array.isArray(connection.tools) && - connection.tools.length > 0; + connection.tools.length > 0 + ? connection.tools + : null; + + // Cache is valid only if it contains every tool that will be needed. + // If `requiredTools` are specified and any are absent, the DB cache is stale + // (e.g. new management tools were deployed since the cache was written). + // In that case fall back to a live fetch so callers never get an empty list. + const cacheHasAllRequired = + !requiredTools?.length || + requiredTools.every((name) => cachedTools?.some((t) => t.name === name)); + + const hasCachedTools = cachedTools !== null && cacheHasAllRequired; // If cached tools exist, listTools returns them without connecting if (hasCachedTools) { placeholder.listTools = async () => ({ - tools: connection.tools!.map((tool) => ({ + tools: cachedTools.map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema as Tool["inputSchema"], @@ -123,7 +140,7 @@ function createLazyClient( })), }); } else { - // No cached tools — must connect to get tool list + // No cached tools (or stale cache) — must connect to get tool list placeholder.listTools = async () => { const real = await getRealClient(); return real.listTools(); @@ -198,16 +215,26 @@ function createLazyClient( * Creates lazy-connecting clients for all connections. Clients with cached * tools in the database will skip the MCP handshake entirely during tool * listing, only connecting when a tool is actually called. + * + * @param selectionMap - Optional map of connection ID to selected tool names. + * Used to validate that the DB cache contains every required tool; if not, + * the client will fetch live instead. */ function createClientMap( connections: ConnectionEntity[], ctx: MeshContext, superUser = false, + selectionMap?: Map, ): Map { const clientMap = new Map(); for (const connection of connections) { - clientMap.set(connection.id, createLazyClient(connection, ctx, superUser)); + const requiredTools = + selectionMap?.get(connection.id)?.selected_tools ?? undefined; + clientMap.set( + connection.id, + createLazyClient(connection, ctx, superUser, requiredTools), + ); } return clientMap; @@ -272,11 +299,14 @@ export class PassthroughClient extends Client { this._connections.set(connection.id, connection); } - // Create lazy-connecting client map (synchronous — no connections established yet) + // Create lazy-connecting client map (synchronous — no connections established yet). + // Pass the selection map so each client can detect a stale DB cache (e.g. when + // new management tools were deployed and the _self cache hasn't been refreshed yet). this._clients = createClientMap( this.options.connections, this.ctx, this.options.superUser, + this._selectionMap, ); // Initialize lazy caches - all share the same ProxyCollection diff --git a/apps/mesh/src/shared/utils/generate-id.ts b/apps/mesh/src/shared/utils/generate-id.ts index 025e02d5dc..5eee8568a5 100644 --- a/apps/mesh/src/shared/utils/generate-id.ts +++ b/apps/mesh/src/shared/utils/generate-id.ts @@ -14,7 +14,8 @@ type IdPrefixes = | "mtag" | "proj" | "ppc" - | "dash"; + | "dash" + | "rpt"; export function generatePrefixedId(prefix: IdPrefixes) { return `${prefix}_${nanoid()}`; diff --git a/apps/mesh/src/storage/reports.ts b/apps/mesh/src/storage/reports.ts new file mode 100644 index 0000000000..f004ad3184 --- /dev/null +++ b/apps/mesh/src/storage/reports.ts @@ -0,0 +1,203 @@ +/** + * Reports Storage Implementation + * + * Handles storage operations for automated reports (REPORTS_BINDING). + * Reports are organization-scoped and stored in the Mesh database. + */ + +import type { Kysely } from "kysely"; +import { generatePrefixedId } from "@/shared/utils/generate-id"; +import type { + Database, + Report, + ReportLifecycleStatus, + ReportSection, + ReportStatus, +} from "./types"; + +// ============================================================================ +// Reports Storage Interface +// ============================================================================ + +export interface ReportsStorage { + list( + organizationId: string, + filters?: { category?: string; status?: ReportStatus }, + ): Promise; + + get(id: string, organizationId: string): Promise; + + upsert( + organizationId: string, + data: Omit & { id?: string }, + ): Promise; + + updateLifecycleStatus( + id: string, + organizationId: string, + status: ReportLifecycleStatus, + ): Promise<{ success: boolean }>; +} + +// ============================================================================ +// Reports Storage Implementation +// ============================================================================ + +export class KyselyReportsStorage implements ReportsStorage { + constructor(private db: Kysely) {} + + async list( + organizationId: string, + filters?: { category?: string; status?: ReportStatus }, + ): Promise { + let query = this.db + .selectFrom("reports") + .selectAll() + .where("organization_id", "=", organizationId) + .orderBy("updated_at", "desc"); + + if (filters?.category) { + query = query.where("category", "=", filters.category); + } + if (filters?.status) { + query = query.where("status", "=", filters.status); + } + + const rows = await query.execute(); + return rows.map((row) => this.fromDbRow(row)); + } + + async get(id: string, organizationId: string): Promise { + const row = await this.db + .selectFrom("reports") + .selectAll() + .where("id", "=", id) + .where("organization_id", "=", organizationId) + .executeTakeFirst(); + + if (!row) return null; + return this.fromDbRow(row); + } + + async upsert( + organizationId: string, + data: Omit & { id?: string }, + ): Promise { + const now = new Date().toISOString(); + const id = data.id ?? generatePrefixedId("rpt"); + + const existing = await this.get(id, organizationId); + + if (existing) { + await this.db + .updateTable("reports") + .set({ + title: data.title, + category: data.category, + status: data.status, + summary: data.summary, + source: data.source ?? null, + tags: data.tags ? JSON.stringify(data.tags) : null, + lifecycle_status: data.lifecycleStatus ?? "unread", + sections: JSON.stringify(data.sections), + updated_at: now, + }) + .where("id", "=", id) + .where("organization_id", "=", organizationId) + .execute(); + + const updated = await this.get(id, organizationId); + if (!updated) throw new Error(`Report ${id} not found after update`); + return updated; + } + + await this.db + .insertInto("reports") + .values({ + id, + organization_id: organizationId, + title: data.title, + category: data.category, + status: data.status, + summary: data.summary, + source: data.source ?? null, + tags: data.tags ? JSON.stringify(data.tags) : null, + lifecycle_status: data.lifecycleStatus ?? "unread", + sections: JSON.stringify(data.sections), + created_at: now, + updated_at: now, + }) + .execute(); + + const inserted = await this.get(id, organizationId); + if (!inserted) throw new Error(`Report ${id} not found after insert`); + return inserted; + } + + async updateLifecycleStatus( + id: string, + organizationId: string, + status: ReportLifecycleStatus, + ): Promise<{ success: boolean }> { + const result = await this.db + .updateTable("reports") + .set({ + lifecycle_status: status, + updated_at: new Date().toISOString(), + }) + .where("id", "=", id) + .where("organization_id", "=", organizationId) + .executeTakeFirst(); + + return { success: (result.numUpdatedRows ?? 0n) > 0n }; + } + + // ============================================================================ + // Private Helper Methods + // ============================================================================ + + private fromDbRow(row: { + id: string; + organization_id: string; + title: string; + category: string; + status: string; + summary: string; + source: string | null; + tags: string | string[] | null; + lifecycle_status: string; + sections: string | ReportSection[]; + created_at: string | Date; + updated_at: string | Date; + }): Report { + const tags = row.tags + ? typeof row.tags === "string" + ? (JSON.parse(row.tags) as string[]) + : row.tags + : undefined; + + const sections = + typeof row.sections === "string" + ? (JSON.parse(row.sections) as ReportSection[]) + : row.sections; + + const updatedAt = + row.updated_at instanceof Date + ? row.updated_at.toISOString() + : row.updated_at; + + return { + id: row.id, + title: row.title, + category: row.category, + status: row.status as ReportStatus, + summary: row.summary, + updatedAt, + source: row.source ?? undefined, + tags: tags && tags.length > 0 ? tags : undefined, + lifecycleStatus: + (row.lifecycle_status as ReportLifecycleStatus) ?? undefined, + sections, + }; + } +} diff --git a/apps/mesh/src/storage/types.ts b/apps/mesh/src/storage/types.ts index 7f27f59341..1f31a8eb6a 100644 --- a/apps/mesh/src/storage/types.ts +++ b/apps/mesh/src/storage/types.ts @@ -522,6 +522,61 @@ export interface MonitoringDashboard { updatedAt: Date | string; } +// ============================================================================ +// Reports Table Definitions +// ============================================================================ + +/** + * Report status (passing, warning, failing, info) + */ +export type ReportStatus = "passing" | "warning" | "failing" | "info"; + +/** + * Report lifecycle status (unread, read, dismissed) + */ +export type ReportLifecycleStatus = "unread" | "read" | "dismissed"; + +/** + * Report section - polymorphic by type (markdown, metrics, table, criteria, note, ranked-list) + */ +import type { ReportSection as BindingsReportSection } from "@decocms/bindings"; +export type ReportSection = BindingsReportSection; + +/** + * Report table definition + * Stores automated reports served via REPORTS_BINDING + */ +export interface ReportTable { + id: string; + organization_id: string; + title: string; + category: string; + status: string; + summary: string; + source: string | null; + tags: JsonArray | null; + lifecycle_status: string; + sections: JsonObject; + created_at: ColumnType; + updated_at: ColumnType; +} + +/** + * Report runtime type (matches ReportSchema from bindings) + */ +export interface Report { + id: string; + title: string; + category: string; + status: ReportStatus; + summary: string; + updatedAt: string; + source?: string; + tags?: string[]; + lifecycleStatus?: ReportLifecycleStatus; + sections: ReportSection[]; +} + // ============================================================================ // Event Bus Table Definitions // ============================================================================ @@ -875,6 +930,7 @@ export interface Database { api_keys: ApiKeyTable; // Better Auth API keys monitoring_logs: MonitoringLogTable; // Tool call monitoring logs monitoring_dashboards: MonitoringDashboardTable; // Custom monitoring dashboards + reports: ReportTable; // Automated reports (REPORTS_BINDING) // OAuth tables (for MCP OAuth server) oauth_clients: OAuthClientTable; diff --git a/apps/mesh/src/tools/connection/connection-tools.test.ts b/apps/mesh/src/tools/connection/connection-tools.test.ts index ab0ccc3b0c..ff4f3a3e2d 100644 --- a/apps/mesh/src/tools/connection/connection-tools.test.ts +++ b/apps/mesh/src/tools/connection/connection-tools.test.ts @@ -84,6 +84,7 @@ describe("Connection Tools", () => { projects: null as never, projectPluginConfigs: null as never, monitoringDashboards: null as never, + reports: null as never, }, vault, authInstance: null as never, diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 777c902a37..ccfb2c4841 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -26,6 +26,7 @@ import * as MonitoringTools from "./monitoring"; import * as MonitoringDashboardTools from "./monitoring-dashboard"; import * as OrganizationTools from "./organization"; import * as ProjectTools from "./projects"; +import * as ReportTools from "./reports"; import * as TagTools from "./tags"; import * as ThreadTools from "./thread"; import * as UserTools from "./user"; @@ -128,6 +129,12 @@ const CORE_TOOLS = [ ProjectTools.PROJECT_DELETE, ProjectTools.PROJECT_PLUGIN_CONFIG_GET, ProjectTools.PROJECT_PLUGIN_CONFIG_UPDATE, + + // Reports tools + ReportTools.REPORTS_LIST, + ReportTools.REPORTS_GET, + ReportTools.REPORTS_UPDATE_STATUS, + ReportTools.REPORTS_UPSERT, ] as const satisfies { name: ToolName }[]; // Plugin tools - collected at startup, gated by org settings at runtime diff --git a/apps/mesh/src/tools/organization/organization-tools.test.ts b/apps/mesh/src/tools/organization/organization-tools.test.ts index 848ca8bf2a..1f78b3db6a 100644 --- a/apps/mesh/src/tools/organization/organization-tools.test.ts +++ b/apps/mesh/src/tools/organization/organization-tools.test.ts @@ -195,6 +195,7 @@ const createMockContext = ( projects: null as never, projectPluginConfigs: null as never, monitoringDashboards: null as never, + reports: null as never, }, vault: null as never, authInstance: authInstance as unknown as BetterAuthInstance, diff --git a/apps/mesh/src/tools/registry.ts b/apps/mesh/src/tools/registry.ts index f237ccff94..774f63c942 100644 --- a/apps/mesh/src/tools/registry.ts +++ b/apps/mesh/src/tools/registry.ts @@ -30,7 +30,8 @@ export type ToolCategory = | "Event Bus" | "Code Execution" | "Tags" - | "Projects"; + | "Projects" + | "Reports"; /** * All tool names - keep in sync with ALL_TOOLS in index.ts @@ -120,6 +121,11 @@ const ALL_TOOL_NAMES = [ "PROJECT_DELETE", "PROJECT_PLUGIN_CONFIG_GET", "PROJECT_PLUGIN_CONFIG_UPDATE", + // Reports tools + "REPORTS_LIST", + "REPORTS_GET", + "REPORTS_UPDATE_STATUS", + "REPORTS_UPSERT", ] as const; /** @@ -535,6 +541,27 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ description: "Update project plugin configuration", category: "Projects", }, + // Reports tools + { + name: "REPORTS_LIST", + description: "List reports with optional filters", + category: "Reports", + }, + { + name: "REPORTS_GET", + description: "Get report by ID with full content", + category: "Reports", + }, + { + name: "REPORTS_UPDATE_STATUS", + description: "Update report lifecycle status", + category: "Reports", + }, + { + name: "REPORTS_UPSERT", + description: "Create or update a report", + category: "Reports", + }, ]; /** @@ -612,6 +639,10 @@ const TOOL_LABELS: Record = { PROJECT_DELETE: "Delete project", PROJECT_PLUGIN_CONFIG_GET: "View plugin config", PROJECT_PLUGIN_CONFIG_UPDATE: "Update plugin config", + REPORTS_LIST: "List reports", + REPORTS_GET: "Get report", + REPORTS_UPDATE_STATUS: "Update report status", + REPORTS_UPSERT: "Create or update report", }; // ============================================================================ @@ -635,6 +666,7 @@ export function getToolsByCategory() { "Code Execution": [], Tags: [], Projects: [], + Reports: [], }; for (const tool of MANAGEMENT_TOOLS) { diff --git a/apps/mesh/src/tools/reports/get.ts b/apps/mesh/src/tools/reports/get.ts new file mode 100644 index 0000000000..a2ec7da38e --- /dev/null +++ b/apps/mesh/src/tools/reports/get.ts @@ -0,0 +1,41 @@ +/** + * REPORTS_GET Tool + * + * Gets a single report by ID with full content. + * Implements REPORTS_BINDING - serves data from Mesh database. + */ + +import { ReportSchema } from "@decocms/bindings"; +import { requireOrganization } from "@/core/mesh-context"; +import { defineTool } from "@/core/define-tool"; +import { z } from "zod"; + +const ReportsGetInputSchema = z.object({ + id: z.string().describe("Report identifier"), +}); + +export const REPORTS_GET = defineTool({ + name: "REPORTS_GET", + description: "Get a report by ID with full content including sections", + annotations: { + title: "Get Report", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: ReportsGetInputSchema, + outputSchema: ReportSchema, + handler: async (input, ctx) => { + const org = requireOrganization(ctx); + await ctx.access.check(); + + const report = await ctx.storage.reports.get(input.id, org.id); + + if (!report) { + throw new Error(`Report not found: ${input.id}`); + } + + return report; + }, +}); diff --git a/apps/mesh/src/tools/reports/index.ts b/apps/mesh/src/tools/reports/index.ts new file mode 100644 index 0000000000..b8ba48d79c --- /dev/null +++ b/apps/mesh/src/tools/reports/index.ts @@ -0,0 +1,12 @@ +/** + * Reports Tools + * + * MCP tools for managing reports in the Mesh database. + * Implements REPORTS_BINDING - REPORTS_LIST, REPORTS_GET, REPORTS_UPDATE_STATUS. + * REPORTS_UPSERT allows publishing reports from agents/CI. + */ + +export { REPORTS_LIST } from "./list"; +export { REPORTS_GET } from "./get"; +export { REPORTS_UPDATE_STATUS } from "./update-status"; +export { REPORTS_UPSERT } from "./upsert"; diff --git a/apps/mesh/src/tools/reports/list.ts b/apps/mesh/src/tools/reports/list.ts new file mode 100644 index 0000000000..845301fca0 --- /dev/null +++ b/apps/mesh/src/tools/reports/list.ts @@ -0,0 +1,61 @@ +/** + * REPORTS_LIST Tool + * + * Lists all reports for the organization with optional filters. + * Implements REPORTS_BINDING - serves data from Mesh database. + */ + +import { ReportStatusSchema, ReportSummarySchema } from "@decocms/bindings"; +import { requireOrganization } from "@/core/mesh-context"; +import { defineTool } from "@/core/define-tool"; +import { z } from "zod"; + +const ReportsListInputSchema = z.object({ + category: z + .string() + .optional() + .describe("Filter by category (e.g. 'performance', 'security')"), + status: ReportStatusSchema.optional().describe("Filter by report status"), +}); + +const ReportsListOutputSchema = z.object({ + reports: z.array(ReportSummarySchema).describe("List of report summaries"), +}); + +export const REPORTS_LIST = defineTool({ + name: "REPORTS_LIST", + description: + "List all reports for the organization with optional category and status filters", + annotations: { + title: "List Reports", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: ReportsListInputSchema, + outputSchema: ReportsListOutputSchema, + handler: async (input, ctx) => { + const org = requireOrganization(ctx); + await ctx.access.check(); + + const reports = await ctx.storage.reports.list(org.id, { + category: input.category, + status: input.status, + }); + + return { + reports: reports.map((r) => ({ + id: r.id, + title: r.title, + category: r.category, + status: r.status, + summary: r.summary, + updatedAt: r.updatedAt, + source: r.source, + tags: r.tags, + lifecycleStatus: r.lifecycleStatus, + })), + }; + }, +}); diff --git a/apps/mesh/src/tools/reports/update-status.ts b/apps/mesh/src/tools/reports/update-status.ts new file mode 100644 index 0000000000..b982bdc032 --- /dev/null +++ b/apps/mesh/src/tools/reports/update-status.ts @@ -0,0 +1,55 @@ +/** + * REPORTS_UPDATE_STATUS Tool + * + * Updates the lifecycle status of a report (unread → read → dismissed). + * Implements REPORTS_BINDING - optional tool for inbox workflow. + */ + +import { ReportLifecycleStatusSchema } from "@decocms/bindings"; +import { requireOrganization } from "@/core/mesh-context"; +import { defineTool } from "@/core/define-tool"; +import { z } from "zod"; + +const ReportsUpdateStatusInputSchema = z.object({ + reportId: z.string().describe("Report identifier"), + lifecycleStatus: ReportLifecycleStatusSchema.describe( + "New lifecycle status for the report", + ), +}); + +const ReportsUpdateStatusOutputSchema = z.object({ + success: z.boolean().describe("Whether the operation succeeded"), + message: z.string().optional().describe("Human-readable result message"), +}); + +export const REPORTS_UPDATE_STATUS = defineTool({ + name: "REPORTS_UPDATE_STATUS", + description: + "Update the lifecycle status of a report (unread, read, dismissed)", + annotations: { + title: "Update Report Status", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: ReportsUpdateStatusInputSchema, + outputSchema: ReportsUpdateStatusOutputSchema, + handler: async (input, ctx) => { + const org = requireOrganization(ctx); + await ctx.access.check(); + + const result = await ctx.storage.reports.updateLifecycleStatus( + input.reportId, + org.id, + input.lifecycleStatus, + ); + + return { + success: result.success, + message: result.success + ? `Report status updated to ${input.lifecycleStatus}` + : "Report not found", + }; + }, +}); diff --git a/apps/mesh/src/tools/reports/upsert.ts b/apps/mesh/src/tools/reports/upsert.ts new file mode 100644 index 0000000000..c61d4776c3 --- /dev/null +++ b/apps/mesh/src/tools/reports/upsert.ts @@ -0,0 +1,89 @@ +/** + * REPORTS_UPSERT Tool + * + * Creates or updates a report in the Mesh database. + * Allows agents, CI, or external services to publish reports. + */ + +import { + ReportLifecycleStatusSchema, + ReportSectionSchema, + ReportStatusSchema, +} from "@decocms/bindings"; +import { requireOrganization } from "@/core/mesh-context"; +import { defineTool } from "@/core/define-tool"; +import { z } from "zod"; + +const ReportsUpsertInputSchema = z.object({ + id: z + .string() + .optional() + .describe("Report ID (optional, generated if omitted)"), + title: z.string().describe("Report title"), + category: z + .string() + .describe( + "Report category (e.g. 'performance', 'security', 'collection-ranking')", + ), + status: ReportStatusSchema.describe("Overall report status"), + summary: z.string().describe("One-line summary of findings"), + source: z + .string() + .optional() + .describe( + "Agent or service that generated the report (e.g. 'collection-reorder', 'security-auditor')", + ), + tags: z.array(z.string()).optional().describe("Free-form tags for filtering"), + lifecycleStatus: ReportLifecycleStatusSchema.optional().describe( + "Inbox lifecycle status (default: unread)", + ), + sections: z + .array(ReportSectionSchema) + .describe("Ordered content sections (markdown, metrics, table, etc.)"), +}); + +const ReportsUpsertOutputSchema = z.object({ + id: z.string().describe("Report identifier"), + title: z.string(), + category: z.string(), + status: ReportStatusSchema, + summary: z.string(), + updatedAt: z.string(), + source: z.string().optional(), + tags: z.array(z.string()).optional(), + lifecycleStatus: z.string().optional(), + sections: z.array(ReportSectionSchema), +}); + +export const REPORTS_UPSERT = defineTool({ + name: "REPORTS_UPSERT", + description: + "Create or update a report in the Mesh database. Use this to publish reports from agents, CI, or external services.", + annotations: { + title: "Upsert Report", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + inputSchema: ReportsUpsertInputSchema, + outputSchema: ReportsUpsertOutputSchema, + handler: async (input, ctx) => { + const org = requireOrganization(ctx); + await ctx.access.check(); + + const report = await ctx.storage.reports.upsert(org.id, { + id: input.id, + title: input.title, + category: input.category, + status: input.status, + summary: input.summary, + source: input.source, + tags: input.tags, + lifecycleStatus: input.lifecycleStatus, + sections: input.sections, + }); + + return report; + }, +}); diff --git a/apps/mesh/src/web/components/binding-selector.tsx b/apps/mesh/src/web/components/binding-selector.tsx index b380a04246..01e54170a9 100644 --- a/apps/mesh/src/web/components/binding-selector.tsx +++ b/apps/mesh/src/web/components/binding-selector.tsx @@ -5,7 +5,7 @@ * Shows connection icons and supports inline installation from registry. */ -import { useConnections } from "@decocms/mesh-sdk"; +import { useConnections, WellKnownOrgMCPId } from "@decocms/mesh-sdk"; import { useBindingConnections } from "@/web/hooks/use-binding"; import { useInstallFromRegistry } from "@/web/hooks/use-install-from-registry"; import { Loading01, Plus } from "@untitledui/icons"; @@ -46,6 +46,8 @@ export interface BindingSelectorProps { className?: string; /** Whether the selector is disabled */ disabled?: boolean; + /** Organization ID - when provided with REPORTS_BINDING, ensures Mesh MCP (Mesh database) is included */ + orgId?: string; } export function BindingSelector({ @@ -57,6 +59,7 @@ export function BindingSelector({ onAddNew, className, disabled = false, + orgId, }: BindingSelectorProps) { const [isLocalInstalling, setIsLocalInstalling] = useState(false); const { installByBinding, isInstalling: isGlobalInstalling } = @@ -98,9 +101,23 @@ export function BindingSelector({ return scope && appName ? { scope, appName } : null; })(); + // For REPORTS_BINDING, include Mesh MCP (Mesh database) if not already in filtered list + const isReportsBinding = + Array.isArray(binding) && + binding.length > 0 && + (binding as { name?: string }[]).some((b) => b.name === "REPORTS_LIST"); + const meshMcp = + isReportsBinding && orgId + ? allConnections?.find((c) => c.id === WellKnownOrgMCPId.SELF(orgId)) + : null; + const meshMcpPrepended = + meshMcp && !filteredConnections.some((c) => c.id === meshMcp.id) + ? [meshMcp] + : []; + // Further filter by app name if bindingType is provided const connections = (() => { - let result = filteredConnections; + let result = [...meshMcpPrepended, ...filteredConnections]; // If we have a Binder, we've already filtered by tools - don't further filter by app name const hasBinderFilter = Array.isArray(binding) && binding.length > 0; diff --git a/apps/mesh/src/web/components/details/virtual-mcp/dependency-selection-dialog.tsx b/apps/mesh/src/web/components/details/virtual-mcp/dependency-selection-dialog.tsx index 47e431e823..d9c99e8661 100644 --- a/apps/mesh/src/web/components/details/virtual-mcp/dependency-selection-dialog.tsx +++ b/apps/mesh/src/web/components/details/virtual-mcp/dependency-selection-dialog.tsx @@ -32,7 +32,7 @@ import { Tool01, } from "@untitledui/icons"; import type { ReactNode } from "react"; -import { Suspense, useReducer } from "react"; +import { Suspense, useReducer, useState } from "react"; import type { VirtualMCPConnection } from "@decocms/mesh-sdk/types"; import { ALL_ITEMS_SELECTED, @@ -153,6 +153,7 @@ function SelectionTab({ onToggleAll, emptyMessage, disabled, + searchPlaceholder, }: { items: SelectableItem[]; selections: SelectionValue; @@ -160,8 +161,19 @@ function SelectionTab({ onToggleAll: () => void; emptyMessage: string; disabled?: boolean; + searchPlaceholder?: string; }) { + const [searchTerm, setSearchTerm] = useState(""); const allItemIds = items.map((item) => item.id); + const displayedItems = searchPlaceholder + ? items.filter( + (item) => + item.id.toLowerCase().includes(searchTerm.toLowerCase()) || + item.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (item.description && + item.description.toLowerCase().includes(searchTerm.toLowerCase())), + ) + : items; // Early return for empty state if (items.length === 0) { @@ -176,6 +188,17 @@ function SelectionTab({ return (
+ {/* Search (optional) */} + {searchPlaceholder && ( +
+ +
+ )} + {/* Select All checkbox */}
- {items.map((item) => ( - onToggle(item.id, allItemIds)} - disabled={disabled} - /> - ))} + {displayedItems.length === 0 ? ( +
+ No matches for "{searchTerm}" +
+ ) : ( + displayedItems.map((item) => ( + onToggle(item.id, allItemIds)} + disabled={disabled} + /> + )) + )}
); @@ -249,6 +286,7 @@ function ToolsTab({ onToggleAll={onToggleAll} emptyMessage={EMPTY_MESSAGE} disabled={disabled} + searchPlaceholder="Search tools..." /> ); } diff --git a/apps/mesh/src/web/components/settings/project-plugins-form.tsx b/apps/mesh/src/web/components/settings/project-plugins-form.tsx index 292688b896..8cb19e9de2 100644 --- a/apps/mesh/src/web/components/settings/project-plugins-form.tsx +++ b/apps/mesh/src/web/components/settings/project-plugins-form.tsx @@ -95,6 +95,7 @@ type PluginRowProps = { label: string; icon?: ReactNode; projectId: string | undefined; + orgId: string | undefined; client: ReturnType; onBindingChange: ( pluginId: string, @@ -113,6 +114,7 @@ function PluginRow({ label, icon, projectId, + orgId, client, onBindingChange, onToggle, @@ -178,6 +180,7 @@ function PluginRow({ placeholder="Select connection..." className="w-56" disabled={isSaving || isConfigLoading} + orgId={orgId} /> )} @@ -390,6 +393,7 @@ export function ProjectPluginsForm() { label={meta?.label ?? plugin.id} icon={meta?.icon} projectId={project.id} + orgId={org.id} client={client} onBindingChange={handleBindingChange} onToggle={handleTogglePlugin} diff --git a/packages/bindings/src/index.ts b/packages/bindings/src/index.ts index 9b255a374c..2f62c5a7ae 100644 --- a/packages/bindings/src/index.ts +++ b/packages/bindings/src/index.ts @@ -123,6 +123,8 @@ export { type ReportsGetOutput, type ReportsUpdateStatusInput, type ReportsUpdateStatusOutput, + type ReportsUpsertInput, + type ReportsUpsertOutput, ReportStatusSchema, ReportLifecycleStatusSchema, MetricItemSchema, diff --git a/packages/bindings/src/well-known/reports.ts b/packages/bindings/src/well-known/reports.ts index 5073d4a266..dde7dd0713 100644 --- a/packages/bindings/src/well-known/reports.ts +++ b/packages/bindings/src/well-known/reports.ts @@ -287,6 +287,54 @@ export type ReportsUpdateStatusOutput = z.infer< typeof ReportsUpdateStatusOutputSchema >; +/** + * REPORTS_UPSERT - Create or update a report (optional tool) + * Only Mesh MCP implements this; external MCPs (e.g. GitHub Repo Reports) do not. + */ +const ReportsUpsertInputSchema = z.object({ + id: z + .string() + .optional() + .describe("Report ID (optional, generated if omitted)"), + title: z.string().describe("Report title"), + category: z + .string() + .describe( + "Report category (e.g. 'performance', 'security', 'collection-ranking')", + ), + status: ReportStatusSchema.describe("Overall report status"), + summary: z.string().describe("One-line summary of findings"), + source: z + .string() + .optional() + .describe( + "Agent or service that generated the report (e.g. 'collection-reorder', 'security-auditor')", + ), + tags: z.array(z.string()).optional().describe("Free-form tags for filtering"), + lifecycleStatus: ReportLifecycleStatusSchema.optional().describe( + "Inbox lifecycle status (default: unread)", + ), + sections: z + .array(ReportSectionSchema) + .describe("Ordered content sections (markdown, metrics, table, etc.)"), +}); + +const ReportsUpsertOutputSchema = z.object({ + id: z.string().describe("Report identifier"), + title: z.string(), + category: z.string(), + status: ReportStatusSchema, + summary: z.string(), + updatedAt: z.string(), + source: z.string().optional(), + tags: z.array(z.string()).optional(), + lifecycleStatus: ReportLifecycleStatusSchema.optional(), + sections: z.array(ReportSectionSchema), +}); + +export type ReportsUpsertInput = z.infer; +export type ReportsUpsertOutput = z.infer; + // ============================================================================ // Binding Definition // ============================================================================ @@ -294,7 +342,7 @@ export type ReportsUpdateStatusOutput = z.infer< /** * Reports Binding * - * Defines the interface for viewing automated reports. + * Defines the interface for viewing and publishing automated reports. * Any MCP that implements this binding can be used with the Reports plugin. * * Required tools: @@ -303,6 +351,7 @@ export type ReportsUpdateStatusOutput = z.infer< * * Optional tools: * - REPORTS_UPDATE_STATUS: Update the lifecycle status of a report (unread → read → dismissed) + * - REPORTS_UPSERT: Create or update a report (Mesh MCP only; used by agents/plugins to publish) */ export const REPORTS_BINDING = [ { @@ -325,6 +374,16 @@ export const REPORTS_BINDING = [ ReportsUpdateStatusInput, ReportsUpdateStatusOutput >, + { + name: "REPORTS_UPSERT" as const, + inputSchema: ReportsUpsertInputSchema, + outputSchema: ReportsUpsertOutputSchema, + opt: true, + } satisfies ToolBinder< + "REPORTS_UPSERT", + ReportsUpsertInput, + ReportsUpsertOutput + >, ] as const satisfies Binder; export type ReportsBinding = typeof REPORTS_BINDING; diff --git a/packages/farmrio-collection-reorder/components/ranking-layout.tsx b/packages/farmrio-collection-reorder/components/ranking-layout.tsx index 648e18f147..2bc100a2af 100644 --- a/packages/farmrio-collection-reorder/components/ranking-layout.tsx +++ b/packages/farmrio-collection-reorder/components/ranking-layout.tsx @@ -14,11 +14,12 @@ import { } from "@decocms/bindings"; import { SELF_MCP_ALIAS_ID, + type ConnectionEntity, useConnections, useMCPClient, useMCPClientOptional, useProjectContext, - type ConnectionEntity, + WellKnownOrgMCPId, } from "@decocms/mesh-sdk"; import { PluginContextProvider } from "@decocms/mesh-sdk/plugins"; import { useNavigate, useSearch } from "@decocms/bindings/plugin-router"; @@ -81,7 +82,15 @@ export default function RankingLayout() { enabled: !!project.id, }); - const validConnections = filterConnectionsByBinding(allConnections); + const bindingConnections = filterConnectionsByBinding(allConnections); + const meshMcpId = WellKnownOrgMCPId.SELF(org.id); + const meshMcp = allConnections?.find((c) => c.id === meshMcpId); + const validConnections = [ + ...(meshMcp && !bindingConnections.some((c) => c.id === meshMcpId) + ? [meshMcp] + : []), + ...bindingConnections, + ]; const configuredConnectionId = pluginConfig?.config?.connectionId; const configuredConnection = configuredConnectionId ? validConnections.find((c) => c.id === configuredConnectionId) diff --git a/packages/mesh-plugin-reports/components/reports-layout.tsx b/packages/mesh-plugin-reports/components/reports-layout.tsx index 8d00aba9c7..b850049afe 100644 --- a/packages/mesh-plugin-reports/components/reports-layout.tsx +++ b/packages/mesh-plugin-reports/components/reports-layout.tsx @@ -16,11 +16,12 @@ import { } from "@decocms/bindings"; import { SELF_MCP_ALIAS_ID, + type ConnectionEntity, useConnections, useMCPClient, useMCPClientOptional, useProjectContext, - type ConnectionEntity, + WellKnownOrgMCPId, } from "@decocms/mesh-sdk"; import { PluginContextProvider } from "@decocms/mesh-sdk/plugins"; import { useNavigate, useSearch } from "@decocms/bindings/plugin-router"; @@ -93,8 +94,16 @@ export default function ReportsLayout() { enabled: !!project.id, }); - // Filter connections by binding - const validConnections = filterConnectionsByBinding(allConnections); + // Filter connections by binding. Include Mesh MCP (Mesh database) - it has REPORTS_* tools. + const bindingConnections = filterConnectionsByBinding(allConnections); + const meshMcpId = WellKnownOrgMCPId.SELF(org.id); + const meshMcp = allConnections?.find((c) => c.id === meshMcpId); + const validConnections = [ + ...(meshMcp && !bindingConnections.some((c) => c.id === meshMcpId) + ? [meshMcp] + : []), + ...bindingConnections, + ]; const configuredConnectionId = pluginConfig?.config?.connectionId; const configuredConnection = configuredConnectionId ? validConnections.find((c) => c.id === configuredConnectionId)