From 1656ab8bcc3adb0b1a3c8d9a0df78e94b164e845 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Wed, 12 Aug 2026 15:59:23 -0400 Subject: [PATCH] feat(tools): Add onboarding status updates Expose a directly available onboarding_status_update tool that sends explicit stage and run state to Sentry. Region-aware routing and org:read authorization reuse the existing API client path. The tool treats updates as silent UI bookkeeping, avoids echoing sensitive context, and returns whether subsequent updates should continue. Generated definitions document the shared contract. --- .../mcp-core/scripts/generate-definitions.ts | 2 + packages/mcp-core/src/api-client/client.ts | 28 +++- packages/mcp-core/src/api-client/schema.ts | 74 +++++++++ packages/mcp-core/src/api-client/types.ts | 6 + packages/mcp-core/src/skills.test.ts | 8 + packages/mcp-core/src/skills.ts | 3 + packages/mcp-core/src/toolDefinitions.json | 85 ++++++++++ packages/mcp-core/src/tools/catalog/index.ts | 2 + .../catalog/onboarding-status-update.test.ts | 148 ++++++++++++++++++ .../tools/catalog/onboarding-status-update.ts | 95 +++++++++++ packages/mcp-core/src/tools/types.ts | 1 + packages/mcp-server-mocks/src/index.ts | 27 ++++ 12 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 packages/mcp-core/src/tools/catalog/onboarding-status-update.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/onboarding-status-update.ts diff --git a/packages/mcp-core/scripts/generate-definitions.ts b/packages/mcp-core/scripts/generate-definitions.ts index 228d39eb3..94805e898 100644 --- a/packages/mcp-core/scripts/generate-definitions.ts +++ b/packages/mcp-core/scripts/generate-definitions.ts @@ -79,6 +79,7 @@ type DefinitionTool = { inputSchema: Record; outputSchema?: ZodTypeAny; skills: string[]; + includeInSkillDefinitions?: boolean; requiredScopes: string[]; experimental?: boolean; hideInExperimentalMode?: boolean; @@ -253,6 +254,7 @@ async function generateSkillDefinitions() { const t = tool as DefinitionTool; return ( isSkillDefinitionTool(t) && + t.includeInSkillDefinitions !== false && Array.isArray(t.skills) && t.skills.includes(skill.id) ); diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 7db61f58e..2642b50d5 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -34,6 +34,7 @@ import { USER_AGENT } from "../version"; import { apiPath } from "./api-path"; import { ApiNotFoundError, ApiValidationError, createApiError } from "./errors"; import { + AgenticOnboardingRunSchema, AIConversationDetailsResponseSchema, AIConversationSummaryListSchema, ApiErrorSchema, @@ -92,6 +93,8 @@ import { UserSchema, } from "./schema"; import type { + AgenticOnboardingRun, + AgenticOnboardingStatusUpdate, AIConversationDetails, AIConversationSpanList, AIConversationSummary, @@ -1858,6 +1861,28 @@ export class SentryApiService { * @param opts Request options * @returns Updated project data */ + async updateAgenticOnboardingStatus( + { + organizationSlug, + update, + }: { + organizationSlug: string; + update: AgenticOnboardingStatusUpdate; + }, + opts?: RequestOptions, + ): Promise { + const body = await this.requestJSON( + apiPath`/organizations/${organizationSlug}/onboarding/agent/status/`, + { + method: "POST", + body: JSON.stringify(update), + }, + opts, + ); + + return AgenticOnboardingRunSchema.parse(body); + } + async updateProject( { organizationSlug, @@ -2904,8 +2929,7 @@ export class SentryApiService { } this.applyTimeParams(searchQuery, effectiveStatsPeriod, start, end); - const path = - apiPath`/projects/${organizationSlug}/${projectSlug}/uptime/${uptimeMonitorId}/checks/`; + const path = apiPath`/projects/${organizationSlug}/${projectSlug}/uptime/${uptimeMonitorId}/checks/`; const body = await this.requestJSON( searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path, undefined, diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index e982d95e2..c29f99eec 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -2076,3 +2076,77 @@ export const TransactionProfileSchema = z .optional(), }) .passthrough(); + +export const AgenticOnboardingStageSchema = z.enum([ + "connect_mcp", + "analyze_project", + "create_project", + "instrument_app", + "plan_test_error", + "send_verification_error", + "receive_verification_error", + "prepare_production", + "check_stack_trace_quality", +]); + +export const AgenticOnboardingStageStatusSchema = z.enum([ + "active", + "waiting", + "completed", + "skipped", + "failed", + "bypassed", +]); + +export const AgenticOnboardingStageStatusUpdateSchema = z.enum([ + "active", + "waiting", + "completed", + "skipped", + "failed", +]); + +export const AgenticOnboardingStageStateSchema = z.object({ + stage: AgenticOnboardingStageSchema, + status: AgenticOnboardingStageStatusSchema.nullable(), + eventNote: z.string().nullable(), +}); + +export const AgenticOnboardingRunStatusSchema = z.enum([ + "active", + "completed", + "failed", + "cancelled", +]); + +export const AgenticOnboardingRunStatusUpdateSchema = z.enum([ + "completed", + "failed", +]); + +export const AgenticOnboardingStatusUpdateSchema = z.object({ + schemaVersion: z.literal(1), + runToken: z.string().regex(/^[A-Za-z0-9]{10}$/), + stage: AgenticOnboardingStageSchema, + status: AgenticOnboardingStageStatusUpdateSchema, + runStatus: AgenticOnboardingRunStatusUpdateSchema.optional(), + eventNote: z.string().trim().min(1).max(256).optional(), + projectSlugs: z.array(z.string().trim().min(1)).min(1).max(100).optional(), + issueIds: z.array(z.string().trim().min(1)).min(1).max(100).optional(), +}); + +export const AgenticOnboardingRunSchema = z.object({ + schemaVersion: z.literal(1), + runId: z.string(), + channelId: z.string(), + clientRunId: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + sequence: z.number().int().nonnegative(), + expiresAt: z.string(), + continueUpdates: z.boolean(), + runStatus: AgenticOnboardingRunStatusSchema, + projectSlugs: z.array(z.string()), + issueIds: z.array(z.string()), + stages: z.array(AgenticOnboardingStageStateSchema), +}); diff --git a/packages/mcp-core/src/api-client/types.ts b/packages/mcp-core/src/api-client/types.ts index 7d609f82a..ffeda5280 100644 --- a/packages/mcp-core/src/api-client/types.ts +++ b/packages/mcp-core/src/api-client/types.ts @@ -40,6 +40,8 @@ */ import type { z } from "zod"; import type { + AgenticOnboardingRunSchema, + AgenticOnboardingStatusUpdateSchema, AssignedToSchema, AIConversationSummaryListSchema, AIConversationSummarySchema, @@ -125,6 +127,10 @@ import type { UserReportListSchema, } from "./schema"; +export type AgenticOnboardingRun = z.infer; +export type AgenticOnboardingStatusUpdate = z.infer< + typeof AgenticOnboardingStatusUpdateSchema +>; export type User = z.infer; export type Organization = z.infer; export type Team = z.infer; diff --git a/packages/mcp-core/src/skills.test.ts b/packages/mcp-core/src/skills.test.ts index 664f66c52..1ab5142db 100644 --- a/packages/mcp-core/src/skills.test.ts +++ b/packages/mcp-core/src/skills.test.ts @@ -32,6 +32,14 @@ function getGeneratedSkillToolDescription(skillId: string, toolName: string) { } describe("skills module", () => { + it("does not advertise workflow-only tools in generated skill prompts", () => { + for (const skill of skillDefinitions) { + expect(skill.tools?.map((tool) => tool.name) ?? []).not.toContain( + "onboarding_status_update", + ); + } + }); + describe("SKILLS registry", () => { it("has all expected skills", () => { expect(SKILLS.inspect).toBeDefined(); diff --git a/packages/mcp-core/src/skills.ts b/packages/mcp-core/src/skills.ts index 2b0a41dc0..b8a3af21f 100644 --- a/packages/mcp-core/src/skills.ts +++ b/packages/mcp-core/src/skills.ts @@ -93,6 +93,9 @@ export async function getSkillsArrayWithCounts(): Promise { if (isWrapperTool(toolName) || isCatalogInfrastructureTool(toolName)) { continue; } + if (tool.includeInSkillDefinitions === false) { + continue; + } if (Array.isArray(tool.skills)) { for (const skill of tool.skills) { counts.set(skill as Skill, (counts.get(skill as Skill) || 0) + 1); diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 2d2b1ef7c..a7930c767 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -3779,6 +3779,91 @@ "skills": ["inspect"], "surface": "catalog" }, + { + "name": "onboarding_status_update", + "description": "Update the progress shown in Sentry's agentic onboarding UI.\n\nUse this tool only when the Sentry getting started skill provides a run token. Call it at the workflow boundaries described by that skill.\n\n\n- Progress updates are operational UI state, not user analytics.\n- Keep eventNote brief and limited to context useful in the progress UI.\n- Do not include source code, repository paths, credentials, error output, or other sensitive data.\n- status is required for every update.\n- Set runStatus to completed or failed only when the entire onboarding run reaches that state.\n- A failed status requires a brief eventNote explaining the failure.\n", + "inputSchema": { + "type": "object", + "properties": { + "organizationSlug": { + "type": "string", + "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." + }, + "regionUrl": { + "default": null, + "anyOf": [ + { + "type": "string", + "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." + }, + { + "type": "null" + } + ] + }, + "runToken": { + "type": "string", + "pattern": "^[A-Za-z0-9]{10}$", + "description": "The 10-character onboarding run token supplied by Sentry." + }, + "stage": { + "type": "string", + "enum": [ + "connect_mcp", + "analyze_project", + "create_project", + "instrument_app", + "plan_test_error", + "send_verification_error", + "receive_verification_error", + "prepare_production", + "check_stack_trace_quality" + ], + "description": "The onboarding stage being updated." + }, + "status": { + "type": "string", + "enum": ["active", "waiting", "completed", "skipped", "failed"], + "description": "The stage's current status." + }, + "runStatus": { + "description": "Set only when the entire onboarding run has completed or failed.", + "type": "string", + "enum": ["completed", "failed"] + }, + "eventNote": { + "description": "Short context to display with this progress event. Required when status is failed.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "projectSlugs": { + "description": "Validated Sentry project slugs. Send only for create_project. Values accumulate across updates; include each project as it becomes usable while active, then all known projects when completed.", + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "issueIds": { + "description": "Validated Sentry issue IDs returned by MCP. Send only for receive_verification_error. Values accumulate across updates; include each matching issue as it is confirmed, then all known issues when completed.", + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": ["organizationSlug", "runToken", "stage", "status"] + }, + "requiredScopes": ["org:read"], + "skills": ["inspect", "seer", "docs", "triage", "project-management"], + "surface": "catalog" + }, { "name": "remove_team_from_project", "description": "Revoke a team's access to an existing Sentry project.\n\nUse this tool when you need to:\n- Remove a team from a project\n- Revoke team access without changing project metadata\n- Check project team assignments before removing access\n\nBe careful when using this tool because it revokes project access.\n\n\nremove_team_from_project(organizationSlug='my-organization', projectSlug='my-project', teamSlug='my-team')\n\n\n\n- The team must already be assigned to the project.\n- This tool will not remove the last team assigned to a project.\n", diff --git a/packages/mcp-core/src/tools/catalog/index.ts b/packages/mcp-core/src/tools/catalog/index.ts index ab77d4818..e519aefea 100644 --- a/packages/mcp-core/src/tools/catalog/index.ts +++ b/packages/mcp-core/src/tools/catalog/index.ts @@ -49,6 +49,7 @@ import getLatestBaseSnapshot from "./get-latest-base-snapshot"; import getAIConversationDetails from "./get-ai-conversation-details"; import searchAIConversations from "./search-ai-conversations"; import addIssueNote from "./add-issue-note"; +import onboardingStatusUpdate from "./onboarding-status-update"; import type { ToolConfig } from "../types"; /** @@ -112,6 +113,7 @@ const catalogTools = { get_ai_conversation_details: getAIConversationDetails, search_ai_conversations: searchAIConversations, add_issue_note: addIssueNote, + onboarding_status_update: onboardingStatusUpdate, } as const satisfies Record>; export default catalogTools; diff --git a/packages/mcp-core/src/tools/catalog/onboarding-status-update.test.ts b/packages/mcp-core/src/tools/catalog/onboarding-status-update.test.ts new file mode 100644 index 000000000..7b52812e4 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/onboarding-status-update.test.ts @@ -0,0 +1,148 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { HttpResponse, http } from "msw"; +import { afterEach, describe, expect, it } from "vitest"; +import onboardingStatusUpdate from "./onboarding-status-update.js"; + +const context = { + constraints: { + organizationSlug: null, + projectSlug: null, + regionUrl: null, + }, + accessToken: "access-token", + userId: "1", +}; + +describe("onboarding_status_update", () => { + afterEach(() => { + mswServer.resetHandlers(); + }); + + it("updates onboarding progress without echoing its inputs", async () => { + let requestBody: unknown; + mswServer.use( + http.post( + "https://us.sentry.io/api/0/organizations/sentry-mcp-evals/onboarding/agent/status/", + async ({ request }) => { + requestBody = await request.json(); + return HttpResponse.json({ + schemaVersion: 1, + runId: "2d27f6654b754dcaa2d26af18274d142", + channelId: "6835652362204cb1b10719783c26983a", + clientRunId: "e806c6f4-fef8-47b4-a720-5ab582b2fcf0", + createdAt: "2026-08-12T12:00:00Z", + updatedAt: "2026-08-12T12:01:00Z", + sequence: 3, + expiresAt: "2026-08-13T12:00:00Z", + continueUpdates: true, + runStatus: "active", + projectSlugs: ["private-project", "worker-project"], + issueIds: [], + stages: [ + { + stage: "analyze_project", + status: "bypassed", + eventNote: null, + }, + ], + }); + }, + ), + ); + + const result = await onboardingStatusUpdate.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: "https://us.sentry.io", + runToken: "a1B2c3D4e5", + stage: "create_project", + status: "completed", + eventNote: "Project already existed.", + projectSlugs: ["private-project", "worker-project"], + }, + context, + ); + + expect(requestBody).toEqual({ + schemaVersion: 1, + runToken: "a1B2c3D4e5", + stage: "create_project", + status: "completed", + eventNote: "Project already existed.", + projectSlugs: ["private-project", "worker-project"], + }); + expect(result).toMatchInlineSnapshot( + `"Onboarding status updated. Continue updates: yes."`, + ); + expect(result).not.toContain("a1B2c3D4e5"); + expect(result).not.toContain("private-project"); + }); + + it("does not accept the backend-derived bypassed status as input", () => { + expect( + onboardingStatusUpdate.inputSchema.status.safeParse("bypassed").success, + ).toBe(false); + expect(onboardingStatusUpdate.inputSchema).not.toHaveProperty( + "failureReason", + ); + expect( + onboardingStatusUpdate.inputSchema.projectSlugs.safeParse([]).success, + ).toBe(false); + expect( + onboardingStatusUpdate.inputSchema.issueIds.safeParse([]).success, + ).toBe(false); + }); + + it("uses the default mock endpoint", async () => { + await expect( + onboardingStatusUpdate.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + runToken: "a1B2c3D4e5", + stage: "connect_mcp", + status: "completed", + }, + context, + ), + ).resolves.toBe("Onboarding status updated. Continue updates: yes."); + }); + + it("reports when the run no longer accepts updates", async () => { + mswServer.use( + http.post( + "https://us.sentry.io/api/0/organizations/sentry-mcp-evals/onboarding/agent/status/", + () => + HttpResponse.json({ + schemaVersion: 1, + runId: "2d27f6654b754dcaa2d26af18274d142", + channelId: "6835652362204cb1b10719783c26983a", + clientRunId: "e806c6f4-fef8-47b4-a720-5ab582b2fcf0", + createdAt: "2026-08-12T12:00:00Z", + updatedAt: "2026-08-12T12:01:00Z", + sequence: 9, + expiresAt: "2026-08-13T12:00:00Z", + continueUpdates: false, + runStatus: "completed", + projectSlugs: [], + issueIds: [], + stages: [], + }), + ), + ); + + await expect( + onboardingStatusUpdate.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: "https://us.sentry.io", + runToken: "a1B2c3D4e5", + stage: "check_stack_trace_quality", + status: "completed", + runStatus: "completed", + }, + context, + ), + ).resolves.toBe("Onboarding status updated. Continue updates: no."); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/onboarding-status-update.ts b/packages/mcp-core/src/tools/catalog/onboarding-status-update.ts new file mode 100644 index 000000000..d4f96932f --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/onboarding-status-update.ts @@ -0,0 +1,95 @@ +import { setTag } from "@sentry/core"; +import { z } from "zod"; +import { + AgenticOnboardingRunStatusUpdateSchema, + AgenticOnboardingStageSchema, + AgenticOnboardingStageStatusUpdateSchema, + AgenticOnboardingStatusUpdateSchema, +} from "../../api-client/schema"; +import { apiServiceFromContext } from "../../internal/tool-helpers/api"; +import { defineTool } from "../../internal/tool-helpers/define"; +import { ParamOrganizationSlug, ParamRegionUrl } from "../../schema"; +import { ALL_SKILLS } from "../../skills"; +import type { ServerContext } from "../../types"; + +export default defineTool({ + name: "onboarding_status_update", + skills: ALL_SKILLS, + includeInSkillDefinitions: false, + requiredScopes: ["org:read"], + description: [ + "Update the progress shown in Sentry's agentic onboarding UI.", + "", + "Use this tool only when the Sentry getting started skill provides a run token. Call it at the workflow boundaries described by that skill.", + "", + "", + "- Progress updates are operational UI state, not user analytics.", + "- Keep eventNote brief and limited to context useful in the progress UI.", + "- Do not include source code, repository paths, credentials, error output, or other sensitive data.", + "- status is required for every update.", + "- Set runStatus to completed or failed only when the entire onboarding run reaches that state.", + "- A failed status requires a brief eventNote explaining the failure.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.nullable().default(null), + runToken: AgenticOnboardingStatusUpdateSchema.shape.runToken.describe( + "The 10-character onboarding run token supplied by Sentry.", + ), + stage: AgenticOnboardingStageSchema.describe( + "The onboarding stage being updated.", + ), + status: AgenticOnboardingStageStatusUpdateSchema.describe( + "The stage's current status.", + ), + runStatus: AgenticOnboardingRunStatusUpdateSchema.optional().describe( + "Set only when the entire onboarding run has completed or failed.", + ), + eventNote: z + .string() + .trim() + .min(1) + .max(256) + .optional() + .describe( + "Short context to display with this progress event. Required when status is failed.", + ), + projectSlugs: + AgenticOnboardingStatusUpdateSchema.shape.projectSlugs.describe( + "Validated Sentry project slugs. Send only for create_project. Values accumulate across updates; include each project as it becomes usable while active, then all known projects when completed.", + ), + issueIds: AgenticOnboardingStatusUpdateSchema.shape.issueIds.describe( + "Validated Sentry issue IDs returned by MCP. Send only for receive_verification_error. Values accumulate across updates; include each matching issue as it is confirmed, then all known issues when completed.", + ), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl ?? context.constraints.regionUrl ?? undefined, + }); + + setTag("organization.slug", params.organizationSlug); + + const run = await apiService.updateAgenticOnboardingStatus({ + organizationSlug: params.organizationSlug, + update: { + schemaVersion: 1, + runToken: params.runToken, + stage: params.stage, + status: params.status, + ...(params.runStatus ? { runStatus: params.runStatus } : {}), + ...(params.eventNote ? { eventNote: params.eventNote } : {}), + ...(params.projectSlugs ? { projectSlugs: params.projectSlugs } : {}), + ...(params.issueIds ? { issueIds: params.issueIds } : {}), + }, + }); + + return `Onboarding status updated. Continue updates: ${run.continueUpdates ? "yes" : "no"}.`; + }, +}); diff --git a/packages/mcp-core/src/tools/types.ts b/packages/mcp-core/src/tools/types.ts index 5cfd1187d..5f956de38 100644 --- a/packages/mcp-core/src/tools/types.ts +++ b/packages/mcp-core/src/tools/types.ts @@ -85,6 +85,7 @@ export interface ToolConfig< description: ToolDescription; inputSchema: TSchema; skills: Skill[]; // Which skill categories this tool belongs to + includeInSkillDefinitions?: boolean; // Whether generated skill prompts advertise this tool requiredScopes: Scope[]; // LEGACY: Which API scopes needed (deprecated, for backward compatibility) experimental?: boolean; // Mark tool as experimental (only shown in experimental mode) hideInExperimentalMode?: boolean; // Hide tool when experimental mode is active (for tools replaced by unified tools) diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index d80589a42..69bea77aa 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -401,6 +401,33 @@ export const restHandlers = buildHandlers([ ]); }, }, + { + method: "post", + path: "/api/0/organizations/sentry-mcp-evals/onboarding/agent/status/", + fetch: () => { + return HttpResponse.json({ + schemaVersion: 1, + runId: "2d27f6654b754dcaa2d26af18274d142", + channelId: "6835652362204cb1b10719783c26983a", + clientRunId: "e806c6f4-fef8-47b4-a720-5ab582b2fcf0", + createdAt: "2026-08-12T12:00:00Z", + updatedAt: "2026-08-12T12:01:00Z", + sequence: 1, + expiresAt: "2026-08-13T12:00:00Z", + continueUpdates: true, + runStatus: "active", + projectSlugs: [], + issueIds: [], + stages: [ + { + stage: "connect_mcp", + status: "completed", + eventNote: null, + }, + ], + }); + }, + }, { method: "get", path: "/api/0/organizations/sentry-mcp-evals/dashboards/",