# PRD: Phase 2.1 — MCP connection config & credential management > Status: implemented. Technical design: `design-phase-2.1-credentials.md`. ## Problem Statement To aggregate upstream MCP servers (Phase 2.2), Harness Nexus first needs a way to _describe_ those connections — their transport (SSE, Streamable HTTP, or stdio), their endpoints, and the secrets needed to authenticate to them. Many hosted MCP servers and vendor APIs require a Bearer token or API key in a header, and stdio servers often take an API key as a command-line argument or environment variable. There is no place to store those descriptions or those secrets, and pasting secrets inline into connection config is insecure. Administrators and users need a managed, encrypted credential store and a connection registry where a credential can be injected into any transport field without exposing the plaintext. ## Solution A configuration layer with two concepts: 1. **Credential** — a reusable, AES-256-GCM-encrypted _named secret_. It carries only a name and a secret (no `kind`, no type). The name is the handle used to reference it elsewhere. Both support global (admin-managed, shared) and personal (owner-only) scoping. 2. **MCP server record** (an `McpServer` row) describing an upstream server, its transport, and its `mode` (`proxy` — Harness Nexus dials it; `direct` — the target tool dials it). A credential's secret is injected via **placeholder interpolation**: a `${cred:NAME}` token written inside any transport string field (url, command, args, env values, header values) is replaced with the decrypted plaintext at resolve time. This phase stores config only; live aggregation is Phase 2.2, and placeholder resolution for direct mode happens at install time (Phase 3.3). ## User Stories 1. As an admin, I want to store an upstream MCP server's token as a credential, so that it isn't pasted into plaintext config. 2. As an admin, I want credentials encrypted at rest, so that a database leak doesn't expose them. 3. As an admin, I want credentials never returned in plaintext by the API, so that they can't be exfiltrated via a read. 4. As a user, I want a masked preview of a credential, so that I can recognize which one it is without seeing the value. 5. As a user, I want to create a personal credential, so that my own MCP connections can use it. 6. As an admin, I want to create a global credential, so that all users' connections can share it. 7. As a user, I want to see global credentials, so that I can reference them in my connections. 8. As a non-admin, I want to be blocked from creating or mutating global credentials, so that shared secrets stay admin-controlled. 9. As a user, I want only my personal credentials listed (plus globals), so that other users' secrets are hidden. 10. As an admin, I want to register an upstream MCP server with an SSE or Streamable HTTP transport, so that it can be aggregated later. 11. As a user, I want to inject a credential into an HTTP header via a `${cred:NAME}` placeholder, so that the upstream receives the right auth without me handling the secret. 12. As a user, I want to inject a credential into a stdio command, args, or env via a `${cred:NAME}` placeholder, so that local tools that take an API key on the command line are supported. 13. As a user, I want to write custom headers as JSON (e.g. `{"Authorization": "Bearer ${cred:token}"}`), so that I control exactly which headers are sent. 14. As a user, I want to see the available `${cred:NAME}` placeholders listed in the MCP form, so that I know what to paste. 15. As a user, I want to import an MCP server config from a JSON blob (`{ "mcpServers": { "name": {...} } }`), so that I don't hand-type vendor-provided config. 16. As a user, I want multi-entry JSON import rejected with a clear message, so that I import one server at a time deliberately. 17. As a developer, I want stdio supported in `direct` mode only, so that Harness Nexus never spawns a third-party subprocess but local tools still work. 18. As a developer, I want config validated by shared zod schemas, so that server, SDK, and web agree on accepted shapes. 19. As a user, I want to delete my own credential, so that I can retire a leaked token. 20. As an admin, I want to delete a global credential, so that shared secrets can be rotated out. 21. As a non-admin, I want a "not found" (not "forbidden") when touching someone else's credential, so that existence isn't leaked. ## Implementation Decisions - **Credential ≠ PAT.** A `PersonalAccessToken` authenticates a user _into_ Harness Nexus; a `Credential` authenticates Harness Nexus _out to_ an upstream. - **Credential is a pure named secret:** `{ name, secret, scope, ownerId }`. No `kind` field — a credential is transport-agnostic; its name is the placeholder handle. - **Encryption:** AES-256-GCM (`node:crypto`), random 12-byte IV per record, wire format `base64(iv):base64(ct):base64(tag)`. Key from `CREDENTIAL_ENCRYPTION_KEY` env, falling back to `JWT_SECRET` (sha256-derived to 32 bytes). - **Secret is write-only at the API:** create/update accept plaintext; every read returns a masked `secretPreview` (first 3 + `…` + last 3, or `***`). Plaintext is decrypted only at resolve time. - **Placeholder interpolation:** a credential is referenced by name as `${cred:NAME}` inside any transport string field (url, command, args, env values, header values). The `resolvePlaceholders` helper (`packages/shared/src/utils/placeholders.ts`) scans a string, looks up the credential by name, decrypts, and substitutes. - **proxy mode:** resolved server-side at connect time in `McpRegistry` (plaintext in memory only, never persisted). - **direct mode:** resolved at install time by the Phase 3.3 writer (the tool dials the connection, so the placeholder becomes a literal in the emitted plugin config; the bundle is then sensitive). - **MCP mode (`proxy` | `direct`):** `proxy` — Harness Nexus dials; SSE / HTTP only. `direct` — the tool dials; SSE / HTTP / stdio. stdio forces `direct` (`409 STDIO_REQUIRES_DIRECT`). See Phase 3.1 design. - **Custom headers:** HTTP transports accept a `headers` record whose values may carry `${cred:NAME}` placeholders. The web UI edits headers as JSON, defaulting to `{"Authorization": "Bearer ${cred:token}"}`. - **Scope model (identical for credentials and mcp-servers):** `global` is readable by any authenticated user but admin-only to mutate; `personal` is owner-only for all operations. Instance-level guards first, per-record ownership (owner-or-admin) on top. Not-found returns `404` (not `403`). - **JSON import:** the web "Import JSON" button parses a single-entry `{ "mcpServers": { "name": {...} } }` blob, infers mode (`command` → direct, `serverUrl`/`url` → proxy), and fills the create form. Multi-entry blobs are rejected with a toast. - **API surface:** `/api/credentials` (POST/GET/PATCH/DELETE), `/api/mcp-servers` (POST/GET/PATCH/DELETE), `/api/mcp-servers/status`. - **Migration:** SQLite migration v2 adds `credentials` and `mcp_servers` tables. ## Testing Decisions - HTTP smoke tests (`scripts/smoke.mjs`) cover: personal/global credential creation, secret masking + omission, scope permission denials, global credential delete by non-admin (404), mcp-server creation with a `${cred:NAME}` placeholder in headers, stdio+direct accepted (201), stdio+proxy rejected (409), non-admin global mcp-server rejection (403). - Tests assert status codes and response shapes (external behavior). ## Out of Scope - Live MCP registry / aggregation / proxy re-exposure (Phase 2.2). - Placeholder resolution for direct mode at install time (Phase 3.3). - Profile CRUD + PAT+profile routing (Phase 2.2). - callable-function scripts (Phase 2.3). - Validating `${cred:NAME}` references a real credential at create time (deferred to resolve time — a dangling ref surfaces as a connection error). - Credential rotation/expiry and key re-encryption migration. ## Further Notes - Full technical design (entity shapes, encryption details, placeholder semantics, scope matrix, API table, stdio policy) lives in `design-phase-2.1-credentials.md`. - `CREDENTIAL_ENCRYPTION_KEY` falls back to `JWT_SECRET`, so no extra env is needed for a dev instance to boot.