feat: enable MCP tools by default

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-20 21:20:02 +00:00
1 parent 98f032f9a9
commit ca94577206
18 files changed
+530 -324

No files matched your search

+2
View File
@@ -1411,6 +1411,8 @@ export interface ToolConnectionTestAgent {
role: string;
title: string | null;
status: string;
/** Zero-based depth in the company reporting tree; roots are highest-ranked. */
orgDepth: number;
effectiveAccess: ToolConnectionAccessSummary;
}
@@ -344,7 +344,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
await tempDb?.cleanup();
});
it("connects a public unknown endpoint with conservative defaults and quarantine", async () => {
it("connects a public unknown endpoint with every discovered tool enabled", async () => {
installMcpOAuthFixture({ auth: "public" });
const company = await createCompany(db);
const service = toolAccessService(db);
@@ -354,18 +354,30 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
expect(result.auth ?? null).toBeNull();
expect(result.actions.readOnly.map((action) => action.toolName)).toEqual(["list_insights"]);
expect(result.actions.canMakeChanges.map((action) => action.toolName)).toEqual(["create_insight"]);
// Reads are on for review, anything that can change data starts off.
expect(result.suggestedDefaults).toMatchObject({ askFirstRiskLevels: ["write", "destructive"] });
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, result.connectionId));
expect(connection).toMatchObject({ transport: "mcp_remote", authKind: "none", status: "draft" });
// An unknown endpoint stays labelled unverified and quarantines whatever it
// grows later, exactly like the curated path's conservative defaults.
expect(connection!.config).toMatchObject({ quarantineNewEntries: true, unverifiedServer: true });
expect(connection!.config).toMatchObject({ quarantineNewEntries: false, unverifiedServer: true });
// No curated definition was consulted: nothing recorded a template key, so
// this connection cannot be depending on gallery metadata for anything.
expect(connection!.config).not.toHaveProperty("sourceTemplateKey");
expect(connection!.config).not.toHaveProperty("connectionMethodKey");
const [profile] = await db.select().from(toolProfiles).where(eq(
toolProfiles.profileKey,
`app:${result.connectionId}`,
));
expect(profile).toBeTruthy();
await expect(db.select().from(toolProfileEntries).where(eq(
toolProfileEntries.profileId,
profile!.id,
))).resolves.toHaveLength(2);
await expect(db.select().from(toolProfileBindings).where(eq(
toolProfileBindings.profileId,
profile!.id,
))).resolves.toEqual([
expect.objectContaining({ targetType: "company", targetId: company.id }),
]);
});
it("emits DNS guidance for a real NXDOMAIN failure", async () => {
@@ -99,6 +99,24 @@ function mockToolsList(tools: unknown[]) {
);
}
const PUBLIC_MCP_FIXTURE_URL = "https://8.8.8.8/api/mcp";
async function withGalleryServerUrl<T>(
slug: string,
serverUrl: string,
operation: () => Promise<T>,
): Promise<T> {
const method = getConnectableAppDefinition(slug)?.methods[0];
if (!method?.defaults) throw new Error(`Missing gallery method defaults for ${slug}`);
const originalServerUrl = method.defaults.serverUrl;
method.defaults.serverUrl = serverUrl;
try {
return await operation();
} finally {
method.defaults.serverUrl = originalServerUrl;
}
}
function createRouteApp(
db: ReturnType<typeof createDb>,
actor?: Express.Request["actor"],
@@ -147,13 +165,14 @@ async function grantBoardUser(
companyId: string,
userId: string,
permissionKeys: string[],
membershipRole: "owner" | "admin" | "operator" | "member" | "viewer" = "operator",
) {
await db.insert(companyMemberships).values({
companyId,
principalType: "user",
principalId: userId,
status: "active",
membershipRole: "operator",
membershipRole,
});
if (permissionKeys.length > 0) {
await db.insert(principalPermissionGrants).values(permissionKeys.map((permissionKey) => ({
@@ -1447,6 +1466,7 @@ describeEmbeddedPostgres("tool access service", () => {
expect(res.body.agents).toHaveLength(1);
expect(res.body.agents[0]).toMatchObject({
id: agent.id,
orgDepth: 0,
effectiveAccess: {
connectionId: connection.id,
toolCount: 1,
@@ -1457,6 +1477,55 @@ describeEmbeddedPostgres("tool access service", () => {
});
});
it("lists only writable agents and ranks the highest accessible agent first", async () => {
const company = await createCompany(db);
const userId = `scoped-tool-tester-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, ["tools:use"], "viewer");
const actor = boardSessionActor(company.id, "viewer", userId);
const root = await createAgent(db, company.id);
const [accessibleManager] = await db.insert(agents).values({
companyId: company.id,
name: "Accessible manager",
role: "manager",
reportsTo: root.id,
status: "active",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
}).returning();
const [accessibleReport] = await db.insert(agents).values({
companyId: company.id,
name: "Accessible report",
role: "engineer",
reportsTo: accessibleManager!.id,
status: "active",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
}).returning();
await db.insert(principalPermissionGrants).values({
companyId: company.id,
principalType: "user",
principalId: userId,
permissionKey: "tasks:assign_scope",
scope: { agentIds: [accessibleManager!.id, accessibleReport!.id] },
grantedByUserId: "owner",
});
const { connection } = await createRemoteToolFixture(db, company.id);
const app = createRouteApp(db, actor, createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }));
const res = await request(app)
.get(`/api/tool-connections/${connection.id}/test-agents`)
.expect(200);
expect(res.body.agents.map((agent: { id: string }) => agent.id)).toEqual([
accessibleManager!.id,
accessibleReport!.id,
]);
expect(res.body.agents.map((agent: { orgDepth: number }) => agent.orgDepth)).toEqual([1, 2]);
expect(res.body.agents).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: root.id })]));
});
it("surfaces a last-changed audit hint attributed to the agent that authored the governing policy", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
@@ -5295,15 +5364,16 @@ describeEmbeddedPostgres("tool access service", () => {
runtimeConfig: {},
}).returning();
const connect = await service.connectGalleryApp(company.id, {
galleryKey: "zapier",
name: "Zapier workspace",
credentialValues: { "credentials.authorization": "zap-secret" },
}, { actorType: "user", actorId: "board" });
const connect = await withGalleryServerUrl("zapier", PUBLIC_MCP_FIXTURE_URL, () =>
service.connectGalleryApp(company.id, {
galleryKey: "zapier",
name: "Zapier workspace",
credentialValues: { "credentials.authorization": "zap-secret" },
}, { actorType: "user", actorId: "board" }));
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledWith(
"https://mcp.zapier.com/api/mcp",
PUBLIC_MCP_FIXTURE_URL,
expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer zap-secret" }),
}),
@@ -5311,7 +5381,7 @@ describeEmbeddedPostgres("tool access service", () => {
expect(connect.connection).toMatchObject({
status: "draft",
enabled: false,
config: expect.objectContaining({ sourceTemplateKey: "zapier", quarantineNewEntries: true }),
config: expect.objectContaining({ sourceTemplateKey: "zapier", quarantineNewEntries: false }),
credentialSecretRefs: [
expect.objectContaining({
configPath: "credentials.authorization",
@@ -5391,6 +5461,10 @@ describeEmbeddedPostgres("tool access service", () => {
]),
);
await db.update(toolConnections).set({
config: { ...connect.connection.config, quarantineNewEntries: true },
}).where(eq(toolConnections.id, connect.connectionId));
fetchMock.mockResolvedValueOnce(mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
@@ -5489,6 +5563,79 @@ describeEmbeddedPostgres("tool access service", () => {
expect(attentionAfterReview.apps).toEqual([]);
});
it("enables every discovered tool by default while preserving tools explicitly turned off later", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const fetchMock = mockToolsList([
{ name: "list_zaps", annotations: { readOnlyHint: true } },
{ name: "update_zap", annotations: { readOnlyHint: false } },
]);
const connect = await withGalleryServerUrl("zapier", PUBLIC_MCP_FIXTURE_URL, () =>
service.connectGalleryApp(company.id, {
galleryKey: "zapier",
credentialValues: { "credentials.authorization": "zap-secret" },
}, { actorType: "user", actorId: "board" }));
const listEntry = connect.catalog.find((entry) => entry.toolName === "list_zaps")!;
const updateEntry = connect.catalog.find((entry) => entry.toolName === "update_zap")!;
const [defaultProfile] = await db.select().from(toolProfiles).where(eq(
toolProfiles.profileKey,
`app:${connect.connectionId}`,
));
expect(defaultProfile).toBeTruthy();
await expect(db.select().from(toolProfileEntries).where(eq(
toolProfileEntries.profileId,
defaultProfile!.id,
))).resolves.toEqual(expect.arrayContaining([
expect.objectContaining({ catalogEntryId: listEntry.id, effect: "include" }),
expect.objectContaining({ catalogEntryId: updateEntry.id, effect: "include" }),
]));
await expect(db.select().from(toolProfileBindings).where(eq(
toolProfileBindings.profileId,
defaultProfile!.id,
))).resolves.toEqual([
expect.objectContaining({ targetType: "company", targetId: company.id }),
]);
await service.finishGalleryAppConnection(company.id, connect.connectionId, {
enabledCatalogEntryIds: [listEntry.id],
askFirstCatalogEntryIds: [],
access: "all_agents",
}, { actorType: "user", actorId: "board" });
fetchMock.mockResolvedValueOnce(mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
result: {
tools: [
{ name: "list_zaps", annotations: { readOnlyHint: true } },
{ name: "update_zap", annotations: { readOnlyHint: false } },
{ name: "create_zap", annotations: { readOnlyHint: false } },
],
},
}));
const refresh = await service.refreshCatalog(connect.connectionId, { actorType: "user", actorId: "board" });
expect(refresh.quarantinedCount).toBe(0);
expect(refresh.catalog).toEqual(expect.arrayContaining([
expect.objectContaining({ toolName: "list_zaps", status: "active" }),
expect.objectContaining({ toolName: "update_zap", status: "active" }),
expect.objectContaining({ toolName: "create_zap", status: "active" }),
]));
const createEntry = refresh.catalog.find((entry) => entry.toolName === "create_zap")!;
const profileEntries = await db.select().from(toolProfileEntries).where(eq(
toolProfileEntries.profileId,
defaultProfile!.id,
));
expect(profileEntries).toEqual(expect.arrayContaining([
expect.objectContaining({ catalogEntryId: listEntry.id }),
expect.objectContaining({ catalogEntryId: createEntry.id }),
]));
expect(profileEntries).not.toEqual(expect.arrayContaining([
expect.objectContaining({ catalogEntryId: updateEntry.id }),
]));
});
it("resolves Notion reads as allowed, mutations as ask-first, and denies cross-company use", async () => {
const company = await createCompany(db);
const otherCompany = await createCompany(db);
+31 -1
View File
@@ -57,6 +57,28 @@ function allowlistIds(config: Record<string, unknown> | null | undefined): strin
return raw.filter((value): value is string => typeof value === "string" && value.trim().length > 0);
}
function agentOrgDepths(rows: Array<{ id: string; reportsTo: string | null }>): Map<string, number> {
const parentById = new Map(rows.map((row) => [row.id, row.reportsTo]));
const depthById = new Map<string, number>();
const depthFor = (agentId: string, path: Set<string>): number => {
const known = depthById.get(agentId);
if (known !== undefined) return known;
const parentId = parentById.get(agentId);
if (!parentId || !parentById.has(parentId) || path.has(agentId)) {
depthById.set(agentId, 0);
return 0;
}
const nextPath = new Set(path).add(agentId);
const depth = depthFor(parentId, nextPath) + 1;
depthById.set(agentId, depth);
return depth;
};
for (const row of rows) depthFor(row.id, new Set());
return depthById;
}
/**
* Classify a connection PATCH into operator-visible lifecycle events so the
* per-app Activity tab can humanize them (PAP-11284). A single update may
@@ -740,9 +762,11 @@ export function toolAccessRoutes(
role: agents.role,
title: agents.title,
status: agents.status,
reportsTo: agents.reportsTo,
})
.from(agents)
.where(eq(agents.companyId, connection.companyId));
const orgDepthByAgentId = agentOrgDepths(rows);
const candidates = [];
for (const agent of rows) {
try {
@@ -751,7 +775,12 @@ export function toolAccessRoutes(
continue;
}
candidates.push({
...agent,
id: agent.id,
name: agent.name,
role: agent.role,
title: agent.title,
status: agent.status,
orgDepth: orgDepthByAgentId.get(agent.id) ?? 0,
effectiveAccess: await options.toolGateway.summarizeConnectionAccessForAgent({
companyId: connection.companyId,
connectionId: connection.id,
@@ -759,6 +788,7 @@ export function toolAccessRoutes(
}),
});
}
candidates.sort((a, b) => a.orgDepth - b.orgDepth || a.name.localeCompare(b.name));
res.json({ agents: candidates });
});
+91 -5
View File
@@ -3088,6 +3088,75 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
return profile;
}
async function enableCatalogEntriesByDefault(input: {
connection: typeof toolConnections.$inferSelect;
newCatalogEntryIds: string[];
activeCatalogEntryIds: string[];
actor?: ActorInfo;
}) {
const profileKey = `app:${input.connection.id}`;
let [profile] = await db
.select()
.from(toolProfiles)
.where(and(
eq(toolProfiles.companyId, input.connection.companyId),
eq(toolProfiles.profileKey, profileKey),
))
.limit(1);
const createdProfile = !profile;
if (!profile) {
[profile] = await db.insert(toolProfiles).values({
companyId: input.connection.companyId,
profileKey,
name: input.connection.name,
description: `Access profile for ${input.connection.name}.`,
status: "active",
defaultAction: "deny",
metadata: { source: "app_gallery_finish", connectionId: input.connection.id },
}).returning();
await db.insert(toolProfileBindings).values({
companyId: input.connection.companyId,
profileId: profile.id,
targetType: "company",
targetId: input.connection.companyId,
priority: 100,
metadata: { source: "app_gallery_finish" },
createdByAgentId: input.actor?.actorType === "agent" ? input.actor.actorId ?? null : null,
createdByUserId: input.actor?.actorType === "user" ? input.actor.actorId ?? null : null,
});
}
// A new connection starts with every discovered action enabled. Later
// refreshes extend that managed profile only for genuinely new actions, so
// an action the operator deliberately turned off remains off.
const candidateIds = [...new Set(
createdProfile ? input.activeCatalogEntryIds : input.newCatalogEntryIds,
)];
if (candidateIds.length === 0) return;
const existingEntries = await db
.select({ catalogEntryId: toolProfileEntries.catalogEntryId })
.from(toolProfileEntries)
.where(and(
eq(toolProfileEntries.companyId, input.connection.companyId),
eq(toolProfileEntries.profileId, profile.id),
inArray(toolProfileEntries.catalogEntryId, candidateIds),
));
const configuredIds = new Set(existingEntries.flatMap((entry) =>
entry.catalogEntryId ? [entry.catalogEntryId] : [],
));
const entryIds = candidateIds.filter((id) => !configuredIds.has(id));
if (entryIds.length === 0) return;
await db.insert(toolProfileEntries).values(entryIds.map((catalogEntryId) => ({
companyId: input.connection.companyId,
profileId: profile.id,
selectorType: "catalog_entry" as const,
effect: "include" as const,
applicationId: input.connection.applicationId,
connectionId: input.connection.id,
catalogEntryId,
})));
}
async function listConnectionInstalls(connectionId: string, companyId?: string): Promise<ToolConnectionInstall[]> {
const connection = await getConnectionRow(connectionId, companyId);
const rows = await db
@@ -4110,7 +4179,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
? "quarantined"
: existing?.status === "disabled"
? "disabled"
: existing?.status === "quarantined"
: quarantineOnRefresh && existing?.status === "quarantined"
? "quarantined"
: "active";
if (shouldQuarantine) quarantinedCount += 1;
@@ -4131,8 +4200,12 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
versionHash: hash,
schemaHash,
lastSeenAt: now,
quarantinedAt: shouldQuarantine ? now : existing.quarantinedAt,
quarantineReason: shouldQuarantine ? "pending_review" : existing.quarantineReason,
quarantinedAt: status === "quarantined"
? shouldQuarantine ? now : existing.quarantinedAt
: null,
quarantineReason: status === "quarantined"
? shouldQuarantine ? "pending_review" : existing.quarantineReason
: null,
updatedAt: now,
})
.where(eq(toolCatalogEntries.id, existing.id))
@@ -4188,6 +4261,19 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
.where(eq(toolRuntimeSlots.connectionId, connection.id));
}
const activeEntries = updatedEntries.filter((entry) => entry.status === "active");
await enableCatalogEntriesByDefault({
connection: updatedConnection,
newCatalogEntryIds: activeEntries
.filter((entry) => {
const previous = existingByName.get(entry.toolName);
return !previous || previous.status === "quarantined";
})
.map((entry) => entry.id),
activeCatalogEntryIds: activeEntries.map((entry) => entry.id),
actor,
});
await audit({
companyId: connection.companyId,
connectionId: connection.id,
@@ -6305,10 +6391,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
sourceTemplateKey: galleryEntry.slug,
connectionMethodKey: method?.key,
methodConfig: normalizedMethodConfig?.values ?? {},
quarantineNewEntries: true,
quarantineNewEntries: false,
...(galleryEntry.slug === "posthog" ? { safeDefault: true } : {}),
}
: { ...baseConfig, quarantineNewEntries: true, unverifiedServer: true };
: { ...baseConfig, quarantineNewEntries: false, unverifiedServer: true };
// A pasted URL may arrive with a client the operator preregistered in the
// provider's own console, because that authorization server supports neither
// CIMD nor dynamic registration. Record the client id now; the secret becomes
+43 -7
View File
@@ -6,6 +6,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppDetail } from "./AppDetail";
import { APP_TABS } from "./app-tabs";
const getConnectionMock = vi.hoisted(() => vi.fn());
const getConnectionInstallsMock = vi.hoisted(() => vi.fn());
@@ -269,6 +270,17 @@ describe("AppDetail", () => {
await flushReact();
}
it("places Test immediately below Setup", () => {
expect(APP_TABS.map((tab) => tab.key)).toEqual([
"setup",
"test",
"review",
"permissions",
"activity",
"advanced",
]);
});
it("pauses the app by flipping the connection enabled flag", async () => {
await renderAppDetail();
@@ -311,19 +323,41 @@ describe("AppDetail", () => {
});
it.each([
["setup", "Agents can use this app"],
["review", "Review 1 new action"],
["permissions", "Action permissions"],
["activity", "No activity yet."],
["advanced", "Technical details"],
])("renders the %s tab panel", async (tab, expectedText) => {
["setup", "Agents can use this app", false],
["review", "Review 1 new action", true],
["permissions", "Action permissions", true],
["activity", "No activity yet.", false],
["advanced", "Technical details", false],
])("renders the %s tab panel", async (tab, expectedText, showsActionCount) => {
mockParams.tab = tab;
await renderAppDetail();
expect(container.textContent).toContain("GitHub");
expect(container.textContent).toContain("2 actions available");
expect(container.textContent?.includes("2 actions available")).toBe(showsActionCount);
expect(container.textContent).toContain(expectedText);
expect(container.querySelector("section.bg-card")).toBeNull();
});
it("renders setup without waiting for tool discovery", async () => {
listCatalogMock.mockImplementation(() => new Promise(() => undefined));
await renderAppDetail();
expect(container.textContent).toContain("Agents can use this app");
expect(container.textContent).not.toContain("Loading tools");
expect(listCatalogMock).not.toHaveBeenCalled();
});
it("shows an explicit lazy-loading state while a tool tab discovers actions", async () => {
mockParams.tab = "permissions";
listCatalogMock.mockImplementation(() => new Promise(() => undefined));
await renderAppDetail();
expect(container.textContent).toContain("GitHub");
expect(container.textContent).toContain("Loading tools…");
expect(container.textContent).not.toContain("Action permissions");
});
it("hides secret URL parameters in advanced technical details", async () => {
@@ -421,6 +455,7 @@ describe("AppDetail", () => {
expect(container.textContent).toContain("Agents can use this app");
expect(container.textContent).not.toContain("Read repo");
expect(container.textContent).not.toContain("Action permissions");
expect(container.querySelector("section.bg-card")).toBeNull();
});
it("shows the Smoke OAuth connection action for the installed HTTP fixture", async () => {
@@ -551,6 +586,7 @@ describe("AppDetail", () => {
const writeSelect = container.querySelector<HTMLSelectElement>('select[aria-label="Write issue permission"]');
expect(readSelect?.value).toBe("allowed");
expect(writeSelect?.value).toBe("ask");
expect(container.querySelector("section.bg-card")).toBeNull();
});
it("persists ask-first for read-only actions from the unified dropdown", async () => {
+83 -36
View File
@@ -62,6 +62,7 @@ export function AppDetail() {
const { setBreadcrumbs } = useBreadcrumbs();
const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null;
const needsCatalog = activeTab === "review" || activeTab === "permissions" || activeTab === "test";
const connectionQuery = useQuery({
queryKey: queryKeys.tools.connection(connectionId),
@@ -81,22 +82,22 @@ export function AppDetail() {
const catalogQuery = useQuery({
queryKey: queryKeys.tools.catalog(connectionId),
queryFn: () => toolsApi.listCatalog(connectionId),
enabled: !!connectionId && !!activeTab,
enabled: !!connectionId && needsCatalog,
});
const profilesQuery = useQuery({
queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
enabled: !!selectedCompanyId && !!activeTab,
enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"),
});
const policiesQuery = useQuery({
queryKey: queryKeys.tools.policies(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listPolicies(selectedCompanyId!),
enabled: !!selectedCompanyId && !!activeTab,
enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"),
});
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && !!activeTab,
enabled: !!selectedCompanyId && (activeTab === "permissions" || activeTab === "activity"),
});
const activityQuery = useQuery({
queryKey: queryKeys.tools.connectionActivity(connectionId),
@@ -359,7 +360,7 @@ export function AppDetail() {
if (!selectedCompanyId) {
return <div className="p-6 text-sm text-muted-foreground">Select a company to manage apps.</div>;
}
if (connectionQuery.isLoading || catalogQuery.isLoading) {
if (connectionQuery.isLoading) {
return (
<div className="max-w-3xl space-y-4">
<Skeleton className="h-10 w-56" />
@@ -385,7 +386,11 @@ export function AppDetail() {
const active = catalog.filter((e) => e.status !== "quarantined" && e.status !== "removed");
const readOnly = active.filter((e) => e.isReadOnly);
const canChange = active.filter((e) => !e.isReadOnly);
const actionCount = active.length;
const actionCount = catalogQuery.data ? active.length : null;
const reviewLoading = catalogQuery.isLoading || profilesQuery.isLoading || policiesQuery.isLoading;
const permissionsLoading = reviewLoading || installsQuery.isLoading || agentsQuery.isLoading;
const reviewFailed = catalogQuery.isError || profilesQuery.isError || policiesQuery.isError;
const permissionsFailed = reviewFailed || installsQuery.isError || agentsQuery.isError;
return (
<div className="max-w-3xl space-y-6 pb-12">
@@ -435,36 +440,58 @@ export function AppDetail() {
/>
)}
{activeTab === "review" && (
<ReviewPanel
connectionId={connectionId}
quarantined={quarantined}
pending={pending}
onReviewQuarantined={reviewQuarantined}
/>
reviewFailed
? <ToolsLoadError onRetry={() => {
void catalogQuery.refetch();
void profilesQuery.refetch();
void policiesQuery.refetch();
}} />
: reviewLoading
? <ToolsLoading />
: <ReviewPanel
connectionId={connectionId}
quarantined={quarantined}
pending={pending}
onReviewQuarantined={reviewQuarantined}
/>
)}
{activeTab === "permissions" && (
<PermissionsPanel
appName={appName}
access={access}
agents={agents}
install={install}
readOnly={readOnly}
canChange={canChange}
quarantined={quarantined}
enabledIds={enabledIds}
askFirstIds={askFirstIds}
pending={pending}
installPending={persistInstall.isPending || installsQuery.isLoading}
refreshPending={refreshTools.isPending}
onSaveAccess={(next) => apply({ access: next })}
onSaveInstall={(next) => persistInstall.mutate(next)}
onRefreshActions={() => refreshTools.mutate()}
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
onReviewQuarantined={reviewQuarantined}
/>
permissionsFailed
? <ToolsLoadError onRetry={() => {
void catalogQuery.refetch();
void profilesQuery.refetch();
void policiesQuery.refetch();
void installsQuery.refetch();
void agentsQuery.refetch();
}} />
: permissionsLoading
? <ToolsLoading />
: <PermissionsPanel
appName={appName}
access={access}
agents={agents}
install={install}
readOnly={readOnly}
canChange={canChange}
quarantined={quarantined}
enabledIds={enabledIds}
askFirstIds={askFirstIds}
pending={pending}
installPending={persistInstall.isPending}
refreshPending={refreshTools.isPending}
onSaveAccess={(next) => apply({ access: next })}
onSaveInstall={(next) => persistInstall.mutate(next)}
onRefreshActions={() => refreshTools.mutate()}
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
onReviewQuarantined={reviewQuarantined}
/>
)}
{activeTab === "test" && (
<TestPanel connectionId={connectionId} appName={appName} active={active} quarantined={quarantined} />
catalogQuery.isError
? <ToolsLoadError onRetry={() => { void catalogQuery.refetch(); }} />
: catalogQuery.isLoading
? <ToolsLoading />
: <TestPanel connectionId={connectionId} appName={appName} active={active} quarantined={quarantined} />
)}
{activeTab === "activity" && (
<ActivityPanel
@@ -515,7 +542,7 @@ function AppDetailHeader({
connection: ToolConnection;
logoEntry: AppGalleryDisplayEntry | null;
status: StatusInfo;
actionCount: number;
actionCount: number | null;
renaming: boolean;
nameDraft: string;
renamePending: boolean;
@@ -573,9 +600,11 @@ function AppDetailHeader({
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-1" /> : null}
<div className="mt-1 flex items-center gap-2">
<StatusBadge status={status} />
<span className="text-xs text-muted-foreground">
{actionCount} {actionCount === 1 ? "action" : "actions"} available
</span>
{actionCount !== null && (
<span className="text-xs text-muted-foreground">
{actionCount} {actionCount === 1 ? "action" : "actions"} available
</span>
)}
</div>
</div>
</div>
@@ -583,6 +612,24 @@ function AppDetailHeader({
);
}
function ToolsLoading() {
return (
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground" role="status">
<Loader2 className="h-4 w-4 animate-spin" />
Loading tools…
</div>
);
}
function ToolsLoadError({ onRetry }: { onRetry: () => void }) {
return (
<div className="space-y-3 py-8">
<p className="text-sm text-destructive">Couldn’t load tools for this app.</p>
<Button size="sm" variant="outline" onClick={onRetry}>Try again</Button>
</div>
);
}
function unverifiedRemoteHost(connection: ToolConnection): string | null {
const sourceTemplateKey = connection.config?.sourceTemplateKey ?? connection.transportConfig.sourceTemplateKey;
if (
+8 -18
View File
@@ -726,7 +726,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
await render();
expect(container.textContent).toContain("Step 1 of 4");
expect(container.textContent).toContain("Step 1 of 3");
expect(container.textContent).toContain("Connect Zapier");
expect(container.textContent).toContain("Add MCP URL");
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
@@ -747,15 +747,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(connectAppMock).toHaveBeenCalledTimes(1);
expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ link: zapierUrl, name: "Zapier" });
expect(container.textContent).toContain("Step 2 of 4");
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
await act(async () => {
buttonByText("Continue with 1 action on")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Step 3 of 4");
expect(container.textContent).toContain("Step 2 of 3");
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
await act(async () => {
@@ -793,7 +785,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
await flushReact();
expect(container.textContent).toContain("Step 4 of 4");
expect(container.textContent).toContain("Step 3 of 3");
expect(container.textContent).toContain("Install Zapier tools?");
expect(container.textContent).toContain("Not yet");
@@ -982,7 +974,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await flushReact();
expect(connectAppMock).toHaveBeenCalledTimes(1);
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=zapier&stage=actions");
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=zapier&stage=access");
const [, input] = connectAppMock.mock.calls[0];
expect(input).toMatchObject({ galleryKey: "zapier", name: "Zapier" });
});
@@ -1156,7 +1148,7 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
.toBe("127.0.0.1:47399/mcp");
});
it("keeps the endpoint host and Unverified server visible from setup through review", async () => {
it("keeps the endpoint host visible while skipping action review", async () => {
await render();
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
@@ -1208,13 +1200,11 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
});
await flushReact();
// Now on the review step — the label has to survive the step change.
expect(container.textContent).toContain("List things");
expect(container.textContent).toContain("Who can use mcp.example.test?");
expect(container.textContent).toContain("Unverified server");
expect(container.textContent).toContain("mcp.example.test");
expect(
Array.from(container.querySelectorAll('[role="switch"]')).map((toggle) => toggle.getAttribute("aria-label")),
).toEqual(["List things allowed", "Search things allowed", "qa_delete_widget allowed"]);
expect(container.textContent).not.toContain("List things");
expect(container.querySelectorAll('[role="switch"]')).toHaveLength(0);
});
it("offers a curated setup as a convenience without leaving the generic flow", async () => {
+16 -192
View File
@@ -22,7 +22,6 @@ import type {
FieldDef,
ToolApplication,
ToolConnection,
ToolAppConnectionActionSummary,
} from "@paperclipai/shared";
import { credentialConfigPath, getAppDefinitionForUrl, getAvailableConnectionMethod, getAvailableConnectionMethods } from "@paperclipai/shared";
import { useNavigate, useParams, useSearchParams } from "@/lib/router";
@@ -67,12 +66,11 @@ import {
} from "./generic-mcp-connect";
import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayload } from "@/lib/tool-installs";
type Step = "gallery" | "key" | "actions" | "who" | "install" | "success";
type Step = "gallery" | "key" | "who" | "install" | "success";
export type OAuthConnectPhase = "entry" | "starting" | "redirecting" | "error";
const ROUTE_STAGE_BY_STEP: Partial<Record<Step, string>> = {
key: "setup",
actions: "actions",
who: "access",
install: "install",
success: "complete",
@@ -86,26 +84,19 @@ function appConnectHref(appKey: string, step: Step): string {
type AppAccessSelection = "all_agents" | { agentIds: string[] };
type InstallMode = "none" | "specific" | "all";
const STEP_LABELS = ["Pick app", "Add your key", "Choose actions", "Choose access", "Install tools"];
const STEP_LABELS = ["Pick app", "Add your key", "Choose access", "Install tools"];
const STEP_INDEX: Record<Exclude<Step, "success">, number> = {
gallery: 0,
key: 1,
actions: 2,
who: 3,
install: 4,
};
const ZAPIER_STEP_INDEX: Record<Exclude<Step, "gallery" | "success">, number> = {
key: 0,
actions: 1,
who: 2,
install: 3,
};
const ZAPIER_STEP_LABELS = ["Add MCP URL", "Choose actions", "Choose access", "Install tools"];
function askFirstLevelsFrom(result: ConnectToolAppResult): string[] {
const raw = (result.suggestedDefaults as { askFirstRiskLevels?: unknown })?.askFirstRiskLevels;
return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : ["write", "destructive"];
}
const ZAPIER_STEP_INDEX: Record<Exclude<Step, "gallery" | "success">, number> = {
key: 0,
who: 1,
install: 2,
};
const ZAPIER_STEP_LABELS = ["Add MCP URL", "Choose access", "Install tools"];
function isGoogleSheetsEntry(entry: AppDefinition | null): boolean {
return entry?.slug === "google-sheets";
@@ -439,11 +430,11 @@ export function AppsConnect() {
setConnectResult(result);
const defaults: Record<string, boolean> = {};
for (const a of result.actions.readOnly) defaults[a.catalogEntryId] = true;
for (const a of result.actions.canMakeChanges) defaults[a.catalogEntryId] = false;
for (const a of result.actions.canMakeChanges) defaults[a.catalogEntryId] = true;
setEnabled(defaults);
setInstallMode("none");
setInstallAgentIds(new Set());
setAppStep("actions");
setAppStep("who");
},
onError: (error) => {
const details = error instanceof ApiError && error.body && typeof error.body === "object"
@@ -553,19 +544,14 @@ export function AppsConnect() {
const finishMutation = useMutation({
mutationFn: async () => {
const askFirstLevels = connectResult ? askFirstLevelsFrom(connectResult) : [];
const changeActions = connectResult?.actions.canMakeChanges ?? [];
const enabledIds = Object.entries(enabled)
.filter(([, on]) => on)
.map(([id]) => id);
const askFirstIds = changeActions
.filter((a) => enabled[a.catalogEntryId] && askFirstLevels.includes(a.riskLevel))
.map((a) => a.catalogEntryId);
const selection: AppAccessSelection =
access === "all" ? "all_agents" : { agentIds: Array.from(agentIds) };
const result = await toolsApi.finishApp(selectedCompanyId!, connectResult!.connectionId, {
enabledCatalogEntryIds: enabledIds,
askFirstCatalogEntryIds: askFirstIds,
askFirstCatalogEntryIds: [],
access: selection,
});
const installState = installMode === "all"
@@ -688,9 +674,9 @@ export function AppsConnect() {
const stepLabels = zapierSource
? ZAPIER_STEP_LABELS
: entry && getAvailableConnectionMethods(entry).length > 1
? ["Pick app", "Choose connection", "Choose actions", "Choose access", "Install tools"]
? ["Pick app", "Choose connection", "Choose access", "Install tools"]
: isGoogleSheetsEntry(entry)
? ["Pick app", "Share sheet", "Choose actions", "Choose access", "Install tools"]
? ["Pick app", "Share sheet", "Choose access", "Install tools"]
: STEP_LABELS;
const stepIndex = zapierSource && step !== "gallery" && step !== "success"
? ZAPIER_STEP_INDEX[step]
@@ -841,24 +827,6 @@ export function AppsConnect() {
/>
)}
{step === "actions" && connectResult && (
<ActionsStep
appName={appName}
result={connectResult}
enabled={enabled}
onToggle={(id, on) => setEnabled((prev) => ({ ...prev, [id]: on }))}
onBulk={(ids, on) =>
setEnabled((prev) => {
const next = { ...prev };
for (const id of ids) next[id] = on;
return next;
})
}
onBack={() => setAppStep("key")}
onContinue={() => setAppStep("who")}
/>
)}
{step === "who" && connectResult && (
<WhoStep
appName={appName}
@@ -867,7 +835,7 @@ export function AppsConnect() {
setAccess={setAccess}
agentIds={agentIds}
setAgentIds={setAgentIds}
onBack={() => setAppStep("actions")}
onBack={() => setAppStep("key")}
onContinue={() => setAppStep("install")}
/>
)}
@@ -1015,7 +983,7 @@ export function OAuthConnectStateScreen({
subtitle="Secure MCP sign-in"
step="key"
activeIndex={0}
labels={["Connect", "Review actions", "Choose access", "Install tools"]}
labels={["Connect", "Choose access", "Install tools"]}
appIdentity={entry ? { name: entry.name, logoUrl: entry.branding.logoUrl } : undefined}
unverifiedHost={unverifiedHost}
onCancel={onCancel}
@@ -1255,7 +1223,7 @@ function GalleryStep({
{zapierSource
? "Paste the complete MCP URL Zapier gives you, including its token."
: byo
? "Paste your MCP server’s URL and we’ll walk you through permissions and review."
? "Paste your MCP server’s URL and every discovered tool will be available immediately."
: "Paste a setup link from an app that is not listed here."}
</p>
{!zapierSource && (
@@ -2100,150 +2068,6 @@ function MethodConfigField({
);
}
function ActionGroup({
title,
hint,
actions,
enabled,
onToggle,
bulkLabel,
onBulk,
askFirstLevels,
}: {
title: string;
hint: string;
actions: ToolAppConnectionActionSummary[];
enabled: Record<string, boolean>;
onToggle: (id: string, on: boolean) => void;
bulkLabel: string;
onBulk: () => void;
askFirstLevels: string[];
}) {
if (actions.length === 0) return null;
return (
<div className="rounded-xl border border-border bg-card">
<div className="flex items-center justify-between border-b border-border px-5 py-2.5 sm:py-3">
<div className="text-sm">
<span className="font-bold text-foreground">{title}</span>
<span className="ml-2 text-muted-foreground">· {hint}</span>
</div>
<button
type="button"
onClick={onBulk}
className="text-xs text-muted-foreground hover:text-foreground"
>
{bulkLabel}
</button>
</div>
<div className="divide-y divide-border">
{actions.map((action) => {
const on = enabled[action.catalogEntryId] ?? false;
const showAskFirst = on && askFirstLevels.includes(action.riskLevel);
return (
<div key={action.catalogEntryId} className="flex items-center gap-4 px-5 py-2.5 sm:py-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">
{action.title ?? action.toolName}
</div>
{action.description && (
<div className="truncate text-xs text-muted-foreground">{action.description}</div>
)}
</div>
{showAskFirst && (
<span className="inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:text-amber-300">
Ask first
</span>
)}
<ToggleSwitch
aria-label={`${action.title ?? action.toolName} allowed`}
checked={on}
onCheckedChange={(next) => onToggle(action.catalogEntryId, next)}
/>
</div>
);
})}
</div>
</div>
);
}
function ActionsStep({
appName,
result,
enabled,
onToggle,
onBulk,
onBack,
onContinue,
}: {
appName: string;
result: ConnectToolAppResult;
enabled: Record<string, boolean>;
onToggle: (id: string, on: boolean) => void;
onBulk: (ids: string[], on: boolean) => void;
onBack: () => void;
onContinue: () => void;
}) {
const askFirstLevels = askFirstLevelsFrom(result);
const { readOnly, canMakeChanges } = result.actions;
const total = readOnly.length + canMakeChanges.length;
const enabledCount = Object.values(enabled).filter(Boolean).length;
return (
<div className="space-y-4 sm:space-y-5">
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full border-2 border-emerald-500 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<Check className="h-3.5 w-3.5" />
</span>
<div>
<div className="text-lg font-bold text-foreground">
Connected to {appName} — it offers {total} {total === 1 ? "action" : "actions"}.
</div>
<div className="text-sm text-muted-foreground">
Read-only actions are on. Anything that can change something starts off — turn on the ones you want.
</div>
</div>
</div>
<ActionGroup
title="Read only"
hint="these can look but not change anything"
actions={readOnly}
enabled={enabled}
onToggle={onToggle}
bulkLabel="Turn all off"
onBulk={() => onBulk(readOnly.map((a) => a.catalogEntryId), false)}
askFirstLevels={askFirstLevels}
/>
<ActionGroup
title="Can make changes"
hint="these change something in another app"
actions={canMakeChanges}
enabled={enabled}
onToggle={onToggle}
bulkLabel="Turn all on"
onBulk={() => onBulk(canMakeChanges.map((a) => a.catalogEntryId), true)}
askFirstLevels={askFirstLevels}
/>
<div className="flex items-center justify-between sm:pt-1">
<Button variant="ghost" onClick={onBack}>
Back
</Button>
<div className="flex items-center gap-3">
<span className="hidden text-xs text-muted-foreground sm:inline">
If {appName} adds new actions later, they start off until you review them.
</span>
<Button onClick={onContinue} disabled={enabledCount === 0}>
Continue with {enabledCount} {enabledCount === 1 ? "action" : "actions"} on
</Button>
</div>
</div>
</div>
);
}
function WhoStep({
appName,
companyId,
+14 -4
View File
@@ -26,10 +26,12 @@ export function ReviewQueueCard({
connectionId,
emptyState = "hidden",
heading = "Waiting for your OK",
plain = false,
}: {
connectionId?: string;
emptyState?: "hidden" | "reassure";
heading?: string;
plain?: boolean;
}) {
const { selectedCompanyId } = useCompany();
@@ -51,7 +53,7 @@ export function ReviewQueueCard({
if (items.length === 0) {
if (emptyState === "hidden") return null;
return (
<div className="rounded-xl border border-border bg-card p-5 text-sm text-muted-foreground">
<div className={plain ? "py-5 text-sm text-muted-foreground" : "rounded-xl border border-border bg-card p-5 text-sm text-muted-foreground"}>
Nothing is waiting for your OK right now.
</div>
);
@@ -68,14 +70,22 @@ export function ReviewQueueCard({
</div>
<div className="space-y-3">
{items.map((item) => (
<ReviewRow key={item.request.id} companyId={selectedCompanyId} item={item} />
<ReviewRow key={item.request.id} companyId={selectedCompanyId} item={item} plain={plain} />
))}
</div>
</section>
);
}
function ReviewRow({ companyId, item }: { companyId: string; item: ToolActionRequestListItem }) {
function ReviewRow({
companyId,
item,
plain,
}: {
companyId: string;
item: ToolActionRequestListItem;
plain: boolean;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [resolving, setResolving] = useState<null | "allow" | "always" | "decline">(null);
@@ -140,7 +150,7 @@ function ReviewRow({ companyId, item }: { companyId: string; item: ToolActionReq
const preview = item.request.previewMarkdown?.trim();
return (
<div className="rounded-xl border border-amber-500/40 bg-amber-500/[0.07] p-4">
<div className={plain ? "py-3" : "rounded-xl border border-amber-500/40 bg-amber-500/[0.07] p-4"}>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
<span className="font-bold text-foreground">{actionLabel(item)}</span>
{item.applicationName && (
@@ -44,8 +44,8 @@ function KeySection({
}) {
const [open, setOpen] = useState(false);
return (
<section className="rounded-xl border border-border bg-card">
<div className="flex items-center justify-between px-5 py-4">
<section>
<div className="flex items-center justify-between">
<div className="flex items-start gap-3">
<Lock className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div>
@@ -62,7 +62,7 @@ function KeySection({
)}
</div>
{open && (
<div className="border-t border-border px-5 py-4">
<div className="pt-4">
<ReconnectForm
connection={connection}
galleryEntry={galleryEntry}
@@ -255,7 +255,7 @@ function ReconnectForm({
function TechnicalDetails({ connection }: { connection: ToolConnection }) {
return (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<section>
<h2 className="text-sm font-bold text-foreground">Technical details</h2>
<dl className="mt-3 grid gap-2 text-xs sm:grid-cols-(--gtc-59)">
<dt className="text-muted-foreground">Address</dt>
@@ -278,11 +278,11 @@ export function DangerZone({
}) {
const [confirming, setConfirming] = useState(false);
return (
<section className="rounded-xl border border-destructive/40 bg-card">
<div className="border-b border-destructive/40 px-5 py-3 text-sm font-bold text-destructive">
<section className="space-y-3">
<div className="text-sm font-bold text-destructive">
Danger zone
</div>
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">Remove this app</p>
<p className="text-xs text-muted-foreground">
@@ -110,8 +110,8 @@ function AccessSection({
const canSave = draft.mode === "all" || draft.agentIds.size > 0;
return (
<section className="rounded-xl border border-border bg-card">
<div className="flex items-center justify-between px-5 py-4">
<section>
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-bold text-foreground">Who can use it</h2>
<p className="mt-0.5 text-sm text-muted-foreground">{summary}</p>
@@ -124,7 +124,7 @@ function AccessSection({
</div>
{editing && (
<div className="space-y-3 border-t border-border px-5 py-4">
<div className="space-y-3 pt-4">
<label className="flex items-start gap-3">
<input
type="radio"
@@ -206,8 +206,8 @@ function InstalledSection({
const installedCount = install.onAll ? liveAgents.length : install.agentIds.size;
return (
<section className="rounded-xl border border-border bg-card">
<div className="flex items-center justify-between gap-3 px-5 py-4">
<section>
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-bold text-foreground">Installed on agents</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
@@ -228,7 +228,7 @@ function InstalledSection({
</div>
</div>
<div className="space-y-3 border-t border-border px-5 py-4">
<div className="space-y-3 pt-4">
<InlineBanner tone="info" compact>
{installInfoNotice(appName)}
</InlineBanner>
@@ -255,12 +255,7 @@ function InstalledSection({
/>
)}
<label
className={cn(
"flex items-start gap-3 rounded-lg border px-3 py-2.5",
install.onAll ? "border-foreground bg-muted/40" : "border-border bg-muted/20",
)}
>
<label className="flex items-start gap-3 py-2.5">
<Checkbox
checked={install.onAll}
disabled={disabled}
@@ -417,8 +412,8 @@ function ActionGroup({
}, [focusId]);
if (actions.length === 0) return null;
return (
<div className="rounded-xl border border-border bg-card">
<div className="border-b border-border px-5 py-3 text-sm">
<div>
<div className="pb-2 text-sm">
<span className="font-bold text-foreground">{title}</span>
<span className="ml-2 text-muted-foreground">- {hint}</span>
</div>
@@ -431,7 +426,7 @@ function ActionGroup({
key={action.id}
ref={focused ? focusRef : undefined}
className={cn(
"flex items-center gap-4 px-5 py-3",
"flex items-center gap-4 py-3",
focused && "rounded-md bg-primary/5 ring-2 ring-primary/40",
)}
data-action-id={action.id}
@@ -26,6 +26,7 @@ export function ReviewPanel({
connectionId={connectionId}
heading="Waiting for your OK"
emptyState={showsQuarantinedActions ? "hidden" : "reassure"}
plain
/>
</div>
);
+9 -9
View File
@@ -80,7 +80,7 @@ function PostHogConfigurationSection({ connection }: { connection: ToolConnectio
["Response mode", typeof config.mode === "string" ? config.mode : "tools"],
];
return (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<section>
<h2 className="text-sm font-bold text-foreground">PostHog access scope</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
This connection is pinned to the project and analytics surface below.
@@ -109,7 +109,7 @@ function OAuthConnectionSection({
onStart: () => void;
}) {
return (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<section>
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="text-sm font-bold text-foreground">
@@ -154,7 +154,7 @@ function GoogleSheetsAllowlistSection({
onUpdateConfig(googleSheetsConfigWithAllowlist(connection.config, nextIds));
return (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<section>
<div>
<h2 className="text-sm font-bold text-foreground">Sheets agents can use</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
@@ -247,7 +247,7 @@ export function AppLifecycleSection({
}) {
const enabled = connection.enabled !== false && connection.status !== "disabled";
return (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<section>
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-sm font-bold text-foreground">
@@ -284,8 +284,8 @@ export function QuarantinedActionsReview({
const count = entries.length;
const selectedIds = entries.filter((entry) => enabledIds.has(entry.id)).map((entry) => entry.id);
return (
<section className="overflow-hidden rounded-xl border border-amber-500/40 bg-amber-500/[0.08]">
<div className="flex flex-wrap items-start justify-between gap-3 px-4 py-3">
<section className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-amber-800 dark:text-amber-200">
Review {count} new {count === 1 ? "action" : "actions"}
@@ -313,12 +313,12 @@ export function QuarantinedActionsReview({
</button>
</div>
</div>
<div className="divide-y divide-amber-500/25 border-y border-amber-500/25 bg-background">
<div className="divide-y divide-border">
{entries.map((entry) => {
const enabled = enabledIds.has(entry.id);
const label = entry.title ?? entry.toolName;
return (
<div key={entry.id} className="flex items-center gap-3 px-4 py-3">
<div key={entry.id} className="flex items-center gap-3 py-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">{label}</div>
{entry.description && (
@@ -342,7 +342,7 @@ export function QuarantinedActionsReview({
);
})}
</div>
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-amber-700 dark:text-amber-300">
{selectedIds.length} of {count} will be on
</span>
@@ -133,6 +133,7 @@ function agent(overrides: Record<string, unknown> = {}) {
role: "engineer",
title: "Engineer",
status: "active",
orgDepth: 1,
effectiveAccess: {
connectionId: "conn-1",
toolCount: 3,
@@ -231,6 +232,23 @@ describe("TestPanel", () => {
expect(container.textContent).toContain("Allowed");
expect(container.textContent).toContain("Ask first");
expect(container.textContent).toContain("Off");
expect(container.querySelector(".bg-card")).toBeNull();
});
it("defaults to the highest-ranked accessible agent", async () => {
listTestAgentsMock.mockResolvedValue({
agents: [
agent({ id: "agent-report", name: "A report", orgDepth: 2 }),
agent({ id: "agent-root", name: "Root agent", orgDepth: 0 }),
agent({ id: "agent-manager", name: "Manager", orgDepth: 1 }),
],
});
await act(async () => renderPanel());
await flushReact();
expect(container.textContent).toContain("Root agent");
expect(container.textContent).not.toContain("A report");
});
it("shows the empty state when there are no actions", async () => {
+16 -14
View File
@@ -136,18 +136,20 @@ export function TestPanel({
});
const agents = useMemo(
() => [...(testAgentsQuery.data?.agents ?? [])].sort((a, b) => a.name.localeCompare(b.name)),
() => [...(testAgentsQuery.data?.agents ?? [])].sort(
(a, b) => a.orgDepth - b.orgDepth || a.name.localeCompare(b.name),
),
[testAgentsQuery.data],
);
const [agentId, setAgentId] = useState<string | null>(null);
// Default to the first agent (alphabetical) that can run at least one action;
// otherwise the first agent we can test as at all.
// The API returns only agents this user may write to. Prefer the highest
// agent in that accessible slice of the org tree, regardless of whether a
// lower-ranked agent happens to have a broader app policy today.
useEffect(() => {
if (agentId && agents.some((a) => a.id === agentId)) return;
if (agents.length === 0) return;
const withAccess = agents.find((a) => a.effectiveAccess.allowedCount > 0);
setAgentId((withAccess ?? agents[0]).id);
setAgentId(agents[0].id);
}, [agents, agentId]);
// Switches the header from "TEST AS" card to the compact "Testing as …" line.
@@ -212,7 +214,7 @@ export function TestPanel({
if (agents.length === 0) {
return (
<div className="rounded-lg border border-border bg-card p-6 text-center">
<div className="py-6 text-center">
<p className="text-sm font-medium text-foreground">No agents to test as</p>
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
Only agents you can assign tasks to can preview {appName}. Give an agent access in{" "}
@@ -266,7 +268,7 @@ export function TestPanel({
</div>
{visibleCount === 0 ? (
<div className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
<div className="py-6 text-center text-sm text-muted-foreground">
No actions match “{query}”. Clear the search to see them all.
</div>
) : (
@@ -311,7 +313,7 @@ export function TestPanel({
function EmptyState({ connectionId, appName }: { connectionId: string; appName: string }) {
return (
<div className="rounded-lg border border-border bg-card p-8 text-center">
<div className="py-8 text-center">
<p className="text-base font-bold text-foreground">Nothing to test yet</p>
<p className="mx-auto mt-1.5 max-w-md text-sm text-muted-foreground">
Once {appName} is connected, the actions it offers will show up here so you can try them out.
@@ -344,7 +346,7 @@ function TestAsHeader({
}) {
if (compact) {
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-3">
<div className="flex flex-wrap items-center justify-between gap-3 pb-3">
<p className="text-sm text-muted-foreground">
Testing as{" "}
<AgentPicker
@@ -361,7 +363,7 @@ function TestAsHeader({
);
}
return (
<div className="rounded-lg border border-border bg-card p-4">
<div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Test as</p>
@@ -546,7 +548,7 @@ function ActionGroup({
<section>
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{heading}</h3>
{subheading && <p className="mb-1.5 -mt-1 text-xs text-muted-foreground">{subheading}</p>}
<div className="divide-y divide-border overflow-hidden rounded-lg border border-border">
<div className="divide-y divide-border">
{entries.map((entry) => (
<ActionRow
key={entry.id}
@@ -599,7 +601,7 @@ function ActionRow({
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border bg-muted/20 px-4 py-4">
<div className="border-t border-border py-4 pl-11">
<ActionTester entry={entry} decision={decision} agent={agent} {...shared} />
</div>
</CollapsibleContent>
@@ -1358,7 +1360,7 @@ function OffExplanation({
return (
<div className="grid gap-3 md:grid-cols-(--gtc-62)">
<div className="space-y-3">
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3">
<div className="flex items-start gap-2">
<Ban className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">{title} is off for {agent.name}.</p>
@@ -1378,7 +1380,7 @@ function OffExplanation({
<p className="text-xs text-muted-foreground">No call will be made — this action is off for {agent.name}.</p>
</div>
<aside className="rounded-md border border-border bg-card p-3">
<aside>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Why this is off</p>
<p className="mt-1.5 text-xs text-muted-foreground">{whyBody}</p>
{auditHint && <p className="mt-1.5 text-(length:--text-micro) text-muted-foreground">{auditHint}</p>}
+1 -1
View File
@@ -2,10 +2,10 @@ import { Activity, Beaker, Inbox, Settings2, ShieldCheck, Wrench } from "lucide-
export const APP_TABS = [
{ key: "setup", label: "Setup", icon: Settings2 },
{ key: "test", label: "Test", icon: Beaker },
{ key: "review", label: "Review", icon: Inbox },
{ key: "permissions", label: "Permissions", icon: ShieldCheck },
{ key: "activity", label: "Activity", icon: Activity },
{ key: "test", label: "Test", icon: Beaker },
{ key: "advanced", label: "Advanced", icon: Wrench },
] as const;
+8 -2
View File
@@ -128,7 +128,12 @@ const DECISIONS: Record<string, ToolConnectionTestDecision> = {
delete_row: "off",
};
function buildAgent(id: string, name: string, decisions: Record<string, ToolConnectionTestDecision>): ToolConnectionTestAgent {
function buildAgent(
id: string,
name: string,
decisions: Record<string, ToolConnectionTestDecision>,
orgDepth = 1,
): ToolConnectionTestAgent {
const tools = CATALOG.map((entry) => decisionTool(entry, decisions[entry.toolName]));
return {
id,
@@ -136,6 +141,7 @@ function buildAgent(id: string, name: string, decisions: Record<string, ToolConn
role: "engineer",
title: "Engineer",
status: "active",
orgDepth,
effectiveAccess: {
connectionId: CONNECTION,
toolCount: tools.length,
@@ -151,7 +157,7 @@ function buildAgent(id: string, name: string, decisions: Record<string, ToolConn
}
const AGENTS: ToolConnectionTestAgent[] = [
buildAgent("agent-claude", "ClaudeCoder", DECISIONS),
buildAgent("agent-claude", "ClaudeCoder", DECISIONS, 0),
buildAgent("agent-codex", "CodexCoder", {
...DECISIONS,
create_sheet: "ask_first",