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;
}