feat(secrets): opt-in injected secret delivery, scoped per server

Completes the path that stops mcpd writing secret VALUES into MCP server
pod specs. With `secretDelivery: injector`, the pod fetches its own
secrets from OpenBao through the agent injector, under a ServiceAccount
and role scoped to just that server's secrets — so the value never enters
etcd, and gitea-mcp cannot read the Grafana token.

Opt-in per server, defaulting to `env`. Every existing server is
bit-for-bit unchanged, and migrating is one reversible decision at a time
rather than a flag day.

The two invariants under most risk, both tested:

- **Opted-out servers produce an identical manifest.** No annotations, no
  serviceAccountName, automountServiceAccountToken still false.

- **Opted-in servers still fail LOUDLY on a bad ref.** Once mcpd stops
  reading a server's secrets, the check e6cd735 added no longer fires for
  it, and a typo'd secretRef would degrade into a vault-agent-init
  crashloop that mcpd reports as a generic pod failure — the same class of
  bug that had gitea-mcp running for weeks on an empty token while
  reporting healthy. `validateServerEnvRefs` resolves every ref and throws
  the value away, purely to keep that error. After the value cache it is a
  cache hit and costs nothing.

Shell quoting is the other silent-failure trap and is treated as part of
the contract: the agent renders `export NAME='value'` and the container
command sources it, so a value containing a space, `$`, a quote or a
newline would truncate and yield an empty token. `shellSingleQuote` is
tested by executing a real /bin/sh over nine adversarial values including
`'; export PWNED=1; '` — and those tests fail against naive quoting,
confirmed before keeping them.

`sh -c <script> arg0 arg1 …` preserves argv via $0/$@, and `exec` keeps
PID 1 as the real process, which matters because mcpd attaches to PID 1's
stdin/stdout for STDIO servers.

Docker/Podman declare `capabilities.secretRefs: false` and fall back to
inline resolution, so local development is untouched. Deleting a server
revokes its identity, after its pods are gone and best-effort — a role no
pod can authenticate as grants nothing, and failing the delete over it
would strand the row.

Per the CLI rules, `secretDelivery`/`entrypoint` are `create` flags,
round-trip through apply -f, and show in `describe server` — which now
also flags servers still inlining secrets into their pod spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
This commit is contained in:
Michal
2026-08-20 23:33:13 +01:00
parent f8427959e5
commit 370fd0a034
22 changed files with 740 additions and 14 deletions

View File

