Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/api/alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
KVNamespace,
Queue,
Worker,
WorkerLoader,
WorkerStub,
Workflow,
} from "alchemy/cloudflare"
Expand Down Expand Up @@ -205,6 +206,10 @@ export const createMapleApi = async ({ stage, domains }: CreateMapleApiOptions)
...optionalSecret("GITHUB_APP_CLIENT_SECRET"),
...optionalSecret("GITHUB_APP_WEBHOOK_SECRET"),
...optionalPlain("GITHUB_API_BASE_URL"),
// Code Mode sandbox (Cloudflare Dynamic Workers). The `run_code` MCP tool
// runs model-written code in an isolate via this `worker_loader` binding;
// its presence activates the tool. Requires Worker Loader beta access.
LOADER: WorkerLoader(),
},
})

Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@effect/platform-bun": "catalog:effect",
"@flue/sdk": "1.0.0-beta.1",
"@maple-dev/effect-sdk": "workspace:*",
"@maple/codemode": "workspace:*",
"@maple/db": "workspace:*",
"@maple/domain": "workspace:*",
"@maple/effect-cloudflare": "workspace:*",
Expand Down
11 changes: 8 additions & 3 deletions apps/api/src/mcp/lib/dashboard-mutations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,15 @@ describe("dashboard mutations on tag-less / description-less dashboards", () =>
const layer = makeLayer(testDb)

let handler: ToolHandler | null = null
// Capture from both tool() and mutatingTool() — update_dashboard registers
// via mutatingTool (it's a mutating tool), but capturing both keeps this
// harness robust regardless of which a tool uses.
const capture = (_name: string, _description: string, _schema: unknown, h: unknown) => {
handler = h as ToolHandler
}
const registrar: McpToolRegistrar = {
tool: (_name, _description, _schema, h) => {
handler = h as ToolHandler
},
tool: capture as McpToolRegistrar["tool"],
mutatingTool: capture as McpToolRegistrar["mutatingTool"],
}
registerUpdateDashboardTool(registrar)
assert.isNotNull(handler)
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/mcp/tools/add-dashboard-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const KNOWN_VISUALIZATIONS = [
] as const

export function registerAddDashboardWidgetTool(server: McpToolRegistrar) {
server.tool(
server.mutatingTool(
TOOL,
'Add a single widget to an existing dashboard without re-sending the whole document. `visualization` MUST be one of: `chart`, `stat`, `gauge`, `table`, `list`, `pie`, `histogram`, `heatmap`, `funnel` — NOT a free-form title. `gauge` renders a single scalar on a radial gauge (same data shape as `stat`); set `display_json.gauge` to `{ min, max }` and `display_json.thresholds` to color the arc. For line/area/bar charts, pass `visualization: "chart"` and `display_type: "line"`/`"area"`/`"bar"`. Two creation paths:\n\n1. **Structured query builder** (default): pass `data_source_json` + `display_json` to wire the widget to a specific endpoint (`custom_query_builder_timeseries`, `service_overview`, etc.). Trace and log queries omit the metric-only fields (`metricName`/`metricType`/`isMonotonic`/`signalSource`) — only `dataSource: "metrics"` queries carry them. `whereClause` is a custom grammar (`=`, `>`, `<`, `>=`, `<=`, `contains`, `exists` joined by ` AND `) — there is NO SQL `IS NULL`/`IS NOT NULL`; use `<key> exists` to require an attribute. See the `maple://instructions` resource for the full widget JSON shape (aggregations per source, groupBy prefixes, units, stat reduceToValue, hideSeries).\n\n2. **Raw ClickHouse SQL**: pass `sql` to create a `raw_sql_chart` widget (the tool builds the dataSource for you — `data_source_json` is ignored). `sql` MUST reference `$__orgFilter`. Macros: `$__orgFilter` (required), `$__timeFilter(Column)`, `$__startTime`, `$__endTime`, `$__interval_s` (only useful when SQL also references it, typically inside `toStartOfInterval(…, INTERVAL $__interval_s SECOND)`).\n\n **Before writing raw SQL, call `describe_warehouse_tables`** to discover real table and column names (no args → list every table; `table: "<name>"` → full column list with types, jsonPaths, sorting key, and curated notes on enum casing, units, sort-key hints). Do not guess table or column names — a hallucinated identifier silently produces an empty chart. Columns are PascalCase; values for `StatusCode`/`SeverityText`/`SpanKind` are Title Case (`\'Error\'` not `\'ERROR\'`); span `Duration` is in nanoseconds (divide by 1e6 for ms).\n\n **SELECT shape per `display_type`** (the renderer is opinionated; wrong aliases → empty or `[object Object]`):\n - `line`/`area`/`bar`: time bucket as first column (alias `bucket`) + ONE OR MORE numeric series columns. Each numeric column becomes one series; the column name becomes the legend label. **String columns are dropped**, so for multi-series (e.g., per-service breakdown) pivot in SQL with `countIf(...)` — tall form (`bucket, ServiceName, count()`) collapses to a single aggregate line. Single-series: `SELECT toStartOfInterval(Timestamp, INTERVAL $__interval_s SECOND) AS bucket, count() AS errors FROM ... WHERE $__orgFilter AND $__timeFilter(Timestamp) GROUP BY bucket ORDER BY bucket`. Multi-series wide form: `SELECT toStartOfInterval(Timestamp, INTERVAL $__interval_s SECOND) AS bucket, countIf(ServiceName=\'api\') AS api, countIf(ServiceName=\'web\') AS web FROM ... GROUP BY bucket ORDER BY bucket`. For dynamic series labels, run a discovery query first (e.g., `query_data` or a quick top-N) and inject the values.\n - `stat`: one scalar aliased `value` — `SELECT count() AS value FROM ... WHERE $__orgFilter AND $__timeFilter(Timestamp)`\n - `pie`: `name` (label) + numeric column; cap with `LIMIT 8`-ish\n - `heatmap`: three columns aliased `x`, `y`, `value` (string-cast numeric x/y)\n - `table`: any rows; columns render in order\n - `histogram`: one numeric column aliased `value` (renderer buckets client-side); add `LIMIT 5000`\n - `funnel`: `name` (string stage label) + numeric column; rows render in returned order as descending bars — `ORDER BY value DESC` for a classic funnel, cap with `LIMIT 8`-ish\n\n If `display_type` is omitted it\'s derived from `visualization` (chart→line via `display_json.chartId`, stat→stat, table→table, pie→pie, histogram→histogram, heatmap→heatmap, funnel→funnel). The stat `reduceToValue` transform is auto-injected.\n\n **See `maple://instructions` for the full table catalog, column lists, and worked examples per display type.**\n\nIf `layout_json` is omitted the widget is auto-placed using the same grid logic as the web UI. Returns the new widget id plus an automatic validation summary (verdict, flags). If `verdict` is `suspicious` or `broken`, fix via `update_dashboard_widget` — the chart will not render meaningful data as-is.',
Schema.Struct({
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/mcp/tools/claim-error-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { ErrorIssueId } from "@maple/domain/http"
const decodeIssueId = Schema.decodeUnknownOption(ErrorIssueId)

export function registerClaimErrorIssueTool(server: McpToolRegistrar) {
server.tool(
server.mutatingTool(
"claim_error_issue",
"Claim a lease on an error issue so other agents don't duplicate work. Issues in 'triage' or 'todo' auto-transition to 'in_progress' on claim. Lease defaults to 30 min; call heartbeat_error_issue before it expires or the issue drops back to 'todo'.",
Schema.Struct({
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/mcp/tools/comment-on-error-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { ErrorIssueId } from "@maple/domain/http"
const decodeIssueId = Schema.decodeUnknownOption(ErrorIssueId)

export function registerCommentOnErrorIssueTool(server: McpToolRegistrar) {
server.tool(
server.mutatingTool(
"comment_on_error_issue",
"Add a comment to the issue's timeline. Use kind='agent_note' for automated reasoning steps (visible in the audit log but styled differently in the UI).",
Schema.Struct({
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/mcp/tools/create-alert-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ const comparatorLabel: Record<string, string> = {
}

export function registerCreateAlertRuleTool(server: McpToolRegistrar) {
server.tool(
server.mutatingTool(
"create_alert_rule",
"Create an alert rule. Use a template for common cases (high_error_rate, slow_p95, slow_p99, low_apdex, throughput_drop) or template='custom' for full control. " +
"Templates auto-fill signal_type, comparator, and a sensible default threshold. " +
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/mcp/tools/create-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ const TIME_RANGE_MAP: Record<string, string> = {
export function registerCreateDashboardTool(server: McpToolRegistrar) {
const templateList = DASHBOARD_TEMPLATES.map((t) => ` ${t.id} — ${t.description}`).join("\n")

server.tool(
server.mutatingTool(
"create_dashboard",
"Create a dashboard from a template, simplified widget specs, or custom JSON.\n\n" +
"Templates:\n" +
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/mcp/tools/delete-alert-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { AlertRuleId } from "@maple/domain"
const decodeAlertRuleId = Schema.decodeUnknownOption(AlertRuleId)

export function registerDeleteAlertRuleTool(server: McpToolRegistrar) {
server.tool(
server.mutatingTool(
"delete_alert_rule",
"Permanently delete an alert rule. This is irreversible and also deletes the rule's incident history, " +
"delivery events, and evaluation state. Requires confirm=true. Use list_alert_rules to find rule IDs.",
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/mcp/tools/heartbeat-error-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ErrorIssueId } from "@maple/domain/http"
const decodeIssueId = Schema.decodeUnknownOption(ErrorIssueId)

export function registerHeartbeatErrorIssueTool(server: McpToolRegistrar) {
server.tool(
server.mutatingTool(
"heartbeat_error_issue",
"Extend the lease on a claimed error issue. Call this periodically while you work; if the lease expires, the issue drops back to 'todo' and any actor can re-claim it.",
Schema.Struct({
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/mcp/tools/mutating.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ describe("MUTATING_TOOL_NAMES", () => {
}
})

it("exactly equals the tools registered via mutatingTool (structural flag <-> shared list)", () => {
// The per-tool `mutating` flag (set at registration via `server.mutatingTool`)
// is the structural truth the run_code gate uses; MUTATING_TOOL_NAMES is the
// static list the chat + /chat/apply paths use (they can't read the flag over
// MCP). This asserts they can't drift in either direction — register a
// mutating tool but forget the list (or vice versa) and CI fails.
const flagged = new Set(mapleToolDefinitions.filter((d) => d.mutating).map((d) => d.name))
const flaggedButUnlisted = [...flagged].filter((n) => !MUTATING_TOOL_NAMES.has(n))
const listedButUnflagged = [...MUTATING_TOOL_NAMES].filter((n) => !flagged.has(n))
expect(flaggedButUnlisted, `registered mutating but absent from MUTATING_TOOL_NAMES: [${flaggedButUnlisted.join(", ")}]`).toEqual([])
expect(listedButUnflagged, `in MUTATING_TOOL_NAMES but not registered via mutatingTool: [${listedButUnflagged.join(", ")}]`).toEqual([])
})

it("excludes read-only tools (so /chat/apply can't run them)", () => {
expect(MUTATING_TOOL_NAMES.has("find_errors")).toBe(false)
expect(MUTATING_TOOL_NAMES.has("search_traces")).toBe(false)
Expand Down
37 changes: 4 additions & 33 deletions apps/api/src/mcp/tools/mutating.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,4 @@
/**
* Base names of the mutating MCP tools that the AI chat gates behind approval.
*
* The Flue chat agent wraps these so a model call returns a `proposed` marker
* instead of mutating (see `apps/chat-flue/src/lib/approval.ts`); the web client
* applies the real change via `POST /api/chat/apply`, which only accepts tools
* in this set. Keep the two lists in sync.
*/
export const MUTATING_TOOL_NAMES: ReadonlySet<string> = new Set([
// dashboards
"create_dashboard",
"update_dashboard",
"add_dashboard_widget",
"update_dashboard_widget",
"remove_dashboard_widget",
"reorder_dashboard_widgets",
"replace_dashboard_widgets",
// alerts
"create_alert_rule",
"update_alert_rule",
"delete_alert_rule",
// error issues
"claim_error_issue",
"release_error_issue",
"transition_error_issue",
"comment_on_error_issue",
"heartbeat_error_issue",
"set_issue_severity",
"update_error_notification_policy",
// fixes / agents
"propose_fix",
"register_agent",
])
// Single source of truth lives in @maple/codemode so the apps/api + apps/chat-flue
// copies can't drift. Re-exported here to keep existing `./mutating` imports stable.
// The fail-closed regression test lives in `./mutating.test.ts`.
export { MUTATING_TOOL_NAMES } from "@maple/codemode"
2 changes: 1 addition & 1 deletion apps/api/src/mcp/tools/propose-fix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const parseArtifactList = (raw: string | undefined): ReadonlyArray<string> => {
}

export function registerProposeFixTool(server: McpToolRegistrar) {
server.tool(
server.mutatingTool(
"propose_fix",
"Attach a fix proposal (PR URL, patch summary, artifacts) to an error issue. Transitions the issue to 'in_review'. The human owner can then accept (→ done) or reject.",
Schema.Struct({
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/mcp/tools/register-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const parseCapabilities = (raw: string | undefined): ReadonlyArray<string> => {
}

export function registerRegisterAgentTool(server: McpToolRegistrar) {
server.tool(
server.mutatingTool(
"register_agent",
"Register an LLM agent with the error-issue system so it can claim and transition issues. Must be called from a human session (not an agent API key). Returns an actor ID to pin via API-key metadata.",
Schema.Struct({
Expand Down
33 changes: 27 additions & 6 deletions apps/api/src/mcp/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { registerRemoveDashboardWidgetTool } from "./remove-dashboard-widget"
import { registerReplaceDashboardWidgetsTool } from "./replace-dashboard-widgets"
import { registerReorderDashboardWidgetsTool } from "./reorder-dashboard-widgets"
import { registerMineLogPatternsTool } from "./mine-log-patterns"
import { registerRunCodeTool } from "./run-code"
import { registerSearchLogsTool } from "./search-logs"
import { registerSearchTracesTool } from "./search-traces"
import { registerSearchSessionsTool } from "./search-sessions"
Expand All @@ -63,6 +64,8 @@ export interface MapleToolDefinition {
readonly description: string
readonly schema: Schema.Decoder<unknown, never>
readonly handler: (params: unknown) => Effect.Effect<McpToolResult, McpToolError, any>
/** True for state-changing tools (registered via `mutatingTool`). The `run_code` sandbox refuses these. */
readonly mutating: boolean
}

export const toInputSchema = (schema: Schema.Top): Record<string, unknown> => {
Expand All @@ -74,14 +77,27 @@ export const toInputSchema = (schema: Schema.Top): Record<string, unknown> => {

const collectMapleToolDefinitions = (): ReadonlyArray<MapleToolDefinition> => {
const definitions: MapleToolDefinition[] = []
const add = (
mutating: boolean,
name: string,
description: string,
schema: Schema.Decoder<unknown, never>,
handler: unknown,
) => {
definitions.push({
name,
description,
schema,
handler: handler as MapleToolDefinition["handler"],
mutating,
})
}
const registrar: McpToolRegistrar = {
tool(name, description, schema, handler) {
definitions.push({
name,
description,
schema,
handler: handler as MapleToolDefinition["handler"],
})
add(false, name, description, schema, handler)
},
mutatingTool(name, description, schema, handler) {
add(true, name, description, schema, handler)
},
}

Expand Down Expand Up @@ -136,6 +152,11 @@ const collectMapleToolDefinitions = (): ReadonlyArray<MapleToolDefinition> => {
registerRegisterAgentTool(registrar)
registerListErrorIncidentsTool(registrar)
registerUpdateErrorNotificationPolicyTool(registrar)
// Code Mode: a single tool whose sandboxed snippet orchestrates the read-only
// tools above. Registered last so it can reference the full set at runtime
// (it dispatches via `mapleToolDefinitions`); inert unless the LOADER sandbox
// binding is present.
registerRunCodeTool(registrar)

return definitions
}
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/mcp/tools/release-error-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const decodeIssueId = Schema.decodeUnknownOption(ErrorIssueId)
const decodeWorkflowState = Schema.decodeUnknownOption(WorkflowState)

export function registerReleaseErrorIssueTool(server: McpToolRegistrar) {
server.tool(
server.mutatingTool(
"release_error_issue",
"Release the lease on an error issue you previously claimed, optionally transitioning it to another workflow state (default: 'todo').",
Schema.Struct({
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/mcp/tools/remove-dashboard-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { withDashboardMutation } from "../lib/dashboard-mutations"
const TOOL = "remove_dashboard_widget"

export function registerRemoveDashboardWidgetTool(server: McpToolRegistrar) {
server.tool(
server.mutatingTool(
TOOL,
"Remove a single widget from a dashboard by id. Other widgets and dashboard metadata are left untouched.",
Schema.Struct({
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/mcp/tools/reorder-dashboard-widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const validateLayoutGeometry = (entries: ReadonlyArray<LayoutEntry>): string[] =
}

export function registerReorderDashboardWidgetsTool(server: McpToolRegistrar) {
server.tool(
server.mutatingTool(
TOOL,
"Reposition or resize one or more widgets on a dashboard in a single call. Only the widgets you include are touched; any widget id not present in layouts_json keeps its existing layout. Useful for drag/drop-style moves without re-sending unrelated widget state.",
Schema.Struct({
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/mcp/tools/replace-dashboard-widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const TOOL = "replace_dashboard_widgets"
const decodeWidget = Schema.decodeUnknownEffect(DashboardWidgetSchema)

export function registerReplaceDashboardWidgetsTool(server: McpToolRegistrar) {
server.tool(
server.mutatingTool(
TOOL,
"Replace ALL widgets on a dashboard in one atomic, validated write — the safe middle ground between many incremental `add/update_dashboard_widget` calls and the corruption-prone full `dashboard_json` replace. Pass `widgets_json`: a JSON array of widget objects (same shape as `widgets[]` from get_dashboard). Each widget's query is validated BEFORE anything is persisted — if any widget references a filter/groupBy the engine can't honor, NOTHING is saved and the offending clauses are returned. Per-widget conveniences: `id` is auto-generated when omitted, and `layout` is auto-placed on a 12-column grid when omitted (so you can pass just `{ visualization, dataSource, display }`). Dashboard metadata (name, description, tags, time range) is left untouched. Returns an automatic validation summary; fix any `suspicious`/`broken` widgets and call again.",
Schema.Struct({
Expand Down
Loading
Loading