feat(mcp) [split 6/8]: add Tools and Profiles UI foundation (#9561)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Governed MCP access spans contracts, runtime enforcement, adapters,
UI surfaces, and operator verification
> - The parity reference PR #9534 is too large for effective automated
or human review
> - The feature therefore needs a linear stack whose individual diffs
stay below the 100-file review limit
> - This pull request is split 6/8 and focuses on UI API, shared
components, and Tools/Profile surfaces
> - The benefit is a standalone, testable review boundary while
preserving byte-for-byte parity at the top of the stack

## Linked Issues or Issue Description

- Related parity reference: #9534
- Problem: Operators need typed clients and administration surfaces that
compile independently before navigation exposes them.
- Proposed solution: Adds UI APIs, hooks, libraries, shared components,
Tools/Profiles pages, and the plugin settings consumer required by the
new company-scoped API.
- Alternatives considered: keeping #9534 as one 403-file review, or
rewriting the feature to manufacture seams; both were rejected in favor
of path extraction plus compile-driven boundary moves.
- Roadmap alignment: this advances the existing governed MCP/tool-access
work already represented by #9534; it does not introduce a separate
roadmap initiative.
- Stack position: base branch is
`pap10341-split/05-runtime-integration`.
- Merge policy: merge bottom-up, in order, only after the complete
eight-PR stack has been reviewed and the top-of-stack parity gate
remains empty.
- Requested review: UXDesigner sanity pass on Tools/Profile surfaces;
Greptile on every PR.

## What Changed

- Adds UI APIs, hooks, libraries, shared components, Tools/Profiles
pages, and the plugin settings consumer required by the new
company-scoped API.
- Keeps this PR below 100 changed files and independently typecheckable.
- Preserves the final tree from #9534 when combined with the other seven
stack levels.

## Verification

- `pnpm typecheck`
- `pnpm check:token-gates` — all gates clean
- Focused UI Vitest run with `NODE_ENV=test` — 28 files, 183 tests
passed

## Risks

- Large dead-code UI additions can drift from activation routes; PR 7
supplies the registration layer and top-level parity catches omissions.
- Stack risk: merging out of order can expose incomplete layers;
mitigate by following the documented bottom-up merge policy.
- Parity risk: later edits to an intermediate branch can drift from
#9534; mitigate by re-running the empty top-of-stack diff before merge.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, exact model ID `gpt-5.4`; runtime-managed context
window; medium reasoning with repository, shell, Git, GitHub CLI, and
code-execution tools enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] Internal references are omitted except the execution-plan link
explicitly required for this coordinated split stack
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge


## Stack Coordination

