// Management MCP tools: create/update + run + control writes. Never delete. // Registered only when the token is admin-scope AND mcp_write_enabled is // on (see agent.ts), and each tool delegates to the shared service layer, which // re-derives the token creator's authority — so MCP can never exceed the human. import { z } from 'zod'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { Env } from '../env'; import type { McpProps } from './gate'; import { actorOf, scopeProjectId } from './scope'; import { run } from './result'; import { parseStructured, parseStructuredArray } from './coerce'; import { redactIntegration } from './redact'; import { createProject, updateProject } from '../domains/projects/service'; import { createVariable, updateVariable, setVariableControl } from '../domains/variables/service'; import { createDashboard, updateDashboard, getDashboard } from '../domains/dashboards/service'; import { newId } from '../platform/lib/ids'; import { WIDGET_IDS, type WidgetType } from '@nodrix/widgets-shared'; import { INTEGRATION_KINDS } from '@nodrix/integrations-shared'; import { createAutomation, updateAutomation, runAutomationNow } from '../domains/automations/service'; import { createIntegration, updateIntegration, testIntegration } from '../domains/integrations/service'; import { dispatchEvent } from '../platform/engine/run'; const project = z .string() .describe('Project id. Optional for a project-scoped token; required for an all-projects token.') .optional(); export function registerWriteTools(server: McpServer, env: Env, props: McpProps): void { const actor = () => actorOf(props); server.registerTool( 'create_project', { description: 'Create a project. Requires an owner/admin token.', inputSchema: { name: z.string() } }, (args) => run(() => createProject(env, actor(), { name: args.name })) ); server.registerTool( 'update_project', { description: 'Rename or re-describe a project.', inputSchema: { project, name: z.string().optional(), description: z.string().nullable().optional() }, }, (args) => run(() => updateProject(env, actor(), scopeProjectId(props, args.project), { name: args.name, description: args.description })) ); server.registerTool( 'create_variable', { description: 'Declare a variable (optionally with a unit). Telemetry also auto-creates variables.', inputSchema: { project, key: z.string(), unit: z.string().nullable().optional() }, }, (args) => run(() => createVariable(env, actor(), scopeProjectId(props, args.project), { key: args.key, unit: args.unit })) ); server.registerTool( 'update_variable', { description: "Update a variable's unit.", inputSchema: { project, variable_id: z.string(), unit: z.string().nullable() }, }, (args) => run(() => updateVariable(env, actor(), scopeProjectId(props, args.project), args.variable_id, { unit: args.unit })) ); server.registerTool( 'set_variable', { description: 'Enqueue a control write to hardware: set a variable value the device picks up on its next poll. The variable must already exist in the project.', inputSchema: { project, variable: z.string().describe('Variable key.'), value: z.any().describe('Value to send (number, boolean, string, or JSON).'), }, }, (args) => run(() => setVariableControl(env, actor(), scopeProjectId(props, args.project), { variable: args.variable, value: args.value })) ); server.registerTool( 'create_dashboard', { description: 'Create a dashboard. Layout is the widget-grid object; omit for an empty grid.', inputSchema: { project, name: z.string(), layout: z.any().optional() }, }, (args) => run(() => createDashboard(env, actor(), scopeProjectId(props, args.project), { name: args.name, layout: parseStructured(args.layout) })) ); server.registerTool( 'update_dashboard', { description: 'Update a dashboard name/description/layout. Pass if_updated_at for optimistic concurrency.', inputSchema: { project, dashboard_id: z.string(), name: z.string().optional(), description: z.string().nullable().optional(), layout: z.any().optional(), if_updated_at: z.number().optional(), }, }, (args) => run(() => updateDashboard(env, actor(), scopeProjectId(props, args.project), args.dashboard_id, { name: args.name, description: args.description, layout: parseStructured(args.layout), if_updated_at: args.if_updated_at, }) ) ); const widgetType = z.enum(WIDGET_IDS as unknown as readonly [WidgetType, ...WidgetType[]]); type WidgetItem = { id: string; type: string; x: number; y: number; w: number; h: number; props: Record }; type DashboardLayout = { grid: { columns: number }; items: WidgetItem[]; mobile?: unknown; refresh?: number }; server.registerTool( 'add_widget', { description: 'Add a widget to a dashboard. Generates the widget id if omitted. Returns the inserted widget. Call list_widget_types first to see the canonical shape of `props` for each widget type — passing the wrong keys (e.g. `series: ["power"]` instead of `series: [{variable:"power"}]`) silently drops the binding.', inputSchema: { project, dashboard_id: z.string(), type: widgetType, x: z.number(), y: z.number(), w: z.number(), h: z.number(), props: z.record(z.string(), z.unknown()).optional(), widget_id: z.string().optional(), }, }, (args) => run(async () => { const pid = scopeProjectId(props, args.project); const dash = await getDashboard(env, pid, args.dashboard_id); const layout = (dash.layout ?? { grid: { columns: 16 }, items: [] }) as DashboardLayout; const widget: WidgetItem = { id: args.widget_id ?? newId('widget'), type: args.type, x: args.x, y: args.y, w: args.w, h: args.h, props: args.props ?? {}, }; const next: DashboardLayout = { ...layout, items: [...(layout.items ?? []), widget] }; await updateDashboard(env, actor(), pid, args.dashboard_id, { layout: next, if_updated_at: dash.updated_at, }); return { dashboard_id: dash.id, widget }; }) ); server.registerTool( 'update_widget', { description: 'Update a single widget in a dashboard. Any field omitted is left unchanged; props REPLACES (not merges) — fetch via list_widgets first if you want to merge. See list_widget_types for the canonical `props` shape of each widget type.', inputSchema: { project, dashboard_id: z.string(), widget_id: z.string(), type: widgetType.optional(), x: z.number().optional(), y: z.number().optional(), w: z.number().optional(), h: z.number().optional(), props: z.record(z.string(), z.unknown()).optional(), }, }, (args) => run(async () => { const pid = scopeProjectId(props, args.project); const dash = await getDashboard(env, pid, args.dashboard_id); const layout = (dash.layout ?? { grid: { columns: 16 }, items: [] }) as DashboardLayout; const items = layout.items ?? []; const idx = items.findIndex((w) => w.id === args.widget_id); if (idx === -1) { throw new Error(`widget ${args.widget_id} not found in dashboard ${args.dashboard_id}`); } const cur = items[idx]!; const next: WidgetItem = { id: cur.id, type: args.type ?? cur.type, x: args.x ?? cur.x, y: args.y ?? cur.y, w: args.w ?? cur.w, h: args.h ?? cur.h, props: args.props ?? cur.props, }; const newItems = items.slice(); newItems[idx] = next; await updateDashboard(env, actor(), pid, args.dashboard_id, { layout: { ...layout, items: newItems }, if_updated_at: dash.updated_at, }); return { dashboard_id: dash.id, widget: next }; }) ); server.registerTool( 'create_automation', { description: 'Create an automation. Prefer `graph` (a {nodes,edges} flow — multi-trigger, conditions/branching) ' + 'for anything beyond a single trigger → linear actions; call list_block_types first for the node ' + 'kinds and their config shapes. A call_integration node’s config is ' + '{ integration_id, operation, params } — see list_integration_kinds for each kind’s operations and ' + 'params. The legacy trigger_type+trigger_config+actions still works for a ' + 'simple linear automation (trigger_type ∈ variable|manual|schedule|sunset_sunrise|event).', inputSchema: { project, name: z.string(), description: z.string().nullable().optional(), graph: z.any().optional().describe('Flow graph { nodes: [{id,kind,config}], edges: [{from,to,port?}] }. Wins over the legacy fields.'), trigger_type: z.enum(['variable', 'manual', 'schedule', 'sunset_sunrise', 'event']).optional(), trigger_config: z.any().optional(), actions: z.array(z.any()).optional(), enabled: z.boolean().optional(), }, }, (args) => run(() => createAutomation(env, actor(), scopeProjectId(props, args.project), { name: args.name, description: args.description, graph: parseStructured(args.graph), trigger_type: args.trigger_type, trigger_config: parseStructured(args.trigger_config), actions: parseStructuredArray(args.actions), enabled: args.enabled, }) ) ); server.registerTool( 'update_automation', { description: 'Update an automation. Pass `graph` to replace the whole flow (see list_block_types); ' + 'or name/enabled/trigger_config/actions for the legacy linear shape.', inputSchema: { project, automation_id: z.string(), name: z.string().optional(), description: z.string().nullable().optional(), enabled: z.boolean().optional(), graph: z.any().optional().describe('Flow graph { nodes, edges }. Replaces the existing flow.'), trigger_type: z.enum(['variable', 'manual', 'schedule', 'sunset_sunrise', 'event']).optional(), trigger_config: z.any().optional(), actions: z.array(z.any()).optional(), }, }, (args) => run(() => updateAutomation(env, actor(), scopeProjectId(props, args.project), args.automation_id, { name: args.name, description: args.description, enabled: args.enabled, graph: parseStructured(args.graph), trigger_type: args.trigger_type, trigger_config: parseStructured(args.trigger_config), actions: parseStructuredArray(args.actions), }) ) ); server.registerTool( 'run_automation', { description: 'Run an automation now (drives manual automations; also a test harness).', inputSchema: { project, automation_id: z.string() }, }, (args) => run(() => runAutomationNow(env, actor(), scopeProjectId(props, args.project), args.automation_id)) ); server.registerTool( 'emit_event', { description: 'Fire a named event, running any enabled event-triggered automations that match it. Also a test harness for the `event` trigger.', inputSchema: { project, event: z.string().describe('Event name, matched against event-trigger automations.'), payload: z.record(z.string(), z.unknown()).optional(), }, }, (args) => run(async () => { const pid = scopeProjectId(props, args.project); const automations_run = await dispatchEvent(env, pid, args.event, args.payload); return { project: pid, event: args.event, automations_run }; }) ); server.registerTool( 'create_integration', { description: `Create an integration. kind ∈ ${INTEGRATION_KINDS.join('|')}.`, inputSchema: { project, name: z.string(), kind: z.enum(INTEGRATION_KINDS), config: z.any().optional(), enabled: z.boolean().optional(), }, }, (args) => run(async () => redactIntegration( await createIntegration(env, actor(), scopeProjectId(props, args.project), { name: args.name, kind: args.kind, config: parseStructured(args.config), enabled: args.enabled, }) ) ) ); server.registerTool( 'update_integration', { description: 'Update an integration (name, config, enabled).', inputSchema: { project, integration_id: z.string(), name: z.string().optional(), config: z.any().optional(), enabled: z.boolean().optional(), }, }, (args) => run(async () => redactIntegration( await updateIntegration(env, actor(), scopeProjectId(props, args.project), args.integration_id, { name: args.name, config: parseStructured(args.config), enabled: args.enabled, }) ) ) ); server.registerTool( 'test_integration', { description: 'Fire an integration once with a synthetic context to verify delivery.', inputSchema: { project, integration_id: z.string() }, }, (args) => run(() => testIntegration(env, actor(), scopeProjectId(props, args.project), args.integration_id)) ); }