mcp-tool-policy.ts5.5 KBView on GitHub /**
* Client-side mirror of the MCP tool-policy wire shape, plus the pure helpers the
* permissions UI is built from.
*
* The types intentionally duplicate `apps/server/src/services/integrations/mcp/types.ts`
* rather than importing it: this module is rendered in jest and must not drag the
* server bundle (drizzle, zod schemas, node crypto) into a jsdom worker. The shape is
* small and is validated server-side by `McpToolPolicySchema` on every write, so a
* drift here fails loudly at `setMcpToolPolicy` instead of silently.
*/
export interface McpArgumentRule {
field: string;
required?: boolean;
matches?: string;
oneOf?: string[];
}
export interface McpToolRule {
toolName: string;
allowed: boolean;
description?: string;
instruction?: string;
requireApproval?: boolean;
pinnedArguments?: Record<string, unknown>;
argumentRules?: McpArgumentRule[];
}
export interface McpToolPolicy {
mode: 'allow_all' | 'allowlist';
rules: McpToolRule[];
lastReviewedAt?: string;
discoveredAt?: string;
}
/** The digest `integrations.listMcpConnections` carries on every row. */
export interface McpToolPolicyDigest {
mode: 'allow_all' | 'allowlist';
allowedCount: number;
ruleCount: number;
discoveredAt?: string | null;
lastReviewedAt?: string | null;
}
/**
* One checklist row. Mirrors `describeToolsAgainstPolicy` on the server so the same
* component renders a pre-save probe result and a saved connection's live tool list.
*/
export interface McpToolRow {
name: string;
description: string;
allowed: boolean;
/** Present in a fresh tools/list but absent from the saved rules. */
isNew?: boolean;
instruction?: string;
requireApproval?: boolean;
argumentRules?: McpArgumentRule[];
pinnedArguments?: Record<string, unknown>;
}
export type McpProbeStatus = 'idle' | 'probing' | 'ready' | 'failed';
/**
* Rows for a server that has just been probed but not yet saved. Deny-by-default:
* a connection is inert until the user ticks something, and nothing is ever "new"
* on a connection that has no history to be new against.
*/
export function toolRowsFromProbe(tools: { name: string; description?: string }[]): McpToolRow[] {
return tools.map((tool) => ({
name: tool.name,
description: tool.description ?? '',
allowed: false,
}));
}
/** Immutably patch one row by tool name; unknown names are a no-op, never an insert. */
export function patchToolRow(
rows: McpToolRow[],
toolName: string,
patch: Partial<McpToolRow>,
): McpToolRow[] {
return rows.map((row) => (row.name === toolName ? { ...row, ...patch } : row));
}
/**
* Build the whole policy from the checklist. The write path is whole-policy, not a
* patch — a permission surface where a partial write can leave a half-applied state
* is one nobody can reason about.
*
* Mode is always 'allowlist': saving the checklist IS the migration off 'allow_all'.
*/
export function buildPolicyFromRows(
rows: McpToolRow[],
existing?: Pick<McpToolPolicy, 'discoveredAt'>,
): McpToolPolicy {
return {
mode: 'allowlist',
rules: rows.map((row) => ({
toolName: row.name,
allowed: row.allowed,
...(row.description ? { description: row.description } : {}),
...(row.instruction?.trim() ? { instruction: row.instruction.trim() } : {}),
...(row.requireApproval ? { requireApproval: true } : {}),
...(row.argumentRules?.length ? { argumentRules: row.argumentRules } : {}),
...(row.pinnedArguments ? { pinnedArguments: row.pinnedArguments } : {}),
})),
...(existing?.discoveredAt ? { discoveredAt: existing.discoveredAt } : {}),
};
}
/**
* A connection left on the pre-policy default. Every connection that existed before
* the policy shipped was backfilled to 'allow_all' so the deploy was a behaviour
* no-op; this is what makes that migration visible instead of silent.
*/
export function needsPermissionReview(policy?: McpToolPolicyDigest | null): boolean {
return policy?.mode === 'allow_all';
}
export function countAllowed(rows: McpToolRow[]): number {
return rows.filter((row) => row.allowed).length;
}
export function countNewTools(rows: McpToolRow[]): number {
return rows.filter((row) => row.isNew).length;
}
/**
* A read-only chip label for one argument constraint. Editing constraints is not a
* UI affordance yet (they are authored via the CLI / API); showing them is, because
* a permission the user cannot see is a permission they cannot audit.
*/
export function describeArgumentRule(rule: McpArgumentRule): string {
if (rule.oneOf?.length) return `${rule.field} is one of ${rule.oneOf.join(', ')}`;
if (rule.matches) return `${rule.field} matches ${rule.matches}`;
if (rule.required) return `${rule.field} is required`;
return rule.field;
}
export function describePinnedArgument(field: string, value: unknown): string {
return `${field} = ${typeof value === 'string' ? value : JSON.stringify(value)}`;
}
/**
* Whether the Connect / Save button is live.
*
* Deliberately independent of the probe: an unreachable or slow-to-warm MCP server
* is a normal state, and refusing to save the connection because its server was
* briefly down would strand the user with no way to retry later. The saved
* connection is deny-by-default, so saving without a tool list grants nothing.
*/
export function canSubmitConnectionForm(input: {
name: string;
serverUrl: string;
probeStatus: McpProbeStatus;
isSaving: boolean;
}): boolean {
if (input.isSaving) return false;
if (input.probeStatus === 'probing') return false;
return input.name.trim().length > 0 && input.serverUrl.trim().length > 0;
}