Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/ai/src/mcp/tool-descriptors.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray<McpToolDescriptor> = [
{"name":"list_error_issue_events","title":"List Error Issue Events","description":"List the audit-log events for an issue (state transitions, claims, comments, agent notes, fix proposals) in reverse-chronological order.","inputSchema":{"type":"object","properties":{"issue_id":{"type":"string","description":"The error issue ID from list_error_issues (a fingerprint from find_errors is not an issue id): its UUID or its REST id (iss_\u2026)"},"limit":{"type":"number","description":"Max events to return (default 100, max 500)"}},"required":["issue_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"issueId":{"type":"string"},"events":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":"string"},"fromState":{"anyOf":[{"type":"string"},{"type":"null"}]},"toState":{"anyOf":[{"type":"string"},{"type":"null"}]},"actorId":{"anyOf":[{"type":"string"},{"type":"null"}]},"createdAt":{"type":"string"},"payload":{"type":"object"},"actor":{"type":"string"}},"required":["id","type","fromState","toState","actorId","createdAt","payload","actor"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["issueId","events","total","limit"],"additionalProperties":true},"annotations":{"title":"List Error Issue Events","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}},
{"name":"register_agent","title":"Register Agent","description":"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.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Unique agent name within the org (1..100 chars)"},"model":{"type":"string","description":"Model identifier, e.g. 'claude-opus-4.7'"},"capabilities_json":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"string"}},"description":"JSON array of capability tags, e.g. [\"auto-triage\",\"patch-proposer\"]"}},"required":["name"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"agentName":{"anyOf":[{"type":"string"},{"type":"null"}]},"model":{"anyOf":[{"type":"string"},{"type":"null"}]},"capabilities":{"type":"array","items":{"type":"string"}}},"required":["id","agentName","model","capabilities"],"additionalProperties":true},"annotations":{"title":"Register Agent","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}},
{"name":"send_maple_feedback","title":"Send Maple Feedback","description":"Report a problem with Maple itself (this MCP server, its tools, API, or docs) to the Maple team: a bug, a missing capability, a misleading description, or something that worked well. Send it yourself, without asking first, when a Maple tool fails in a way that looks like Maple's fault or Maple got in the way of the task; skip it only if the user's instructions say not to send Maple feedback. Not for problems in the user's own services. The report is visible to the Maple team and to the user's organization, so tell the user in one line that you sent it. Describe Maple's behaviour, not the user's data: leave out secrets, customer data, and raw log or span contents.","inputSchema":{"type":"object","properties":{"kind":{"type":"string","enum":["bug","feature_request","improvement","documentation","praise","other"],"description":"bug (broken or wrong), feature_request (missing capability), improvement (works but could be better), documentation (a description or doc misled you), praise, or other"},"summary":{"type":"string","description":"One line: what happened or what you want (max 200 chars)"},"reason":{"type":"string","description":"Why you are sending this: what you were trying to do, what got in the way, and why it matters (max 4000 chars)"},"details":{"type":"string","description":"Reproduction steps, the call you made, expected versus actual (max 10000 chars)"},"impact":{"type":"string","enum":["blocking","degraded","minor"],"description":"blocking (could not finish), degraded (finished with a workaround), or minor"},"related_to":{"type":"string","description":"The tool, API endpoint, or page this is about, e.g. 'search_traces'"},"agent_type":{"type":"string","enum":["coding_agent","chat_assistant","autonomous_agent","ci","other"],"description":"coding_agent (Claude Code, Cursor, Codex), chat_assistant, autonomous_agent, ci, or other (the default)"},"agent_name":{"type":"string","description":"Your client or agent name, e.g. 'claude-code'. Default: the name your client sent when it connected"},"model":{"type":"string","description":"The model you run on, e.g. 'claude-opus-5-5'"},"agent_version":{"type":"string","description":"Your client or agent version"}},"required":["kind","summary","reason"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"kind":{"type":"string"},"impact":{"anyOf":[{"type":"string"},{"type":"null"}]},"summary":{"type":"string"},"agentType":{"type":"string"},"agentName":{"anyOf":[{"type":"string"},{"type":"null"}]},"agentModel":{"anyOf":[{"type":"string"},{"type":"null"}]},"createdAt":{"type":"string"}},"required":["id","kind","impact","summary","agentType","agentName","agentModel","createdAt"],"additionalProperties":true},"annotations":{"title":"Send Maple Feedback","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}},
{"name":"list_maple_feedback","title":"List Maple Feedback","description":"Maple team only: read the feedback agents sent about Maple (send_maple_feedback, POST /v2/agent_feedback) across every organization, newest first, with the full reason and details. Refused for any other organization.","inputSchema":{"type":"object","properties":{"kind":{"type":"string","enum":["bug","feature_request","improvement","documentation","praise","other"],"description":"Only this kind of feedback"},"impact":{"type":"string","enum":["blocking","degraded","minor"],"description":"Only this impact"},"start_time":{"type":"string","description":"Only feedback sent at or after this time, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)"},"related_to":{"type":"string","description":"Only feedback whose related tool, endpoint or page contains this text (case-insensitive)"},"org_id":{"type":"string","description":"Only feedback from this organization (`org_\u2026`)"},"limit":{"type":"number","description":"Max reports to return (default 50, max 200)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"reports":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"orgId":{"type":"string"},"userId":{"type":"string"},"kind":{"type":"string"},"impact":{"anyOf":[{"type":"string"},{"type":"null"}]},"summary":{"type":"string"},"reason":{"type":"string"},"details":{"anyOf":[{"type":"string"},{"type":"null"}]},"relatedTo":{"anyOf":[{"type":"string"},{"type":"null"}]},"agentType":{"type":"string"},"agentName":{"anyOf":[{"type":"string"},{"type":"null"}]},"agentModel":{"anyOf":[{"type":"string"},{"type":"null"}]},"agentVersion":{"anyOf":[{"type":"string"},{"type":"null"}]},"source":{"type":"string"},"createdAt":{"type":"string"}},"required":["id","orgId","userId","kind","impact","summary","reason","details","relatedTo","agentType","agentName","agentModel","agentVersion","source","createdAt"],"additionalProperties":true}},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"filters":{"type":"object","properties":{"kind":{"type":"string"},"impact":{"type":"string"},"startTime":{"type":"string"},"relatedTo":{"type":"string"},"orgId":{"type":"string"}},"additionalProperties":true}},"required":["reports","limit","filters"],"additionalProperties":true},"annotations":{"title":"List Maple Feedback","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}},
{"name":"list_error_incidents","title":"List Error Incidents","description":"Error incidents: flare-ups of an error issue (alert-rule breaches are list_alert_incidents). An issue gets a 'first_seen' incident when it opens and a 'regression' incident when it fires after being resolved; incidents auto-resolve after ~30 min of silence. No time window: without issue_id or issue_ids, every currently open incident in the org; with them, those issues' incidents, open and resolved, newest first. Polling many issues? Pass them all as issue_ids in one call.","inputSchema":{"type":"object","properties":{"issue_id":{"type":"string","description":"Only this issue's incidents, resolved ones included. Omit for open incidents org-wide.: its UUID or its REST id (iss_\u2026)"},"issue_ids":{"type":"array","items":{"type":"string"},"description":"Only these issues' incidents, resolved ones included, in one read (at most 100). Each is its UUID or its REST id (iss_\u2026)"},"limit":{"type":"number","description":"Max incidents to return (default 100, max 500)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"incidents":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"issueId":{"type":"string"},"status":{"type":"string"},"reason":{"type":"string"},"firstTriggeredAt":{"type":"string"},"lastTriggeredAt":{"type":"string"},"resolvedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"occurrenceCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["id","issueId","status","reason","firstTriggeredAt","lastTriggeredAt","resolvedAt","occurrenceCount"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"openCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"issueId":{"type":"string"},"issueIds":{"type":"array","items":{"type":"string"}},"issuesWithoutIncidents":{"type":"array","items":{"type":"string"}},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"hasMore":{"type":"boolean"}},"required":["incidents","total","openCount"],"additionalProperties":true},"annotations":{"title":"List Error Incidents","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}},
{"name":"get_error_notification_policy","title":"Get Error Notification Policy","description":"Read the org-wide error notification policy: whether it is on, which alert destinations it delivers to (name, UUID and REST id), which events notify, min_occurrence_count and the occurrence count a new error's first notification actually waits for. update_error_notification_policy changes it.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"enabled":{"type":"boolean"},"destinationIds":{"type":"array","items":{"type":"string"}},"destinationNamesUnavailable":{"type":"boolean"},"destinations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]},"enabled":{"type":"boolean"}},"required":["id","publicId","name","type","enabled"],"additionalProperties":true}},"notifyOnFirstSeen":{"type":"boolean"},"notifyOnRegression":{"type":"boolean"},"notifyOnResolve":{"type":"boolean"},"minOccurrenceCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"firstNotificationThreshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"severity":{"type":"string"},"updatedAt":{"type":"string"},"updatedBy":{"type":"string"}},"required":["enabled","destinationIds","destinations","notifyOnFirstSeen","notifyOnRegression","notifyOnResolve","minOccurrenceCount","firstNotificationThreshold","severity","updatedAt","updatedBy"],"additionalProperties":true},"annotations":{"title":"Get Error Notification Policy","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}},
{"name":"update_error_notification_policy","title":"Update Error Notification Policy","description":"Configure the org-wide error notification policy: whether error incidents (first seen, regression, auto-resolve) are delivered to alert destinations, and which ones. Org admins and engineers only. Omit a field to leave it unchanged; get_error_notification_policy reads the current policy. A new error becomes an issue (and can notify) only after 3 occurrences; min_occurrence_count cannot lower that.","inputSchema":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Enable notifications overall"},"destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations (from list_alert_destinations) to deliver to, replacing the current ones. Pass [] to clear them. Each is its UUID or its REST id (dest_\u2026)"},"notify_on_first_seen":{"type":"boolean","description":"Notify when a new error becomes an issue (after its 3rd occurrence)"},"notify_on_regression":{"type":"boolean","description":"Notify when a resolved issue re-occurs"},"notify_on_resolve":{"type":"boolean","description":"Notify when an incident is auto-resolved after the silence window"},"min_occurrence_count":{"type":"number","description":"Only notify when an incident opens with at least this many occurrences, checked once when it opens. New errors already need 3 to become an issue, so values of 3 or less only affect regressions"},"severity":{"type":"string","enum":["warning","critical"],"description":"Severity label carried on the notifications"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"enabled":{"type":"boolean"},"destinationIds":{"type":"array","items":{"type":"string"}},"destinationNamesUnavailable":{"type":"boolean"},"destinations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":{"anyOf":[{"type":"string"},{"type":"null"}]},"enabled":{"type":"boolean"}},"required":["id","publicId","name","type","enabled"],"additionalProperties":true}},"notifyOnFirstSeen":{"type":"boolean"},"notifyOnRegression":{"type":"boolean"},"notifyOnResolve":{"type":"boolean"},"minOccurrenceCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"firstNotificationThreshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"severity":{"type":"string"},"updatedAt":{"type":"string"},"updatedBy":{"type":"string"}},"required":["enabled","destinationIds","destinations","notifyOnFirstSeen","notifyOnRegression","notifyOnResolve","minOccurrenceCount","firstNotificationThreshold","severity","updatedAt","updatedBy"],"additionalProperties":true},"annotations":{"title":"Update Error Notification Policy","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}},
Expand Down
124 changes: 124 additions & 0 deletions apps/ai/src/mcp/tools/__tests__/list-maple-feedback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { afterEach, describe, expect, it } from "@effect/vitest"
import { Array as Arr, Context, Effect, Layer, Result, Schema } from "effect"
import { OrgId, RoleName, UserId } from "@maple/domain/http"
import { ListMapleFeedbackOutput } from "@maple/domain/mcp-outputs"
import { cleanupTestDbs, createTestDb, type TestDb } from "@maple/backend/platform/test-pglite"
import type { TenantContext } from "@maple/backend/services/auth/tenant-context"
import { AgentFeedbackService } from "@maple/backend/services/feedback/AgentFeedbackService"
import { type AuditActorInfo, CurrentAuditActor } from "@maple/backend/services/auth/audit-actor"
import { testEnv } from "@maple/backend/services/integrations/vcs/__tests__/harness"
import { CurrentMcpTenant } from "../../lib/query-warehouse"
import { executeRegisteredMcpToolUnscoped } from "../registry"

const createdDbs: TestDb[] = []
afterEach(() => cleanupTestDbs(createdDbs))

const INTERNAL = Schema.decodeSync(OrgId)("org_maple_internal")
const CUSTOMER = Schema.decodeSync(OrgId)("org_customer")
const USER = Schema.decodeSync(UserId)("user_feedback")

const tenant = (orgId: OrgId): TenantContext => ({
orgId,
userId: USER,
roles: [Schema.decodeSync(RoleName)("root")],
authMode: "self_hosted",
})

const OAUTH_USER: AuditActorInfo = { type: "user", source: "mcp" }

const layer = (
orgId: OrgId,
options: { readonly actor?: AuditActorInfo; readonly env?: Record<string, unknown> } = {},
) =>
Layer.mergeAll(
AgentFeedbackService.layer.pipe(
Layer.provide(
Layer.mergeAll(
createTestDb(createdDbs).layer,
testEnv(options.env ?? { MAPLE_INTERNAL_ORG_ID: INTERNAL }),
),
),
),
Layer.succeed(CurrentMcpTenant, tenant(orgId)),
Layer.succeed(CurrentAuditActor, "actor" in options ? options.actor : OAUTH_USER),
)

// The registry's requirements name every tool's services; this tool reaches only the ones above.
const withoutUnreachedServices = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E> =>
Effect.provide(effect, Context.makeUnsafe<R>(new Map()))

const call = (params: unknown) =>
executeRegisteredMcpToolUnscoped("list_maple_feedback", params, "mcp").pipe(
Effect.result,
withoutUnreachedServices,
)

const expectRefused = (result: Result.Result<unknown, { readonly _tag: string }>) => {
expect(Result.isFailure(result)).toBe(true)
if (Result.isFailure(result)) {
expect(result.failure).toMatchObject({
_tag: "@maple/mcp/errors/McpAuthInvalidError",
reason: "insufficient_scope",
})
}
}

const seed = Effect.gen(function* () {
const service = yield* AgentFeedbackService
yield* Effect.forEach([INTERNAL, CUSTOMER], (orgId) =>
service.submit(orgId, USER, {
kind: "bug",
summary: `from ${orgId}`,
reason: "Secret customer context",
agent: { type: "coding_agent" },
source: "mcp",
}),
)
})

describe("list_maple_feedback", () => {
it.effect("gives Maple's internal org every org's feedback", () =>
Effect.gen(function* () {
yield* seed
const result = yield* call({})
expect(Result.isSuccess(result)).toBe(true)
if (Result.isSuccess(result)) {
const output = Schema.decodeUnknownSync(ListMapleFeedbackOutput)(
result.success.structuredContent,
)
expect(Arr.map(output.reports, (report) => report.orgId).sort()).toEqual(
[CUSTOMER, INTERNAL].sort(),
)
expect(result.success.content[0]?.text).toContain("Secret customer context")
}
}).pipe(Effect.provide(layer(INTERNAL))),
)

it.effect("refuses a customer org, even for its own feedback", () =>
Effect.gen(function* () {
yield* seed
expectRefused(yield* call({ org_id: CUSTOMER }))
}).pipe(Effect.provide(layer(CUSTOMER))),
)

it.effect("refuses Maple's own agents and the bot (internal service token) in the internal org", () =>
Effect.gen(function* () {
yield* seed
expectRefused(yield* call({}))
}).pipe(Effect.provide(layer(INTERNAL, { actor: { type: "system", source: "system" } }))),
)

it.effect("refuses an in-process call that carries no transport actor", () =>
Effect.gen(function* () {
yield* seed
expectRefused(yield* call({}))
}).pipe(Effect.provide(layer(INTERNAL, { actor: undefined }))),
)

it.effect("refuses every org while MAPLE_INTERNAL_ORG_ID is unset", () =>
Effect.gen(function* () {
yield* seed
expectRefused(yield* call({}))
}).pipe(Effect.provide(layer(INTERNAL, { env: {} }))),
)
})
Loading
Loading