- Internal execution plan:
[PAP-13874](/PAP/issues/PAP-13874#document-plan)
- Parity reference: #9534
- Stack: #9556 → #9557 → #9558 → #9559 → #9560 → #9561 → #9562 → #9563
- Merge bottom-up only after full-stack review and an empty parity diff
at #9563.


## UI Evidence

QA captured these from the live Garden MCP split stack at 1440px and
verified clean rendering:

![Profiles
index](https://raw.githubusercontent.com/paperclipai/paperclip/ac72de3ec85feb14c5ec1ed0a3d39b29baa0abff/doc/screenshots/garden-mcp-split-stack/profiles-index.png)

![Profile
wizard](https://raw.githubusercontent.com/paperclipai/paperclip/ac72de3ec85feb14c5ec1ed0a3d39b29baa0abff/doc/screenshots/garden-mcp-split-stack/profile-wizard.png)


![Policies](https://raw.githubusercontent.com/paperclipai/paperclip/ac72de3ec85feb14c5ec1ed0a3d39b29baa0abff/doc/screenshots/garden-mcp-split-stack/policies.png)

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-07-14 15:31:12 -05:00
1 parent 9c8adee48b
commit a116713b9a
86 files changed
+16969 -63

No files matched your search

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { TranscriptEntry } from "../../adapters";
import type { ToolRunDecision } from "@paperclipai/shared";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "../MarkdownBody";
import { cn, formatTokens } from "../../lib/utils";
import { runningLabelText } from "../../lib/status-colors";
@@ -24,6 +25,7 @@ const RAW_INITIAL_ROWS = 180;
interface RunTranscriptViewProps {
entries: TranscriptEntry[];
toolDecisions?: readonly ToolRunDecision[];
mode?: TranscriptMode;
density?: TranscriptDensity;
limit?: number;
@@ -55,6 +57,8 @@ type TranscriptBlock =
endTs?: string;
name: string;
toolUseId?: string;
invocationId?: string;
actionRequestId?: string;
input: unknown;
result?: string;
isError?: boolean;
@@ -74,6 +78,9 @@ type TranscriptBlock =
items: Array<{
ts: string;
endTs?: string;
toolUseId?: string;
invocationId?: string;
actionRequestId?: string;
input: unknown;
result?: string;
isError?: boolean;
@@ -88,6 +95,9 @@ type TranscriptBlock =
ts: string;
endTs?: string;
name: string;
toolUseId?: string;
invocationId?: string;
actionRequestId?: string;
input: unknown;
result?: string;
isError?: boolean;
@@ -325,6 +335,89 @@ function summarizeToolResult(result: string | undefined, isError: boolean | unde
return truncate(firstLine, density === "compact" ? 84 : 140);
}
type ToolDecisionRefs = {
toolUseId?: string;
invocationId?: string;
actionRequestId?: string;
};
type ToolDecisionMaps = {
byInvocationId: Map<string, ToolRunDecision>;
byActionRequestId: Map<string, ToolRunDecision>;
};
function buildToolDecisionMaps(decisions: readonly ToolRunDecision[] | undefined): ToolDecisionMaps {
const byInvocationId = new Map<string, ToolRunDecision>();
const byActionRequestId = new Map<string, ToolRunDecision>();
for (const decision of decisions ?? []) {
byInvocationId.set(decision.invocation.id, decision);
if (decision.actionRequest?.id) {
byActionRequestId.set(decision.actionRequest.id, decision);
}
if (decision.latestAuditEvent?.actionRequestId) {
byActionRequestId.set(decision.latestAuditEvent.actionRequestId, decision);
}
}
return { byInvocationId, byActionRequestId };
}
function findToolDecision(maps: ToolDecisionMaps, refs: ToolDecisionRefs): ToolRunDecision | null {
if (refs.invocationId) {
const decision = maps.byInvocationId.get(refs.invocationId);
if (decision) return decision;
}
if (refs.actionRequestId) {
const decision = maps.byActionRequestId.get(refs.actionRequestId);
if (decision) return decision;
}
if (refs.toolUseId) {
return maps.byInvocationId.get(refs.toolUseId) ?? maps.byActionRequestId.get(refs.toolUseId) ?? null;
}
return null;
}
function summarizeToolDecision(decision: ToolRunDecision | null): { label: string; className: string; detail?: string } | null {
if (!decision) return null;
if (decision.pendingAction) {
return {
label: "Needs approval",
className: "text-amber-700 dark:text-amber-300",
detail: `Action request ${decision.pendingAction.actionRequestId.slice(0, 8)}`,
};
}
if (decision.denialReason || decision.invocation.status === "denied" || decision.outcome === "denied") {
return {
label: "Denied",
className: "text-red-700 dark:text-red-300",
detail: decision.denialReason ?? decision.reasonCode ?? undefined,
};
}
if (decision.invocation.status === "failed" || decision.invocation.status === "timed_out" || decision.outcome === "failure" || decision.outcome === "timeout") {
return {
label: decision.invocation.status === "timed_out" || decision.outcome === "timeout" ? "Timed out" : "Failed",
className: "text-red-700 dark:text-red-300",
detail: decision.denialReason ?? decision.reasonCode ?? undefined,
};
}
if (decision.actionRequest?.status === "approved") {
return { label: "Approved", className: "text-emerald-700 dark:text-emerald-300" };
}
if (decision.actionRequest?.status === "executed") {
return { label: "Executed", className: "text-emerald-700 dark:text-emerald-300" };
}
if (decision.decision === "allow" || decision.invocation.status === "authorized" || decision.invocation.status === "executing" || decision.invocation.status === "succeeded") {
return { label: "Allowed", className: "text-emerald-700 dark:text-emerald-300" };
}
if (decision.decision === "require_approval" || decision.invocation.approvalState === "pending") {
return { label: "Needs approval", className: "text-amber-700 dark:text-amber-300" };
}
return {
label: humanizeLabel(decision.invocation.status),
className: "text-foreground/70",
detail: decision.reasonCode ?? undefined,
};
}
function parseSystemActivity(text: string): { activityId?: string; name: string; status: "running" | "completed" } | null {
const match = text.match(/^item (started|completed):\s*([a-z0-9_-]+)(?:\s+\(id=([^)]+)\))?$/i);
if (!match) return null;
@@ -368,6 +461,9 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
pending.push({
ts: block.ts,
endTs: block.endTs,
toolUseId: block.toolUseId,
invocationId: block.invocationId,
actionRequestId: block.actionRequestId,
input: block.input,
result: block.result,
isError: block.isError,
@@ -412,6 +508,9 @@ function groupToolBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
ts: block.ts,
endTs: block.endTs,
name: block.name,
toolUseId: block.toolUseId,
invocationId: block.invocationId,
actionRequestId: block.actionRequestId,
input: block.input,
result: block.result,
isError: block.isError,
@@ -484,6 +583,8 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
ts: entry.ts,
name: displayToolName(entry.name, entry.input),
toolUseId,
invocationId: entry.invocationId,
actionRequestId: entry.actionRequestId,
input: entry.input,
status: "running",
};
@@ -726,14 +827,69 @@ function TranscriptThinkingBlock({
);
}
function ToolDecisionBadge({ decision }: { decision: ToolRunDecision | null }) {
const summary = summarizeToolDecision(decision);
if (!summary) return null;
return (
<span className={cn("text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow)", summary.className)}>
{summary.label}
</span>
);
}
function ToolDecisionInlineDetail({ decision }: { decision: ToolRunDecision | null }) {
const summary = summarizeToolDecision(decision);
if (!summary?.detail) return null;
return (
<div className="mt-1 break-words text-(length:--text-micro) text-muted-foreground">
{summary.detail}
</div>
);
}
function ToolDecisionDetails({ decision, compact }: { decision: ToolRunDecision | null; compact: boolean }) {
if (!decision) return null;
const actionRequest = decision.actionRequest;
return (
<div className={cn(
"rounded-lg border border-border/60 bg-background/60 p-2",
compact ? "text-(length:--text-micro)" : "text-xs",
)}>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground">Decision</span>
<ToolDecisionBadge decision={decision} />
{decision.reasonCode && <span className="font-mono text-muted-foreground">{decision.reasonCode}</span>}
</div>
{decision.denialReason && (
<div className="mt-1 break-words text-red-700 dark:text-red-300">
{decision.denialReason}
</div>
)}
<div className="mt-2 grid gap-1 font-mono text-muted-foreground sm:grid-cols-2">
<span>invocation {decision.invocation.id.slice(0, 8)}</span>
<span>audit {decision.auditEvents.length}</span>
{actionRequest && <span>action {actionRequest.status} {actionRequest.id.slice(0, 8)}</span>}
{actionRequest?.interactionId && <span>card {actionRequest.interactionId.slice(0, 8)}</span>}
</div>
{decision.pendingAction?.previewMarkdown && (
<MarkdownBody className="mt-2 text-(length:--text-micro) leading-5 text-foreground/75 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
{decision.pendingAction.previewMarkdown}
</MarkdownBody>
)}
</div>
);
}
function TranscriptToolCard({
block,
density,
decision,
}: {
block: Extract<TranscriptBlock, { type: "tool" }>;
density: TranscriptDensity;
decision: ToolRunDecision | null;
}) {
const [open, setOpen] = useState(block.status === "error");
const [open, setOpen] = useState(block.status === "error" || Boolean(decision?.pendingAction || decision?.denialReason));
const compact = density === "compact";
const parsedResult = parseStructuredToolResult(block.result);
const statusLabel =
@@ -784,10 +940,12 @@ function TranscriptToolCard({
<span className={cn("text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow)", statusTone)}>
{statusLabel}
</span>
<ToolDecisionBadge decision={decision} />
</div>
<div className={cn("mt-1 break-words text-foreground/80", compact ? "text-xs" : "text-sm")}>
{summary}
</div>
<ToolDecisionInlineDetail decision={decision} />
</div>
<button
type="button"
@@ -822,6 +980,7 @@ function TranscriptToolCard({
</pre>
</div>
</div>
<ToolDecisionDetails decision={decision} compact={compact} />
</div>
</div>
)}
@@ -837,14 +996,22 @@ function hasSelectedText() {
function TranscriptCommandGroup({
block,
density,
toolDecisionMaps,
}: {
block: Extract<TranscriptBlock, { type: "command_group" }>;
density: TranscriptDensity;
toolDecisionMaps: ToolDecisionMaps;
}) {
const [open, setOpen] = useState(false);
const compact = density === "compact";
const runningItem = [...block.items].reverse().find((item) => item.status === "running");
const latestItem = block.items[block.items.length - 1] ?? null;
const highlightedDecision =
block.items
.map((item) => findToolDecision(toolDecisionMaps, item))
.find((decision) => decision?.pendingAction || decision?.denialReason)
?? block.items.map((item) => findToolDecision(toolDecisionMaps, item)).find(Boolean)
?? null;
const hasError = block.items.some((item) => item.status === "error");
const isRunning = Boolean(runningItem);
const showExpandedErrorState = open && hasError;
@@ -898,6 +1065,12 @@ function TranscriptCommandGroup({
<div className="text-(length:--text-micro) font-semibold uppercase leading-none tracking-(--tracking-label) text-muted-foreground/70">
{title}
</div>
{highlightedDecision && (
<div className="mt-1">
<ToolDecisionBadge decision={highlightedDecision} />
<ToolDecisionInlineDetail decision={highlightedDecision} />
</div>
)}
{subtitle && (
<div className={cn("mt-1 break-words font-mono text-foreground/85", compact ? "text-xs" : "text-sm")}>
{subtitle}
@@ -951,6 +1124,7 @@ function TranscriptCommandGroup({
{formatToolPayload(item.result)}
</pre>
)}
<ToolDecisionDetails decision={findToolDecision(toolDecisionMaps, item)} compact={compact} />
</div>
))}
</div>
@@ -962,15 +1136,23 @@ function TranscriptCommandGroup({
function TranscriptToolGroup({
block,
density,
toolDecisionMaps,
}: {
block: Extract<TranscriptBlock, { type: "tool_group" }>;
density: TranscriptDensity;
toolDecisionMaps: ToolDecisionMaps;
}) {
const [open, setOpen] = useState(false);
const compact = density === "compact";
const runningItem = [...block.items].reverse().find((item) => item.status === "running");
const hasError = block.items.some((item) => item.status === "error");
const isRunning = Boolean(runningItem);
const highlightedDecision =
block.items
.map((item) => findToolDecision(toolDecisionMaps, item))
.find((decision) => decision?.pendingAction || decision?.denialReason)
?? block.items.map((item) => findToolDecision(toolDecisionMaps, item)).find(Boolean)
?? null;
const uniqueNames = [...new Set(block.items.map((item) => item.name))];
const toolLabel =
uniqueNames.length === 1
@@ -1024,6 +1206,12 @@ function TranscriptToolGroup({
<div className={cn("font-semibold uppercase leading-none tracking-(--tracking-label)", compact ? "text-(length:--text-nano)" : "text-(length:--text-micro)", "text-muted-foreground/70")}>
{title}
</div>
{highlightedDecision && (
<div className="mt-1">
<ToolDecisionBadge decision={highlightedDecision} />
<ToolDecisionInlineDetail decision={highlightedDecision} />
</div>
)}
{subtitle && (
<div className={cn("mt-1 break-words font-mono text-foreground/85", compact ? "text-xs" : "text-sm")}>
{subtitle}
@@ -1065,6 +1253,7 @@ function TranscriptToolGroup({
)}>
{item.status === "running" ? "Running" : item.status === "error" ? "Errored" : "Completed"}
</span>
<ToolDecisionBadge decision={findToolDecision(toolDecisionMaps, item)} />
</div>
<div className={cn("grid gap-2 pl-7", compact ? "grid-cols-1" : "lg:grid-cols-2")}>
<div>
@@ -1085,6 +1274,9 @@ function TranscriptToolGroup({
</div>
)}
</div>
<div className="pl-7">
<ToolDecisionDetails decision={findToolDecision(toolDecisionMaps, item)} compact={compact} />
</div>
</div>
))}
</div>
@@ -1501,6 +1693,7 @@ function RawTranscriptView({
export function RunTranscriptView({
entries,
toolDecisions,
mode = "nice",
density = "comfortable",
limit,
@@ -1511,6 +1704,7 @@ export function RunTranscriptView({
thinkingClassName,
externalReferences,
}: RunTranscriptViewProps) {
const toolDecisionMaps = useMemo(() => buildToolDecisionMaps(toolDecisions), [toolDecisions]);
const blocks = useMemo(
() => (mode === "raw" ? [] : normalizeTranscript(entries, streaming)),
[entries, mode, streaming],
@@ -1556,9 +1750,19 @@ export function RunTranscriptView({
externalReferences={externalReferences}
/>
)}
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
{block.type === "tool_group" && <TranscriptToolGroup block={block} density={density} />}
{block.type === "tool" && (
<TranscriptToolCard
block={block}
density={density}
decision={findToolDecision(toolDecisionMaps, block)}
/>
)}
{block.type === "command_group" && (
<TranscriptCommandGroup block={block} density={density} toolDecisionMaps={toolDecisionMaps} />
)}
{block.type === "tool_group" && (
<TranscriptToolGroup block={block} density={density} toolDecisionMaps={toolDecisionMaps} />
)}
{block.type === "diff_group" && <TranscriptDiffGroup block={block} density={density} />}
{block.type === "stderr_group" && <TranscriptStderrGroup block={block} density={density} />}
{block.type === "system_group" && <TranscriptSystemGroup block={block} density={density} />}