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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 35 additions & 18 deletions packages/docs/src/content/docs/operate/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -32,7 +34,7 @@ claude mcp add --transport http junior https://<your-domain>/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.
Expand All @@ -45,7 +47,15 @@ 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:

```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:

Expand All @@ -54,33 +64,40 @@ curl -i https://<your-domain>/api/mcp -X POST
curl https://<your-domain>/.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` | `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. |

| 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.
- Personal tokens do not work on `/api/mcp`. Only tokens from this OAuth flow work.

## 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

Expand Down
69 changes: 69 additions & 0 deletions packages/junior/src/api/mcp/automation.ts
Original file line number Diff line number Diff line change
@@ -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<typeof mcpAutomationSchema> {
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),
};
}
105 changes: 105 additions & 0 deletions packages/junior/src/api/mcp/conversation.ts
Original file line number Diff line number Diff line change
@@ -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<typeof mcpConversationSchema>;

/** 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 searchConversations.");

/** 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<typeof mcpMessageSchema>;

/**
* 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),
},
];
});
}
15 changes: 9 additions & 6 deletions packages/junior/src/api/mcp/oauth/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
11 changes: 10 additions & 1 deletion packages/junior/src/api/mcp/oauth/consent-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -45,8 +46,15 @@ function consentPage(args: {
const clientName = args.authorization.clientName ?? clientHost;
const button = (action: string, label: string, primary: boolean) =>
`<button type="submit" name="action" value="${action}" style="border:0;border-radius:.375rem;cursor:pointer;font:inherit;font-weight:700;padding:.625rem 1rem;margin-right:.5rem;${primary ? "background:#a78bfa;color:#000" : "background:#333;color:#fff"}">${label}</button>`;
const scopeItems = (parseMcpScopes(args.authorization.scope) ?? [])
.map(
(scope) =>
`<li style="margin:0 0 .25rem">${escapeHtml(MCP_SCOPE_DESCRIPTIONS[scope])} <code style="color:#8a8a8a">${escapeHtml(scope)}</code></li>`,
)
.join("");
return renderMcpPage({
bodyHtml: ` <p style="margin:0 0 1rem;color:#b8b8b8;line-height:1.5">The app is published at <strong style="color:#fff">${escapeHtml(clientHost)}</strong>. It can list your ${escapeHtml(args.agentName)} conversations.</p>
bodyHtml: ` <p style="margin:0 0 .5rem;color:#b8b8b8;line-height:1.5">The app is published at <strong style="color:#fff">${escapeHtml(clientHost)}</strong>. In ${escapeHtml(args.agentName)}, it can:</p>
<ul style="margin:0 0 1rem;padding-left:1.25rem;color:#b8b8b8;line-height:1.5">${scopeItems}</ul>
<form method="post">
<input type="hidden" name="csrf" value="${escapeHtml(args.csrfToken)}">
${button("approve", "Allow", true)}${button("deny", "Deny", false)}
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading