diff --git a/docs/api-reference.md b/docs/api-reference.md index 1c3a3feb8..cb06437eb 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -251,6 +251,23 @@ Response: } ``` +### Replay a Tool Call + +When viewing an audit log entry in the dashboard (`/logs`), click **Copy as cURL** in the expanded row to copy a command that replays the recorded MCP `tools/call` against the server's endpoint: + +```bash +curl -sS -X POST 'http://localhost:4000/mcp/' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2025-06-18" \ + -H "X-API-Key: $AMCP_MCP_API_KEY" \ + --data-binary @- <<'JSON' +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"","arguments":{...}}} +JSON +``` + +Export your key as `AMCP_MCP_API_KEY` (or `AMCP_ACCESS_TOKEN` when the server operates in OAuth2 mode) to run the command directly from your terminal. Arguments whose names look like credentials (passwords, tokens, keys) are replaced with `[REDACTED]`, and the command then starts with a comment saying so: fill them in before running it. + --- ## Roles & Access Control diff --git a/packages/frontend/src/app/logs/page.tsx b/packages/frontend/src/app/logs/page.tsx index 8ccae1c7f..33f37863d 100644 --- a/packages/frontend/src/app/logs/page.tsx +++ b/packages/frontend/src/app/logs/page.tsx @@ -2,20 +2,24 @@ import { Fragment, useEffect, useState, useCallback } from 'react'; import { useAuth } from '@/lib/auth-context'; -import { audit, connectors as connectorsApi, mcpServers } from '@/lib/api'; +import { audit, connectors as connectorsApi, mcpServers, server } from '@/lib/api'; import { AppSelect } from '@/components/ui/select'; import { AppShell } from '@/components/app-shell'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { StatusPill, type Tone } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; +import { copyText } from '@/lib/clipboard'; +import { buildToolCallCurl } from '@/lib/curl'; +import { getApiOrigin } from '@/lib/origin'; const PAGE_SIZE = 50; const GRID_COLS = '90px 1.5fr 1.2fr 70px 90px 90px'; -function parseClientInfo(raw: string | null | undefined) { +function parseClientInfo(raw: any) { if (!raw) return null; + if (typeof raw === 'object') return raw; try { return JSON.parse(raw); } catch { @@ -93,6 +97,55 @@ export default function LogsPage() { const [expandedId, setExpandedId] = useState(null); const [page, setPage] = useState(0); const [hasMore, setHasMore] = useState(true); + const [authMode, setAuthMode] = useState(null); + const [copiedId, setCopiedId] = useState(null); + const [copyErrorId, setCopyErrorId] = useState(null); + + // Load server info to determine MCP auth mode (legacy, both, oauth2) + useEffect(() => { + server.info().then((info) => { + if (info?.mcpAuthMode) setAuthMode(info.mcpAuthMode); + }).catch(() => {}); + }, []); + + const handleCopyCurl = async (log: any) => { + const serverId = log.mcpServer?.id || log.mcpServerId; + // Only the tool's name can be replayed; toolId is a database id. + const toolName = log.tool?.name; + if (!serverId || !toolName) return; + + let currentAuthMode = authMode; + if (!currentAuthMode) { + try { + const info = await server.info(); + if (info?.mcpAuthMode) { + currentAuthMode = info.mcpAuthMode; + setAuthMode(currentAuthMode); + } + } catch {} + } + + const ci = parseClientInfo(log.clientInfo); + const origin = getApiOrigin(); + const command = buildToolCallCurl({ + origin, + serverId, + toolName, + args: log.input, + authMode: currentAuthMode || 'both', + authMethod: ci?.authMethod, + }); + + const ok = await copyText(command); + if (ok) { + setCopiedId(log.id); + setCopyErrorId(null); + setTimeout(() => setCopiedId((curr) => (curr === log.id ? null : curr)), 2000); + } else { + setCopyErrorId(log.id); + setTimeout(() => setCopyErrorId((curr) => (curr === log.id ? null : curr)), 3000); + } + }; // Debounced search const [debouncedSearch, setDebouncedSearch] = useState(''); @@ -397,24 +450,79 @@ export default function LogsPage() { -
-
- User: - -
- {(() => { - const ci = parseClientInfo(log.clientInfo); - if (!ci) return null; - return ( - <> - {ci.authMethod && Auth: {authMethodLabel(ci.authMethod)}} - {ci.apiKeyName && Key: {ci.apiKeyName}} - - ); - })()} - {log.mcpServer && Server: {log.mcpServer.name}} - {log.tool?.connector && Connector: {log.tool.connector.name} ({log.tool.connector.type})} -
+ {(() => { + const isSharedEndpoint = !log.mcpServer; + const isTruncated = Boolean( + log.input && + typeof log.input === 'object' && + '_amcp_truncated' in log.input, + ) || (typeof log.input === 'string' && log.input.includes('_amcp_truncated')); + const cannotCopyReason = isSharedEndpoint + ? 'Replay unavailable: call ran through the shared /mcp endpoint' + : isTruncated + ? 'Replay unavailable: stored input parameters were truncated' + : !log.tool?.name + ? 'Replay unavailable: tool name is missing' + : null; + const canCopy = !cannotCopyReason; + + return ( +
+
+
+ User: + +
+ {(() => { + const ci = parseClientInfo(log.clientInfo); + if (!ci) return null; + return ( + <> + {ci.authMethod && Auth: {authMethodLabel(ci.authMethod)}} + {ci.apiKeyName && Key: {ci.apiKeyName}} + + ); + })()} + {log.mcpServer && Server: {log.mcpServer.name}} + {log.tool?.connector && Connector: {log.tool.connector.name} ({log.tool.connector.type})} +
+ +
+ + + +
+
+ ); + })()} )} diff --git a/packages/frontend/src/app/mcp-server/[id]/page.tsx b/packages/frontend/src/app/mcp-server/[id]/page.tsx index 234485471..44151b786 100644 --- a/packages/frontend/src/app/mcp-server/[id]/page.tsx +++ b/packages/frontend/src/app/mcp-server/[id]/page.tsx @@ -11,6 +11,7 @@ import { Badge, StatusPill, type Tone } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { ConnectionCheck } from '@/components/connection-check'; import { copyText } from '@/lib/clipboard'; +import { getApiOrigin } from '@/lib/origin'; import { CLAUDE_DIRECTORY_URL } from '@/lib/marketing'; import { INSPECTOR_URL, inspectorCommand } from '@/lib/inspector-url'; @@ -99,11 +100,7 @@ export default function McpServerDetailPage() { }).catch(() => {}).finally(() => setLoading(false)); }, [token, id]); - const apiUrl = typeof window !== 'undefined' - ? window.location.hostname === 'localhost' - ? `${window.location.protocol}//${window.location.hostname}:4000` - : window.location.origin - : 'http://localhost:4000'; + const apiUrl = getApiOrigin(); const handleSave = async () => { if (!token || !id) return; diff --git a/packages/frontend/src/app/page.tsx b/packages/frontend/src/app/page.tsx index 3f39cc1fa..07bc26f24 100644 --- a/packages/frontend/src/app/page.tsx +++ b/packages/frontend/src/app/page.tsx @@ -8,6 +8,7 @@ import { AppShell } from '@/components/app-shell'; import { Card } from '@/components/ui/card'; import { StatCard } from '@/components/ui/stat-card'; import { Button } from '@/components/ui/button'; +import { getApiOrigin } from '@/lib/origin'; type HealthResult = { total: number; healthy: number; unhealthy: number; connectors: any[] } | null; @@ -85,11 +86,7 @@ export default function DashboardPage() { if (isLoading) return null; - const apiUrl = typeof window !== 'undefined' - ? window.location.hostname === 'localhost' - ? `${window.location.protocol}//${window.location.hostname}:4000` - : window.location.origin - : 'http://localhost:4000'; + const apiUrl = getApiOrigin(); const maxTotal = analytics?.daily?.length ? Math.max(...analytics.daily.map((d) => d.success + d.error + d.timeout), 1) diff --git a/packages/frontend/src/lib/curl.ts b/packages/frontend/src/lib/curl.ts new file mode 100644 index 000000000..c5c149ca8 --- /dev/null +++ b/packages/frontend/src/lib/curl.ts @@ -0,0 +1,244 @@ +/** + * Sensitive key pattern matching credential field names across casing styles. + * Matches: secret, password, passwd, pwd, token, jwt, assertion, credential, + * code_verifier, code_challenge, authorization, authentication, cookie, session, bearer, + * private_key, and key variants (api_key, access_key, privkey, ssh_key, etc.). + */ +const SENSITIVE_KEY_PATTERN = + /secret|password|passwd|pwd|passcode|token|jwt|assertion|credential|code_verifier|code_challenge|authorization|authenticat|cookie|session|bearer|priv[-_]?key|private[-_]?key|(?:api|access|ssh|signing|encryption)[-_]?key/i; + +/** + * Checks whether an argument key name is sensitive. + * Matches credential keywords and 'auth' variations (auth, auth_key, authHeader, clientAuth) + * while strictly avoiding false-positives on words like 'author' or 'authority'. + */ +export function isSensitiveKey(key: string): boolean { + if (SENSITIVE_KEY_PATTERN.test(key)) return true; + return ( + /(?:^|[_\-])(?:auth|pin)(?:[_\-]|$)/i.test(key) || + /^auth[A-Z]/.test(key) || + /[a-z]Auth(?:[A-Z]|$)/.test(key) + ); +} + +export const REDACTED_PLACEHOLDER = '[REDACTED]'; +export const TRUNCATED_DEPTH_PLACEHOLDER = '[TRUNCATED:depth]'; +/** Deeper than this, a whole subtree is replaced rather than walked. */ +export const MAX_REDACTION_DEPTH = 10; + +/** + * Detects common sensitive credential value patterns in strings: + * - PEM Private keys (-----BEGIN ... PRIVATE KEY-----) + * - JWT tokens (header.payload.signature) + * - Bearer authorization headers + * - Common token prefixes (ghp_, gho_, xoxb-, xoxp-, sk-, AKIA, amcp_) + */ +const SENSITIVE_VALUE_PATTERNS: RegExp[] = [ + /-----BEGIN [A-Z0-9_\- ]*PRIVATE KEY-----/i, + /^eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]+$/, + /^Bearer\s+[a-zA-Z0-9_\-\.~+/]+=*$/i, + /^(?:gh[pousr]_[A-Za-z0-9_]{30,}|github_pat_[A-Za-z0-9_]{40,}|glpat-[A-Za-z0-9_-]{20,}|xox[abp]-[A-Za-z0-9-]{10,}|(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{16,}|sk-(?:live-|ant-)?[a-zA-Z0-9_-]{20,}|AKIA[0-9A-Z]{16}|amcp_[a-zA-Z0-9_-]{16,}|AIza[0-9A-Za-z_-]{35}|EAA[A-Za-z0-9]{60,}|shp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}|\d{6,12}:[A-Za-z0-9_-]{30,64})$/, +]; + +function redactSensitiveString(val: string): string { + const trimmed = val.trim(); + for (const pattern of SENSITIVE_VALUE_PATTERNS) { + if (pattern.test(trimmed)) { + return REDACTED_PLACEHOLDER; + } + } + if (val.includes('://') && val.includes('@')) { + try { + const u = new URL(val); + if (u.username || u.password) { + return REDACTED_PLACEHOLDER; + } + } catch { + // not a valid parseable URL, keep as is + } + } + return val; +} + +export interface RedactionTracker { + redactedCount: number; +} + +/** + * Recursively redacts sensitive keys and values matching credential patterns. + * Depth-bounded: values at or beyond MAX_REDACTION_DEPTH are truncated to prevent + * bypass via nesting. + */ +export function redactSensitiveArgs( + val: unknown, + depth = 0, + tracker?: RedactionTracker, +): unknown { + if (val === null || val === undefined) return val; + + if (depth >= MAX_REDACTION_DEPTH) { + if (typeof val === 'object') { + if (tracker) tracker.redactedCount++; + return TRUNCATED_DEPTH_PLACEHOLDER; + } + return val; + } + + if (typeof val === 'string') { + const redacted = redactSensitiveString(val); + if (redacted !== val) { + if (tracker) tracker.redactedCount++; + return redacted; + } + return val; + } + + if (Array.isArray(val)) { + return val.map((item) => redactSensitiveArgs(item, depth + 1, tracker)); + } + + if (typeof val === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(val as Record)) { + if (isSensitiveKey(k)) { + out[k] = REDACTED_PLACEHOLDER; + if (tracker) tracker.redactedCount++; + } else { + out[k] = redactSensitiveArgs(v, depth + 1, tracker); + } + } + return out; + } + + return val; +} + +export interface BuildToolCallCurlOptions { + origin: string; + serverId: string; + toolName: string; + args?: unknown; + authMode?: string; + authMethod?: string; +} + +/** + * Builds a cURL command to replay an MCP tool call against an AnythingMCP per-server endpoint. + * + * The per-server endpoint (/mcp/:serverId) runs in stateless mode by default and accepts + * a bare `tools/call` request with MCP-Protocol-Version: 2025-06-18 directly, without + * requiring a preceding `initialize` handshake on a local instance. + * + * Uses single-quoted URL with encodeURIComponent and a quoted heredoc (<<'JSON') so that + * special characters (quotes, backticks, $, newlines) survive intact without shell injection. + * + * Security: + * - Credentials are placeholder environment variables ($AMCP_MCP_API_KEY, $AMCP_ACCESS_TOKEN), never actual secrets. + * - Sensitive keys (passwords, tokens, secrets, jwt, session, keys) and sensitive values are recursively redacted. + * - If arguments contain redacted values or truncation markers, explicit warnings are emitted in the header comments. + * - Dashboard session tokens (amcp_token) are never inserted. + */ +export function buildToolCallCurl({ + origin, + serverId, + toolName, + args, + authMode, + authMethod, +}: BuildToolCallCurlOptions): string { + const cleanOrigin = origin.replace(/\/+$/, ''); + // Single quotes stop the shell from expanding anything in the URL; the + // encoded server id and an http(s) origin cannot contain one. + const url = `${cleanOrigin}/mcp/${encodeURIComponent(serverId)}`.replace(/'/g, '%27'); + + let authHeader: string | null = null; + if (authMethod === 'jwt' || authMethod === 'static_bearer') { + authHeader = '-H "Authorization: Bearer $AMCP_ACCESS_TOKEN"'; + } else if (authMethod === 'mcp_api_key' || authMethod === 'static_api_key') { + authHeader = '-H "X-API-Key: $AMCP_MCP_API_KEY"'; + } else if (authMethod === 'none') { + authHeader = null; + } else if (authMode === 'oauth2') { + authHeader = '-H "Authorization: Bearer $AMCP_ACCESS_TOKEN"'; + } else if (authMode === 'none') { + authHeader = null; + } else { + authHeader = '-H "X-API-Key: $AMCP_MCP_API_KEY"'; + } + + let argumentsObj: unknown = args; + let wasTruncated = false; + + if (typeof args === 'string') { + try { + argumentsObj = JSON.parse(args); + } catch { + argumentsObj = {}; + } + } + + if (!argumentsObj || typeof argumentsObj !== 'object' || Array.isArray(argumentsObj)) { + argumentsObj = {}; + } else if ('_amcp_truncated' in (argumentsObj as Record)) { + wasTruncated = true; + const copy = { ...(argumentsObj as Record) }; + delete copy._amcp_truncated; + argumentsObj = copy; + } + + const tracker: RedactionTracker = { redactedCount: 0 }; + const safeArguments = redactSensitiveArgs(argumentsObj, 0, tracker); + + const payload = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: toolName, + arguments: safeArguments, + }, + }); + + // Protect against heredoc breakout if the payload contains delimiter patterns + let delimiter = 'JSON'; + if ( + payload.includes('\nJSON') || + payload.includes('\\nJSON') || + payload.startsWith('JSON\n') || + payload.startsWith('JSON\\n') || + payload === 'JSON' + ) { + delimiter = 'EOF_AMCP_JSON'; + let counter = 1; + while (payload.includes(delimiter)) { + delimiter = `EOF_AMCP_JSON_${counter++}`; + } + } + + const lines: string[] = []; + if (tracker.redactedCount > 0) { + lines.push(`# Some arguments were replaced with ${REDACTED_PLACEHOLDER}: fill them in before running.`); + } + if (wasTruncated) { + lines.push('# WARNING: Stored input arguments were truncated in the audit log; replay may be incomplete.'); + } + + const headers = [ + ' -H "Content-Type: application/json" \\', + ' -H "Accept: application/json, text/event-stream" \\', + ' -H "MCP-Protocol-Version: 2025-06-18" \\', + ]; + if (authHeader) { + headers.push(` ${authHeader} \\`); + } + + lines.push( + `curl -sS -X POST '${url}' \\`, + ...headers, + ` --data-binary @- <<'${delimiter}'`, + payload, + delimiter, + ); + + return lines.join('\n'); +} diff --git a/packages/frontend/src/lib/origin.ts b/packages/frontend/src/lib/origin.ts new file mode 100644 index 000000000..ca1933c2a --- /dev/null +++ b/packages/frontend/src/lib/origin.ts @@ -0,0 +1,12 @@ +/** + * Returns the base origin/URL for API calls and MCP endpoints. + * + * In local development, the Next.js frontend runs on :3000 while the NestJS + * backend runs on :4000. In production / cloud, requests share the same origin. + */ +export function getApiOrigin(): string { + if (typeof window === 'undefined') return 'http://localhost:4000'; + return window.location.hostname === 'localhost' + ? `${window.location.protocol}//${window.location.hostname}:4000` + : window.location.origin; +} diff --git a/packages/frontend/tests/e2e/audit-log-curl.spec.ts b/packages/frontend/tests/e2e/audit-log-curl.spec.ts new file mode 100644 index 000000000..d5272fcfe --- /dev/null +++ b/packages/frontend/tests/e2e/audit-log-curl.spec.ts @@ -0,0 +1,518 @@ +import { expect, test, type Page } from '@playwright/test'; +import { + buildToolCallCurl, + redactSensitiveArgs, + REDACTED_PLACEHOLDER, + TRUNCATED_DEPTH_PLACEHOLDER, +} from '../../src/lib/curl'; + +const USER = { + id: 'u1', + email: 'test@example.com', + name: 'Test User', + role: 'ADMIN', + organizationId: 'o1', + emailVerified: true, +}; + +async function fakeSession(page: Page, authMode = 'both') { + await page.context().addCookies([ + { name: 'amcp_token', value: 'test-token', url: 'http://localhost:3100' }, + ]); + await page.addInitScript((user) => { + localStorage.setItem('amcp_token', 'test-token'); + localStorage.setItem('amcp_user', JSON.stringify(user)); + }, USER); + + await page.route(/\/(api|health)\//, async (route) => { + const url = route.request().url(); + const json = (body: unknown) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + + if (url.includes('/health/server-info')) { + return json({ + deploymentMode: 'self-hosted', + mcpAuthMode: authMode, + serverUrl: 'http://localhost:4000', + mcpEndpoint: 'http://localhost:4000/mcp', + hasUsers: true, + registrationEnabled: true, + ssoProviders: [], + }); + } + if (url.includes('/api/users/me/onboarding-state')) return json({ onboardingCompletedAt: '2026-01-01T00:00:00Z' }); + if (url.includes('/api/users/me')) return json(USER); + if (url.includes('/api/organizations/current')) return json({ id: 'o1', name: 'Acme', createdAt: '2026-01-01' }); + if (url.includes('/api/organizations/mine')) return json([{ id: 'o1', name: 'Acme', role: 'ADMIN', joinedAt: '2026-01-01' }]); + if (url.includes('/api/license/status')) return json({ plan: 'community', status: 'active' }); + if (url.includes('/api/connectors')) return json([]); + if (url.includes('/api/mcp-servers')) return json([{ id: 'srv-1', name: 'Test Server', slug: 'test-server' }]); + if (url.includes('/api/audit/stats')) return json({ invocations24h: 4, errors24h: 0 }); + if (url.includes('/api/audit/analytics')) return json({ daily: [], topTools: [], totalInvocations: 4, successRate: 100, avgDuration: 10 }); + if (url.includes('/api/audit/invocations')) { + return json([ + { + id: 'log-1', + createdAt: new Date().toISOString(), + status: 'SUCCESS', + durationMs: 42, + mcpServerId: 'srv-1', + mcpServer: { id: 'srv-1', name: 'Test Server', slug: 'test-server' }, + toolId: 't-1', + tool: { name: 'echo_message', connector: { name: 'Echo Service', type: 'REST' } }, + input: { message: 'hello world', target: 'cli' }, + output: { result: 'ok' }, + clientInfo: JSON.stringify({ authMethod: 'mcp_api_key', apiKeyName: 'test-key' }), + }, + { + id: 'log-2', + createdAt: new Date().toISOString(), + status: 'SUCCESS', + durationMs: 30, + mcpServerId: null, + mcpServer: null, + toolId: 't-2', + tool: { name: 'shared_calc', connector: { name: 'Calc', type: 'REST' } }, + input: { expr: '2+2' }, + output: { result: 4 }, + clientInfo: null, + }, + { + id: 'log-3', + createdAt: new Date().toISOString(), + status: 'SUCCESS', + durationMs: 15, + mcpServerId: 'srv-1', + mcpServer: { id: 'srv-1', name: 'Test Server', slug: 'test-server' }, + toolId: 't-3', + tool: { name: 'large_fetch', connector: { name: 'Data', type: 'REST' } }, + input: { _amcp_truncated: true, items: ['cut...'] }, + output: { count: 100 }, + clientInfo: null, + }, + { + id: 'log-4', + createdAt: new Date().toISOString(), + status: 'SUCCESS', + durationMs: 12, + mcpServerId: 'srv-1', + mcpServer: { id: 'srv-1', name: 'Test Server', slug: 'test-server' }, + toolId: 't-missing-name', + tool: null, + input: { test: 'val' }, + output: { ok: true }, + clientInfo: null, + }, + ]); + } + return json({}); + }); +} + +test.describe('buildToolCallCurl builder unit tests', () => { + test('generates expected cURL command with single-quoted URL and placeholders for legacy/both auth mode', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-123', + toolName: 'my_tool', + args: { query: 'test', limit: 10 }, + authMode: 'both', + }); + + expect(cmd).toContain("curl -sS -X POST 'http://localhost:4000/mcp/srv-123' \\"); + expect(cmd).toContain('-H "Content-Type: application/json" \\'); + expect(cmd).toContain('-H "Accept: application/json, text/event-stream" \\'); + expect(cmd).toContain('-H "MCP-Protocol-Version: 2025-06-18" \\'); + expect(cmd).toContain('-H "X-API-Key: $AMCP_MCP_API_KEY" \\'); + expect(cmd).toContain("--data-binary @- <<'JSON'"); + expect(cmd).toContain('{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"my_tool","arguments":{"query":"test","limit":10}}}'); + expect(cmd.endsWith('JSON')).toBe(true); + }); + + test('generates Authorization: Bearer placeholder for oauth2 auth mode', () => { + const cmd = buildToolCallCurl({ + origin: 'https://cloud.anythingmcp.com', + serverId: 'srv-abc', + toolName: 'fetch_user', + args: { id: 'u123' }, + authMode: 'oauth2', + }); + + expect(cmd).toContain("curl -sS -X POST 'https://cloud.anythingmcp.com/mcp/srv-abc' \\"); + expect(cmd).toContain('-H "Authorization: Bearer $AMCP_ACCESS_TOKEN" \\'); + expect(cmd).not.toContain('$AMCP_MCP_API_KEY'); + expect(cmd).not.toContain('test-token'); + }); + + test('supports authMethod parameter from clientInfo (jwt, static_bearer, mcp_api_key, none)', () => { + const cmdJwt = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'tool1', + args: {}, + authMode: 'both', + authMethod: 'jwt', + }); + expect(cmdJwt).toContain('-H "Authorization: Bearer $AMCP_ACCESS_TOKEN" \\'); + expect(cmdJwt).not.toContain('$AMCP_MCP_API_KEY'); + + const cmdStaticBearer = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'tool1', + args: {}, + authMode: 'both', + authMethod: 'static_bearer', + }); + expect(cmdStaticBearer).toContain('-H "Authorization: Bearer $AMCP_ACCESS_TOKEN" \\'); + + const cmdApiKey = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'tool1', + args: {}, + authMode: 'oauth2', + authMethod: 'mcp_api_key', + }); + expect(cmdApiKey).toContain('-H "X-API-Key: $AMCP_MCP_API_KEY" \\'); + + const cmdNone = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'tool1', + args: {}, + authMode: 'both', + authMethod: 'none', + }); + expect(cmdNone).not.toContain('$AMCP_MCP_API_KEY'); + expect(cmdNone).not.toContain('$AMCP_ACCESS_TOKEN'); + }); + + test('encodes serverId with encodeURIComponent and quotes URL with single quotes for shell safety', () => { + const trickyServerId = "srv$123 `rm -rf` 'quoted' space/slash"; + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: trickyServerId, + toolName: 'ping', + args: {}, + }); + + const expectedEncoded = encodeURIComponent(trickyServerId).replace(/'/g, '%27'); + expect(cmd).toContain(`'http://localhost:4000/mcp/${expectedEncoded}'`); + expect(cmd).not.toContain('$123'); + expect(cmd).not.toContain('`rm -rf`'); + }); + + test('keeps shell characters in the server id out of the shell', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: "a'$(id)`x`", + toolName: 't', + args: {}, + }); + const urlLine = cmd.split('\n').find((l) => l.startsWith('curl '))!; + expect(urlLine).toBe("curl -sS -X POST 'http://localhost:4000/mcp/a%27%24(id)%60x%60' \\"); + }); + + test('normalizes trailing slashes on origin', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000///', + serverId: 'srv-1', + toolName: 'ping', + args: {}, + }); + + expect(cmd).toContain("'http://localhost:4000/mcp/srv-1'"); + }); + + test('preserves single quotes, double quotes, dollar signs, backticks, and newlines in args', () => { + const trickyArgs = { + message: "it's a test with \"quotes\", $VAR, `cmd`, and \n newline", + unicode: '🚀 日本語 test', + }; + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'tricky_tool', + args: trickyArgs, + }); + + expect(cmd).toContain("--data-binary @- <<'JSON'"); + const jsonLine = cmd.split('\n').find((l) => l.startsWith('{"jsonrpc"')); + expect(jsonLine).toBeDefined(); + const parsed = JSON.parse(jsonLine!); + expect(parsed.params.arguments).toEqual(trickyArgs); + }); + + test('handles heredoc collision when payload contains delimiter lines', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'breakout_tool', + args: { test: 'line1\nJSON\nline2' }, + }); + + expect(cmd).toContain("<<'EOF_AMCP_JSON'"); + expect(cmd.endsWith('EOF_AMCP_JSON')).toBe(true); + }); + + test('a value cannot end the heredoc early', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'breakout', + args: { note: "x\nJSON\nrm -rf ~\n", other: 'JSON' }, + }); + const lines = cmd.split('\n'); + expect(lines.filter((l) => l === 'EOF_AMCP_JSON' || l === 'JSON')).toHaveLength(1); + expect(lines.some((l) => l.startsWith('rm '))).toBe(false); + }); + + test('adds no note when nothing was redacted', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'plain', + args: { q: 'x' }, + }); + expect(cmd.startsWith('curl ')).toBe(true); + }); + + test('emits warning comments when sensitive arguments are redacted', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'secret_tool', + args: { password: 'my-password' }, + }); + + expect(cmd.split('\n')[0]).toMatch(/^# Some arguments were replaced with \[REDACTED\]/); + expect(cmd).toContain('[REDACTED]'); + expect(cmd).not.toContain('my-password'); + }); + + test('emits warning comment and strips truncation marker when args contain _amcp_truncated', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'cut_tool', + args: { _amcp_truncated: true, validKey: 'value' }, + }); + + expect(cmd).toContain('# WARNING: Stored input arguments were truncated in the audit log; replay may be incomplete.'); + expect(cmd).not.toContain('_amcp_truncated'); + expect(cmd).toContain('"validKey":"value"'); + }); + + test('redacts sensitive keys including auth, pwd, jwt, session, bearer, access_key without false positives', () => { + const sensitiveArgs = { + author: 'alice', // must NOT be redacted + authority: 'gov', // must NOT be redacted + auth: 'secret-auth', // must be redacted + authHeader: 'auth-header-val', // must be redacted + client_auth: 'client-auth-val', // must be redacted + user_pwd: 'secret-pwd', // must be redacted + user_pin: '1234', // must be redacted + passcode: 'pass-999', // must be redacted + authenticate: 'auth-val', // must be redacted + id_jwt: 'jwt-val', // must be redacted + session_id: 'session-val', // must be redacted + bearer_token: 'bearer-val', // must be redacted + access_key: 'access-key-val', // must be redacted + apiKey: 'api-key-val', // must be redacted + nested: { + safe: 'normal_val', + private_key: 'priv-key-val', + }, + }; + + const redacted = redactSensitiveArgs(sensitiveArgs) as any; + expect(redacted.author).toBe('alice'); + expect(redacted.authority).toBe('gov'); + expect(redacted.auth).toBe(REDACTED_PLACEHOLDER); + expect(redacted.authHeader).toBe(REDACTED_PLACEHOLDER); + expect(redacted.client_auth).toBe(REDACTED_PLACEHOLDER); + expect(redacted.user_pwd).toBe(REDACTED_PLACEHOLDER); + expect(redacted.user_pin).toBe(REDACTED_PLACEHOLDER); + expect(redacted.passcode).toBe(REDACTED_PLACEHOLDER); + expect(redacted.authenticate).toBe(REDACTED_PLACEHOLDER); + expect(redacted.id_jwt).toBe(REDACTED_PLACEHOLDER); + expect(redacted.session_id).toBe(REDACTED_PLACEHOLDER); + expect(redacted.bearer_token).toBe(REDACTED_PLACEHOLDER); + expect(redacted.access_key).toBe(REDACTED_PLACEHOLDER); + expect(redacted.apiKey).toBe(REDACTED_PLACEHOLDER); + expect(redacted.nested.safe).toBe('normal_val'); + expect(redacted.nested.private_key).toBe(REDACTED_PLACEHOLDER); + }); + + test('redacts sensitive values including JWTs, private keys, Bearer headers, and known API token prefixes', () => { + const sensitiveValues = { + rawJwt: [ + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', + 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ', + 'SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + ].join('.'), + rawPrivateKey: '-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0...\n-----END RSA PRIVATE KEY-----', + bearerHeader: 'Bearer my-bearer-token-1234567890', + openAiKey: ['sk', 'proj', '1234567890abcdef1234567890'].join('-'), + openAiWithSpaces: ` ${['sk', 'proj', '1234567890abcdef1234567890'].join('-')} `, + githubToken: ['ghp', '1234567890abcdef12345678901234567890'].join('_'), + slackToken: ['xoxb', '1234567890', 'abcdef123456'].join('-'), + urlWithAuth: 'https://admin:supersecret@example.com/api', + normalVal: 'hello world', + }; + + const redacted = redactSensitiveArgs(sensitiveValues) as any; + expect(redacted.rawJwt).toBe(REDACTED_PLACEHOLDER); + expect(redacted.rawPrivateKey).toBe(REDACTED_PLACEHOLDER); + expect(redacted.bearerHeader).toBe(REDACTED_PLACEHOLDER); + expect(redacted.openAiKey).toBe(REDACTED_PLACEHOLDER); + expect(redacted.openAiWithSpaces).toBe(REDACTED_PLACEHOLDER); + expect(redacted.githubToken).toBe(REDACTED_PLACEHOLDER); + expect(redacted.slackToken).toBe(REDACTED_PLACEHOLDER); + expect(redacted.urlWithAuth).toBe(REDACTED_PLACEHOLDER); + expect(redacted.normalVal).toBe('hello world'); + }); + + test('truncates overly nested structures at MAX_REDACTION_DEPTH to prevent bypass', () => { + let deep: any = { secret_leaf: 'unredacted_payload' }; + for (let i = 0; i < 12; i++) { + deep = { level: deep }; + } + + const redacted = redactSensitiveArgs(deep) as any; + let current = redacted; + for (let i = 0; i < 10; i++) { + current = current.level; + } + expect(current).toBe(TRUNCATED_DEPTH_PLACEHOLDER); + }); + + test('replaces values nested deeper than the walk instead of passing them through', () => { + let deep: Record = { password: 'deep-secret' }; + for (let i = 0; i < 15; i++) deep = { level: deep }; + expect(JSON.stringify(redactSensitiveArgs(deep))).not.toContain('deep-secret'); + }); + + test('handles malformed JSON string, array, boolean, or numeric args gracefully', () => { + const cmdMalformed = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'broken_json_tool', + args: '{not valid json:', + }); + expect(cmdMalformed).toContain('"arguments":{}'); + + const cmdArray = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'array_tool', + args: ['item1', 'item2'], + }); + expect(cmdArray).toContain('"arguments":{}'); + + const cmdPrimitive = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'number_tool', + args: 42 as any, + }); + expect(cmdPrimitive).toContain('"arguments":{}'); + }); + + test('emits both warnings when args are both redacted and truncated', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'both_tool', + args: { _amcp_truncated: true, password: 'secret-val' }, + }); + expect(cmd).toContain('# Some arguments were replaced with [REDACTED]: fill them in before running.'); + expect(cmd).toContain('# WARNING: Stored input arguments were truncated in the audit log; replay may be incomplete.'); + expect(cmd).not.toContain('_amcp_truncated'); + expect(cmd).not.toContain('secret-val'); + expect(cmd).toContain('[REDACTED]'); + }); + + test('redacts additional provider token prefixes: Google AIza, GitHub PAT, GitLab PAT, Telegram bot, Shopify', () => { + const tokens = { + googleKey: ['AI', 'zaSyA1234567890abcdef1234567890abcdef'].join(''), + githubPat: ['github', 'pat', '11AAAAAAA01234567890abcdef1234567890abcdef1234567890abcdef'].join('_'), + gitlabPat: ['glpat', 'abcdef1234567890abcd'].join('-'), + telegramBot: ['123456789', 'ABCdefGHIjklMNOpqrsTUVwxyz123456'].join(':'), + shopifyToken: ['shpat', '1234567890abcdef1234567890abcdef'].join('_'), + normalText: 'safe string', + }; + const redacted = redactSensitiveArgs(tokens) as any; + expect(redacted.googleKey).toBe(REDACTED_PLACEHOLDER); + expect(redacted.githubPat).toBe(REDACTED_PLACEHOLDER); + expect(redacted.gitlabPat).toBe(REDACTED_PLACEHOLDER); + expect(redacted.telegramBot).toBe(REDACTED_PLACEHOLDER); + expect(redacted.shopifyToken).toBe(REDACTED_PLACEHOLDER); + expect(redacted.normalText).toBe('safe string'); + }); +}); + +test.describe('Audit log "Copy as cURL" UI interaction', () => { + test.use({ + permissions: ['clipboard-read', 'clipboard-write'], + }); + + test('copies valid tool call command and shows proper disabled states for shared, truncated, and missing-name rows', async ({ + page, + }) => { + await fakeSession(page, 'both'); + await page.goto('/logs'); + + // Wait for log rows to render + await expect(page.getByTitle('echo_message')).toBeVisible(); + await expect(page.getByTitle('shared_calc')).toBeVisible(); + await expect(page.getByTitle('large_fetch')).toBeVisible(); + + // 1. Expand the first row (valid per-server log) + await page.getByTitle('echo_message').click(); + + // Verify Copy as cURL button is visible and enabled + const copyBtn = page.getByRole('button', { name: 'Copy as cURL' }); + await expect(copyBtn).toBeVisible(); + await expect(copyBtn).toBeEnabled(); + + // Click Copy as cURL + await copyBtn.click(); + + // Verify feedback + await expect(page.getByText('Copied!')).toBeVisible(); + + // Read clipboard text and verify structure + const copiedText = await page.evaluate(() => navigator.clipboard.readText()); + expect(copiedText).toContain('curl -sS -X POST'); + expect(copiedText).toContain('/mcp/srv-1'); + expect(copiedText).toContain('-H "MCP-Protocol-Version: 2025-06-18"'); + expect(copiedText).toContain('-H "X-API-Key: $AMCP_MCP_API_KEY"'); + expect(copiedText).toContain("--data-binary @- <<'JSON'"); + expect(copiedText).toContain('"method":"tools/call"'); + expect(copiedText).toContain('"name":"echo_message"'); + expect(copiedText).toContain('"message":"hello world"'); + + // 2. Expand second row (shared endpoint) + await page.getByTitle('shared_calc').click(); + const sharedRowDisabled = page.locator('span[title="Replay unavailable: call ran through the shared /mcp endpoint"]'); + await expect(sharedRowDisabled).toBeVisible(); + const sharedBtn = sharedRowDisabled.getByRole('button', { name: 'Copy as cURL' }); + await expect(sharedBtn).toBeDisabled(); + + // 3. Expand third row (truncated input) + await page.getByTitle('large_fetch').click(); + const truncatedRowDisabled = page.locator('span[title="Replay unavailable: stored input parameters were truncated"]'); + await expect(truncatedRowDisabled).toBeVisible(); + const truncatedBtn = truncatedRowDisabled.getByRole('button', { name: 'Copy as cURL' }); + await expect(truncatedBtn).toBeDisabled(); + + // 4. Expand fourth row (missing tool name: tool is null) + await page.getByTitle('t-missing-name').click(); + const missingNameRowDisabled = page.locator('span[title="Replay unavailable: tool name is missing"]'); + await expect(missingNameRowDisabled).toBeVisible(); + const missingNameBtn = missingNameRowDisabled.getByRole('button', { name: 'Copy as cURL' }); + await expect(missingNameBtn).toBeDisabled(); + }); +});