From a6ca7d72e2c14bdf4160e6293236f3dff1f7b4ca Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:08:09 +0000 Subject: [PATCH 1/3] feat(mcp): Add scoped read tools for conversations, review items, and automations Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- .../src/content/docs/operate/mcp-server.md | 51 ++-- packages/junior/src/api/mcp/AGENTS.md | 10 +- packages/junior/src/api/mcp/automation.ts | 69 +++++ packages/junior/src/api/mcp/conversation.ts | 105 +++++++ .../junior/src/api/mcp/oauth/authorization.ts | 15 +- .../junior/src/api/mcp/oauth/consent-page.ts | 11 +- packages/junior/src/api/mcp/oauth/grants.ts | 32 ++- packages/junior/src/api/mcp/oauth/metadata.ts | 8 +- packages/junior/src/api/mcp/oauth/scopes.ts | 47 +++ packages/junior/src/api/mcp/review-item.ts | 66 +++++ packages/junior/src/api/mcp/route.ts | 21 +- packages/junior/src/api/mcp/server.ts | 52 +++- packages/junior/src/api/mcp/tool-context.ts | 59 ++++ .../src/api/mcp/tools/archive-conversation.ts | 56 ++++ .../src/api/mcp/tools/get-automation.ts | 72 +++++ .../src/api/mcp/tools/get-conversation.ts | 97 +++++++ .../src/api/mcp/tools/get-review-item.ts | 47 +++ .../src/api/mcp/tools/list-automations.ts | 56 ++++ .../mcp/tools/list-conversation-messages.ts | 109 +++++++ .../src/api/mcp/tools/list-conversations.ts | 63 +--- .../src/api/mcp/tools/list-review-items.ts | 72 +++++ .../src/api/mcp/tools/stop-conversation.ts | 52 ++++ packages/junior/src/app.ts | 1 + .../tests/integration/api/mcp/route.test.ts | 270 +++++++++++++++++- 24 files changed, 1331 insertions(+), 110 deletions(-) create mode 100644 packages/junior/src/api/mcp/automation.ts create mode 100644 packages/junior/src/api/mcp/conversation.ts create mode 100644 packages/junior/src/api/mcp/oauth/scopes.ts create mode 100644 packages/junior/src/api/mcp/review-item.ts create mode 100644 packages/junior/src/api/mcp/tool-context.ts create mode 100644 packages/junior/src/api/mcp/tools/archive-conversation.ts create mode 100644 packages/junior/src/api/mcp/tools/get-automation.ts create mode 100644 packages/junior/src/api/mcp/tools/get-conversation.ts create mode 100644 packages/junior/src/api/mcp/tools/get-review-item.ts create mode 100644 packages/junior/src/api/mcp/tools/list-automations.ts create mode 100644 packages/junior/src/api/mcp/tools/list-conversation-messages.ts create mode 100644 packages/junior/src/api/mcp/tools/list-review-items.ts create mode 100644 packages/junior/src/api/mcp/tools/stop-conversation.ts diff --git a/packages/docs/src/content/docs/operate/mcp-server.md b/packages/docs/src/content/docs/operate/mcp-server.md index ee136fe67b..d7f8801b41 100644 --- a/packages/docs/src/content/docs/operate/mcp-server.md +++ b/packages/docs/src/content/docs/operate/mcp-server.md @@ -2,7 +2,7 @@ title: MCP Server description: Connect Claude Code and other MCP clients to Junior at /api/mcp. type: tutorial -summary: Connect an MCP client to the remote Junior MCP server, sign in with Google, and list your Conversations. +summary: Connect an MCP client to the remote Junior MCP server, sign in with Google, and read your Conversations, Review items, and Automations. prerequisites: - /operate/dashboard/ related: @@ -11,7 +11,9 @@ related: - /operate/security-hardening/ --- -Junior has a remote MCP server at `/api/mcp`. An MCP client, such as Claude Code, can use it to list your Junior Conversations. The client signs in through your Junior dashboard with Google. It can see only the Conversations that you started or take part in. +Junior has a remote MCP server at `/api/mcp`. An MCP client, such as Claude Code, can use it to read your Junior Conversations, Review items, and Automations. The client signs in through your Junior dashboard with Google. It can see only the Conversations that you started or take part in. + +The server does not start agent work. No tool sends a message, answers a Review item, or runs an Automation. To ask Junior for work, use Slack or the dashboard. ## Before you start @@ -32,7 +34,7 @@ claude mcp add --transport http junior https:///api/mcp Start Claude Code, run `/mcp`, select `junior`, and select **Authenticate**. Claude Code opens your browser. 1. Sign in with Google if the dashboard asks you to. -2. Read the consent page. It shows the client name and the host that publishes the client, for example `claude.ai`. +2. Read the consent page. It shows the client name, the host that publishes the client (for example `claude.ai`), and each scope that the client asks for. 3. Select **Allow** only if you started this connection. The browser returns to Claude Code, and the server shows as connected. @@ -47,6 +49,14 @@ Use the junior MCP server to list my conversations. Claude Code calls the `listConversations` tool. The result shows up to 50 Conversations, newest activity first. Each one has its title, status, location, timestamps, and a dashboard link when the dashboard URL is known. +Then ask for one Conversation: + +```text +Use junior to summarize my latest conversation. +``` + +Claude Code calls `getConversation`. It returns the Brief and the last assistant reply, not the full transcript. + You can also check discovery without a client: ```bash @@ -54,20 +64,26 @@ curl -i https:///api/mcp -X POST curl https:///.well-known/oauth-protected-resource/api/mcp ``` -The first request returns `401` with a `WWW-Authenticate` header. The second request returns the resource metadata, with the `conversations:read` scope. +The first request returns `401` with a `WWW-Authenticate` header. The second request returns the resource metadata, with every scope in `scopes_supported`. + +## Scopes and tools + +A client gets only the tools of the scopes that you allow. A client that does not ask for a scope asks for all of them. -## The `listConversations` tool +| Scope | Tools | Effect | +| ---------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------- | +| `conversations:read` | `listConversations`, `getConversation`, `listConversationMessages` | Read the Conversations that you started or take part in. | +| `conversations:manage` | `archiveConversation`, `stopConversation` | Archive a Conversation for you, or stop its running Turn. | +| `review_items:read` | `listReviewItems`, `getReviewItem` | Read the Review items that you can see on the Review desk. | +| `automations:read` | `listAutomations`, `getAutomation` | Read the Automations that you can see, and their runs. | -| Input | Type | Effect | -| -------- | -------------------------- | ----------------------------------------------------------------------------------------------- | -| `q` | string | Search titles and Briefs. A search ignores `status`. | -| `status` | `"active"` or `"archived"` | List Conversations that you have not archived (the default), or the Conversations you archived. | +`getConversation` returns metadata, the Brief, and the last assistant reply. `listConversationMessages` returns the user and assistant messages, newest last, 10 at a time by default. Pass its `nextCursor` as `before` to read older messages. Tool calls are not included. -The tool is read-only. It has no pagination. +`stopConversation` drops the messages that wait for the running Turn. It does not start a new Turn. Review item and Automation results include a dashboard link. Answer a Review item or change an Automation there. ## Access and tokens -- The only scope is `conversations:read`. +- A connection keeps the scopes that you allowed. To change them, connect the client again. - Access tokens last 1 hour. Your client refreshes them for you. Each refresh token works once. - A connection ends 30 days after you allow it, even when the client refreshes its tokens. Then connect the client again. - Each request checks the [allowed domains](/reference/config-and-env/#allowed-domains) again. If your domain is removed, the connection stops working. @@ -75,12 +91,13 @@ The tool is read-only. It has no pagination. ## Troubleshooting -| Symptom | Cause and fix | -| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `404` on `/api/mcp` | The dashboard is disabled, or it does not require sign-in. Configure the [dashboard](/operate/dashboard/) with sign-in required. | -| "This app cannot connect" | The client did not send a valid CIMD client ID, or its redirect URI is not in its metadata document. Update the client, or use another client. | -| "This sign-in request expired" | The request is older than 10 minutes or was already used. Start the connection again from the client. | -| The client asks you to connect again | The connection reached its 30-day limit, or Junior revoked it. Authenticate again. | +| Symptom | Cause and fix | +| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `404` on `/api/mcp` | The dashboard is disabled, or it does not require sign-in. Configure the [dashboard](/operate/dashboard/) with sign-in required. | +| "This app cannot connect" | The client did not send a valid CIMD client ID, or its redirect URI is not in its metadata document. Update the client, or use another client. | +| "This sign-in request expired" | The request is older than 10 minutes or was already used. Start the connection again from the client. | +| The client asks you to connect again | The connection reached its 30-day limit, or Junior revoked it. Authenticate again. | +| A tool is missing from the client | The connection does not have the scope of that tool. Connections made before a scope existed do not get it. Authenticate again and allow the scope. | ## Next step diff --git a/packages/junior/src/api/mcp/AGENTS.md b/packages/junior/src/api/mcp/AGENTS.md index 061b7d02c0..98e53780d5 100644 --- a/packages/junior/src/api/mcp/AGENTS.md +++ b/packages/junior/src/api/mcp/AGENTS.md @@ -4,6 +4,13 @@ servers. Do not mix the two. - `listConversations` relies on the membership guarantee of `readConversationFeed`. Never pass `codeRepositoryId` or `actorEmail`. + Every other conversation tool checks `isConversationParticipant` first. +- No tool sends a message, answers or dismisses a Review item, or starts a + Turn or an Automation run. A Review item action sends a message to its + Conversations, so Review items stay read-only. +- Each scope in `oauth/scopes.ts` unlocks the tools in `TOOLS_BY_SCOPE`. A + client sees only the tools of its granted scopes. A grant stores its + scopes as one space-separated value. Refresh never changes it. - Keep OAuth records in the `StateAdapter` with a TTL. No table. - Clients identify only with Client ID Metadata Documents (CIMD). No client registration. @@ -13,8 +20,7 @@ - A CIMD fetch connects only to public addresses on its own socket and does not follow redirects. Cache failed fetches, because authorize needs no sign-in. -- PKCE `S256` is required. The only scope is `conversations:read`. The - `resource` must be the exact `/api/mcp` URL. +- PKCE `S256` is required. The `resource` must be the exact `/api/mcp` URL. - The first signed-in user who opens a consent page owns it. Approve and deny need a same-origin `POST` with that user's newest form token. - A refresh token reuse inside the rotation grace window gets `invalid_grant` diff --git a/packages/junior/src/api/mcp/automation.ts b/packages/junior/src/api/mcp/automation.ts new file mode 100644 index 0000000000..70cafba499 --- /dev/null +++ b/packages/junior/src/api/mcp/automation.ts @@ -0,0 +1,69 @@ +/** Own the MCP view of one Automation, shared by the automation tools. */ +import { z } from "zod"; +import type { AutomationSummary } from "@/api/schema/automation"; +import { getDashboardTaskLink } from "@/chat/dashboard-link"; + +/** MCP view of one Automation. It omits cost and run-count charts. */ +export const mcpAutomationSchema = z + .object({ + id: z.string(), + kind: z.enum(["scheduled", "event"]), + title: z.string(), + instruction: z.string(), + status: z.enum(["active", "blocked", "paused", "completed"]), + statusReason: z.string().optional(), + trigger: z + .string() + .describe("The schedule, or the events and filters that start a run."), + nextRunAt: z.string().optional(), + destination: z.string().describe("Slack channel that gets the results."), + createdBy: z.string(), + ownedByYou: z.boolean(), + lastRunAt: z.string().optional(), + lastRunStatus: z.enum(["blocked", "completed", "failed"]).optional(), + lastConversationId: z.string().optional(), + url: z.string().optional(), + }) + .strict(); + +function triggerText(automation: AutomationSummary): string { + if (automation.kind === "scheduled") { + return `${automation.schedule} (${automation.timezone})`; + } + const { events, filters } = automation.trigger; + return [`${events.join(", ")} on ${automation.resource}`, ...filters].join( + "; ", + ); +} + +/** Map an Automation summary to its MCP view. */ +export function mcpAutomation( + automation: AutomationSummary, +): z.output { + const url = getDashboardTaskLink(automation.id); + return { + id: automation.id, + kind: automation.kind, + title: automation.title, + instruction: automation.instruction, + status: automation.status, + ...(automation.statusReason + ? { statusReason: automation.statusReason } + : undefined), + trigger: triggerText(automation), + ...(automation.kind === "scheduled" && automation.nextRunAt + ? { nextRunAt: automation.nextRunAt } + : undefined), + destination: automation.destination.label, + createdBy: automation.createdBy, + ownedByYou: automation.ownedByViewer, + ...(automation.lastRunAt ? { lastRunAt: automation.lastRunAt } : undefined), + ...(automation.lastRunStatus + ? { lastRunStatus: automation.lastRunStatus } + : undefined), + ...(automation.lastConversationId + ? { lastConversationId: automation.lastConversationId } + : undefined), + ...(url ? { url } : undefined), + }; +} diff --git a/packages/junior/src/api/mcp/conversation.ts b/packages/junior/src/api/mcp/conversation.ts new file mode 100644 index 0000000000..4d01bfeb28 --- /dev/null +++ b/packages/junior/src/api/mcp/conversation.ts @@ -0,0 +1,105 @@ +/** Own the MCP view of one Conversation, shared by the conversation tools. */ +import { z } from "zod"; +import { + conversationReportStatusSchema, + conversationSurfaceSchema, + type ConversationReportEvent, + type ConversationSummaryReport, +} from "@/api/schema/conversation"; +import { getDashboardConversationLink } from "@/chat/dashboard-link"; + +/** MCP view of one Conversation. It omits dashboard-only fields. */ +export const mcpConversationSchema = z + .object({ + conversationId: z.string(), + title: z.string(), + status: conversationReportStatusSchema, + surface: conversationSurfaceSchema, + channelName: z.string().optional(), + locationUrl: z.string().optional(), + startedAt: z.string(), + lastSeenAt: z.string(), + archivedAt: z.string().optional(), + url: z.string().optional(), + }) + .strict(); + +type McpConversation = z.output; + +/** Map a Conversation summary to its MCP view. */ +export function mcpConversation( + summary: ConversationSummaryReport, +): McpConversation { + return { + conversationId: summary.conversationId, + title: summary.displayTitle, + status: summary.status, + surface: summary.surface, + channelName: summary.channelNameRedacted ? undefined : summary.channelName, + locationUrl: summary.locationUrl, + startedAt: summary.startedAt, + lastSeenAt: summary.lastSeenAt, + archivedAt: summary.archivedAt ?? undefined, + url: getDashboardConversationLink(summary.conversationId), + }; +} + +/** Conversation id param shared by the conversation tools. */ +export const mcpConversationIdSchema = z + .string() + .min(1) + .describe("Conversation id from listConversations."); + +/** MCP view of one user or assistant Message. */ +export const mcpMessageSchema = z + .object({ + seq: z.number().int(), + createdAt: z.string(), + role: z.enum(["assistant", "user"]), + author: z.string().optional(), + text: z.string(), + attachments: z + .array( + z + .object({ + filename: z.string(), + contentType: z.string(), + bytes: z.number().int(), + }) + .strict(), + ) + .optional(), + }) + .strict(); + +type McpMessage = z.output; + +/** + * Keep the user and assistant Messages of a report event page, in order. + * System Messages and Messages with no text or attachment are left out. + */ +export function mcpMessages( + events: readonly ConversationReportEvent[], +): McpMessage[] { + return events.flatMap((event) => { + const data = event.data; + if (data.type !== "message" || data.role === "system") return []; + const text = data.text ?? ""; + const attachments = data.attachments?.map( + ({ bytes, contentType, filename }) => ({ bytes, contentType, filename }), + ); + if (!text && !attachments?.length) return []; + const author = + data.actorIdentity?.fullName ?? data.actorIdentity?.slackUserName; + return [ + { + seq: event.seq, + createdAt: event.createdAt, + role: data.role, + ...(author ? { author } : undefined), + text, + ...(attachments?.length ? { attachments } : undefined), + }, + ]; + }); +} diff --git a/packages/junior/src/api/mcp/oauth/authorization.ts b/packages/junior/src/api/mcp/oauth/authorization.ts index 37cc0b0e80..b69aa56cb2 100644 --- a/packages/junior/src/api/mcp/oauth/authorization.ts +++ b/packages/junior/src/api/mcp/oauth/authorization.ts @@ -9,8 +9,9 @@ import { randomBytes } from "node:crypto"; import { z } from "zod"; import { redirectUriMatches, resolveMcpClient } from "./client-metadata"; -import { MCP_SCOPE, type McpUrls } from "./metadata"; +import type { McpUrls } from "./metadata"; import { renderMcpPage } from "./page"; +import { MCP_ALL_SCOPES, mcpScopeValueSchema, parseMcpScopes } from "./scopes"; import { mcpStateKey, readRecord, sha256Hex, type McpState } from "./state"; const AUTHORIZATION_TTL_MS = 10 * 60 * 1000; @@ -27,7 +28,7 @@ const mcpAuthorizationSchema = z expiresAtMs: z.number().int().positive(), redirectUri: z.string().min(1), resource: z.string().min(1), - scope: z.literal(MCP_SCOPE), + scope: mcpScopeValueSchema, state: z.string().max(STATE_MAX_LENGTH).optional(), }) .strict(); @@ -205,9 +206,11 @@ export async function handleMcpAuthorize(args: { if (oauthState !== undefined && oauthState.length > STATE_MAX_LENGTH) { return fail("invalid_request", "The state parameter is too long."); } - const scope = params.get("scope")?.trim(); - if (scope && scope !== MCP_SCOPE) { - return fail("invalid_scope", `The only scope is ${MCP_SCOPE}.`); + // A request without a scope asks for every scope. Consent lists them. + const requestedScope = params.get("scope")?.trim() || MCP_ALL_SCOPES; + const scopes = parseMcpScopes(requestedScope); + if (!scopes) { + return fail("invalid_scope", `Use only these scopes: ${MCP_ALL_SCOPES}.`); } if (params.get("resource") !== args.urls.resource) { return fail( @@ -224,7 +227,7 @@ export async function handleMcpAuthorize(args: { expiresAtMs: Date.now() + AUTHORIZATION_TTL_MS, redirectUri, resource: args.urls.resource, - scope: MCP_SCOPE, + scope: scopes.join(" "), state: oauthState, }; await args.state.set( diff --git a/packages/junior/src/api/mcp/oauth/consent-page.ts b/packages/junior/src/api/mcp/oauth/consent-page.ts index 9ff4b4456d..eafe6d2699 100644 --- a/packages/junior/src/api/mcp/oauth/consent-page.ts +++ b/packages/junior/src/api/mcp/oauth/consent-page.ts @@ -18,6 +18,7 @@ import { import { issueAuthorizationCode } from "./grants"; import { resolveMcpUrls } from "./metadata"; import { renderMcpPage } from "./page"; +import { MCP_SCOPE_DESCRIPTIONS, parseMcpScopes } from "./scopes"; import { sha256Hex, type McpState } from "./state"; function expiredPage(): Response { @@ -45,8 +46,15 @@ function consentPage(args: { const clientName = args.authorization.clientName ?? clientHost; const button = (action: string, label: string, primary: boolean) => ``; + const scopeItems = (parseMcpScopes(args.authorization.scope) ?? []) + .map( + (scope) => + `
  • ${escapeHtml(MCP_SCOPE_DESCRIPTIONS[scope])} ${escapeHtml(scope)}
  • `, + ) + .join(""); return renderMcpPage({ - bodyHtml: `

    The app is published at ${escapeHtml(clientHost)}. It can list your ${escapeHtml(args.agentName)} conversations.

    + bodyHtml: `

    The app is published at ${escapeHtml(clientHost)}. In ${escapeHtml(args.agentName)}, it can:

    +
      ${scopeItems}
    ${button("approve", "Allow", true)}${button("deny", "Deny", false)} @@ -138,6 +146,7 @@ export async function handleMcpConsentPage(args: { email: args.user.email, redirectUri: authorization.redirectUri, resource: authorization.resource, + scope: authorization.scope, state: args.state, }); return authorizationRedirect({ diff --git a/packages/junior/src/api/mcp/oauth/grants.ts b/packages/junior/src/api/mcp/oauth/grants.ts index 5db6f3b37b..6ac06df512 100644 --- a/packages/junior/src/api/mcp/oauth/grants.ts +++ b/packages/junior/src/api/mcp/oauth/grants.ts @@ -7,7 +7,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { z } from "zod"; import { logWarn } from "@/chat/logging"; -import { MCP_SCOPE } from "./metadata"; +import { mcpScopeValueSchema, parseMcpScopes, type McpScope } from "./scopes"; import { mcpStateKey, readRecord, sha256Hex, type McpState } from "./state"; const CODE_TTL_MS = 60 * 1000; @@ -29,7 +29,7 @@ const authorizationCodeSchema = z grantId: z.string().uuid(), redirectUri: z.string().min(1), resource: z.string().min(1), - scope: z.literal(MCP_SCOPE), + scope: mcpScopeValueSchema, }) .strict(); @@ -40,7 +40,7 @@ const grantSchema = z /** Absolute end of the grant. Refresh never moves it. */ expiresAtMs: z.number().int().positive(), resource: z.string().min(1), - scope: z.literal(MCP_SCOPE), + scope: mcpScopeValueSchema, }) .strict(); @@ -98,6 +98,8 @@ export async function issueAuthorizationCode(args: { email: string; redirectUri: string; resource: string; + /** Approved scopes as a stored scope value. */ + scope: string; state: McpState; }): Promise { const code = randomBytes(32).toString("base64url"); @@ -108,7 +110,7 @@ export async function issueAuthorizationCode(args: { grantId: randomUUID(), redirectUri: args.redirectUri, resource: args.resource, - scope: MCP_SCOPE, + scope: args.scope, }; await args.state.set(codeKey(code), record, CODE_TTL_MS); return code; @@ -249,9 +251,13 @@ export async function refreshGrant(args: { ) { return invalidGrant("The refresh token is not valid."); } - if (args.scope !== undefined && args.scope !== grant.scope) { + // Refresh cannot narrow or widen a grant. Connect again for other scopes. + if ( + args.scope !== undefined && + parseMcpScopes(args.scope)?.join(" ") !== grant.scope + ) { return { - description: `The only scope is ${MCP_SCOPE}.`, + description: `Refresh keeps the granted scope: ${grant.scope}.`, error: "invalid_scope", ok: false, }; @@ -282,16 +288,19 @@ export async function refreshGrant(args: { return await issueTokens(args.state, token.grantId, grant); } +/** The user and scopes of one valid MCP access token. */ +export type McpAccessGrant = { email: string; scopes: McpScope[] }; + /** - * Return the user email of one MCP access token for one resource. Personal - * tokens and other bearer values never match, because only this module - * stores MCP access tokens. + * Return the user email and scopes of one MCP access token for one + * resource. Personal tokens and other bearer values never match, because + * only this module stores MCP access tokens. */ export async function authenticateMcpAccessToken(args: { resource: string; state: McpState; token: string; -}): Promise { +}): Promise { if (!args.token.startsWith(ACCESS_TOKEN_PREFIX)) return undefined; const token = await readRecord( args.state, @@ -301,5 +310,6 @@ export async function authenticateMcpAccessToken(args: { if (!token) return undefined; const grant = await readGrant(args.state, token.grantId); if (!grant || grant.resource !== args.resource) return undefined; - return grant.email; + // The stored value is already validated, so it always parses. + return { email: grant.email, scopes: parseMcpScopes(grant.scope) ?? [] }; } diff --git a/packages/junior/src/api/mcp/oauth/metadata.ts b/packages/junior/src/api/mcp/oauth/metadata.ts index a80d5265bd..10ce89016b 100644 --- a/packages/junior/src/api/mcp/oauth/metadata.ts +++ b/packages/junior/src/api/mcp/oauth/metadata.ts @@ -1,13 +1,13 @@ /** - * Own the MCP OAuth paths, scope, and discovery documents. + * Own the MCP OAuth paths and discovery documents. * * Every URL comes from the trusted public origin of the app. The documents * advertise Client ID Metadata Documents and no client registration endpoint. */ import { resolvePublicOrigin } from "@/api/public-origin"; +import { MCP_SCOPES } from "./scopes"; export const MCP_PATH = "/api/mcp"; -export const MCP_SCOPE = "conversations:read"; export const MCP_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/api/mcp"; export const MCP_AUTHORIZATION_SERVER_METADATA_PATH = @@ -58,7 +58,7 @@ export function protectedResourceMetadata(urls: McpUrls) { resource: urls.resource, authorization_servers: [urls.issuer], bearer_methods_supported: ["header"], - scopes_supported: [MCP_SCOPE], + scopes_supported: [...MCP_SCOPES], }; } @@ -68,7 +68,7 @@ export function authorizationServerMetadata(urls: McpUrls) { issuer: urls.issuer, authorization_endpoint: urls.authorize, token_endpoint: urls.token, - scopes_supported: [MCP_SCOPE], + scopes_supported: [...MCP_SCOPES], response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"], diff --git a/packages/junior/src/api/mcp/oauth/scopes.ts b/packages/junior/src/api/mcp/oauth/scopes.ts new file mode 100644 index 0000000000..46c90c3fdb --- /dev/null +++ b/packages/junior/src/api/mcp/oauth/scopes.ts @@ -0,0 +1,47 @@ +/** + * Own the MCP OAuth scopes. Each scope unlocks a fixed set of tools. No + * scope lets a client send a message or start a Turn. + * + * A grant stores its scopes as one space-separated string (RFC 6749 section + * 3.3), so grants from before the scope split still parse. + */ +import { z } from "zod"; + +/** Every scope, in the order that the consent page shows it. */ +export const MCP_SCOPES = [ + "conversations:read", + "conversations:manage", + "review_items:read", + "automations:read", +] as const; + +export type McpScope = (typeof MCP_SCOPES)[number]; + +/** What each scope lets the client do, as the consent page says it. */ +export const MCP_SCOPE_DESCRIPTIONS: Record = { + "conversations:read": + "List and read the conversations that you started or take part in", + "conversations:manage": "Archive your conversations and stop a running Turn", + "review_items:read": "List and read your review items", + "automations:read": "List and read the automations that you can see", +}; + +/** The `scope` value that names every scope. */ +export const MCP_ALL_SCOPES = MCP_SCOPES.join(" "); + +/** + * Parse a space-separated scope value into known scopes in canonical order. + * Return `undefined` when it is empty or names an unknown scope. + */ +export function parseMcpScopes(value: string): McpScope[] | undefined { + const names = value.split(" ").filter(Boolean); + if (names.length === 0) return undefined; + if (!names.every((name) => (MCP_SCOPES as readonly string[]).includes(name))) + return undefined; + return MCP_SCOPES.filter((scope) => names.includes(scope)); +} + +/** A stored scope value: known scopes, space-separated, in canonical order. */ +export const mcpScopeValueSchema = z + .string() + .refine((value) => parseMcpScopes(value)?.join(" ") === value); diff --git a/packages/junior/src/api/mcp/review-item.ts b/packages/junior/src/api/mcp/review-item.ts new file mode 100644 index 0000000000..a138706afa --- /dev/null +++ b/packages/junior/src/api/mcp/review-item.ts @@ -0,0 +1,66 @@ +/** Own the MCP view of one Review item, shared by the review item tools. */ +import { z } from "zod"; +import { getDashboardReviewItemLink } from "@/chat/dashboard-link"; +import { canActOnReviewItem } from "@/chat/review-items/access"; +import type { ReviewItem } from "@/chat/review-items/store"; + +/** MCP view of one Review item. Answers happen in Slack or the dashboard. */ +export const mcpReviewItemSchema = z + .object({ + id: z.string(), + kind: z.enum(["notice", "action"]), + status: z.enum(["open", "done", "dismissed"]), + title: z.string(), + body: z.string(), + question: z.string().optional(), + assignees: z + .array(z.string()) + .describe("Names or emails. Empty when anyone can act."), + canAct: z.boolean().describe("True when you can answer or dismiss it."), + conversationIds: z.array(z.string()), + subject: z + .object({ + id: z.string(), + title: z.string().optional(), + url: z.string().optional(), + }) + .strict() + .optional(), + createdAt: z.string(), + updatedAt: z.string(), + url: z.string().optional().describe("Dashboard link to answer it."), + }) + .strict(); + +/** Map a Review item to its MCP view for one user. */ +export function mcpReviewItem( + item: ReviewItem, + userId: string, +): z.output { + const url = getDashboardReviewItemLink(item.id); + return { + id: item.id, + kind: item.kind, + status: item.status, + title: item.title, + body: item.body, + ...(item.request ? { question: item.request.question } : undefined), + assignees: item.assignedToEveryone + ? [] + : item.assignees.map((person) => person.displayName ?? person.email), + canAct: canActOnReviewItem(item, userId), + conversationIds: item.conversationIds, + ...(item.subject + ? { + subject: { + id: item.subject.id, + ...(item.subject.title ? { title: item.subject.title } : undefined), + ...(item.subject.url ? { url: item.subject.url } : undefined), + }, + } + : undefined), + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + ...(url ? { url } : undefined), + }; +} diff --git a/packages/junior/src/api/mcp/route.ts b/packages/junior/src/api/mcp/route.ts index 5d7576b420..a2a4928372 100644 --- a/packages/junior/src/api/mcp/route.ts +++ b/packages/junior/src/api/mcp/route.ts @@ -4,6 +4,7 @@ */ import { Hono } from "hono"; import { resolveViewerUser } from "@/chat/plugins/viewer"; +import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; import { isAllowedSignInEmail } from "@/chat/sign-in/allowlist"; import { handleMcpAuthorize } from "./oauth/authorization"; import { authenticateMcpAccessToken } from "./oauth/grants"; @@ -11,7 +12,6 @@ import { MCP_AUTHORIZATION_SERVER_METADATA_PATH, MCP_AUTHORIZE_PATH, MCP_PATH, - MCP_SCOPE, MCP_TOKEN_PATH, MCP_RESOURCE_METADATA_PATH, authorizationServerMetadata, @@ -19,6 +19,7 @@ import { resolveMcpUrls, type McpUrls, } from "./oauth/metadata"; +import { MCP_ALL_SCOPES } from "./oauth/scopes"; import { handleMcpServerRequest } from "./server"; import type { McpState } from "./oauth/state"; import { handleMcpToken, mcpTokenBodyLimit } from "./oauth/token"; @@ -37,6 +38,8 @@ type McpHttpHandlerOptions = { allowedDomains: readonly string[]; /** Configured public origin. Unset allows only loopback request origins. */ baseURL?: string; + /** Conversation work queue. Stopping a Turn clears its queued work. */ + queue: ConversationWorkQueue; state: McpState; }; @@ -50,7 +53,7 @@ function unauthorized(urls: McpUrls, sentToken: boolean): Response { status: 401, headers: { "cache-control": "no-store", - "www-authenticate": `Bearer ${error}resource_metadata="${urls.resourceMetadata}", scope="${MCP_SCOPE}"`, + "www-authenticate": `Bearer ${error}resource_metadata="${urls.resourceMetadata}", scope="${MCP_ALL_SCOPES}"`, }, }); } @@ -90,7 +93,7 @@ export function createMcpHttpHandler( app.on(["GET", "POST", "DELETE"], MCP_PATH, async (c) => { const requestUrls = urls(c.req.raw); const token = bearerToken(c.req.raw); - const email = token + const grant = token ? await authenticateMcpAccessToken({ resource: requestUrls.resource, state: options.state, @@ -98,15 +101,19 @@ export function createMcpHttpHandler( }) : undefined; const user = - email && isAllowedSignInEmail(email, options.allowedDomains) - ? await resolveViewerUser(email) + grant && isAllowedSignInEmail(grant.email, options.allowedDomains) + ? await resolveViewerUser(grant.email) : undefined; - if (!user) return unauthorized(requestUrls, token !== undefined); + if (!grant || !user) return unauthorized(requestUrls, token !== undefined); // The stateless transport has no session to stream or delete. if (c.req.method !== "POST") { return new Response(null, { status: 405, headers: { allow: "POST" } }); } - return await handleMcpServerRequest(c.req.raw, user); + return await handleMcpServerRequest(c.req.raw, { + queue: options.queue, + scopes: grant.scopes, + user, + }); }); return app.fetch; diff --git a/packages/junior/src/api/mcp/server.ts b/packages/junior/src/api/mcp/server.ts index d1cefc44bc..899cdb2cbf 100644 --- a/packages/junior/src/api/mcp/server.ts +++ b/packages/junior/src/api/mcp/server.ts @@ -8,23 +8,61 @@ */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import type { User } from "@sentry/junior-plugin-api"; import { JUNIOR_VERSION } from "@/version"; +import type { McpScope } from "./oauth/scopes"; +import type { McpToolContext } from "./tool-context"; +import { registerArchiveConversationTool } from "./tools/archive-conversation"; +import { registerGetAutomationTool } from "./tools/get-automation"; +import { registerGetConversationTool } from "./tools/get-conversation"; +import { registerGetReviewItemTool } from "./tools/get-review-item"; +import { registerListAutomationsTool } from "./tools/list-automations"; +import { registerListConversationMessagesTool } from "./tools/list-conversation-messages"; import { registerListConversationsTool } from "./tools/list-conversations"; +import { registerListReviewItemsTool } from "./tools/list-review-items"; +import { registerStopConversationTool } from "./tools/stop-conversation"; -/** Build the MCP server for one authenticated user. */ -function createMcpServer(user: User): McpServer { - const server = new McpServer({ name: "junior", version: JUNIOR_VERSION }); - registerListConversationsTool(server, user); +type RegisterTool = (server: McpServer, context: McpToolContext) => void; + +/** The tools that each scope unlocks. A client sees only its granted tools. */ +const TOOLS_BY_SCOPE: Record = { + "conversations:read": [ + registerListConversationsTool, + registerGetConversationTool, + registerListConversationMessagesTool, + ], + "conversations:manage": [ + registerArchiveConversationTool, + registerStopConversationTool, + ], + "review_items:read": [registerListReviewItemsTool, registerGetReviewItemTool], + "automations:read": [registerListAutomationsTool, registerGetAutomationTool], +}; + +const INSTRUCTIONS = + "Junior is a Slack agent. These tools read your Junior conversations, review items, and automations. They cannot send a message or start agent work. To ask Junior for work or to answer a review item, use the Slack or dashboard link in a result."; + +/** Build the MCP server for one authenticated user and granted scopes. */ +function createMcpServer( + context: McpToolContext, + scopes: readonly McpScope[], +): McpServer { + const server = new McpServer( + { name: "junior", version: JUNIOR_VERSION }, + { instructions: INSTRUCTIONS }, + ); + for (const scope of scopes) { + for (const register of TOOLS_BY_SCOPE[scope]) register(server, context); + } return server; } /** Answer one Streamable HTTP POST for an authenticated user. */ export async function handleMcpServerRequest( request: Request, - user: User, + args: McpToolContext & { scopes: readonly McpScope[] }, ): Promise { - const server = createMcpServer(user); + const { scopes, ...context } = args; + const server = createMcpServer(context, scopes); const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true, sessionIdGenerator: undefined, diff --git a/packages/junior/src/api/mcp/tool-context.ts b/packages/junior/src/api/mcp/tool-context.ts new file mode 100644 index 0000000000..821afb1cb7 --- /dev/null +++ b/packages/junior/src/api/mcp/tool-context.ts @@ -0,0 +1,59 @@ +/** Own what every MCP tool gets: its request context and result shapes. */ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { HTTPException } from "hono/http-exception"; +import type { User } from "@sentry/junior-plugin-api"; +import { getDb } from "@/chat/db"; +import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; +import { readConversationAccessFromSql } from "@/api/conversations/access"; + +/** Services and the authenticated user for one MCP request. */ +export interface McpToolContext { + queue: ConversationWorkQueue; + user: User; +} + +/** + * Return one structured tool result. The text block holds the same JSON, as + * the MCP spec recommends for clients that do not read `structuredContent`. + */ +export function mcpToolResult>( + result: T, +): { content: [{ type: "text"; text: string }]; structuredContent: T } { + return { + content: [{ type: "text", text: JSON.stringify(result) }], + structuredContent: result, + }; +} + +/** Return a tool error that the client model can read and repair. */ +export function mcpToolError(message: string): CallToolResult { + return { content: [{ type: "text", text: message }], isError: true }; +} + +/** + * Turn a client error from a shared API function, such as a `404` or a + * `409`, into a tool error. Rethrow every other failure. + */ +export function apiToolError(error: unknown): CallToolResult { + if (error instanceof HTTPException && error.status < 500) { + return mcpToolError(error.message); + } + throw error; +} + +/** + * Return whether `user` started or takes part in a Conversation. MCP tools + * read and change only these Conversations, the same set that + * `listConversations` shows. + */ +export async function isConversationParticipant( + user: User, + conversationId: string, +): Promise { + const access = await readConversationAccessFromSql( + getDb(), + [conversationId], + user, + ); + return access.get(conversationId)?.isParticipant ?? false; +} diff --git a/packages/junior/src/api/mcp/tools/archive-conversation.ts b/packages/junior/src/api/mcp/tools/archive-conversation.ts new file mode 100644 index 0000000000..e483bcddd3 --- /dev/null +++ b/packages/junior/src/api/mcp/tools/archive-conversation.ts @@ -0,0 +1,56 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { archiveConversation } from "@/api/conversations/archive"; +import { mcpConversationIdSchema } from "../conversation"; +import { + apiToolError, + mcpToolResult, + type McpToolContext, +} from "../tool-context"; + +/** Register `archiveConversation`, which archives or restores for the user. */ +export function registerArchiveConversationTool( + server: McpServer, + { user }: McpToolContext, +): void { + server.registerTool( + "archiveConversation", + { + title: "Archive conversation", + description: + "Archive one of your conversations, or restore it. Archiving hides it only for you, and new activity restores it.", + inputSchema: { + conversationId: mcpConversationIdSchema, + archived: z + .boolean() + .default(true) + .describe("False restores an archived conversation."), + lastSeenAt: z + .string() + .datetime() + .describe( + "`lastSeenAt` from listConversations or getConversation. The archive fails when the conversation has newer activity.", + ), + }, + outputSchema: { archivedAt: z.string().nullable() }, + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: false, + }, + }, + async ({ archived, conversationId, lastSeenAt }) => { + try { + return mcpToolResult( + await archiveConversation(user, conversationId, { + archived, + lastSeenAt, + }), + ); + } catch (error) { + return apiToolError(error); + } + }, + ); +} diff --git a/packages/junior/src/api/mcp/tools/get-automation.ts b/packages/junior/src/api/mcp/tools/get-automation.ts new file mode 100644 index 0000000000..cdc9d4de64 --- /dev/null +++ b/packages/junior/src/api/mcp/tools/get-automation.ts @@ -0,0 +1,72 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { automationExecutionStatusSchema } from "@/api/schema/automation"; +import { + readViewerAutomationExecutions, + ViewerTaskNotFoundError, +} from "@/chat/automations/read"; +import { mcpAutomation, mcpAutomationSchema } from "../automation"; +import { + mcpToolError, + mcpToolResult, + type McpToolContext, +} from "../tool-context"; + +const RECENT_RUN_LIMIT = 10; + +/** Register `getAutomation`, which reads one Automation and its recent runs. */ +export function registerGetAutomationTool( + server: McpServer, + { user }: McpToolContext, +): void { + server.registerTool( + "getAutomation", + { + title: "Get automation", + description: `Get one automation and its ${RECENT_RUN_LIMIT} newest runs.`, + inputSchema: { + id: z.string().min(1).describe("Automation id from listAutomations."), + }, + outputSchema: { + automation: mcpAutomationSchema, + runs: z.array( + z + .object({ + executedAt: z.string(), + status: automationExecutionStatusSchema, + error: z.string().optional(), + conversationId: z.string().optional(), + }) + .strict(), + ), + }, + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + }, + async ({ id }) => { + try { + const list = await readViewerAutomationExecutions(user, id); + return mcpToolResult({ + automation: mcpAutomation(list.automation), + runs: list.executions + .slice(0, RECENT_RUN_LIMIT) + .map(({ conversationId, error, executedAt, status }) => ({ + executedAt, + status, + ...(error ? { error } : undefined), + ...(conversationId ? { conversationId } : undefined), + })), + }); + } catch (error) { + if (error instanceof ViewerTaskNotFoundError) { + return mcpToolError("Automation not found."); + } + throw error; + } + }, + ); +} diff --git a/packages/junior/src/api/mcp/tools/get-conversation.ts b/packages/junior/src/api/mcp/tools/get-conversation.ts new file mode 100644 index 0000000000..1b224b3d56 --- /dev/null +++ b/packages/junior/src/api/mcp/tools/get-conversation.ts @@ -0,0 +1,97 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { readConversationDetail } from "@/api/conversations/detail"; +import { + conversationBriefSchema, + type ConversationBrief, +} from "@/chat/briefs/schema"; +import { + mcpConversation, + mcpConversationIdSchema, + mcpConversationSchema, + mcpMessages, + mcpMessageSchema, +} from "../conversation"; +import { + isConversationParticipant, + mcpToolError, + mcpToolResult, + type McpToolContext, +} from "../tool-context"; + +// The Brief without its activity counts and search keywords. +const mcpBriefSchema = conversationBriefSchema + .omit({ keywords: true, record: true, schemaVersion: true }) + .extend({ updatedAt: z.string() }); + +function mcpBrief( + brief: ConversationBrief, + updatedAt: string, +): z.output { + return { + summary: brief.summary, + intent: brief.intent, + outcome: brief.outcome, + decisions: brief.decisions, + openDecisions: brief.openDecisions, + facts: brief.facts, + links: brief.links, + updatedAt, + }; +} + +// The newest report events to search for the last Reply. A long tool run +// can push the Reply out of this window. Then `listConversationMessages` +// still finds it. +const RECENT_EVENT_LIMIT = 100; + +/** + * Register `getConversation`. It returns metadata, the Brief, and the last + * Reply, not the whole transcript. + */ +export function registerGetConversationTool( + server: McpServer, + { user }: McpToolContext, +): void { + server.registerTool( + "getConversation", + { + title: "Get conversation", + description: + "Get one conversation: its metadata, its Brief (a summary of intent, outcome, and state), and the last assistant reply. Use listConversationMessages for the full transcript.", + inputSchema: { conversationId: mcpConversationIdSchema }, + outputSchema: { + conversation: mcpConversationSchema, + brief: mcpBriefSchema.optional(), + lastReply: mcpMessageSchema.optional(), + }, + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + }, + async ({ conversationId }) => { + const detail = (await isConversationParticipant(user, conversationId)) + ? await readConversationDetail(conversationId, { + limit: RECENT_EVENT_LIMIT, + viewer: user, + }) + : undefined; + if (!detail) return mcpToolError("Conversation not found."); + const lastReply = mcpMessages(detail.events) + .filter((message) => message.role === "assistant") + .at(-1); + return mcpToolResult({ + conversation: mcpConversation(detail), + ...(detail.brief + ? { + brief: mcpBrief(detail.brief.content, detail.brief.updatedAt), + } + : undefined), + ...(lastReply ? { lastReply } : undefined), + }); + }, + ); +} diff --git a/packages/junior/src/api/mcp/tools/get-review-item.ts b/packages/junior/src/api/mcp/tools/get-review-item.ts new file mode 100644 index 0000000000..d5b6112ce0 --- /dev/null +++ b/packages/junior/src/api/mcp/tools/get-review-item.ts @@ -0,0 +1,47 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + readReviewItemForUser, + ReviewItemActionError, +} from "@/chat/review-items/actions"; +import { mcpReviewItem, mcpReviewItemSchema } from "../review-item"; +import { + mcpToolError, + mcpToolResult, + type McpToolContext, +} from "../tool-context"; + +/** Register `getReviewItem`, which reads one Review item in any status. */ +export function registerGetReviewItemTool( + server: McpServer, + { user }: McpToolContext, +): void { + server.registerTool( + "getReviewItem", + { + title: "Get review item", + description: "Get one review item, open or closed.", + inputSchema: { + id: z.string().min(1).describe("Review item id from listReviewItems."), + }, + outputSchema: { item: mcpReviewItemSchema }, + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + }, + async ({ id }) => { + try { + const item = await readReviewItemForUser(user, id); + return mcpToolResult({ item: mcpReviewItem(item, user.id) }); + } catch (error) { + if (error instanceof ReviewItemActionError) { + return mcpToolError(error.message); + } + throw error; + } + }, + ); +} diff --git a/packages/junior/src/api/mcp/tools/list-automations.ts b/packages/junior/src/api/mcp/tools/list-automations.ts new file mode 100644 index 0000000000..8261c99ae2 --- /dev/null +++ b/packages/junior/src/api/mcp/tools/list-automations.ts @@ -0,0 +1,56 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { automationListQuerySchema } from "@/api/schema/automation"; +import { readViewerAutomations } from "@/chat/automations/read"; +import { mcpAutomation, mcpAutomationSchema } from "../automation"; +import { mcpToolResult, type McpToolContext } from "../tool-context"; + +/** Register `listAutomations`, which lists the Automations the user can see. */ +export function registerListAutomationsTool( + server: McpServer, + { user }: McpToolContext, +): void { + // oxlint-disable-next-line anti-slop/no-shape-in-symbol-names -- `shape` is the Zod object API. + const listQuery = automationListQuerySchema.shape; + server.registerTool( + "listAutomations", + { + title: "List automations", + description: + "List the automations that you can see, newest first. An automation is a saved instruction that runs on a schedule or on events.", + inputSchema: { + q: listQuery.q.describe( + "Text to search for in titles and instructions.", + ), + scope: z + .enum(["all", "mine", "attention"]) + .default("all") + .describe( + "`mine` lists the ones you created. `attention` lists the ones that are blocked, cannot run, or had a problem in their last run.", + ), + state: listQuery.state.describe("Filter by state."), + page: listQuery.page.describe("Page number, from 1."), + }, + outputSchema: { + automations: z.array(mcpAutomationSchema), + total: z.number().int().describe("Matches across every page."), + }, + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + }, + async ({ page, q, scope, state }) => { + const list = await readViewerAutomations( + user, + automationListQuerySchema.parse({ page, q, scope, state }), + ); + return mcpToolResult({ + automations: list.automations.map(mcpAutomation), + total: list.total, + }); + }, + ); +} diff --git a/packages/junior/src/api/mcp/tools/list-conversation-messages.ts b/packages/junior/src/api/mcp/tools/list-conversation-messages.ts new file mode 100644 index 0000000000..88eef7e275 --- /dev/null +++ b/packages/junior/src/api/mcp/tools/list-conversation-messages.ts @@ -0,0 +1,109 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + decodeConversationCursor, + encodeConversationCursor, +} from "@/api/conversations/cursor"; +import { readConversationEventPage } from "@/api/conversations/event-page"; +import { readConversationRecordFromSql } from "@/api/conversations/list"; +import { getSqlExecutor } from "@/chat/db"; +import { + mcpConversationIdSchema, + mcpMessages, + mcpMessageSchema, +} from "../conversation"; +import { + isConversationParticipant, + mcpToolError, + mcpToolResult, + type McpToolContext, +} from "../tool-context"; + +// Report events read for one page. Tool calls and other events share this +// budget, so a page can hold fewer Messages than `limit`. +const EVENT_SCAN_LIMIT = 200; + +/** Register `listConversationMessages`, which pages a transcript backward. */ +export function registerListConversationMessagesTool( + server: McpServer, + { user }: McpToolContext, +): void { + server.registerTool( + "listConversationMessages", + { + title: "List conversation messages", + description: + "List the user and assistant messages of one conversation, oldest first, ending with the newest. Tool calls are not included.", + inputSchema: { + conversationId: mcpConversationIdSchema, + before: z + .string() + .min(1) + .max(1_000) + .optional() + .describe( + "`nextCursor` from an earlier call. Lists the messages before it.", + ), + limit: z + .number() + .int() + .min(1) + .max(50) + .default(10) + .describe("Maximum number of messages."), + }, + outputSchema: { + messages: z.array(mcpMessageSchema), + nextCursor: z + .string() + .optional() + .describe("Set when older messages can exist."), + }, + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + }, + async ({ before, conversationId, limit }) => { + const record = (await isConversationParticipant(user, conversationId)) + ? await readConversationRecordFromSql(conversationId) + : undefined; + if (!record) return mcpToolError("Conversation not found."); + const beforeSeq = before + ? decodeConversationCursor({ conversationId, cursor: before })?.seq + : undefined; + if (before && beforeSeq === undefined) { + return mcpToolError( + "The cursor is not valid. Omit `before` to read the newest messages.", + ); + } + if (record.conversation.transcriptPurgedAtMs !== undefined) { + return mcpToolResult({ messages: [] }); + } + // Participants can see private content. + const page = await readConversationEventPage(getSqlExecutor(), { + beforeSeq, + canExposePayload: true, + conversationId, + limit: EVENT_SCAN_LIMIT, + }); + const scanned = mcpMessages(page.events); + const messages = scanned.slice(-limit); + const olderSeq = + scanned.length > messages.length ? messages[0]?.seq : page.previousSeq; + return mcpToolResult({ + messages, + ...(olderSeq === undefined + ? undefined + : { + nextCursor: encodeConversationCursor({ + conversationId, + seq: olderSeq, + }), + }), + }); + }, + ); +} diff --git a/packages/junior/src/api/mcp/tools/list-conversations.ts b/packages/junior/src/api/mcp/tools/list-conversations.ts index 1cf5ec58e6..faedb3bdb4 100644 --- a/packages/junior/src/api/mcp/tools/list-conversations.ts +++ b/packages/junior/src/api/mcp/tools/list-conversations.ts @@ -1,52 +1,14 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { User } from "@sentry/junior-plugin-api"; import { z } from "zod"; import { readConversationFeed } from "@/api/conversations/list"; -import { - conversationFeedQuerySchema, - conversationReportStatusSchema, - conversationSurfaceSchema, - type ConversationSummaryReport, -} from "@/api/schema/conversation"; -import { getDashboardConversationLink } from "@/chat/dashboard-link"; +import { conversationFeedQuerySchema } from "@/api/schema/conversation"; +import { mcpConversation, mcpConversationSchema } from "../conversation"; +import { mcpToolResult, type McpToolContext } from "../tool-context"; -/** MCP view of one Conversation. It omits dashboard-only fields. */ -const mcpConversationSchema = z - .object({ - conversationId: z.string(), - title: z.string(), - status: conversationReportStatusSchema, - surface: conversationSurfaceSchema, - channelName: z.string().optional(), - locationUrl: z.string().optional(), - startedAt: z.string(), - lastSeenAt: z.string(), - archivedAt: z.string().optional(), - url: z.string().optional(), - }) - .strict(); - -type McpConversation = z.output; - -function mcpConversation(summary: ConversationSummaryReport): McpConversation { - return { - conversationId: summary.conversationId, - title: summary.displayTitle, - status: summary.status, - surface: summary.surface, - channelName: summary.channelNameRedacted ? undefined : summary.channelName, - locationUrl: summary.locationUrl, - startedAt: summary.startedAt, - lastSeenAt: summary.lastSeenAt, - archivedAt: summary.archivedAt ?? undefined, - url: getDashboardConversationLink(summary.conversationId), - }; -} - -/** Register `listConversations`, which lists the Conversations of `user`. */ +/** Register `listConversations`, which lists the Conversations of the user. */ export function registerListConversationsTool( server: McpServer, - user: User, + { user }: McpToolContext, ): void { // oxlint-disable-next-line anti-slop/no-shape-in-symbol-names -- `shape` is the Zod object API. const feedQuery = conversationFeedQuerySchema.shape; @@ -64,15 +26,18 @@ export function registerListConversationsTool( }, // The SDK checks `structuredContent` against this schema. outputSchema: { conversations: z.array(mcpConversationSchema) }, - annotations: { readOnlyHint: true, openWorldHint: false }, + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, }, async ({ q, status }) => { const feed = await readConversationFeed({ q, status, viewer: user }); - const result = { conversations: feed.conversations.map(mcpConversation) }; - return { - content: [{ type: "text", text: JSON.stringify(result) }], - structuredContent: result, - }; + return mcpToolResult({ + conversations: feed.conversations.map(mcpConversation), + }); }, ); } diff --git a/packages/junior/src/api/mcp/tools/list-review-items.ts b/packages/junior/src/api/mcp/tools/list-review-items.ts new file mode 100644 index 0000000000..249ee22690 --- /dev/null +++ b/packages/junior/src/api/mcp/tools/list-review-items.ts @@ -0,0 +1,72 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getDb } from "@/chat/db"; +import { reviewItemReaderForUser } from "@/chat/review-items/access"; +import { searchReviewItems } from "@/chat/review-items/store"; +import { mcpReviewItem, mcpReviewItemSchema } from "../review-item"; +import { mcpToolResult, type McpToolContext } from "../tool-context"; + +/** Register `listReviewItems`, which lists open Review items for the user. */ +export function registerListReviewItemsTool( + server: McpServer, + { user }: McpToolContext, +): void { + server.registerTool( + "listReviewItems", + { + title: "List review items", + description: + "List open review items that you can see, newest change first. A review item is a question or notice that waits for a person.", + inputSchema: { + q: z + .string() + .trim() + .min(1) + .max(200) + .optional() + .describe("Words to find in the title or body."), + conversationId: z + .string() + .min(1) + .optional() + .describe("List only the items linked to this conversation."), + limit: z + .number() + .int() + .min(1) + .max(50) + .default(20) + .describe("Maximum number of items."), + }, + outputSchema: { + items: z.array(mcpReviewItemSchema), + truncated: z.boolean().describe("True when more items match."), + }, + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + }, + async ({ conversationId, limit, q }) => { + // Read one more item than the limit to learn whether more match. + const items = await searchReviewItems( + getDb(), + reviewItemReaderForUser(user), + { + ...(conversationId ? { conversationId } : undefined), + limit: limit + 1, + ...(q ? { query: q } : undefined), + statuses: ["open"], + }, + ); + return mcpToolResult({ + items: items + .slice(0, limit) + .map((item) => mcpReviewItem(item, user.id)), + truncated: items.length > limit, + }); + }, + ); +} diff --git a/packages/junior/src/api/mcp/tools/stop-conversation.ts b/packages/junior/src/api/mcp/tools/stop-conversation.ts new file mode 100644 index 0000000000..0e8f032c08 --- /dev/null +++ b/packages/junior/src/api/mcp/tools/stop-conversation.ts @@ -0,0 +1,52 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { stopConversationTurnForViewer } from "@/api/conversations/stop"; +import { mcpConversationIdSchema } from "../conversation"; +import { + apiToolError, + mcpToolResult, + type McpToolContext, +} from "../tool-context"; + +/** Register `stopConversation`, which stops the active Turn. */ +export function registerStopConversationTool( + server: McpServer, + { queue, user }: McpToolContext, +): void { + server.registerTool( + "stopConversation", + { + title: "Stop conversation", + description: + "Stop the running turn of one of your conversations. Messages that wait for the turn are dropped. It starts no new work.", + inputSchema: { conversationId: mcpConversationIdSchema }, + outputSchema: { + status: z + .enum(["no_work", "requested"]) + .describe("`no_work` when nothing was running."), + droppedMessages: z.number().int(), + }, + annotations: { + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: false, + }, + }, + async ({ conversationId }) => { + try { + const result = await stopConversationTurnForViewer( + user, + conversationId, + { queue }, + ); + return mcpToolResult({ + status: result.status, + droppedMessages: result.stoppedInboundMessageIds.length, + }); + } catch (error) { + return apiToolError(error); + } + }, + ); +} diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index d97c085fe1..dfc8f53dbe 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -750,6 +750,7 @@ export async function createApp( handleMcpRequest ??= createMcpHttpHandler({ allowedDomains, baseURL: resolvePublicBaseURL(dashboard), + queue: conversationWorkQueue, state: getMcpState(), }); return handleMcpRequest(c.req.raw); diff --git a/packages/junior/tests/integration/api/mcp/route.test.ts b/packages/junior/tests/integration/api/mcp/route.test.ts index 052a030f40..85ffbc2b3b 100644 --- a/packages/junior/tests/integration/api/mcp/route.test.ts +++ b/packages/junior/tests/integration/api/mcp/route.test.ts @@ -2,9 +2,11 @@ import { createHash, randomBytes } from "node:crypto"; import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createApp } from "@/app"; -import { getSqlExecutor } from "@/chat/db"; +import { appendConversationBrief } from "@/chat/briefs/store"; +import { getConversationEventStore, getDb, getSqlExecutor } from "@/chat/db"; import { createSqlStore } from "@/chat/conversations/sql/store"; import { resolveViewerUser } from "@/chat/plugins/viewer"; +import { insertReviewItem } from "@/chat/review-items/store"; import { juniorConversationParticipants } from "@/db/schema"; import { createPersonalToken } from "@/personal-tokens/store"; import { @@ -14,6 +16,7 @@ import { viewerCookie, withMcpClient, } from "../../../fixtures/mcp-http"; +import { conversationBriefFixture } from "../../../fixtures/conversation-brief"; import { MCP_CLIENT_ID, MCP_CLIENT_NAME, @@ -25,6 +28,8 @@ const VIEWER = "mcp-viewer@example.com"; const OTHER_VIEWER = "mcp-other@example.com"; // Claude Code listens on a random loopback port. const REDIRECT_URI = "http://127.0.0.1:53682/callback"; +const ALL_SCOPES = + "conversations:read conversations:manage review_items:read automations:read"; type TokenBody = { access_token: string; @@ -145,13 +150,15 @@ async function seedConversation(args: { async function approveClient( app: Hono, email: string, -): Promise<{ code: string; verifier: string }> { + scope = "conversations:read", +): Promise<{ code: string; consentHtml: string; verifier: string }> { const pkce = createPkce(); const authorize = await app.request( authorizeUrl({ client_id: MCP_CLIENT_ID, code_challenge: pkce.challenge, redirect_uri: REDIRECT_URI, + scope, }), ); const consentUrl = authorize.headers.get("location") ?? ""; @@ -165,7 +172,28 @@ async function approveClient( "code", ); if (!code) throw new Error("MCP consent returned no code"); - return { code, verifier: pkce.verifier }; + return { code, consentHtml: page.html, verifier: pkce.verifier }; +} + +/** Connect the test client as one viewer and return its access token. */ +async function connect(app: Hono, email: string, scope = ALL_SCOPES) { + const response = await exchangeCode( + app, + await approveClient(app, email, scope), + ); + return ((await response.json()) as TokenBody).access_token; +} + +/** Call one tool with the official MCP SDK client. */ +async function callTool( + app: Hono, + token: string, + name: string, + args: Record = {}, +) { + return await withMcpClient(app, token, (client) => + client.callTool({ name, arguments: args }), + ); } function exchangeCode( @@ -269,13 +297,13 @@ describe("remote MCP HTTP", () => { resource: MCP_TEST_URL, authorization_servers: [MCP_TEST_ORIGIN], bearer_methods_supported: ["header"], - scopes_supported: ["conversations:read"], + scopes_supported: ALL_SCOPES.split(" "), }); expect(serverMetadata).toEqual({ issuer: MCP_TEST_ORIGIN, authorization_endpoint: `${MCP_TEST_ORIGIN}/api/mcp/oauth/authorize`, token_endpoint: `${MCP_TEST_ORIGIN}/api/mcp/oauth/token`, - scopes_supported: ["conversations:read"], + scopes_supported: ALL_SCOPES.split(" "), response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"], @@ -346,7 +374,7 @@ describe("remote MCP HTTP", () => { expect(removedDomain.status).toBe(401); expect(response.status).toBe(401); expect(response.headers.get("www-authenticate")).toBe( - `Bearer error="invalid_token", resource_metadata="${MCP_TEST_ORIGIN}/.well-known/oauth-protected-resource/api/mcp", scope="conversations:read"`, + `Bearer error="invalid_token", resource_metadata="${MCP_TEST_ORIGIN}/.well-known/oauth-protected-resource/api/mcp", scope="${ALL_SCOPES}"`, ); }); @@ -441,6 +469,236 @@ describe("remote MCP HTTP", () => { }, ); + it("shows the requested scopes and lists only their tools", async () => { + const app = await createApp({ allowedDomains: ALLOWED_DOMAINS }); + // An empty scope asks for every scope. + const everyScope = await approveClient(app, VIEWER, ""); + const everyTokens = (await ( + await exchangeCode(app, everyScope) + ).json()) as TokenBody; + const readOnly = await connect(app, VIEWER, "conversations:read"); + const toolNames = async (token: string) => + (await withMcpClient(app, token, (client) => client.listTools())).tools + .map((tool) => tool.name) + .sort(); + const invalidScope = await app.request( + authorizeUrl({ + client_id: MCP_CLIENT_ID, + code_challenge: createPkce().challenge, + redirect_uri: REDIRECT_URI, + scope: "conversations:write", + }), + ); + const widenedRefresh = await requestTokens(app, { + client_id: MCP_CLIENT_ID, + grant_type: "refresh_token", + refresh_token: everyTokens.refresh_token, + resource: MCP_TEST_URL, + scope: "conversations:read", + }); + + for (const scope of ALL_SCOPES.split(" ")) { + expect(everyScope.consentHtml).toContain(scope); + } + expect(everyTokens.scope).toBe(ALL_SCOPES); + expect(await toolNames(everyTokens.access_token)).toEqual([ + "archiveConversation", + "getAutomation", + "getConversation", + "getReviewItem", + "listAutomations", + "listConversationMessages", + "listConversations", + "listReviewItems", + "stopConversation", + ]); + expect(await toolNames(readOnly)).toEqual([ + "getConversation", + "listConversationMessages", + "listConversations", + ]); + expect( + new URL(invalidScope.headers.get("location") ?? "").searchParams.get( + "error", + ), + ).toBe("invalid_scope"); + await expect(widenedRefresh.json()).resolves.toMatchObject({ + error: "invalid_scope", + }); + }); + + it("reads a participant's Conversation in pieces and hides other Conversations", async () => { + const app = await createApp({ allowedDomains: ALLOWED_DOMAINS }); + const conversationId = "slack:C123:1700000000.000300"; + const otherConversationId = "slack:C123:1700000000.000400"; + await seedConversation({ + conversationId, + email: VIEWER, + title: "Viewer incident", + }); + await seedConversation({ + conversationId: otherConversationId, + email: OTHER_VIEWER, + title: "Other incident", + }); + await getConversationEventStore().append( + conversationId, + ( + [ + { role: "user", text: "What broke?" }, + { role: "assistant", text: "The relay deploy broke ingestion." }, + { role: "user", text: "Roll it back." }, + { role: "assistant", text: "The rollback is done." }, + ] as const + ).map(({ role, text }, index) => ({ + data: { + type: "message" as const, + messageId: `message-${index}`, + role, + text, + }, + createdAtMs: Date.now() + index, + })), + ); + await appendConversationBrief(getDb(), { + content: conversationBriefFixture({ summary: "Relay was rolled back." }), + conversationId, + modelId: "test-model", + searchText: "relay rollback", + throughSeq: 0, + turnId: "brief-turn", + }); + const token = await connect(app, VIEWER); + + const conversation = await callTool(app, token, "getConversation", { + conversationId, + }); + const newest = await callTool(app, token, "listConversationMessages", { + conversationId, + limit: 3, + }); + const older = await callTool(app, token, "listConversationMessages", { + before: (newest.structuredContent as { nextCursor: string }).nextCursor, + conversationId, + }); + const hidden = await callTool(app, token, "getConversation", { + conversationId: otherConversationId, + }); + const stopped = await callTool(app, token, "stopConversation", { + conversationId, + }); + const archived = await callTool(app, token, "archiveConversation", { + conversationId, + lastSeenAt: ( + conversation.structuredContent as { + conversation: { lastSeenAt: string }; + } + ).conversation.lastSeenAt, + }); + + expect(conversation.structuredContent).toMatchObject({ + conversation: { conversationId, title: "Viewer incident" }, + brief: { + summary: "Relay was rolled back.", + updatedAt: expect.any(String), + }, + lastReply: { role: "assistant", text: "The rollback is done." }, + }); + expect(conversation.structuredContent).not.toHaveProperty("brief.record"); + expect( + (newest.structuredContent as { messages: { text: string }[] }).messages, + ).toEqual([ + expect.objectContaining({ text: "The relay deploy broke ingestion." }), + expect.objectContaining({ text: "Roll it back." }), + expect.objectContaining({ text: "The rollback is done." }), + ]); + expect(older.structuredContent).toEqual({ + messages: [ + expect.objectContaining({ role: "user", text: "What broke?" }), + ], + }); + expect(hidden).toMatchObject({ + isError: true, + content: [{ type: "text", text: "Conversation not found." }], + }); + expect(stopped.structuredContent).toEqual({ + status: "no_work", + droppedMessages: 0, + }); + expect(archived.structuredContent).toEqual({ + archivedAt: expect.any(String), + }); + }); + + it("lists the viewer's Review items and reads Automations", async () => { + const app = await createApp({ allowedDomains: ALLOWED_DOMAINS }); + await seedConversation({ + conversationId: "slack:C123:1700000000.000500", + email: VIEWER, + title: "Release review", + }); + const viewer = await resolveViewerUser(VIEWER); + const other = await resolveViewerUser(OTHER_VIEWER); + const addItem = async (userId: string, title: string) => + (await insertReviewItem(getDb(), { + addedByConversationId: "slack:C123:1700000000.000500", + assignedToEveryone: false, + assigneeUserIds: [userId], + body: "Pick one.", + conversationIds: ["slack:C123:1700000000.000500"], + dedupeKey: title, + kind: "action", + nowMs: Date.now(), + request: { + question: "Ship it?", + fields: [ + { + id: "decision", + kind: "choice", + label: "Decision", + options: [{ label: "Ship" }, { label: "Wait" }], + }, + ], + }, + title, + visibility: "private", + }))!; + const mine = await addItem(viewer!.id, "Approve the release"); + const theirs = await addItem(other!.id, "Approve the budget"); + const token = await connect(app, VIEWER); + + const listed = await callTool(app, token, "listReviewItems"); + const hidden = await callTool(app, token, "getReviewItem", { + id: theirs.id, + }); + const automations = await callTool(app, token, "listAutomations"); + const missingAutomation = await callTool(app, token, "getAutomation", { + id: "missing", + }); + + expect(listed.structuredContent).toEqual({ + items: [ + expect.objectContaining({ + id: mine.id, + canAct: true, + question: "Ship it?", + status: "open", + title: "Approve the release", + }), + ], + truncated: false, + }); + expect(hidden).toMatchObject({ isError: true }); + expect(automations.structuredContent).toEqual({ + automations: [], + total: 0, + }); + expect(missingAutomation).toMatchObject({ + isError: true, + content: [{ type: "text", text: "Automation not found." }], + }); + }); + it("does not mount when dashboard sign-in is not required", async () => { const app = await createApp({ dashboard: { authRequired: false, baseURL: MCP_TEST_ORIGIN }, From c4ee476a2e21ef4b93b6c037de969aa54c4d0e59 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:07:02 +0000 Subject: [PATCH 2/3] ref(mcp): Rename list tools to search listConversations, listReviewItems, and listAutomations all take a text query and filters, so name them searchConversations, searchReviewItems, and searchAutomations. listConversationMessages keeps its name because it pages one transcript and has no query. Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- .../src/content/docs/operate/mcp-server.md | 14 +++++++------- packages/junior/src/api/mcp/conversation.ts | 2 +- packages/junior/src/api/mcp/server.ts | 18 ++++++++++++------ packages/junior/src/api/mcp/tool-context.ts | 2 +- .../src/api/mcp/tools/archive-conversation.ts | 2 +- .../junior/src/api/mcp/tools/get-automation.ts | 2 +- .../src/api/mcp/tools/get-review-item.ts | 5 ++++- ...st-automations.ts => search-automations.ts} | 10 +++++----- ...onversations.ts => search-conversations.ts} | 12 ++++++------ ...-review-items.ts => search-review-items.ts} | 12 ++++++------ .../tests/integration/api/mcp/route.test.ts | 16 ++++++++-------- 11 files changed, 52 insertions(+), 43 deletions(-) rename packages/junior/src/api/mcp/tools/{list-automations.ts => search-automations.ts} (84%) rename packages/junior/src/api/mcp/tools/{list-conversations.ts => search-conversations.ts} (74%) rename packages/junior/src/api/mcp/tools/{list-review-items.ts => search-review-items.ts} (82%) diff --git a/packages/docs/src/content/docs/operate/mcp-server.md b/packages/docs/src/content/docs/operate/mcp-server.md index d7f8801b41..1ca6ed1829 100644 --- a/packages/docs/src/content/docs/operate/mcp-server.md +++ b/packages/docs/src/content/docs/operate/mcp-server.md @@ -47,7 +47,7 @@ Ask Claude Code to list your Junior Conversations: Use the junior MCP server to list my conversations. ``` -Claude Code calls the `listConversations` tool. The result shows up to 50 Conversations, newest activity first. Each one has its title, status, location, timestamps, and a dashboard link when the dashboard URL is known. +Claude Code calls the `searchConversations` tool. The result shows up to 50 Conversations, newest activity first. Each one has its title, status, location, timestamps, and a dashboard link when the dashboard URL is known. Then ask for one Conversation: @@ -70,12 +70,12 @@ The first request returns `401` with a `WWW-Authenticate` header. The second req A client gets only the tools of the scopes that you allow. A client that does not ask for a scope asks for all of them. -| Scope | Tools | Effect | -| ---------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------- | -| `conversations:read` | `listConversations`, `getConversation`, `listConversationMessages` | Read the Conversations that you started or take part in. | -| `conversations:manage` | `archiveConversation`, `stopConversation` | Archive a Conversation for you, or stop its running Turn. | -| `review_items:read` | `listReviewItems`, `getReviewItem` | Read the Review items that you can see on the Review desk. | -| `automations:read` | `listAutomations`, `getAutomation` | Read the Automations that you can see, and their runs. | +| Scope | Tools | Effect | +| ---------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------- | +| `conversations:read` | `searchConversations`, `getConversation`, `listConversationMessages` | Read the Conversations that you started or take part in. | +| `conversations:manage` | `archiveConversation`, `stopConversation` | Archive a Conversation for you, or stop its running Turn. | +| `review_items:read` | `searchReviewItems`, `getReviewItem` | Read the Review items that you can see on the Review desk. | +| `automations:read` | `searchAutomations`, `getAutomation` | Read the Automations that you can see, and their runs. | `getConversation` returns metadata, the Brief, and the last assistant reply. `listConversationMessages` returns the user and assistant messages, newest last, 10 at a time by default. Pass its `nextCursor` as `before` to read older messages. Tool calls are not included. diff --git a/packages/junior/src/api/mcp/conversation.ts b/packages/junior/src/api/mcp/conversation.ts index 4d01bfeb28..2d85a144bc 100644 --- a/packages/junior/src/api/mcp/conversation.ts +++ b/packages/junior/src/api/mcp/conversation.ts @@ -48,7 +48,7 @@ export function mcpConversation( export const mcpConversationIdSchema = z .string() .min(1) - .describe("Conversation id from listConversations."); + .describe("Conversation id from searchConversations."); /** MCP view of one user or assistant Message. */ export const mcpMessageSchema = z diff --git a/packages/junior/src/api/mcp/server.ts b/packages/junior/src/api/mcp/server.ts index 899cdb2cbf..baa2051eee 100644 --- a/packages/junior/src/api/mcp/server.ts +++ b/packages/junior/src/api/mcp/server.ts @@ -15,10 +15,10 @@ import { registerArchiveConversationTool } from "./tools/archive-conversation"; import { registerGetAutomationTool } from "./tools/get-automation"; import { registerGetConversationTool } from "./tools/get-conversation"; import { registerGetReviewItemTool } from "./tools/get-review-item"; -import { registerListAutomationsTool } from "./tools/list-automations"; +import { registerSearchAutomationsTool } from "./tools/search-automations"; import { registerListConversationMessagesTool } from "./tools/list-conversation-messages"; -import { registerListConversationsTool } from "./tools/list-conversations"; -import { registerListReviewItemsTool } from "./tools/list-review-items"; +import { registerSearchConversationsTool } from "./tools/search-conversations"; +import { registerSearchReviewItemsTool } from "./tools/search-review-items"; import { registerStopConversationTool } from "./tools/stop-conversation"; type RegisterTool = (server: McpServer, context: McpToolContext) => void; @@ -26,7 +26,7 @@ type RegisterTool = (server: McpServer, context: McpToolContext) => void; /** The tools that each scope unlocks. A client sees only its granted tools. */ const TOOLS_BY_SCOPE: Record = { "conversations:read": [ - registerListConversationsTool, + registerSearchConversationsTool, registerGetConversationTool, registerListConversationMessagesTool, ], @@ -34,8 +34,14 @@ const TOOLS_BY_SCOPE: Record = { registerArchiveConversationTool, registerStopConversationTool, ], - "review_items:read": [registerListReviewItemsTool, registerGetReviewItemTool], - "automations:read": [registerListAutomationsTool, registerGetAutomationTool], + "review_items:read": [ + registerSearchReviewItemsTool, + registerGetReviewItemTool, + ], + "automations:read": [ + registerSearchAutomationsTool, + registerGetAutomationTool, + ], }; const INSTRUCTIONS = diff --git a/packages/junior/src/api/mcp/tool-context.ts b/packages/junior/src/api/mcp/tool-context.ts index 821afb1cb7..e1901d632f 100644 --- a/packages/junior/src/api/mcp/tool-context.ts +++ b/packages/junior/src/api/mcp/tool-context.ts @@ -44,7 +44,7 @@ export function apiToolError(error: unknown): CallToolResult { /** * Return whether `user` started or takes part in a Conversation. MCP tools * read and change only these Conversations, the same set that - * `listConversations` shows. + * `searchConversations` shows. */ export async function isConversationParticipant( user: User, diff --git a/packages/junior/src/api/mcp/tools/archive-conversation.ts b/packages/junior/src/api/mcp/tools/archive-conversation.ts index e483bcddd3..733a1a34ee 100644 --- a/packages/junior/src/api/mcp/tools/archive-conversation.ts +++ b/packages/junior/src/api/mcp/tools/archive-conversation.ts @@ -29,7 +29,7 @@ export function registerArchiveConversationTool( .string() .datetime() .describe( - "`lastSeenAt` from listConversations or getConversation. The archive fails when the conversation has newer activity.", + "`lastSeenAt` from searchConversations or getConversation. The archive fails when the conversation has newer activity.", ), }, outputSchema: { archivedAt: z.string().nullable() }, diff --git a/packages/junior/src/api/mcp/tools/get-automation.ts b/packages/junior/src/api/mcp/tools/get-automation.ts index cdc9d4de64..ca4a1f4b3e 100644 --- a/packages/junior/src/api/mcp/tools/get-automation.ts +++ b/packages/junior/src/api/mcp/tools/get-automation.ts @@ -25,7 +25,7 @@ export function registerGetAutomationTool( title: "Get automation", description: `Get one automation and its ${RECENT_RUN_LIMIT} newest runs.`, inputSchema: { - id: z.string().min(1).describe("Automation id from listAutomations."), + id: z.string().min(1).describe("Automation id from searchAutomations."), }, outputSchema: { automation: mcpAutomationSchema, diff --git a/packages/junior/src/api/mcp/tools/get-review-item.ts b/packages/junior/src/api/mcp/tools/get-review-item.ts index d5b6112ce0..c37fc4bed9 100644 --- a/packages/junior/src/api/mcp/tools/get-review-item.ts +++ b/packages/junior/src/api/mcp/tools/get-review-item.ts @@ -22,7 +22,10 @@ export function registerGetReviewItemTool( title: "Get review item", description: "Get one review item, open or closed.", inputSchema: { - id: z.string().min(1).describe("Review item id from listReviewItems."), + id: z + .string() + .min(1) + .describe("Review item id from searchReviewItems."), }, outputSchema: { item: mcpReviewItemSchema }, annotations: { diff --git a/packages/junior/src/api/mcp/tools/list-automations.ts b/packages/junior/src/api/mcp/tools/search-automations.ts similarity index 84% rename from packages/junior/src/api/mcp/tools/list-automations.ts rename to packages/junior/src/api/mcp/tools/search-automations.ts index 8261c99ae2..a552714b68 100644 --- a/packages/junior/src/api/mcp/tools/list-automations.ts +++ b/packages/junior/src/api/mcp/tools/search-automations.ts @@ -5,19 +5,19 @@ import { readViewerAutomations } from "@/chat/automations/read"; import { mcpAutomation, mcpAutomationSchema } from "../automation"; import { mcpToolResult, type McpToolContext } from "../tool-context"; -/** Register `listAutomations`, which lists the Automations the user can see. */ -export function registerListAutomationsTool( +/** Register `searchAutomations`, which searches the Automations the user can see. */ +export function registerSearchAutomationsTool( server: McpServer, { user }: McpToolContext, ): void { // oxlint-disable-next-line anti-slop/no-shape-in-symbol-names -- `shape` is the Zod object API. const listQuery = automationListQuerySchema.shape; server.registerTool( - "listAutomations", + "searchAutomations", { - title: "List automations", + title: "Search automations", description: - "List the automations that you can see, newest first. An automation is a saved instruction that runs on a schedule or on events.", + "Search the automations that you can see, newest first. An automation is a saved instruction that runs on a schedule or on events.", inputSchema: { q: listQuery.q.describe( "Text to search for in titles and instructions.", diff --git a/packages/junior/src/api/mcp/tools/list-conversations.ts b/packages/junior/src/api/mcp/tools/search-conversations.ts similarity index 74% rename from packages/junior/src/api/mcp/tools/list-conversations.ts rename to packages/junior/src/api/mcp/tools/search-conversations.ts index faedb3bdb4..a10f90d847 100644 --- a/packages/junior/src/api/mcp/tools/list-conversations.ts +++ b/packages/junior/src/api/mcp/tools/search-conversations.ts @@ -5,23 +5,23 @@ import { conversationFeedQuerySchema } from "@/api/schema/conversation"; import { mcpConversation, mcpConversationSchema } from "../conversation"; import { mcpToolResult, type McpToolContext } from "../tool-context"; -/** Register `listConversations`, which lists the Conversations of the user. */ -export function registerListConversationsTool( +/** Register `searchConversations`, which searches the Conversations of the user. */ +export function registerSearchConversationsTool( server: McpServer, { user }: McpToolContext, ): void { // oxlint-disable-next-line anti-slop/no-shape-in-symbol-names -- `shape` is the Zod object API. const feedQuery = conversationFeedQuerySchema.shape; server.registerTool( - "listConversations", + "searchConversations", { - title: "List conversations", + title: "Search conversations", description: - "List up to 50 of your conversations, newest activity first. Only conversations that you started or take part in are listed.", + "Search your conversations and return up to 50, newest activity first. Only conversations that you started or take part in match.", inputSchema: { q: feedQuery.q.describe("Text to search for in titles and Briefs."), status: feedQuery.status.describe( - "List conversations that you have not archived (default) or that you archived. A search ignores this filter.", + "Return conversations that you have not archived (default) or that you archived. A search ignores this filter.", ), }, // The SDK checks `structuredContent` against this schema. diff --git a/packages/junior/src/api/mcp/tools/list-review-items.ts b/packages/junior/src/api/mcp/tools/search-review-items.ts similarity index 82% rename from packages/junior/src/api/mcp/tools/list-review-items.ts rename to packages/junior/src/api/mcp/tools/search-review-items.ts index 249ee22690..d9d87eeffe 100644 --- a/packages/junior/src/api/mcp/tools/list-review-items.ts +++ b/packages/junior/src/api/mcp/tools/search-review-items.ts @@ -6,17 +6,17 @@ import { searchReviewItems } from "@/chat/review-items/store"; import { mcpReviewItem, mcpReviewItemSchema } from "../review-item"; import { mcpToolResult, type McpToolContext } from "../tool-context"; -/** Register `listReviewItems`, which lists open Review items for the user. */ -export function registerListReviewItemsTool( +/** Register `searchReviewItems`, which searches open Review items for the user. */ +export function registerSearchReviewItemsTool( server: McpServer, { user }: McpToolContext, ): void { server.registerTool( - "listReviewItems", + "searchReviewItems", { - title: "List review items", + title: "Search review items", description: - "List open review items that you can see, newest change first. A review item is a question or notice that waits for a person.", + "Search the open review items that you can see, newest change first. A review item is a question or notice that waits for a person.", inputSchema: { q: z .string() @@ -29,7 +29,7 @@ export function registerListReviewItemsTool( .string() .min(1) .optional() - .describe("List only the items linked to this conversation."), + .describe("Return only the items linked to this conversation."), limit: z .number() .int() diff --git a/packages/junior/tests/integration/api/mcp/route.test.ts b/packages/junior/tests/integration/api/mcp/route.test.ts index 85ffbc2b3b..bc0a644af4 100644 --- a/packages/junior/tests/integration/api/mcp/route.test.ts +++ b/packages/junior/tests/integration/api/mcp/route.test.ts @@ -278,7 +278,7 @@ describe("remote MCP HTTP", () => { }); const tokens = (await issued.json()) as TokenBody; const listed = await withMcpClient(app, tokens.access_token, (client) => - client.callTool({ name: "listConversations", arguments: {} }), + client.callTool({ name: "searchConversations", arguments: {} }), ); const rotated = await refresh(app, tokens.refresh_token); const rotatedTokens = (await rotated.json()) as TokenBody; @@ -287,7 +287,7 @@ describe("remote MCP HTTP", () => { rotatedTokens.access_token, (client) => client.callTool({ - name: "listConversations", + name: "searchConversations", arguments: { q: "roadmap" }, }), ); @@ -506,16 +506,16 @@ describe("remote MCP HTTP", () => { "getAutomation", "getConversation", "getReviewItem", - "listAutomations", "listConversationMessages", - "listConversations", - "listReviewItems", + "searchAutomations", + "searchConversations", + "searchReviewItems", "stopConversation", ]); expect(await toolNames(readOnly)).toEqual([ "getConversation", "listConversationMessages", - "listConversations", + "searchConversations", ]); expect( new URL(invalidScope.headers.get("location") ?? "").searchParams.get( @@ -667,11 +667,11 @@ describe("remote MCP HTTP", () => { const theirs = await addItem(other!.id, "Approve the budget"); const token = await connect(app, VIEWER); - const listed = await callTool(app, token, "listReviewItems"); + const listed = await callTool(app, token, "searchReviewItems"); const hidden = await callTool(app, token, "getReviewItem", { id: theirs.id, }); - const automations = await callTool(app, token, "listAutomations"); + const automations = await callTool(app, token, "searchAutomations"); const missingAutomation = await callTool(app, token, "getAutomation", { id: "missing", }); From ab0431294cf71d64eaf08160e825c2e5d3d6ebb4 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:45:58 +0000 Subject: [PATCH 3/3] test(mcp): Add one integration test file per MCP tool Each tool in src/api/mcp/tools now has a test file at the same path under tests/integration/api/mcp/tools. Each file covers the tool's main result and its access or failure rule. route.test.ts keeps only the OAuth, transport, and scope scenarios. The shared OAuth helpers move to the mcp-http fixture. Conversation, Review item, and Automation seeding moves to a new mcp-tools fixture. stopConversation now checks participation first. A non-participant got "Only conversation participants can stop this turn.", which showed that the Conversation exists. Like the other conversation tools, it now reports "Conversation not found." --- .../src/api/mcp/tools/stop-conversation.ts | 5 + packages/junior/tests/fixtures/mcp-http.ts | 168 ++++++ packages/junior/tests/fixtures/mcp-tools.ts | 157 ++++++ .../tests/integration/api/mcp/route.test.ts | 492 +++--------------- .../mcp/tools/archive-conversation.test.ts | 88 ++++ .../api/mcp/tools/get-automation.test.ts | 88 ++++ .../api/mcp/tools/get-conversation.test.ts | 81 +++ .../api/mcp/tools/get-review-item.test.ts | 63 +++ .../tools/list-conversation-messages.test.ts | 109 ++++ .../api/mcp/tools/search-automations.test.ts | 72 +++ .../mcp/tools/search-conversations.test.ts | 77 +++ .../api/mcp/tools/search-review-items.test.ts | 81 +++ .../api/mcp/tools/stop-conversation.test.ts | 48 ++ 13 files changed, 1115 insertions(+), 414 deletions(-) create mode 100644 packages/junior/tests/fixtures/mcp-tools.ts create mode 100644 packages/junior/tests/integration/api/mcp/tools/archive-conversation.test.ts create mode 100644 packages/junior/tests/integration/api/mcp/tools/get-automation.test.ts create mode 100644 packages/junior/tests/integration/api/mcp/tools/get-conversation.test.ts create mode 100644 packages/junior/tests/integration/api/mcp/tools/get-review-item.test.ts create mode 100644 packages/junior/tests/integration/api/mcp/tools/list-conversation-messages.test.ts create mode 100644 packages/junior/tests/integration/api/mcp/tools/search-automations.test.ts create mode 100644 packages/junior/tests/integration/api/mcp/tools/search-conversations.test.ts create mode 100644 packages/junior/tests/integration/api/mcp/tools/search-review-items.test.ts create mode 100644 packages/junior/tests/integration/api/mcp/tools/stop-conversation.test.ts diff --git a/packages/junior/src/api/mcp/tools/stop-conversation.ts b/packages/junior/src/api/mcp/tools/stop-conversation.ts index 0e8f032c08..e2bbbd409a 100644 --- a/packages/junior/src/api/mcp/tools/stop-conversation.ts +++ b/packages/junior/src/api/mcp/tools/stop-conversation.ts @@ -4,6 +4,8 @@ import { stopConversationTurnForViewer } from "@/api/conversations/stop"; import { mcpConversationIdSchema } from "../conversation"; import { apiToolError, + isConversationParticipant, + mcpToolError, mcpToolResult, type McpToolContext, } from "../tool-context"; @@ -34,6 +36,9 @@ export function registerStopConversationTool( }, }, async ({ conversationId }) => { + if (!(await isConversationParticipant(user, conversationId))) { + return mcpToolError("Conversation not found."); + } try { const result = await stopConversationTurnForViewer( user, diff --git a/packages/junior/tests/fixtures/mcp-http.ts b/packages/junior/tests/fixtures/mcp-http.ts index f2afff65cf..f28f2ca29b 100644 --- a/packages/junior/tests/fixtures/mcp-http.ts +++ b/packages/junior/tests/fixtures/mcp-http.ts @@ -1,12 +1,20 @@ +import { createHash, randomBytes } from "node:crypto"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { Hono } from "hono"; import { vi } from "vitest"; +import { createApp } from "@/app"; import type { AuthenticatedRoute } from "@/dashboard-routes"; import { resolveViewerUser } from "@/chat/plugins/viewer"; +import { MCP_CLIENT_ID } from "../msw/handlers/mcp-client-metadata"; export const MCP_TEST_ORIGIN = "http://junior.test"; export const MCP_TEST_URL = `${MCP_TEST_ORIGIN}/api/mcp`; +export const MCP_TEST_ALLOWED_DOMAINS = ["example.com"]; +export const MCP_TEST_VIEWER = "mcp-viewer@example.com"; +export const MCP_TEST_OTHER_VIEWER = "mcp-other@example.com"; +// Claude Code listens on a random loopback port. +export const MCP_TEST_REDIRECT_URI = "http://127.0.0.1:53682/callback"; /** Cookie that the fake dashboard reads in place of a Google sign-in session. */ const TEST_VIEWER_COOKIE = "junior_test_viewer"; @@ -65,3 +73,163 @@ export async function withMcpClient( await client.close(); } } + +export type McpTokenBody = { + access_token: string; + expires_in: number; + refresh_token: string; + scope: string; + token_type: string; +}; + +/** Create one RFC 7636 PKCE verifier and its S256 challenge. */ +export function createPkce(): { challenge: string; verifier: string } { + const verifier = randomBytes(32).toString("base64url"); + return { + challenge: createHash("sha256").update(verifier).digest("base64url"), + verifier, + }; +} + +/** Build an MCP authorization request URL with valid defaults. */ +export function mcpAuthorizeUrl(params: Record): string { + const url = new URL(`${MCP_TEST_ORIGIN}/api/mcp/oauth/authorize`); + for (const [name, value] of Object.entries({ + response_type: "code", + code_challenge_method: "S256", + scope: "conversations:read", + resource: MCP_TEST_URL, + ...params, + })) { + url.searchParams.set(name, value); + } + return url.toString(); +} + +/** Open the consent page as one viewer and return its form token. */ +export async function openMcpConsentPage( + app: Hono, + consentUrl: string, + email: string, +): Promise<{ csrf?: string; html: string; status: number }> { + const response = await app.request(consentUrl, { + headers: { cookie: viewerCookie(email) }, + }); + const html = await response.text(); + return { + csrf: html.match(/name="csrf" value="([^"]+)"/)?.[1], + html, + status: response.status, + }; +} + +/** Approve consent as the viewer who opened the page. */ +export async function approveMcpConsent( + app: Hono, + args: { consentUrl: string; csrf: string; email: string }, +): Promise { + return await app.request(args.consentUrl, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + cookie: viewerCookie(args.email), + origin: MCP_TEST_ORIGIN, + }, + body: new URLSearchParams({ action: "approve", csrf: args.csrf }), + }); +} + +/** POST one form-encoded request to the MCP token endpoint. */ +export async function requestMcpTokens( + app: Hono, + params: Record, +): Promise { + return await app.request(`${MCP_TEST_ORIGIN}/api/mcp/oauth/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(params), + }); +} + +/** Approve the test client as one viewer and return its code and verifier. */ +export async function approveMcpClient( + app: Hono, + email: string, + scope = "conversations:read", +): Promise<{ code: string; consentHtml: string; verifier: string }> { + const pkce = createPkce(); + const authorize = await app.request( + mcpAuthorizeUrl({ + client_id: MCP_CLIENT_ID, + code_challenge: pkce.challenge, + redirect_uri: MCP_TEST_REDIRECT_URI, + scope, + }), + ); + const consentUrl = authorize.headers.get("location") ?? ""; + const page = await openMcpConsentPage(app, consentUrl, email); + const approved = await approveMcpConsent(app, { + consentUrl, + csrf: page.csrf ?? "", + email, + }); + const code = new URL(approved.headers.get("location") ?? "").searchParams.get( + "code", + ); + if (!code) throw new Error("MCP consent returned no code"); + return { code, consentHtml: page.html, verifier: pkce.verifier }; +} + +/** Exchange an authorization code for MCP tokens. */ +export function exchangeMcpCode( + app: Hono, + args: { code: string; verifier: string }, +): Promise { + return requestMcpTokens(app, { + client_id: MCP_CLIENT_ID, + code: args.code, + code_verifier: args.verifier, + grant_type: "authorization_code", + redirect_uri: MCP_TEST_REDIRECT_URI, + resource: MCP_TEST_URL, + }); +} + +/** + * Connect the test client as one viewer and return its access token. An + * empty scope asks for every scope. + */ +export async function connectMcpClient( + app: Hono, + email: string, + scope = "", +): Promise { + const response = await exchangeMcpCode( + app, + await approveMcpClient(app, email, scope), + ); + return ((await response.json()) as McpTokenBody).access_token; +} + +/** Call one tool with the official MCP SDK client. */ +export async function callMcpTool( + app: Hono, + token: string, + name: string, + args: Record = {}, +) { + return await withMcpClient(app, token, (client) => + client.callTool({ name, arguments: args }), + ); +} + +/** + * Connect one viewer to a new app and return a function that calls one + * tool as that viewer. An empty scope asks for every scope. + */ +export async function connectMcpTools(email = MCP_TEST_VIEWER, scope = "") { + const app = await createApp({ allowedDomains: MCP_TEST_ALLOWED_DOMAINS }); + const token = await connectMcpClient(app, email, scope); + return (name: string, args: Record = {}) => + callMcpTool(app, token, name, args); +} diff --git a/packages/junior/tests/fixtures/mcp-tools.ts b/packages/junior/tests/fixtures/mcp-tools.ts new file mode 100644 index 0000000000..dd9f82db88 --- /dev/null +++ b/packages/junior/tests/fixtures/mcp-tools.ts @@ -0,0 +1,157 @@ +import { getConversationEventStore, getDb, getSqlExecutor } from "@/chat/db"; +import { createSqlStore } from "@/chat/conversations/sql/store"; +import { resolveViewerUser } from "@/chat/plugins/viewer"; +import { insertReviewItem, type ReviewItem } from "@/chat/review-items/store"; +import { saveScheduledAutomation } from "@/chat/scheduled-automations/tasks"; +import { juniorConversationParticipants } from "@/db/schema"; + +const DESTINATION = { + channelId: "C123", + platform: "slack" as const, + teamId: "T123", +}; + +async function requireViewer(email: string) { + const viewer = await resolveViewerUser(email); + if (!viewer) throw new Error(`missing viewer for ${email}`); + return viewer; +} + +/** Store one root Conversation that the named viewer takes part in. */ +export async function seedMcpConversation(args: { + conversationId: string; + email: string; + title: string; +}): Promise { + const sql = getSqlExecutor(); + await createSqlStore(sql).recordActivity({ + conversationId: args.conversationId, + destination: DESTINATION, + nowMs: Date.now(), + title: args.title, + visibility: "public", + }); + const viewer = await requireViewer(args.email); + await sql.db().insert(juniorConversationParticipants).values({ + lastMessageAt: new Date(), + rootConversationId: args.conversationId, + userId: viewer.id, + }); +} + +/** Append user and assistant messages to one Conversation, oldest first. */ +export async function appendMcpMessages( + conversationId: string, + messages: readonly { role: "assistant" | "user"; text: string }[], +): Promise { + const nowMs = Date.now(); + await getConversationEventStore().append( + conversationId, + messages.map(({ role, text }, index) => ({ + data: { + type: "message" as const, + messageId: `message-${index}`, + role, + text, + }, + createdAtMs: nowMs + index, + })), + ); +} + +/** Store one open private action Review item assigned to the named viewer. */ +export async function seedMcpReviewItem(args: { + conversationId: string; + email: string; + title: string; +}): Promise { + const viewer = await requireViewer(args.email); + const item = await insertReviewItem(getDb(), { + addedByConversationId: args.conversationId, + assignedToEveryone: false, + assigneeUserIds: [viewer.id], + body: "Pick one.", + conversationIds: [args.conversationId], + dedupeKey: args.title, + kind: "action", + nowMs: Date.now(), + request: { + question: "Ship it?", + fields: [ + { + id: "decision", + kind: "choice", + label: "Decision", + options: [{ label: "Ship" }, { label: "Wait" }], + }, + ], + }, + title: args.title, + visibility: "private", + }); + if (!item) throw new Error(`Review item ${args.title} was not stored`); + return item; +} + +/** + * Store one daily scheduled Automation that the named viewer created. Its + * creator gets a Slack identity in `T123` with the given Slack user ID. A + * private Automation posts to a private channel, which hides it from people + * who did not create it. + */ +export async function seedMcpAutomation(args: { + email: string; + id: string; + instruction: string; + slackUserId: string; + title: string; + visibility: "private" | "public"; +}): Promise { + const destination = { + ...DESTINATION, + channelId: args.visibility === "public" ? "C123" : "CPRIVATE", + }; + await createSqlStore(getSqlExecutor()).recordActivity({ + actor: { + email: args.email, + platform: "slack", + slackUserId: args.slackUserId, + teamId: destination.teamId, + }, + conversationId: `slack:${destination.channelId}:${args.id}`, + destination, + visibility: args.visibility, + }); + const creator = await requireViewer(args.email); + const identity = creator.identities.find( + (candidate) => candidate.provider === "slack", + ); + if (!identity) throw new Error(`missing Slack identity for ${args.email}`); + const nowMs = Date.now(); + await saveScheduledAutomation(getDb(), { + id: args.id, + conversationAccess: { audience: "channel", visibility: args.visibility }, + createdAtMs: nowMs, + createdBy: { slackUserId: args.slackUserId }, + creatorIdentityId: identity.id, + credentialMode: "creator", + destination, + nextRunAtMs: nowMs + 60_000, + outcomes: [], + schedule: { + description: "", + kind: "recurring", + recurrence: { + frequency: "daily", + interval: 1, + startDate: new Date(nowMs).toISOString().slice(0, 10), + time: { hour: 12, minute: 0 }, + }, + timezone: "UTC", + }, + status: "active", + task: { text: args.instruction }, + title: args.title, + updatedAtMs: nowMs, + }); +} diff --git a/packages/junior/tests/integration/api/mcp/route.test.ts b/packages/junior/tests/integration/api/mcp/route.test.ts index bc0a644af4..428cb860eb 100644 --- a/packages/junior/tests/integration/api/mcp/route.test.ts +++ b/packages/junior/tests/integration/api/mcp/route.test.ts @@ -1,115 +1,38 @@ -import { createHash, randomBytes } from "node:crypto"; import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createApp } from "@/app"; -import { appendConversationBrief } from "@/chat/briefs/store"; -import { getConversationEventStore, getDb, getSqlExecutor } from "@/chat/db"; -import { createSqlStore } from "@/chat/conversations/sql/store"; -import { resolveViewerUser } from "@/chat/plugins/viewer"; -import { insertReviewItem } from "@/chat/review-items/store"; -import { juniorConversationParticipants } from "@/db/schema"; import { createPersonalToken } from "@/personal-tokens/store"; import { + approveMcpClient, + approveMcpConsent, + connectMcpClient, + createPkce, + exchangeMcpCode, + MCP_TEST_ALLOWED_DOMAINS, MCP_TEST_ORIGIN, + MCP_TEST_OTHER_VIEWER, + MCP_TEST_REDIRECT_URI, MCP_TEST_URL, + MCP_TEST_VIEWER, + mcpAuthorizeUrl, + type McpTokenBody, mockMcpDashboardConfig, - viewerCookie, + openMcpConsentPage, + requestMcpTokens, withMcpClient, } from "../../../fixtures/mcp-http"; -import { conversationBriefFixture } from "../../../fixtures/conversation-brief"; +import { seedMcpConversation } from "../../../fixtures/mcp-tools"; import { MCP_CLIENT_ID, MCP_CLIENT_NAME, MCP_MISMATCHED_CLIENT_ID, } from "../../../msw/handlers/mcp-client-metadata"; -const ALLOWED_DOMAINS = ["example.com"]; -const VIEWER = "mcp-viewer@example.com"; -const OTHER_VIEWER = "mcp-other@example.com"; -// Claude Code listens on a random loopback port. -const REDIRECT_URI = "http://127.0.0.1:53682/callback"; const ALL_SCOPES = "conversations:read conversations:manage review_items:read automations:read"; -type TokenBody = { - access_token: string; - expires_in: number; - refresh_token: string; - scope: string; - token_type: string; -}; - mockMcpDashboardConfig(); -/** Create one RFC 7636 PKCE verifier and its S256 challenge. */ -function createPkce(): { challenge: string; verifier: string } { - const verifier = randomBytes(32).toString("base64url"); - return { - challenge: createHash("sha256").update(verifier).digest("base64url"), - verifier, - }; -} - -/** Build an authorization request URL with valid defaults. */ -function authorizeUrl(params: Record): string { - const url = new URL(`${MCP_TEST_ORIGIN}/api/mcp/oauth/authorize`); - for (const [name, value] of Object.entries({ - response_type: "code", - code_challenge_method: "S256", - scope: "conversations:read", - resource: MCP_TEST_URL, - ...params, - })) { - url.searchParams.set(name, value); - } - return url.toString(); -} - -/** Open the consent page as one viewer and return its form token. */ -async function openConsentPage( - app: Hono, - consentUrl: string, - email: string, -): Promise<{ csrf?: string; html: string; status: number }> { - const response = await app.request(consentUrl, { - headers: { cookie: viewerCookie(email) }, - }); - const html = await response.text(); - return { - csrf: html.match(/name="csrf" value="([^"]+)"/)?.[1], - html, - status: response.status, - }; -} - -/** Approve consent as the viewer who opened the page. */ -async function approveConsent( - app: Hono, - args: { consentUrl: string; csrf: string; email: string }, -): Promise { - return await app.request(args.consentUrl, { - method: "POST", - headers: { - "content-type": "application/x-www-form-urlencoded", - cookie: viewerCookie(args.email), - origin: MCP_TEST_ORIGIN, - }, - body: new URLSearchParams({ action: "approve", csrf: args.csrf }), - }); -} - -/** POST one form-encoded request to the MCP token endpoint. */ -async function requestTokens( - app: Hono, - params: Record, -): Promise { - return await app.request(`${MCP_TEST_ORIGIN}/api/mcp/oauth/token`, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams(params), - }); -} - /** Send a raw `tools/list` request with one bearer token. */ async function listTools(app: Hono, token: string): Promise { return await app.request(MCP_TEST_URL, { @@ -123,95 +46,8 @@ async function listTools(app: Hono, token: string): Promise { }); } -/** Store one root Conversation that the named viewer takes part in. */ -async function seedConversation(args: { - conversationId: string; - email: string; - title: string; -}): Promise { - const sql = getSqlExecutor(); - await createSqlStore(sql).recordActivity({ - conversationId: args.conversationId, - destination: { channelId: "C123", platform: "slack", teamId: "T123" }, - nowMs: Date.now(), - title: args.title, - visibility: "public", - }); - const viewer = await resolveViewerUser(args.email); - if (!viewer) throw new Error(`missing viewer for ${args.email}`); - await sql.db().insert(juniorConversationParticipants).values({ - lastMessageAt: new Date(), - rootConversationId: args.conversationId, - userId: viewer.id, - }); -} - -/** Approve the test client as one viewer and return its code and verifier. */ -async function approveClient( - app: Hono, - email: string, - scope = "conversations:read", -): Promise<{ code: string; consentHtml: string; verifier: string }> { - const pkce = createPkce(); - const authorize = await app.request( - authorizeUrl({ - client_id: MCP_CLIENT_ID, - code_challenge: pkce.challenge, - redirect_uri: REDIRECT_URI, - scope, - }), - ); - const consentUrl = authorize.headers.get("location") ?? ""; - const page = await openConsentPage(app, consentUrl, email); - const approved = await approveConsent(app, { - consentUrl, - csrf: page.csrf ?? "", - email, - }); - const code = new URL(approved.headers.get("location") ?? "").searchParams.get( - "code", - ); - if (!code) throw new Error("MCP consent returned no code"); - return { code, consentHtml: page.html, verifier: pkce.verifier }; -} - -/** Connect the test client as one viewer and return its access token. */ -async function connect(app: Hono, email: string, scope = ALL_SCOPES) { - const response = await exchangeCode( - app, - await approveClient(app, email, scope), - ); - return ((await response.json()) as TokenBody).access_token; -} - -/** Call one tool with the official MCP SDK client. */ -async function callTool( - app: Hono, - token: string, - name: string, - args: Record = {}, -) { - return await withMcpClient(app, token, (client) => - client.callTool({ name, arguments: args }), - ); -} - -function exchangeCode( - app: Hono, - args: { code: string; verifier: string }, -): Promise { - return requestTokens(app, { - client_id: MCP_CLIENT_ID, - code: args.code, - code_verifier: args.verifier, - grant_type: "authorization_code", - redirect_uri: REDIRECT_URI, - resource: MCP_TEST_URL, - }); -} - function refresh(app: Hono, refreshToken: string): Promise { - return requestTokens(app, { + return requestMcpTokens(app, { client_id: MCP_CLIENT_ID, grant_type: "refresh_token", refresh_token: refreshToken, @@ -225,16 +61,16 @@ describe("remote MCP HTTP", () => { }); it("connects a CIMD client and lists only the signed-in user's Conversations", async () => { - const app = await createApp({ allowedDomains: ALLOWED_DOMAINS }); + const app = await createApp({ allowedDomains: MCP_TEST_ALLOWED_DOMAINS }); const pkce = createPkce(); - await seedConversation({ + await seedMcpConversation({ conversationId: "slack:C123:1700000000.000100", - email: VIEWER, + email: MCP_TEST_VIEWER, title: "Viewer launch plan", }); - await seedConversation({ + await seedMcpConversation({ conversationId: "slack:C123:1700000000.000200", - email: OTHER_VIEWER, + email: MCP_TEST_OTHER_VIEWER, title: "Other person's roadmap", }); @@ -255,33 +91,37 @@ describe("remote MCP HTTP", () => { const authorize = await app.request( `${serverMetadata.authorization_endpoint}?${new URL( - authorizeUrl({ + mcpAuthorizeUrl({ client_id: MCP_CLIENT_ID, code_challenge: pkce.challenge, - redirect_uri: REDIRECT_URI, + redirect_uri: MCP_TEST_REDIRECT_URI, state: "client-state", }), ).searchParams.toString()}`, ); const consentUrl = authorize.headers.get("location") ?? ""; - const page = await openConsentPage(app, consentUrl, VIEWER); - const otherPage = await openConsentPage(app, consentUrl, OTHER_VIEWER); - const approved = await approveConsent(app, { + const page = await openMcpConsentPage(app, consentUrl, MCP_TEST_VIEWER); + const otherPage = await openMcpConsentPage( + app, + consentUrl, + MCP_TEST_OTHER_VIEWER, + ); + const approved = await approveMcpConsent(app, { consentUrl, csrf: page.csrf ?? "", - email: VIEWER, + email: MCP_TEST_VIEWER, }); const callback = new URL(approved.headers.get("location") ?? ""); - const issued = await exchangeCode(app, { + const issued = await exchangeMcpCode(app, { code: callback.searchParams.get("code") ?? "", verifier: pkce.verifier, }); - const tokens = (await issued.json()) as TokenBody; + const tokens = (await issued.json()) as McpTokenBody; const listed = await withMcpClient(app, tokens.access_token, (client) => client.callTool({ name: "searchConversations", arguments: {} }), ); const rotated = await refresh(app, tokens.refresh_token); - const rotatedTokens = (await rotated.json()) as TokenBody; + const rotatedTokens = (await rotated.json()) as McpTokenBody; const rotatedSearch = await withMcpClient( app, rotatedTokens.access_token, @@ -315,7 +155,9 @@ describe("remote MCP HTTP", () => { expect(page.html).toContain(`Connect ${MCP_CLIENT_NAME}`); expect(page.html).toContain("mcp-client.example.test"); expect(otherPage.status).toBe(403); - expect(`${callback.origin}${callback.pathname}`).toBe(REDIRECT_URI); + expect(`${callback.origin}${callback.pathname}`).toBe( + MCP_TEST_REDIRECT_URI, + ); expect(callback.searchParams.get("state")).toBe("client-state"); expect(callback.searchParams.get("iss")).toBe(MCP_TEST_ORIGIN); expect(issued.status).toBe(200); @@ -327,26 +169,12 @@ describe("remote MCP HTTP", () => { scope: "conversations:read", token_type: "Bearer", }); - expect(listed).toEqual({ - content: [ - { - type: "text", - text: JSON.stringify(listed.structuredContent), - }, + expect(listed.structuredContent).toEqual({ + conversations: [ + expect.objectContaining({ + conversationId: "slack:C123:1700000000.000100", + }), ], - structuredContent: { - conversations: [ - { - conversationId: "slack:C123:1700000000.000100", - title: "Viewer launch plan", - status: "completed", - surface: "slack", - startedAt: expect.any(String), - lastSeenAt: expect.any(String), - url: `${MCP_TEST_ORIGIN}/conversations/${encodeURIComponent("slack:C123:1700000000.000100")}`, - }, - ], - }, }); expect(rotated.status).toBe(200); expect(rotatedTokens.refresh_token).not.toBe(tokens.refresh_token); @@ -356,14 +184,14 @@ describe("remote MCP HTTP", () => { }); it("rejects a personal token and a token for a domain that is no longer allowed", async () => { - const app = await createApp({ allowedDomains: ALLOWED_DOMAINS }); + const app = await createApp({ allowedDomains: MCP_TEST_ALLOWED_DOMAINS }); const personal = await createPersonalToken({ - email: VIEWER, + email: MCP_TEST_VIEWER, name: "MCP rejected token", }); const tokens = (await ( - await exchangeCode(app, await approveClient(app, VIEWER)) - ).json()) as TokenBody; + await exchangeMcpCode(app, await approveMcpClient(app, MCP_TEST_VIEWER)) + ).json()) as McpTokenBody; const response = await listTools(app, personal.token); const removedDomain = await listTools( @@ -379,17 +207,17 @@ describe("remote MCP HTTP", () => { }); it("ends a grant 30 days after consent even when it is refreshed", async () => { - const app = await createApp({ allowedDomains: ALLOWED_DOMAINS }); + const app = await createApp({ allowedDomains: MCP_TEST_ALLOWED_DOMAINS }); const day = 24 * 60 * 60 * 1000; const startMs = Date.now(); const tokens = (await ( - await exchangeCode(app, await approveClient(app, VIEWER)) - ).json()) as TokenBody; + await exchangeMcpCode(app, await approveMcpClient(app, MCP_TEST_VIEWER)) + ).json()) as McpTokenBody; vi.useFakeTimers({ now: startMs + 29 * day, toFake: ["Date"] }); const refreshed = (await ( await refresh(app, tokens.refresh_token) - ).json()) as TokenBody; + ).json()) as McpTokenBody; vi.setSystemTime(startMs + 31 * day); const afterEnd = await refresh(app, refreshed.refresh_token); @@ -401,25 +229,25 @@ describe("remote MCP HTTP", () => { }); it("revokes the grant when a code or a rotated refresh token is reused", async () => { - const app = await createApp({ allowedDomains: ALLOWED_DOMAINS }); - const approval = await approveClient(app, VIEWER); + const app = await createApp({ allowedDomains: MCP_TEST_ALLOWED_DOMAINS }); + const approval = await approveMcpClient(app, MCP_TEST_VIEWER); const first = (await ( - await exchangeCode(app, approval) - ).json()) as TokenBody; - const reusedCode = await exchangeCode(app, approval); + await exchangeMcpCode(app, approval) + ).json()) as McpTokenBody; + const reusedCode = await exchangeMcpCode(app, approval); const afterCodeReuse = await refresh(app, first.refresh_token); const second = (await ( - await exchangeCode(app, await approveClient(app, VIEWER)) - ).json()) as TokenBody; + await exchangeMcpCode(app, await approveMcpClient(app, MCP_TEST_VIEWER)) + ).json()) as McpTokenBody; const rotatedOnce = (await ( await refresh(app, second.refresh_token) - ).json()) as TokenBody; + ).json()) as McpTokenBody; // A concurrent refresh by another client process loses without logout. const racedRefresh = await refresh(app, second.refresh_token); const rotated = (await ( await refresh(app, rotatedOnce.refresh_token) - ).json()) as TokenBody; + ).json()) as McpTokenBody; vi.useFakeTimers({ now: Date.now() + 61_000, toFake: ["Date"] }); const reusedRefresh = await refresh(app, rotatedOnce.refresh_token); const afterRefreshReuse = await refresh(app, rotated.refresh_token); @@ -441,20 +269,24 @@ describe("remote MCP HTTP", () => { }); it.each([ - ["a private IP client ID", "https://10.0.0.5/client.json", REDIRECT_URI], + [ + "a private IP client ID", + "https://10.0.0.5/client.json", + MCP_TEST_REDIRECT_URI, + ], [ "a document for another client ID", MCP_MISMATCHED_CLIENT_ID, - REDIRECT_URI, + MCP_TEST_REDIRECT_URI, ], ["an unregistered redirect URI", MCP_CLIENT_ID, "https://evil.example/cb"], ])( "shows an error without redirecting for %s", async (_, clientId, redirectUri) => { - const app = await createApp({ allowedDomains: ALLOWED_DOMAINS }); + const app = await createApp({ allowedDomains: MCP_TEST_ALLOWED_DOMAINS }); const response = await app.request( - authorizeUrl({ + mcpAuthorizeUrl({ client_id: clientId, code_challenge: createPkce().challenge, redirect_uri: redirectUri, @@ -470,26 +302,30 @@ describe("remote MCP HTTP", () => { ); it("shows the requested scopes and lists only their tools", async () => { - const app = await createApp({ allowedDomains: ALLOWED_DOMAINS }); + const app = await createApp({ allowedDomains: MCP_TEST_ALLOWED_DOMAINS }); // An empty scope asks for every scope. - const everyScope = await approveClient(app, VIEWER, ""); + const everyScope = await approveMcpClient(app, MCP_TEST_VIEWER, ""); const everyTokens = (await ( - await exchangeCode(app, everyScope) - ).json()) as TokenBody; - const readOnly = await connect(app, VIEWER, "conversations:read"); + await exchangeMcpCode(app, everyScope) + ).json()) as McpTokenBody; + const readOnly = await connectMcpClient( + app, + MCP_TEST_VIEWER, + "conversations:read", + ); const toolNames = async (token: string) => (await withMcpClient(app, token, (client) => client.listTools())).tools .map((tool) => tool.name) .sort(); const invalidScope = await app.request( - authorizeUrl({ + mcpAuthorizeUrl({ client_id: MCP_CLIENT_ID, code_challenge: createPkce().challenge, - redirect_uri: REDIRECT_URI, + redirect_uri: MCP_TEST_REDIRECT_URI, scope: "conversations:write", }), ); - const widenedRefresh = await requestTokens(app, { + const widenedRefresh = await requestMcpTokens(app, { client_id: MCP_CLIENT_ID, grant_type: "refresh_token", refresh_token: everyTokens.refresh_token, @@ -527,178 +363,6 @@ describe("remote MCP HTTP", () => { }); }); - it("reads a participant's Conversation in pieces and hides other Conversations", async () => { - const app = await createApp({ allowedDomains: ALLOWED_DOMAINS }); - const conversationId = "slack:C123:1700000000.000300"; - const otherConversationId = "slack:C123:1700000000.000400"; - await seedConversation({ - conversationId, - email: VIEWER, - title: "Viewer incident", - }); - await seedConversation({ - conversationId: otherConversationId, - email: OTHER_VIEWER, - title: "Other incident", - }); - await getConversationEventStore().append( - conversationId, - ( - [ - { role: "user", text: "What broke?" }, - { role: "assistant", text: "The relay deploy broke ingestion." }, - { role: "user", text: "Roll it back." }, - { role: "assistant", text: "The rollback is done." }, - ] as const - ).map(({ role, text }, index) => ({ - data: { - type: "message" as const, - messageId: `message-${index}`, - role, - text, - }, - createdAtMs: Date.now() + index, - })), - ); - await appendConversationBrief(getDb(), { - content: conversationBriefFixture({ summary: "Relay was rolled back." }), - conversationId, - modelId: "test-model", - searchText: "relay rollback", - throughSeq: 0, - turnId: "brief-turn", - }); - const token = await connect(app, VIEWER); - - const conversation = await callTool(app, token, "getConversation", { - conversationId, - }); - const newest = await callTool(app, token, "listConversationMessages", { - conversationId, - limit: 3, - }); - const older = await callTool(app, token, "listConversationMessages", { - before: (newest.structuredContent as { nextCursor: string }).nextCursor, - conversationId, - }); - const hidden = await callTool(app, token, "getConversation", { - conversationId: otherConversationId, - }); - const stopped = await callTool(app, token, "stopConversation", { - conversationId, - }); - const archived = await callTool(app, token, "archiveConversation", { - conversationId, - lastSeenAt: ( - conversation.structuredContent as { - conversation: { lastSeenAt: string }; - } - ).conversation.lastSeenAt, - }); - - expect(conversation.structuredContent).toMatchObject({ - conversation: { conversationId, title: "Viewer incident" }, - brief: { - summary: "Relay was rolled back.", - updatedAt: expect.any(String), - }, - lastReply: { role: "assistant", text: "The rollback is done." }, - }); - expect(conversation.structuredContent).not.toHaveProperty("brief.record"); - expect( - (newest.structuredContent as { messages: { text: string }[] }).messages, - ).toEqual([ - expect.objectContaining({ text: "The relay deploy broke ingestion." }), - expect.objectContaining({ text: "Roll it back." }), - expect.objectContaining({ text: "The rollback is done." }), - ]); - expect(older.structuredContent).toEqual({ - messages: [ - expect.objectContaining({ role: "user", text: "What broke?" }), - ], - }); - expect(hidden).toMatchObject({ - isError: true, - content: [{ type: "text", text: "Conversation not found." }], - }); - expect(stopped.structuredContent).toEqual({ - status: "no_work", - droppedMessages: 0, - }); - expect(archived.structuredContent).toEqual({ - archivedAt: expect.any(String), - }); - }); - - it("lists the viewer's Review items and reads Automations", async () => { - const app = await createApp({ allowedDomains: ALLOWED_DOMAINS }); - await seedConversation({ - conversationId: "slack:C123:1700000000.000500", - email: VIEWER, - title: "Release review", - }); - const viewer = await resolveViewerUser(VIEWER); - const other = await resolveViewerUser(OTHER_VIEWER); - const addItem = async (userId: string, title: string) => - (await insertReviewItem(getDb(), { - addedByConversationId: "slack:C123:1700000000.000500", - assignedToEveryone: false, - assigneeUserIds: [userId], - body: "Pick one.", - conversationIds: ["slack:C123:1700000000.000500"], - dedupeKey: title, - kind: "action", - nowMs: Date.now(), - request: { - question: "Ship it?", - fields: [ - { - id: "decision", - kind: "choice", - label: "Decision", - options: [{ label: "Ship" }, { label: "Wait" }], - }, - ], - }, - title, - visibility: "private", - }))!; - const mine = await addItem(viewer!.id, "Approve the release"); - const theirs = await addItem(other!.id, "Approve the budget"); - const token = await connect(app, VIEWER); - - const listed = await callTool(app, token, "searchReviewItems"); - const hidden = await callTool(app, token, "getReviewItem", { - id: theirs.id, - }); - const automations = await callTool(app, token, "searchAutomations"); - const missingAutomation = await callTool(app, token, "getAutomation", { - id: "missing", - }); - - expect(listed.structuredContent).toEqual({ - items: [ - expect.objectContaining({ - id: mine.id, - canAct: true, - question: "Ship it?", - status: "open", - title: "Approve the release", - }), - ], - truncated: false, - }); - expect(hidden).toMatchObject({ isError: true }); - expect(automations.structuredContent).toEqual({ - automations: [], - total: 0, - }); - expect(missingAutomation).toMatchObject({ - isError: true, - content: [{ type: "text", text: "Automation not found." }], - }); - }); - it("does not mount when dashboard sign-in is not required", async () => { const app = await createApp({ dashboard: { authRequired: false, baseURL: MCP_TEST_ORIGIN }, diff --git a/packages/junior/tests/integration/api/mcp/tools/archive-conversation.test.ts b/packages/junior/tests/integration/api/mcp/tools/archive-conversation.test.ts new file mode 100644 index 0000000000..3b81aac562 --- /dev/null +++ b/packages/junior/tests/integration/api/mcp/tools/archive-conversation.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { + connectMcpTools, + MCP_TEST_OTHER_VIEWER, + MCP_TEST_VIEWER, + mockMcpDashboardConfig, +} from "../../../../fixtures/mcp-http"; +import { seedMcpConversation } from "../../../../fixtures/mcp-tools"; + +mockMcpDashboardConfig(); + +const CONVERSATION_ID = "slack:C123:1700000000.000300"; + +function conversationIds(structuredContent: unknown): string[] { + return ( + structuredContent as { conversations: { conversationId: string }[] } + ).conversations.map((conversation) => conversation.conversationId); +} + +describe("archiveConversation MCP tool", () => { + it("archives a Conversation for the viewer and restores it", async () => { + await seedMcpConversation({ + conversationId: CONVERSATION_ID, + email: MCP_TEST_VIEWER, + title: "Viewer incident", + }); + const call = await connectMcpTools(); + const lastSeenAt = ( + (await call("getConversation", { conversationId: CONVERSATION_ID })) + .structuredContent as { conversation: { lastSeenAt: string } } + ).conversation.lastSeenAt; + + const archived = await call("archiveConversation", { + conversationId: CONVERSATION_ID, + lastSeenAt, + }); + const active = await call("searchConversations"); + const archivedList = await call("searchConversations", { + status: "archived", + }); + const restored = await call("archiveConversation", { + archived: false, + conversationId: CONVERSATION_ID, + lastSeenAt, + }); + + expect(archived.structuredContent).toEqual({ + archivedAt: expect.any(String), + }); + expect(conversationIds(active.structuredContent)).toEqual([]); + expect(conversationIds(archivedList.structuredContent)).toEqual([ + CONVERSATION_ID, + ]); + expect(restored.structuredContent).toEqual({ archivedAt: null }); + }); + + it("refuses a stale lastSeenAt and another person's Conversation", async () => { + await seedMcpConversation({ + conversationId: CONVERSATION_ID, + email: MCP_TEST_VIEWER, + title: "Viewer incident", + }); + await seedMcpConversation({ + conversationId: "slack:C123:1700000000.000400", + email: MCP_TEST_OTHER_VIEWER, + title: "Other incident", + }); + const call = await connectMcpTools(); + + const stale = await call("archiveConversation", { + conversationId: CONVERSATION_ID, + lastSeenAt: new Date(0).toISOString(), + }); + const hidden = await call("archiveConversation", { + conversationId: "slack:C123:1700000000.000400", + lastSeenAt: new Date().toISOString(), + }); + + expect(stale).toMatchObject({ + isError: true, + content: [{ type: "text", text: "Conversation received new activity." }], + }); + expect(hidden).toMatchObject({ + isError: true, + content: [{ type: "text", text: "Conversation not found." }], + }); + }); +}); diff --git a/packages/junior/tests/integration/api/mcp/tools/get-automation.test.ts b/packages/junior/tests/integration/api/mcp/tools/get-automation.test.ts new file mode 100644 index 0000000000..0c50121179 --- /dev/null +++ b/packages/junior/tests/integration/api/mcp/tools/get-automation.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { recordAutomationExecution } from "@/chat/automations/execution-stats"; +import { + connectMcpTools, + MCP_TEST_OTHER_VIEWER, + MCP_TEST_VIEWER, + mockMcpDashboardConfig, +} from "../../../../fixtures/mcp-http"; +import { seedMcpAutomation } from "../../../../fixtures/mcp-tools"; + +mockMcpDashboardConfig(); + +describe("getAutomation MCP tool", () => { + it("returns one Automation with its newest runs first", async () => { + await seedMcpAutomation({ + email: MCP_TEST_VIEWER, + id: "sched_viewer_digest", + instruction: "Post the weekly issue digest.", + slackUserId: "U123", + title: "Weekly digest", + visibility: "public", + }); + const nowMs = Date.now(); + await recordAutomationExecution("sched_viewer_digest", { + executionId: "run-1", + nowMs: nowMs - 60_000, + }); + await recordAutomationExecution("sched_viewer_digest", { + errorMessage: "Dispatch failed.", + executionId: "run-2", + nowMs, + status: "failed", + }); + const call = await connectMcpTools(); + + const result = await call("getAutomation", { id: "sched_viewer_digest" }); + + expect(result.structuredContent).toEqual({ + automation: expect.objectContaining({ + id: "sched_viewer_digest", + lastRunStatus: "failed", + title: "Weekly digest", + }), + runs: [ + { + error: "Dispatch failed.", + executedAt: new Date(nowMs).toISOString(), + status: "failed", + }, + { + executedAt: new Date(nowMs - 60_000).toISOString(), + status: "completed", + }, + ], + }); + }); + + it("reports a missing or hidden Automation as not found", async () => { + // The viewer's own Automation gives them a Slack identity in the team. + await seedMcpAutomation({ + email: MCP_TEST_VIEWER, + id: "sched_viewer_digest", + instruction: "Post the weekly issue digest.", + slackUserId: "U123", + title: "Weekly digest", + visibility: "public", + }); + await seedMcpAutomation({ + email: MCP_TEST_OTHER_VIEWER, + id: "sched_other_private", + instruction: "This private Automation must stay hidden.", + slackUserId: "U456", + title: "Private check", + visibility: "private", + }); + const call = await connectMcpTools(); + + const missing = await call("getAutomation", { id: "missing" }); + const hidden = await call("getAutomation", { id: "sched_other_private" }); + + for (const result of [missing, hidden]) { + expect(result).toMatchObject({ + isError: true, + content: [{ type: "text", text: "Automation not found." }], + }); + } + }); +}); diff --git a/packages/junior/tests/integration/api/mcp/tools/get-conversation.test.ts b/packages/junior/tests/integration/api/mcp/tools/get-conversation.test.ts new file mode 100644 index 0000000000..27339dab66 --- /dev/null +++ b/packages/junior/tests/integration/api/mcp/tools/get-conversation.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { appendConversationBrief } from "@/chat/briefs/store"; +import { getDb } from "@/chat/db"; +import { conversationBriefFixture } from "../../../../fixtures/conversation-brief"; +import { + connectMcpTools, + MCP_TEST_OTHER_VIEWER, + MCP_TEST_VIEWER, + mockMcpDashboardConfig, +} from "../../../../fixtures/mcp-http"; +import { + appendMcpMessages, + seedMcpConversation, +} from "../../../../fixtures/mcp-tools"; + +mockMcpDashboardConfig(); + +const CONVERSATION_ID = "slack:C123:1700000000.000300"; + +describe("getConversation MCP tool", () => { + it("returns the metadata, the Brief, and only the last assistant reply", async () => { + await seedMcpConversation({ + conversationId: CONVERSATION_ID, + email: MCP_TEST_VIEWER, + title: "Viewer incident", + }); + await appendMcpMessages(CONVERSATION_ID, [ + { role: "user", text: "What broke?" }, + { role: "assistant", text: "The relay deploy broke ingestion." }, + { role: "user", text: "Roll it back." }, + { role: "assistant", text: "The rollback is done." }, + ]); + await appendConversationBrief(getDb(), { + content: conversationBriefFixture({ summary: "Relay was rolled back." }), + conversationId: CONVERSATION_ID, + modelId: "test-model", + searchText: "relay rollback", + throughSeq: 0, + turnId: "brief-turn", + }); + const call = await connectMcpTools(); + + const result = await call("getConversation", { + conversationId: CONVERSATION_ID, + }); + + expect(result.structuredContent).toEqual({ + conversation: expect.objectContaining({ + conversationId: CONVERSATION_ID, + title: "Viewer incident", + }), + brief: expect.objectContaining({ + summary: "Relay was rolled back.", + updatedAt: expect.any(String), + }), + lastReply: expect.objectContaining({ + role: "assistant", + text: "The rollback is done.", + }), + }); + expect(result.structuredContent).not.toHaveProperty("brief.record"); + }); + + it("reports another person's Conversation as not found", async () => { + await seedMcpConversation({ + conversationId: CONVERSATION_ID, + email: MCP_TEST_OTHER_VIEWER, + title: "Other incident", + }); + const call = await connectMcpTools(); + + const result = await call("getConversation", { + conversationId: CONVERSATION_ID, + }); + + expect(result).toMatchObject({ + isError: true, + content: [{ type: "text", text: "Conversation not found." }], + }); + }); +}); diff --git a/packages/junior/tests/integration/api/mcp/tools/get-review-item.test.ts b/packages/junior/tests/integration/api/mcp/tools/get-review-item.test.ts new file mode 100644 index 0000000000..e08a637013 --- /dev/null +++ b/packages/junior/tests/integration/api/mcp/tools/get-review-item.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + connectMcpTools, + MCP_TEST_OTHER_VIEWER, + MCP_TEST_VIEWER, + mockMcpDashboardConfig, +} from "../../../../fixtures/mcp-http"; +import { + seedMcpConversation, + seedMcpReviewItem, +} from "../../../../fixtures/mcp-tools"; + +mockMcpDashboardConfig(); + +const CONVERSATION_ID = "slack:C123:1700000000.000500"; + +describe("getReviewItem MCP tool", () => { + it("returns a Review item that the viewer can act on", async () => { + await seedMcpConversation({ + conversationId: CONVERSATION_ID, + email: MCP_TEST_VIEWER, + title: "Release review", + }); + const item = await seedMcpReviewItem({ + conversationId: CONVERSATION_ID, + email: MCP_TEST_VIEWER, + title: "Approve the release", + }); + const call = await connectMcpTools(); + + const result = await call("getReviewItem", { id: item.id }); + + expect(result.structuredContent).toEqual({ + item: expect.objectContaining({ + id: item.id, + body: "Pick one.", + canAct: true, + kind: "action", + question: "Ship it?", + title: "Approve the release", + }), + }); + }); + + it("refuses another person's private Review item", async () => { + await seedMcpConversation({ + conversationId: CONVERSATION_ID, + email: MCP_TEST_OTHER_VIEWER, + title: "Budget review", + }); + const item = await seedMcpReviewItem({ + conversationId: CONVERSATION_ID, + email: MCP_TEST_OTHER_VIEWER, + title: "Approve the budget", + }); + const call = await connectMcpTools(); + + const result = await call("getReviewItem", { id: item.id }); + + expect(result).toMatchObject({ isError: true }); + expect(JSON.stringify(result)).not.toContain("Approve the budget"); + }); +}); diff --git a/packages/junior/tests/integration/api/mcp/tools/list-conversation-messages.test.ts b/packages/junior/tests/integration/api/mcp/tools/list-conversation-messages.test.ts new file mode 100644 index 0000000000..0ce8076519 --- /dev/null +++ b/packages/junior/tests/integration/api/mcp/tools/list-conversation-messages.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { + connectMcpTools, + MCP_TEST_OTHER_VIEWER, + MCP_TEST_VIEWER, + mockMcpDashboardConfig, +} from "../../../../fixtures/mcp-http"; +import { + appendMcpMessages, + seedMcpConversation, +} from "../../../../fixtures/mcp-tools"; + +mockMcpDashboardConfig(); + +const CONVERSATION_ID = "slack:C123:1700000000.000300"; +const OTHER_CONVERSATION_ID = "slack:C123:1700000000.000400"; + +describe("listConversationMessages MCP tool", () => { + it("pages back from the newest message with a cursor", async () => { + await seedMcpConversation({ + conversationId: CONVERSATION_ID, + email: MCP_TEST_VIEWER, + title: "Viewer incident", + }); + await appendMcpMessages(CONVERSATION_ID, [ + { role: "user", text: "What broke?" }, + { role: "assistant", text: "The relay deploy broke ingestion." }, + { role: "user", text: "Roll it back." }, + { role: "assistant", text: "The rollback is done." }, + ]); + const call = await connectMcpTools(); + + const newest = await call("listConversationMessages", { + conversationId: CONVERSATION_ID, + limit: 3, + }); + const older = await call("listConversationMessages", { + before: (newest.structuredContent as { nextCursor: string }).nextCursor, + conversationId: CONVERSATION_ID, + }); + + expect(newest.structuredContent).toEqual({ + messages: [ + expect.objectContaining({ text: "The relay deploy broke ingestion." }), + expect.objectContaining({ text: "Roll it back." }), + expect.objectContaining({ text: "The rollback is done." }), + ], + nextCursor: expect.any(String), + }); + expect(older.structuredContent).toEqual({ + messages: [ + expect.objectContaining({ role: "user", text: "What broke?" }), + ], + }); + }); + + it("rejects a cursor from another Conversation and hides other Conversations", async () => { + await seedMcpConversation({ + conversationId: CONVERSATION_ID, + email: MCP_TEST_VIEWER, + title: "Viewer incident", + }); + await seedMcpConversation({ + conversationId: OTHER_CONVERSATION_ID, + email: MCP_TEST_OTHER_VIEWER, + title: "Other incident", + }); + await appendMcpMessages(CONVERSATION_ID, [ + { role: "user", text: "First." }, + { role: "assistant", text: "Second." }, + ]); + await seedMcpConversation({ + conversationId: "slack:C123:1700000000.000500", + email: MCP_TEST_VIEWER, + title: "Second viewer incident", + }); + await appendMcpMessages("slack:C123:1700000000.000500", [ + { role: "user", text: "Third." }, + { role: "assistant", text: "Fourth." }, + ]); + const call = await connectMcpTools(); + const page = await call("listConversationMessages", { + conversationId: "slack:C123:1700000000.000500", + limit: 1, + }); + + const wrongCursor = await call("listConversationMessages", { + before: (page.structuredContent as { nextCursor: string }).nextCursor, + conversationId: CONVERSATION_ID, + }); + const hidden = await call("listConversationMessages", { + conversationId: OTHER_CONVERSATION_ID, + }); + + expect(wrongCursor).toMatchObject({ + isError: true, + content: [ + { + type: "text", + text: "The cursor is not valid. Omit `before` to read the newest messages.", + }, + ], + }); + expect(hidden).toMatchObject({ + isError: true, + content: [{ type: "text", text: "Conversation not found." }], + }); + }); +}); diff --git a/packages/junior/tests/integration/api/mcp/tools/search-automations.test.ts b/packages/junior/tests/integration/api/mcp/tools/search-automations.test.ts new file mode 100644 index 0000000000..6dd06c0abb --- /dev/null +++ b/packages/junior/tests/integration/api/mcp/tools/search-automations.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + connectMcpTools, + MCP_TEST_OTHER_VIEWER, + MCP_TEST_VIEWER, + mockMcpDashboardConfig, +} from "../../../../fixtures/mcp-http"; +import { seedMcpAutomation } from "../../../../fixtures/mcp-tools"; + +mockMcpDashboardConfig(); + +function automationIds(structuredContent: unknown): string[] { + return (structuredContent as { automations: { id: string }[] }).automations + .map((automation) => automation.id) + .sort(); +} + +describe("searchAutomations MCP tool", () => { + it("returns the viewer's and public Automations and filters them", async () => { + await seedMcpAutomation({ + email: MCP_TEST_VIEWER, + id: "sched_viewer_digest", + instruction: "Post the weekly issue digest.", + slackUserId: "U123", + title: "Weekly digest", + visibility: "public", + }); + await seedMcpAutomation({ + email: MCP_TEST_OTHER_VIEWER, + id: "sched_other_incidents", + instruction: "Summarize open incidents.", + slackUserId: "U456", + title: "Incident summary", + visibility: "public", + }); + await seedMcpAutomation({ + email: MCP_TEST_OTHER_VIEWER, + id: "sched_other_private", + instruction: "This private Automation must stay hidden.", + slackUserId: "U456", + title: "Private check", + visibility: "private", + }); + const call = await connectMcpTools(); + + const all = await call("searchAutomations"); + const mine = await call("searchAutomations", { scope: "mine" }); + const matched = await call("searchAutomations", { q: "incidents" }); + + expect(automationIds(all.structuredContent)).toEqual([ + "sched_other_incidents", + "sched_viewer_digest", + ]); + expect(all.structuredContent).toMatchObject({ total: 2 }); + expect(mine.structuredContent).toEqual({ + automations: [ + expect.objectContaining({ + id: "sched_viewer_digest", + kind: "scheduled", + instruction: "Post the weekly issue digest.", + ownedByYou: true, + status: "active", + title: "Weekly digest", + }), + ], + total: 1, + }); + expect(automationIds(matched.structuredContent)).toEqual([ + "sched_other_incidents", + ]); + }); +}); diff --git a/packages/junior/tests/integration/api/mcp/tools/search-conversations.test.ts b/packages/junior/tests/integration/api/mcp/tools/search-conversations.test.ts new file mode 100644 index 0000000000..0fbfb13499 --- /dev/null +++ b/packages/junior/tests/integration/api/mcp/tools/search-conversations.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + connectMcpTools, + MCP_TEST_ORIGIN, + MCP_TEST_OTHER_VIEWER, + MCP_TEST_VIEWER, + mockMcpDashboardConfig, +} from "../../../../fixtures/mcp-http"; +import { seedMcpConversation } from "../../../../fixtures/mcp-tools"; + +mockMcpDashboardConfig(); + +describe("searchConversations MCP tool", () => { + it("returns only the Conversations that the viewer takes part in", async () => { + await seedMcpConversation({ + conversationId: "slack:C123:1700000000.000100", + email: MCP_TEST_VIEWER, + title: "Viewer launch plan", + }); + await seedMcpConversation({ + conversationId: "slack:C123:1700000000.000200", + email: MCP_TEST_OTHER_VIEWER, + title: "Other person's roadmap", + }); + const call = await connectMcpTools(); + + const result = await call("searchConversations"); + + expect(result).toEqual({ + content: [ + { type: "text", text: JSON.stringify(result.structuredContent) }, + ], + structuredContent: { + conversations: [ + { + conversationId: "slack:C123:1700000000.000100", + title: "Viewer launch plan", + status: "completed", + surface: "slack", + startedAt: expect.any(String), + lastSeenAt: expect.any(String), + url: `${MCP_TEST_ORIGIN}/conversations/${encodeURIComponent("slack:C123:1700000000.000100")}`, + }, + ], + }, + }); + }); + + it("matches the query against the viewer's Conversations only", async () => { + await seedMcpConversation({ + conversationId: "slack:C123:1700000000.000300", + email: MCP_TEST_VIEWER, + title: "Budget review", + }); + await seedMcpConversation({ + conversationId: "slack:C123:1700000000.000400", + email: MCP_TEST_VIEWER, + title: "Launch plan", + }); + await seedMcpConversation({ + conversationId: "slack:C123:1700000000.000500", + email: MCP_TEST_OTHER_VIEWER, + title: "Budget forecast", + }); + const call = await connectMcpTools(); + + const result = await call("searchConversations", { q: "budget" }); + + expect(result.structuredContent).toEqual({ + conversations: [ + expect.objectContaining({ + conversationId: "slack:C123:1700000000.000300", + }), + ], + }); + }); +}); diff --git a/packages/junior/tests/integration/api/mcp/tools/search-review-items.test.ts b/packages/junior/tests/integration/api/mcp/tools/search-review-items.test.ts new file mode 100644 index 0000000000..fc22856ccb --- /dev/null +++ b/packages/junior/tests/integration/api/mcp/tools/search-review-items.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { + connectMcpTools, + MCP_TEST_OTHER_VIEWER, + MCP_TEST_VIEWER, + mockMcpDashboardConfig, +} from "../../../../fixtures/mcp-http"; +import { + seedMcpConversation, + seedMcpReviewItem, +} from "../../../../fixtures/mcp-tools"; + +mockMcpDashboardConfig(); + +const CONVERSATION_ID = "slack:C123:1700000000.000500"; + +async function seedRelease() { + await seedMcpConversation({ + conversationId: CONVERSATION_ID, + email: MCP_TEST_VIEWER, + title: "Release review", + }); +} + +describe("searchReviewItems MCP tool", () => { + it("returns only the open Review items that the viewer can see", async () => { + await seedRelease(); + const mine = await seedMcpReviewItem({ + conversationId: CONVERSATION_ID, + email: MCP_TEST_VIEWER, + title: "Approve the release", + }); + await seedMcpReviewItem({ + conversationId: CONVERSATION_ID, + email: MCP_TEST_OTHER_VIEWER, + title: "Approve the budget", + }); + const call = await connectMcpTools(); + + const result = await call("searchReviewItems"); + + expect(result.structuredContent).toEqual({ + items: [ + expect.objectContaining({ + id: mine.id, + canAct: true, + conversationIds: [CONVERSATION_ID], + question: "Ship it?", + status: "open", + title: "Approve the release", + url: expect.any(String), + }), + ], + truncated: false, + }); + }); + + it("filters by text and marks a result cut by the limit", async () => { + await seedRelease(); + for (const title of ["Approve the release", "Approve the budget"]) { + await seedMcpReviewItem({ + conversationId: CONVERSATION_ID, + email: MCP_TEST_VIEWER, + title, + }); + } + const call = await connectMcpTools(); + + const matched = await call("searchReviewItems", { q: "budget" }); + const limited = await call("searchReviewItems", { limit: 1 }); + + expect(matched.structuredContent).toEqual({ + items: [expect.objectContaining({ title: "Approve the budget" })], + truncated: false, + }); + expect(limited.structuredContent).toEqual({ + items: [expect.any(Object)], + truncated: true, + }); + }); +}); diff --git a/packages/junior/tests/integration/api/mcp/tools/stop-conversation.test.ts b/packages/junior/tests/integration/api/mcp/tools/stop-conversation.test.ts new file mode 100644 index 0000000000..90145b1e1e --- /dev/null +++ b/packages/junior/tests/integration/api/mcp/tools/stop-conversation.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + connectMcpTools, + MCP_TEST_OTHER_VIEWER, + MCP_TEST_VIEWER, + mockMcpDashboardConfig, +} from "../../../../fixtures/mcp-http"; +import { seedMcpConversation } from "../../../../fixtures/mcp-tools"; + +mockMcpDashboardConfig(); + +describe("stopConversation MCP tool", () => { + it("reports no work when no Turn is running", async () => { + await seedMcpConversation({ + conversationId: "slack:C123:1700000000.000300", + email: MCP_TEST_VIEWER, + title: "Viewer incident", + }); + const call = await connectMcpTools(); + + const result = await call("stopConversation", { + conversationId: "slack:C123:1700000000.000300", + }); + + expect(result.structuredContent).toEqual({ + status: "no_work", + droppedMessages: 0, + }); + }); + + it("reports another person's Conversation as not found", async () => { + await seedMcpConversation({ + conversationId: "slack:C123:1700000000.000400", + email: MCP_TEST_OTHER_VIEWER, + title: "Other incident", + }); + const call = await connectMcpTools(); + + const result = await call("stopConversation", { + conversationId: "slack:C123:1700000000.000400", + }); + + expect(result).toMatchObject({ + isError: true, + content: [{ type: "text", text: "Conversation not found." }], + }); + }); +});