@@ -44,6 +44,8 @@ const ServerSpecSchema = z.object({
env: z.array(ServerEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
volumes: z.array(VolumeSpecSchema).default([]),
secretDelivery: z.enum(['env', 'injector']).optional(),
entrypoint: z.array(z.string()).optional(),
});
const SecretSpecSchema = z.object({
@@ -136,6 +138,8 @@ const TemplateSpecSchema = z.object({
env: z.array(TemplateEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
volumes: z.array(VolumeSpecSchema).default([]),
secretDelivery: z.enum(['env', 'injector']).optional(),
entrypoint: z.array(z.string()).optional(),
});
const UserSpecSchema = z.object({

View File

@@ -252,6 +252,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.option('--health-check-interval <seconds>', 'Readiness probe interval in seconds (default 60)')
.option('--health-check-timeout <seconds>', 'Readiness probe timeout in seconds (default 10)')
.option('--health-check-failure-threshold <count>', 'Consecutive failures before the instance is marked unhealthy (default 3)')
.option('--secret-delivery <mode>', 'How secret env reaches the container: env (default, value written into the pod spec) or injector (pod fetches its own secrets from OpenBao under a scoped identity)')
.option('--entrypoint <argv>', 'Comma-separated argv to exec under the injector wrapper. Required for --secret-delivery injector on a dockerImage server, whose ENTRYPOINT mcpd cannot introspect')
.option('--from-template <name>', 'Create from template (name or name:version)')
.option('--env-from-secret <secret>', 'Map template env vars from a secret')
.option('--force', 'Update if already exists')
@@ -327,6 +329,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
if (opts.description !== undefined) body.description = opts.description;
if (opts.transport) body.transport = opts.transport;
if (opts.replicas) body.replicas = parseInt(opts.replicas, 10);
if (opts.secretDelivery) body.secretDelivery = opts.secretDelivery;
if (opts.entrypoint) body.entrypoint = (opts.entrypoint as string).split(',').map((a) => a.trim()).filter(Boolean);
if (opts.packageName) body.packageName = opts.packageName;
if (opts.runtime) body.runtime = opts.runtime;
if (opts.dockerImage) body.dockerImage = opts.dockerImage;

View File

@@ -21,6 +21,20 @@ function formatServerDetail(server: Record<string, unknown>): string {
lines.push(`${pad('Name:')}${server.name}`);
lines.push(`${pad('Transport:')}${server.transport ?? '-'}`);
lines.push(`${pad('Replicas:')}${server.replicas ?? 1}`);
{
// Surface the remaining exposure rather than leaving it silent: a server
// still on `env` with secret refs has those VALUES written into its pod
// spec, i.e. cleartext in etcd for anyone with `get pod`.
const delivery = (server as { secretDelivery?: string }).secretDelivery ?? 'env';
const env = (server.env ?? []) as Array<{ valueFrom?: unknown }>;
const hasSecretRefs = env.some((e) => e.valueFrom !== undefined);
const note = delivery === 'injector'
? ' (pod fetches its own secrets under a scoped OpenBao identity)'
: hasSecretRefs
? ' ⚠ secret values are inlined into the pod spec'
: '';
lines.push(`${pad('Secret Delivery:')}${delivery}${note}`);
}
if (server.dockerImage) lines.push(`${pad('Docker Image:')}${server.dockerImage}`);
if (server.packageName) lines.push(`${pad('Package:')}${server.packageName}`);
if (server.externalUrl) lines.push(`${pad('External URL:')}${server.externalUrl}`);

View File

@@ -0,0 +1,12 @@
-- Per-server secret delivery mode.
--
-- 'env' preserves today's behaviour exactly: mcpd resolves secret refs and
-- writes the VALUES into the pod spec. Defaulting to it means every existing
-- server is unchanged by this migration, and migration to 'injector' is an
-- explicit, reversible, per-server decision.
ALTER TABLE "McpServer" ADD COLUMN "secretDelivery" TEXT NOT NULL DEFAULT 'env';
-- argv to exec after the injector's rendered secrets are sourced. Only needed
-- for dockerImage servers, where the image ENTRYPOINT is what would otherwise
-- run and mcpd cannot introspect it.
ALTER TABLE "McpServer" ADD COLUMN "entrypoint" JSONB;

View File

@@ -71,9 +71,23 @@ model McpServer {
env Json @default("[]")
healthCheck Json?
volumes Json @default("[]")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// How secret-backed env reaches the container.
/// env — mcpd resolves values and writes them into the pod spec.
/// Cleartext in etcd, readable by anyone with `get pod`.
/// injector — the pod fetches its own secrets from OpenBao via the agent
/// injector, using a ServiceAccount + role scoped to just this
/// server's secrets. The value never touches the pod spec.
/// Defaults to `env` so existing servers are bit-for-bit unchanged.
secretDelivery String @default("env")
/// argv the injector wrapper must exec after sourcing the rendered secrets.
/// Only needed for dockerImage servers using `injector`, where the image's
/// own ENTRYPOINT is what would otherwise run and mcpd cannot introspect it.
entrypoint Json?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
templateName String?
templateVersion String?
@@ -109,8 +123,22 @@ model McpTemplate {
env Json @default("[]")
healthCheck Json?
volumes Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// How secret-backed env reaches the container.
/// env — mcpd resolves values and writes them into the pod spec.
/// Cleartext in etcd, readable by anyone with `get pod`.
/// injector — the pod fetches its own secrets from OpenBao via the agent
/// injector, using a ServiceAccount + role scoped to just this
/// server's secrets. The value never touches the pod spec.
/// Defaults to `env` so existing servers are bit-for-bit unchanged.
secretDelivery String @default("env")
/// argv the injector wrapper must exec after sourcing the rendered secrets.
/// Only needed for dockerImage servers using `injector`, where the image's
/// own ENTRYPOINT is what would otherwise run and mcpd cannot introspect it.
entrypoint Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([name])
}

View File

@@ -25,6 +25,9 @@ import { SecretBackendService } from './services/secret-backend.service.js';
import { SecretMigrateService } from './services/secret-migrate.service.js';
import { bootstrapSecretBackends } from './bootstrap/secret-backends.js';
import { backfillSecretKeyNames } from './bootstrap/secret-key-names.js';
import { ServerIdentityService } from './services/server-identity.service.js';
import { K8sServiceAccountPort } from './services/k8s/service-account-port.js';
import { K8sOfficialClient } from './services/k8s/k8s-client-official.js';
import { warmSecretCache } from './bootstrap/warm-secret-cache.js';
import { registerSecretBackendRoutes } from './routes/secret-backends.js';
import { registerSecretMigrateRoutes } from './routes/secret-migrate.js';
@@ -519,8 +522,38 @@ async function main(): Promise<void> {
// AgentService + ChatService get fully wired below once projectService and
// mcpProxyService are constructed (ChatService needs them via the
// ChatToolDispatcher bridge).
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretService);
// Per-server OpenBao identities, for servers with `secretDelivery: injector`.
// Kubernetes-only: it needs a ServiceAccount per server and an orchestrator
// that can honour secret references. Left undefined elsewhere, which makes
// InstanceService fall back to inline resolution — so Docker/Podman local
// development is unaffected.
const serverIdentityService = process.env['MCPD_ORCHESTRATOR'] === 'kubernetes'
? (() => {
const k8s = new K8sOfficialClient();
return new ServerIdentityService(
secretBackendService,
new K8sServiceAccountPort(k8s.core, k8s.serversNamespace),
{
info: (obj: Record<string, unknown>, msg: string): void => { app.log.info(obj, msg); },
warn: (obj: Record<string, unknown>, msg: string): void => { app.log.warn(obj, msg); },
},
{
// Must be the mount that validates THIS cluster's SA tokens; the
// chart default (`auth/kubernetes`) belongs to a different cluster
// and holds none of our roles.
authPath: process.env['MCPD_SECRET_INJECTOR_AUTH_PATH'] ?? 'auth/kubernetes',
mount: process.env['MCPD_SECRET_INJECTOR_MOUNT'] ?? 'secret',
pathPrefix: process.env['MCPD_SECRET_INJECTOR_PREFIX'] ?? 'mcpctl',
},
);
})()
: undefined;
const instanceService = new InstanceService(
instanceRepo, serverRepo, orchestrator, secretService, serverIdentityService,
);
serverService.setInstanceService(instanceService);
if (serverIdentityService !== undefined) serverService.setServerIdentity(serverIdentityService);
const projectService = new ProjectService(projectRepo, serverRepo);
const auditLogService = new AuditLogService(auditLogRepo);
const auditEventService = new AuditEventService(auditEventRepo);

View File

@@ -40,6 +40,9 @@ export interface BackupServer {
env: unknown;
healthCheck: unknown;
volumes: unknown;
/** Optional so backups taken before the field remain restorable. */
secretDelivery?: string;
entrypoint?: unknown;
}
export interface BackupSecret {
@@ -142,6 +145,8 @@ export class BackupService {
env: s.env,
healthCheck: s.healthCheck,
volumes: s.volumes,
secretDelivery: s.secretDelivery,
entrypoint: s.entrypoint,
}));
}

View File

@@ -177,6 +177,11 @@ export class RestoreService {
// a claim of the same name if one survived, and otherwise starts on a
// fresh empty volume.
volumes: (server.volumes ?? []) as Parameters<IMcpServerRepository['create']>[0]['volumes'],
// Preserve the delivery mode across a backup/restore round-trip.
// Falling back to 'env' keeps pre-field backups restorable, and 'env'
// is the safe default: it works without the per-server OpenBao
// identity, which a restore cannot assume still exists.
secretDelivery: (server.secretDelivery === 'injector' ? 'injector' : 'env'),
};
if (server.packageName) createData.packageName = server.packageName;
if (server.runtime) createData.runtime = server.runtime;

View File

@@ -29,6 +29,14 @@ function mapState(state: string | undefined): ContainerInfo['state'] {
}
export class DockerContainerManager implements McpOrchestrator {
/**
* No injector equivalent exists for plain Docker/Podman containers, so
* `envFromSecret` cannot be honoured here — mcpd resolves those refs and
* merges them into `env` instead. Local development therefore keeps today's
* behaviour exactly.
*/
readonly capabilities = { secretRefs: false } as const;
private docker: Docker;
constructor(opts?: Docker.DockerOptions) {

View File

@@ -44,3 +44,63 @@ export async function resolveServerEnv(
return result;
}
/**
* Split a server's env into inline values and secret references, WITHOUT
* resolving the references.
*
* Used for injector-delivered servers, where the pod fetches its own values and
* mcpd must never materialise them. `resolveServerEnv` above stays as-is: it is
* still the path for Docker, for opted-out servers, and for the generated
* client `.mcp.json`.
*/
export function partitionServerEnv(server: McpServer): {
inline: Record<string, string>;
refs: Array<{ name: string; secretName: string; key: string }>;
} {
const entries = (server.env ?? []) as ServerEnvEntry[];
const inline: Record<string, string> = {};
const refs: Array<{ name: string; secretName: string; key: string }> = [];
for (const entry of entries) {
if (entry.value !== undefined) {
inline[entry.name] = entry.value;
} else if (entry.valueFrom?.secretRef) {
refs.push({
name: entry.name,
secretName: entry.valueFrom.secretRef.name,
key: entry.valueFrom.secretRef.key,
});
}
}
return { inline, refs };
}
/**
* Resolve every reference and DISCARD the values, purely to make a missing
* secret or key fail loudly at instance start.
*
* Once a server is injector-delivered, mcpd no longer reads its secrets, so the
* check that commit e6cd735 bought stops firing for it: a bad secretRef would
* degrade from a clear `secret resolution failed: …` into a vault-agent-init
* crashloop that mcpd reports as a generic pod failure. That is the exact class
* of bug that had gitea-mcp running for weeks with an empty token while
* reporting healthy — so we keep paying for the check. After the value cache it
* is a cache hit and costs essentially nothing.
*/
export async function validateServerEnvRefs(
server: McpServer,
resolver: SecretResolver,
): Promise<void> {
const { refs } = partitionServerEnv(server);
for (const ref of refs) {
try {
await resolver.resolve(ref.secretName, ref.key);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(
`Cannot resolve secret for server '${server.name}' env '${ref.name}': ${msg}`,
);
}
}
}

View File

@@ -2,7 +2,28 @@ import type { McpInstance, McpServer } from '@prisma/client';
import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
import type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrator.js';
import { NotFoundError } from './mcp-server.service.js';
import { resolveServerEnv, type SecretResolver } from './env-resolver.js';
import {
resolveServerEnv,
partitionServerEnv,
validateServerEnvRefs,
type SecretResolver,
} from './env-resolver.js';
/**
* What InstanceService needs from the per-server identity layer. Kept narrow
* and local so the orchestration stays testable without an OpenBao backend.
*/
export interface ServerIdentityProvisioner {
/** Converge the server's identity; resolves to the ServiceAccount/role name. */
ensureFor(server: McpServer): Promise<string>;
/** Injector pod annotations for these refs under that identity. */
annotationsFor(
identity: string,
refs: Array<{ name: string; secretName: string; key: string }>,
): Record<string, string>;
/** Rewrite argv to source the rendered secrets before exec'ing the server. */
wrapCommand(server: McpServer, command: string[] | undefined): string[] | undefined;
}
/** Runner images for package-based MCP servers, keyed by runtime name. */
const RUNNER_IMAGES: Record<string, string> = {
@@ -62,6 +83,8 @@ export class InstanceService {
private serverRepo: IMcpServerRepository,
private orchestrator: McpOrchestrator,
private secretResolver?: SecretResolver,
/** Provisions per-server OpenBao identities. Absent = injector unavailable. */
private serverIdentity?: ServerIdentityProvisioner,
) {}
/**
@@ -501,11 +524,31 @@ export class InstanceService {
// will retry it in-place on the next tick whose nextRetryAt has
// elapsed. Optional/missing env vars should be modeled as `value: ""`
// entries on the server, not as silent secret-resolution failures.
// Injected delivery needs BOTH an orchestrator that can honour references
// (Docker cannot) and a provisioner for the per-server identity. Falling
// back to inline resolution when either is missing keeps local dev and
// plaintext-backend setups working unchanged.
const useInjector = server.secretDelivery === 'injector'
&& this.orchestrator.capabilities?.secretRefs === true
&& this.serverIdentity !== undefined;
if (this.secretResolver) {
try {
const resolvedEnv = await resolveServerEnv(server, this.secretResolver);
if (Object.keys(resolvedEnv).length > 0) {
spec.env = resolvedEnv;
if (useInjector) {
// Resolve and DISCARD, purely so a missing secret still fails here
// with a clear message instead of becoming a vault-agent-init
// crashloop that mcpd reports as a generic pod failure. See
// validateServerEnvRefs.
await validateServerEnvRefs(server, this.secretResolver);
const { inline, refs } = partitionServerEnv(server);
if (Object.keys(inline).length > 0) spec.env = inline;
spec.envFromSecret = refs;
} else {
const resolvedEnv = await resolveServerEnv(server, this.secretResolver);
if (Object.keys(resolvedEnv).length > 0) {
spec.env = resolvedEnv;
}
}
} catch (envErr) {
const msg = envErr instanceof Error ? envErr.message : String(envErr);
@@ -513,6 +556,22 @@ export class InstanceService {
}
}
if (useInjector && spec.envFromSecret !== undefined && spec.envFromSecret.length > 0) {
try {
// Converge the identity BEFORE the pod exists: a pod whose role is
// not yet written logs in, gets no capabilities, and crashloops.
const identity = await this.serverIdentity!.ensureFor(server);
spec.serviceAccountName = identity;
spec.automountServiceAccountToken = true;
spec.annotations = this.serverIdentity!.annotationsFor(identity, spec.envFromSecret);
const wrapped = this.serverIdentity!.wrapCommand(server, spec.command);
if (wrapped !== undefined) spec.command = wrapped;
} catch (idErr) {
const msg = idErr instanceof Error ? idErr.message : String(idErr);
return this.markInstanceError(instance, `secret identity provisioning failed: ${msg}`);
}
}
// Pull image if not available locally
try {
await this.orchestrator.pullImage(image);

View File

@@ -97,6 +97,9 @@ function podToContainerInfo(pod: V1Pod): ContainerInfo {
}
export class KubernetesOrchestrator implements McpOrchestrator {
/** Pods can fetch their own secrets via the OpenBao agent injector. */
readonly capabilities = { secretRefs: true } as const;
private client: K8sOfficialClient;
private namespace: string;

View File

@@ -10,8 +10,10 @@ export interface K8sPodManifest {
name: string;
namespace: string;
labels: Record<string, string>;
annotations?: Record<string, string>;
};
spec: {
serviceAccountName?: string;
containers: Array<{
name: string;
image: string;
@@ -41,6 +43,108 @@ export interface K8sPodManifest {
};
}
/** Directory the OpenBao agent renders this pod's secrets into. */
export const INJECTED_SECRET_DIR = '/vault/secrets';
/**
* Quote a shell value for a file that will be `.`-sourced: single quotes, with
* any embedded `'` closed-escaped-reopened.
*
* Not cosmetic. The rendered file is sourced by /bin/sh, so a value containing
* a space, `$`, a quote or a newline would otherwise truncate or mangle the
* variable — *silently*, producing an empty token and a server that starts,
* answers tools/list and reports healthy while every authenticated call fails.
* That is precisely the failure this change exists to prevent, so the escaping
* is part of the contract, not a detail.
*/
export function shellSingleQuote(inner: string): string {
return "'" + inner.split("'").join("'\\''") + "'";
}
export interface InjectorEnvRef {
/** Env var name inside the container. */
name: string;
/** mcpctl Secret name. */
secretName: string;
/** Key within that secret. */
key: string;
}
export interface InjectorConfig {
/** OpenBao role to log in as — one per server, scoped to its own secrets. */
role: string;
/** Auth mount that validates THIS cluster's ServiceAccount tokens. */
authPath: string;
/** KV v2 mount, e.g. `secret`. */
mount: string;
/** Path prefix under the mount, e.g. `mcpctl`. */
pathPrefix: string;
}
/**
* Pod annotations that drive the OpenBao Agent Injector.
*
* One rendered file per referenced Secret (the agent keys files by the
* annotation suffix), each holding `export NAME='value'` lines for every env
* var drawn from that Secret.
*
* `agent-pre-populate-only` is deliberate: the agent runs as an init container
* and exits. MCP servers cannot reload env anyway, so a long-lived sidecar
* would buy nothing and cost memory in every pod — and these pods run at a flat
* 512Mi where Node-based servers have already been OOMKilled.
*/
export function buildInjectorAnnotations(
refs: InjectorEnvRef[],
cfg: InjectorConfig,
): Record<string, string> {
const bySecret = new Map<string, InjectorEnvRef[]>();
for (const ref of refs) {
const list = bySecret.get(ref.secretName) ?? [];
list.push(ref);
bySecret.set(ref.secretName, list);
}
const annotations: Record<string, string> = {
'vault.hashicorp.com/agent-inject': 'true',
'vault.hashicorp.com/agent-pre-populate-only': 'true',
'vault.hashicorp.com/role': cfg.role,
// Without this the agent logs in at the default `auth/kubernetes`, which
// validates a DIFFERENT cluster's ServiceAccounts and holds none of our
// roles — every login would fail with a confusing permission error.
'vault.hashicorp.com/auth-path': cfg.authPath,
};
const prefix = cfg.pathPrefix === '' ? '' : `${cfg.pathPrefix}/`;
for (const [secretName, entries] of [...bySecret.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
const path = `${cfg.mount}/data/${prefix}${secretName}`;
annotations[`vault.hashicorp.com/agent-inject-secret-${secretName}`] = path;
const lines = entries
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((e) => `export ${e.name}=${shellSingleQuote(`{{ .Data.data.${e.key} }}`)}`);
annotations[`vault.hashicorp.com/agent-inject-template-${secretName}`] =
`{{- with secret "${path}" -}}\n${lines.join('\n')}\n{{- end -}}`;
}
return annotations;
}
/**
* Rewrite the container command to source the rendered secrets, then exec the
* real process.
*
* `sh -c <script> arg0 arg1 …` binds `$0`/`$@`, which is how the server's own
* argv survives intact. `exec` matters just as much: mcpd attaches to PID 1's
* stdin/stdout for STDIO servers, so the real process must REPLACE the shell
* rather than run forked beneath it.
*/
export function wrapCommandForInjector(argv: string[], secretNames: string[]): string[] {
const sources = [...secretNames]
.sort((a, b) => a.localeCompare(b))
.map((n) => `. ${INJECTED_SECRET_DIR}/${n}`)
.join('; ');
return ['/bin/sh', '-c', `${sources}; exec "$0" "$@"`, ...argv];
}
export interface K8sPvcManifest {
apiVersion: 'v1';
kind: 'PersistentVolumeClaim';
@@ -225,12 +329,19 @@ export function generatePodSpec(spec: ContainerSpec, namespace: string): K8sPodM
name: sanitizeName(spec.name),
namespace,
labels,
...(spec.annotations && Object.keys(spec.annotations).length > 0
? { annotations: spec.annotations }
: {}),
},
spec: {
...(spec.serviceAccountName !== undefined
? { serviceAccountName: spec.serviceAccountName }
: {}),
containers: [buildContainerSpec(spec)],
restartPolicy: 'Always',
// MCP server pods don't need k8s API access
automountServiceAccountToken: false,
// MCP server pods don't need k8s API access — EXCEPT when the OpenBao
// agent is injected, which logs in with the projected SA token.
automountServiceAccountToken: spec.automountServiceAccountToken ?? false,
...buildPodVolumes(spec),
// On mixed-arch clusters, constrain to the same arch as mcpd
// (runner images are typically single-arch)

View File

@@ -0,0 +1,52 @@
/**
* Kubernetes adapter for `ServiceAccountPort` — one ServiceAccount per MCP
* server, so each pod authenticates to OpenBao as itself.
*
* Both operations are idempotent by design: `ensureFor` runs on every instance
* start and `removeFor` on every server delete, so "already exists" and "not
* found" are normal outcomes, not errors.
*/
import type { CoreV1Api } from '@kubernetes/client-node';
import type { ServiceAccountPort } from '../server-identity.service.js';
const MCPCTL_LABEL = 'mcpctl.managed';
function statusOf(err: unknown): number | undefined {
const e = err as { code?: number; statusCode?: number; body?: { code?: number } };
return e?.code ?? e?.statusCode ?? e?.body?.code;
}
export class K8sServiceAccountPort implements ServiceAccountPort {
constructor(private readonly core: CoreV1Api, readonly namespace: string) {}
async ensure(name: string): Promise<void> {
try {
await this.core.createNamespacedServiceAccount({
namespace: this.namespace,
body: {
metadata: {
name,
namespace: this.namespace,
labels: { [MCPCTL_LABEL]: 'true' },
},
// No k8s API power is wanted here — this SA exists purely as an
// OpenBao identity. The pod still needs its token projected, which
// is set on the pod spec, not here.
automountServiceAccountToken: false,
},
});
} catch (err) {
if (statusOf(err) === 409) return; // already exists — the normal path
throw err;
}
}
async remove(name: string): Promise<void> {
try {
await this.core.deleteNamespacedServiceAccount({ name, namespace: this.namespace });
} catch (err) {
if (statusOf(err) === 404) return;
throw err;
}
}
}

View File

@@ -4,6 +4,8 @@ import type { InstanceService } from './instance.service.js';
import { CreateMcpServerSchema, UpdateMcpServerSchema } from '../validation/mcp-server.schema.js';
export class McpServerService {
private serverIdentity?: { removeFor(serverName: string): Promise<void> };
private instanceService: InstanceService | null = null;
constructor(private readonly repo: IMcpServerRepository) {}
@@ -55,14 +57,27 @@ export class McpServerService {
async delete(id: string): Promise<void> {
// Verify exists
await this.getById(id);
const server = await this.getById(id);
// Stop all containers before DB cascade
if (this.instanceService) {
await this.instanceService.removeAllForServer(id);
}
// Revoke the per-server OpenBao identity, if it had one. Best-effort and
// AFTER the pods are gone: a leftover role that no pod can authenticate as
// grants nothing, whereas failing the delete over it would strand the
// server row. Unconditional rather than gated on secretDelivery, so a
// server flipped back to `env` before deletion is still cleaned up.
if (this.serverIdentity) {
await this.serverIdentity.removeFor(server.name);
}
await this.repo.delete(id);
}
/** Setter injection, matching setInstanceService — constructed later in main. */
setServerIdentity(identity: { removeFor(serverName: string): Promise<void> }): void {
this.serverIdentity = identity;
}
// ── Backup/restore helpers ──
async upsertByName(data: Record<string, unknown>): Promise<McpServer> {

View File

@@ -47,6 +47,21 @@ export interface ContainerSpec {
command?: string[];
/** Environment variables */
env?: Record<string, string>;
/**
* Env the ORCHESTRATOR must arrange for the container to obtain itself,
* carrying the reference and never the value.
*
* Kept separate from `env` rather than replacing it: `env` is the plaintext
* shape every existing server still uses, and the two must coexist while
* servers migrate one at a time.
*/
envFromSecret?: Array<{ name: string; secretName: string; key: string }>;
/** Orchestrator hints. On Kubernetes these become pod annotations. */
annotations?: Record<string, string>;
/** Identity the pod runs as — how the backend knows which secrets it may read. */
serviceAccountName?: string;
/** Injected agents need the projected SA token; plain servers do not. */
automountServiceAccountToken?: boolean;
/** Host port to bind (null = auto-assign) */
hostPort?: number | null;
/** Container port to expose */
@@ -91,6 +106,17 @@ export interface ExecResult {
}
export interface McpOrchestrator {
/**
* What this backend can do beyond running containers.
*
* `secretRefs` means the orchestrator can honour `ContainerSpec.envFromSecret`
* — i.e. arrange for the container to fetch its own secret values. Docker and
* Podman cannot, so mcpd resolves those refs itself and merges them into
* `env`. Declaring the difference here keeps it one visible decision at the
* call site instead of a silent divergence inside each implementation.
*/
readonly capabilities?: { secretRefs: boolean };
/** Pull an image if not present locally */
pullImage(image: string): Promise<void>;

View File

@@ -32,6 +32,11 @@
import type { McpServer } from '@prisma/client';
import type { SecretBackendService } from './secret-backend.service.js';
import type { ServerEnvEntry } from '../validation/mcp-server.schema.js';
import {
buildInjectorAnnotations,
wrapCommandForInjector,
type InjectorEnvRef,
} from './k8s/manifest-generator.js';
/** Minimal Kubernetes surface this needs — keeps the service testable. */
export interface ServiceAccountPort {
@@ -53,6 +58,22 @@ const NOOP_LOG: ServerIdentityLog = { info: () => undefined, warn: () => undefin
/** Shared prefix. mcpd's OpenBao grant is confined to exactly this prefix. */
export const IDENTITY_PREFIX = 'mcpctl-server-';
/** Where the injector should authenticate and look for secrets. */
export interface InjectorSettings {
/** Auth mount validating THIS cluster's ServiceAccount tokens. */
authPath: string;
/** KV v2 mount. */
mount: string;
/** Path prefix under the mount. */
pathPrefix: string;
}
export const DEFAULT_INJECTOR_SETTINGS: InjectorSettings = {
authPath: 'auth/kubernetes',
mount: 'secret',
pathPrefix: 'mcpctl',
};
export class ServerIdentityService {
private readonly log: ServerIdentityLog;
@@ -60,10 +81,41 @@ export class ServerIdentityService {
private readonly backends: SecretBackendService,
private readonly serviceAccounts: ServiceAccountPort,
log?: ServerIdentityLog,
private readonly injector: InjectorSettings = DEFAULT_INJECTOR_SETTINGS,
) {
this.log = log ?? NOOP_LOG;
}
/** Injector pod annotations for these refs, under the given identity. */
annotationsFor(identity: string, refs: InjectorEnvRef[]): Record<string, string> {
return buildInjectorAnnotations(refs, {
role: identity,
authPath: this.injector.authPath,
mount: this.injector.mount,
pathPrefix: this.injector.pathPrefix,
});
}
/**
* Rewrite argv so the rendered secrets are sourced before the server runs.
*
* `command` is what mcpd already computed: for package-based servers that is
* the runner image's entrypoint plus the package, which mcpd owns. For a
* dockerImage server it may be absent — the image's own ENTRYPOINT would run,
* and mcpd cannot introspect it, which is why `entrypoint` is required on the
* server row in that case (enforced at validation).
*/
wrapCommand(
server: Pick<McpServer, 'env' | 'entrypoint'>,
command: string[] | undefined,
): string[] | undefined {
const secretNames = this.secretNamesFor(server);
if (secretNames.length === 0) return command;
const argv = command ?? (server.entrypoint as string[] | null) ?? undefined;
if (argv === undefined || argv.length === 0) return command;
return wrapCommandForInjector(argv, secretNames);
}
/** Identity name for a server — also the SA, policy and role name. */
identityNameFor(serverName: string): string {
return `${IDENTITY_PREFIX}${serverName}`;

View File

@@ -19,6 +19,20 @@ export const ServerEnvEntrySchema = z.object({
export type ServerEnvEntry = z.infer<typeof ServerEnvEntrySchema>;
/**
* How secret-backed env reaches the container.
*
* `env` (default) resolves values in mcpd and writes them into the pod spec —
* cleartext in etcd, readable by anyone with `get pod`. `injector` has the pod
* fetch its own secrets from OpenBao, under an identity scoped to just that
* server's secrets, so the value never touches the pod spec.
*
* Defaulting to `env` keeps every existing server bit-for-bit unchanged;
* migrating is an explicit, reversible, per-server decision.
*/
export const SecretDeliverySchema = z.enum(['env', 'injector']);
export type SecretDelivery = z.infer<typeof SecretDeliverySchema>;
export const CreateMcpServerSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
description: z.string().max(1000).default(''),
@@ -34,6 +48,8 @@ export const CreateMcpServerSchema = z.object({
env: z.array(ServerEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
volumes: z.array(VolumeSpecSchema).default([]),
secretDelivery: SecretDeliverySchema.default('env'),
entrypoint: z.array(z.string()).optional(),
}).refine(
(s) => s.volumes.length === 0 || s.replicas <= 1,
{
@@ -42,6 +58,16 @@ export const CreateMcpServerSchema = z.object({
+ 'so the extra replicas would sit unschedulable on a volume they cannot mount.',
path: ['replicas'],
},
).refine(
(s) => s.secretDelivery !== 'injector' || s.dockerImage === undefined || s.entrypoint !== undefined,
{
message:
'secretDelivery: injector on a dockerImage server needs an explicit entrypoint. '
+ 'The injector renders secrets to a file, so the container command is rewritten to source '
+ 'it and exec the real process — and mcpd cannot read the image\'s own ENTRYPOINT to know '
+ 'what that process is. Package-based servers do not need this (mcpd owns their entrypoint).',
path: ['entrypoint'],
},
);
export const UpdateMcpServerSchema = z.object({
@@ -58,6 +84,8 @@ export const UpdateMcpServerSchema = z.object({
env: z.array(ServerEnvEntrySchema).optional(),
healthCheck: HealthCheckSchema.nullable().optional(),
volumes: z.array(VolumeSpecSchema).optional(),
secretDelivery: SecretDeliverySchema.optional(),
entrypoint: z.array(z.string()).nullable().optional(),
});
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;

View File

@@ -0,0 +1,67 @@
/**
* Instance-start wiring for injector delivery.
*
* Two invariants dominate: an opted-OUT server behaves exactly as before, and
* an opted-IN server still fails LOUDLY on a bad secret ref. The second is the
* one at risk — once mcpd stops reading a server's secrets, the fail-loud check
* from e6cd735 no longer fires for it, and a typo'd ref degrades into a
* vault-agent-init crashloop reported as a generic pod failure.
*/
import { describe, it, expect, vi } from 'vitest';
import { partitionServerEnv, validateServerEnvRefs } from '../src/services/env-resolver.js';
import type { McpServer } from '@prisma/client';
function server(overrides: Partial<McpServer> = {}): McpServer {
return {
name: 'gitea',
env: [
{ name: 'GITEA_HOST', value: 'https://example' },
{ name: 'GITEA_ACCESS_TOKEN', valueFrom: { secretRef: { name: 'gitea-creds', key: 'GITEA_ACCESS_TOKEN' } } },
],
...overrides,
} as McpServer;
}
describe('partitionServerEnv', () => {
it('separates inline values from references without resolving anything', () => {
const { inline, refs } = partitionServerEnv(server());
expect(inline).toEqual({ GITEA_HOST: 'https://example' });
expect(refs).toEqual([
{ name: 'GITEA_ACCESS_TOKEN', secretName: 'gitea-creds', key: 'GITEA_ACCESS_TOKEN' },
]);
});
it('never carries a secret value — that is the whole point', () => {
const { inline } = partitionServerEnv(server());
expect(JSON.stringify(inline)).not.toContain('TOKEN_VALUE');
expect(Object.keys(inline)).not.toContain('GITEA_ACCESS_TOKEN');
});
it('handles a server with no env at all', () => {
expect(partitionServerEnv(server({ env: [] }))).toEqual({ inline: {}, refs: [] });
});
});
describe('validateServerEnvRefs — preserves the fail-loud invariant', () => {
it('resolves every ref and discards the value', async () => {
const resolve = vi.fn(async () => 'super-secret');
await validateServerEnvRefs(server(), { resolve });
expect(resolve).toHaveBeenCalledWith('gitea-creds', 'GITEA_ACCESS_TOKEN');
});
it('throws the SAME message shape as inline resolution on a bad ref', async () => {
// instance.service turns this into markInstanceError("secret resolution
// failed: …"), which is what an operator greps for.
const resolve = vi.fn(async () => { throw new Error("Secret 'gitea-creds' has no key 'NOPE'"); });
await expect(validateServerEnvRefs(
server({ env: [{ name: 'T', valueFrom: { secretRef: { name: 'gitea-creds', key: 'NOPE' } } }] as never }),
{ resolve },
)).rejects.toThrow(/Cannot resolve secret for server 'gitea' env 'T'/);
});
it('does nothing for a server with no refs', async () => {
const resolve = vi.fn();
await validateServerEnvRefs(server({ env: [{ name: 'X', value: 'y' }] as never }), { resolve });
expect(resolve).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,138 @@
/**
* Injector manifest generation.
*
* The two things that must not break: a server NOT opted in produces a
* byte-identical manifest to before, and a rendered secret value survives the
* shell intact whatever it contains. The second is the subtle one — bad quoting
* fails silently, yielding an empty token and a server that reports healthy
* while every authenticated call fails.
*/
import { describe, it, expect } from 'vitest';
import { execFileSync } from 'node:child_process';
import { writeFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
generatePodSpec,
buildInjectorAnnotations,
wrapCommandForInjector,
shellSingleQuote,
} from '../src/services/k8s/manifest-generator.js';
import type { ContainerSpec } from '../src/services/orchestrator.js';
const BASE: ContainerSpec = { name: 'gitea', image: 'gitea/mcp:latest' } as ContainerSpec;
const CFG = { role: 'mcpctl-server-gitea', authPath: 'auth/kubernetes-worker0', mount: 'secret', pathPrefix: 'mcpctl' };
describe('generatePodSpec — opted-out servers are untouched', () => {
it('emits no annotations, no serviceAccountName, automount still false', () => {
const pod = generatePodSpec({ ...BASE, env: { TOKEN: 'plain' } } as ContainerSpec, 'mcpctl-servers');
expect(pod.metadata.annotations).toBeUndefined();
expect(pod.spec.serviceAccountName).toBeUndefined();
expect(pod.spec.automountServiceAccountToken).toBe(false);
expect(pod.spec.containers[0]?.env).toEqual([{ name: 'TOKEN', value: 'plain' }]);
});
});
describe('generatePodSpec — opted-in servers', () => {
const spec = {
...BASE,
annotations: buildInjectorAnnotations(
[{ name: 'GITEA_ACCESS_TOKEN', secretName: 'gitea-creds', key: 'GITEA_ACCESS_TOKEN' }],
CFG,
),
serviceAccountName: 'mcpctl-server-gitea',
automountServiceAccountToken: true,
} as ContainerSpec;
it('carries no secret VALUE anywhere in the manifest', () => {
const pod = generatePodSpec(spec, 'mcpctl-servers');
const json = JSON.stringify(pod);
expect(json).not.toContain('de4c69ed');
expect(pod.spec.containers[0]?.env ?? []).toEqual([]);
});
it('runs as its own ServiceAccount with the SA token projected', () => {
const pod = generatePodSpec(spec, 'mcpctl-servers');
expect(pod.spec.serviceAccountName).toBe('mcpctl-server-gitea');
// The agent cannot log in without it; false here is a silent crashloop.
expect(pod.spec.automountServiceAccountToken).toBe(true);
});
it('pins the auth path to the mount that validates THIS cluster', () => {
const a = buildInjectorAnnotations([{ name: 'T', secretName: 's', key: 'k' }], CFG);
expect(a['vault.hashicorp.com/auth-path']).toBe('auth/kubernetes-worker0');
expect(a['vault.hashicorp.com/role']).toBe('mcpctl-server-gitea');
// init container only — no sidecar in a 512Mi pod
expect(a['vault.hashicorp.com/agent-pre-populate-only']).toBe('true');
});
it('groups env vars by the secret they come from', () => {
const a = buildInjectorAnnotations([
{ name: 'HOST', secretName: 'gitea-creds', key: 'GITEA_HOST' },
{ name: 'TOKEN', secretName: 'gitea-creds', key: 'GITEA_ACCESS_TOKEN' },
{ name: 'OTHER', secretName: 'other-creds', key: 'K' },
], CFG);
expect(a['vault.hashicorp.com/agent-inject-secret-gitea-creds']).toBe('secret/data/mcpctl/gitea-creds');
expect(a['vault.hashicorp.com/agent-inject-secret-other-creds']).toBe('secret/data/mcpctl/other-creds');
const tpl = a['vault.hashicorp.com/agent-inject-template-gitea-creds'] ?? '';
expect(tpl).toContain('export HOST=');
expect(tpl).toContain('export TOKEN=');
expect(tpl).not.toContain('export OTHER=');
});
it('is stable under input reordering', () => {
const refs = [
{ name: 'B', secretName: 'z', key: 'k' },
{ name: 'A', secretName: 'a', key: 'k' },
];
expect(buildInjectorAnnotations(refs, CFG)).toEqual(buildInjectorAnnotations([...refs].reverse(), CFG));
});
});
describe('wrapCommandForInjector', () => {
it('preserves argv exactly via $0/$@ and execs so PID 1 is the real process', () => {
// exec matters: mcpd attaches to PID 1 stdin/stdout for STDIO servers.
const cmd = wrapCommandForInjector(['node', 'server.js', '--port', '3000'], ['a-creds']);
expect(cmd[0]).toBe('/bin/sh');
expect(cmd[1]).toBe('-c');
expect(cmd[2]).toContain('exec "$0" "$@"');
expect(cmd.slice(3)).toEqual(['node', 'server.js', '--port', '3000']);
});
it('sources every referenced secret file, in a stable order', () => {
const cmd = wrapCommandForInjector(['x'], ['b-creds', 'a-creds']);
expect(cmd[2]).toBe('. /vault/secrets/a-creds; . /vault/secrets/b-creds; exec "$0" "$@"');
});
});
describe('shell quoting survives adversarial values', () => {
// Executed by a real /bin/sh: asserting on the string alone would prove
// nothing about what the shell actually does with it.
const nasty = [
'plain',
'with space',
"single'quote",
'double"quote',
'$USER and `whoami`',
'semi;colon && rm -rf /',
'new\nline',
'back\\slash',
"'; export PWNED=1; '",
];
it.each(nasty)('round-trips %j through a sourced file', (value) => {
const dir = mkdtempSync(join(tmpdir(), 'mcpctl-quote-'));
const file = join(dir, 'env');
writeFileSync(file, `export SECRET=${shellSingleQuote(value)}\n`);
const out = execFileSync('/bin/sh', ['-c', `. ${file}; printf '%s' "$SECRET"`], { encoding: 'utf-8' });
expect(out).toBe(value);
});
it('does not let a crafted value execute anything', () => {
const dir = mkdtempSync(join(tmpdir(), 'mcpctl-quote-'));
const file = join(dir, 'env');
writeFileSync(file, `export SECRET=${shellSingleQuote("'; touch /tmp/mcpctl-pwned; '")}\n`);
const out = execFileSync('/bin/sh', ['-c', `. ${file}; printf '%s' "$PWNED_MARKER"`], { encoding: 'utf-8' });
expect(out).toBe('');
});
});