prd phase 2.1 credentials - sinrimin/harness-nexus GitHub Wiki
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:
- 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. - MCP server record (an
McpServerrow) describing an upstream server, its transport, and itsmode(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
- 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.
- As an admin, I want credentials encrypted at rest, so that a database leak doesn't expose them.
- As an admin, I want credentials never returned in plaintext by the API, so that they can't be exfiltrated via a read.
- As a user, I want a masked preview of a credential, so that I can recognize which one it is without seeing the value.
- As a user, I want to create a personal credential, so that my own MCP connections can use it.
- As an admin, I want to create a global credential, so that all users' connections can share it.
- As a user, I want to see global credentials, so that I can reference them in my connections.
- As a non-admin, I want to be blocked from creating or mutating global credentials, so that shared secrets stay admin-controlled.
- As a user, I want only my personal credentials listed (plus globals), so that other users' secrets are hidden.
- 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.
- 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. - 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. - 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. - As a user, I want to see the available
${cred:NAME}placeholders listed in the MCP form, so that I know what to paste. - 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. - As a user, I want multi-entry JSON import rejected with a clear message, so that I import one server at a time deliberately.
- As a developer, I want stdio supported in
directmode only, so that Harness Nexus never spawns a third-party subprocess but local tools still work. - As a developer, I want config validated by shared zod schemas, so that server, SDK, and web agree on accepted shapes.
- As a user, I want to delete my own credential, so that I can retire a leaked token.
- As an admin, I want to delete a global credential, so that shared secrets can be rotated out.
- 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
PersonalAccessTokenauthenticates a user into Harness Nexus; aCredentialauthenticates Harness Nexus out to an upstream. - Credential is a pure named secret:
{ name, secret, scope, ownerId }. Nokindfield — a credential is transport-agnostic; its name is the placeholder handle. - Encryption: AES-256-GCM (
node:crypto), random 12-byte IV per record, wire formatbase64(iv):base64(ct):base64(tag). Key fromCREDENTIAL_ENCRYPTION_KEYenv, falling back toJWT_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). TheresolvePlaceholdershelper (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).
- proxy mode: resolved server-side at connect time in
- MCP mode (
proxy|direct):proxy— Harness Nexus dials; SSE / HTTP only.direct— the tool dials; SSE / HTTP / stdio. stdio forcesdirect(409 STDIO_REQUIRES_DIRECT). See Phase 3.1 design. - Custom headers: HTTP transports accept a
headersrecord 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):
globalis readable by any authenticated user but admin-only to mutate;personalis owner-only for all operations. Instance-level guards first, per-record ownership (owner-or-admin) on top. Not-found returns404(not403). - 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
credentialsandmcp_serverstables.
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_KEYfalls back toJWT_SECRET, so no extra env is needed for a dev instance to boot.