Compare commits

...

5 Commits

Author SHA1 Message Date
Michal
c16d7964c9 fix(gitea): pin the rebuilt image by digest and match upstream's CMD form
Some checks failed
CI/CD / lint (pull_request) Successful in 1m17s
CI/CD / typecheck (pull_request) Successful in 2m39s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / build (pull_request) Successful in 2m28s
CI/CD / smoke (pull_request) Failing after 3m4s
CI/CD / publish (pull_request) Has been skipped
Two corrections to the shell-bearing rebuild.

**Pin by digest.** It copied from `:latest`, so a rebuild silently ships
whatever upstream has moved to. When a probe started failing right after a
rebuild I could not tell a version change from a broken build, and burned
time on the wrong one — the binary's own `--version` prints 1.1.0 while
the image label says 1.6.0, so that was a red herring too. Now pinned to
sha256:dda8d56e…, which IS the running 1.6.0.

**CMD, not ENTRYPOINT.** Upstream sets `Cmd: ["/app/gitea-mcp"]` with no
entrypoint. mcpd maps a server's `command` to k8s `args`, which REPLACES
Cmd but only APPENDS to an ENTRYPOINT — so the ENTRYPOINT form would have
changed how the binary is invoked for any server that sets a command.
Matching upstream's shape keeps the non-injected path byte-identical.

gitea also needs `entrypoint` on its server row: its `command` is [], so
there is nothing for the injector wrapper to wrap without it. Set to
["/usr/local/bin/gitea-mcp"] via apply -f (patch cannot express an array).

Verified live: gitea RUNNING/healthy on secretDelivery: injector, with
vault-agent-init present and the command wrapped as
  ["/bin/sh","-c",". /vault/secrets/gitea-creds; exec \"$0\" \"$@\"",
   "/usr/local/bin/gitea-mcp"]
`get_me` — which needs read:user, the scope that started this whole
session — returns the real account. Plaintext credentials across all
mcpctl server pod specs: 4 -> 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-21 17:07:23 +01:00
Michal
f097c0f4d5 feat(gitea): rebuild gitea-mcp on a shell-bearing base
Some checks failed
CI/CD / lint (pull_request) Successful in 1m20s
CI/CD / test (pull_request) Successful in 1m30s
CI/CD / typecheck (pull_request) Successful in 2m52s
CI/CD / smoke (pull_request) Failing after 2m1s
CI/CD / build (pull_request) Successful in 2m23s
CI/CD / publish (pull_request) Has been skipped
Upstream docker.gitea.com/gitea-mcp-server is distroless — `Cmd` is
["/app/gitea-mcp"] and there is no /bin/sh at any path (verified by
exec'ing every candidate against the running pod).

That is fine until the server wants secretDelivery: injector. The OpenBao
agent renders secrets to a FILE, so mcpd wraps the container command as
`sh -c '. /vault/secrets/<name>; exec "$0" "$@"'`, which needs a shell.
gitea was the only server in the fleet blocked on this, and so the only
one whose token had to stay inline in its pod spec.

Copying one static Go binary onto debian:stable-slim is cheaper than
building and maintaining a static envexec shim, and follows the precedent
in deploy/Dockerfile.docmost-mcp — this repo already rebuilds third-party
MCP servers when it needs to change how they run.

ca-certificates is required rather than incidental: the binary talks HTTPS
to mysources.co.uk and the distroless base shipped a trust store we are
leaving behind. Verified in the built image: shell present, binary runs,
ca-certificates.crt present.

ENTRYPOINT is kept so the plain (non-injected) path behaves exactly like
upstream; mcpd replaces it with the sourcing wrapper only when the server
opts in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-21 11:16:23 +01:00
c79bdab51b Merge pull request 'fix(secrets): wrap an image server's own command, not just entrypoint' (#121) from fix/injector-image-server-argv into main
Some checks failed
CI/CD / lint (push) Has been cancelled
CI/CD / typecheck (push) Has been cancelled
CI/CD / test (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-08-21 10:14:42 +00:00
Michal
913c0fbdc6 fix(secrets): wrap an image server's own command, not just entrypoint
Some checks failed
CI/CD / lint (pull_request) Successful in 1m25s
CI/CD / test (pull_request) Successful in 1m32s
CI/CD / typecheck (pull_request) Successful in 3m0s
CI/CD / smoke (pull_request) Failing after 2m0s
CI/CD / build (pull_request) Successful in 4m34s
CI/CD / publish (pull_request) Has been skipped
Migrating docmost and my-home-assistant, both rendered their secret and
neither picked it up. The pod spec showed why:

  command: (empty)
  args:    ["node","build/index.js"]
  server.entrypoint: (unset)

wrapCommand consulted only `server.entrypoint` for non-package servers, so
with `entrypoint` unset it returned undefined, the wrapper was skipped
entirely, and the container ran its normal command — which never sourced
/vault/secrets/<name>. The failure is silent by construction: the agent
init container succeeds, the file is there, and the server simply starts
with empty credentials.

An explicit `command` on an image server is already a complete command
line — mcpd's exec mode would run exactly it — so it should be wrapped
verbatim. `entrypoint` is only needed when there is no command at all and
the image's own ENTRYPOINT would take over.

Now branches on the three real shapes: package server (prepend the runner
entrypoint mcpd owns), image + command (use verbatim), image only (require
the declared entrypoint).

Seven tests, one per shape plus the two undefined cases. Reintroducing the
old logic fails two of them — checked before keeping.

Both servers were rolled back to secretDelivery: env and are healthy; they
can migrate once this ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-21 11:14:39 +01:00
beb57baf58 Merge pull request 'fix(secrets): injector-delivered servers must attach, not exec' (#120) from feat/injector-attach-mode into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m19s
CI/CD / lint (push) Successful in 2m24s
CI/CD / test (push) Successful in 1m27s
CI/CD / smoke (push) Failing after 1m59s
CI/CD / build (push) Successful in 4m40s
CI/CD / publish (push) Has been skipped
2026-08-21 01:05:53 +00:00
4 changed files with 159 additions and 10 deletions

View File

@@ -0,0 +1,43 @@
# gitea-mcp-server, rebuilt on a shell-bearing base.
#
# WHY THIS EXISTS
# ---------------
# Upstream `docker.gitea.com/gitea-mcp-server` is distroless: `Cmd` is
# ["/app/gitea-mcp"] and there is no /bin/sh at any path (verified by exec'ing
# every candidate against the running pod).
#
# That is fine until the server needs `secretDelivery: injector`. The OpenBao
# agent renders secrets to a FILE, so mcpd wraps the container command as
# `sh -c '. /vault/secrets/<name>; exec "$0" "$@"'` — which needs a shell. With
# no shell the pod cannot source its own credentials, and gitea was the single
# server in the fleet blocked on this.
#
# Copying one static Go binary onto debian:stable-slim is cheaper than building
# and maintaining a static "envexec" shim, and follows the precedent already set
# by deploy/Dockerfile.docmost-mcp — this repo already rebuilds third-party MCP
# servers when it needs to change how they run.
#
# ca-certificates is required, not incidental: the binary talks HTTPS to
# https://mysources.co.uk and a distroless base ships its own trust store which
# we are leaving behind.
FROM debian:stable-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Pinned by DIGEST, not :latest. `latest` moves, and a rebuild that silently
# ships a different server version is indistinguishable from a broken rebuild —
# chased exactly that here when a probe started failing after a rebuild.
# This digest is gitea-mcp-server 1.6.0 (label org.opencontainers.image.version),
# the build that was running when this image was introduced.
# To bump: skopeo inspect docker://docker.gitea.com/gitea-mcp-server:latest
COPY --from=docker.gitea.com/gitea-mcp-server@sha256:dda8d56e6a91fa89cad186becc27c7aa83d74acdd5dc69f89af840d7bb78a631 /app/gitea-mcp /usr/local/bin/gitea-mcp
WORKDIR /app
# CMD, not ENTRYPOINT — matching upstream, which sets Cmd ["/app/gitea-mcp"] and
# no entrypoint. mcpd maps a server's `command` to k8s `args`, which REPLACES
# Cmd but only appends to an ENTRYPOINT; keeping the same form means the plain
# (non-injected) path behaves byte-identically to upstream.
CMD ["/usr/local/bin/gitea-mcp"]

36
scripts/build-gitea-mcp.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
# Build gitea-mcp Docker image and push to Gitea container registry
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
# Load .env for GITEA_TOKEN
if [ -f .env ]; then
set -a; source .env; set +a
fi
# Push directly to internal address (external proxy has body size limit)
REGISTRY="10.0.0.194:3012"
IMAGE="gitea-mcp"
TAG="${1:-latest}"
echo "==> Building gitea-mcp image..."
podman build -t "$IMAGE:$TAG" -f deploy/Dockerfile.gitea-mcp .
echo "==> Tagging as $REGISTRY/michal/$IMAGE:$TAG..."
podman tag "$IMAGE:$TAG" "$REGISTRY/michal/$IMAGE:$TAG"
echo "==> Logging in to $REGISTRY..."
podman login --tls-verify=false -u michal -p "$GITEA_TOKEN" "$REGISTRY"
echo "==> Pushing to $REGISTRY/michal/$IMAGE:$TAG..."
podman push --tls-verify=false "$REGISTRY/michal/$IMAGE:$TAG"
# Ensure package is linked to the repository
source "$SCRIPT_DIR/link-package.sh"
link_package "container" "$IMAGE"
echo "==> Done!"
echo " Image: $REGISTRY/michal/$IMAGE:$TAG"

View File

@@ -135,17 +135,29 @@ export class ServerIdentityService {
const secretNames = this.secretNamesFor(server); const secretNames = this.secretNamesFor(server);
if (secretNames.length === 0) return undefined; if (secretNames.length === 0) return undefined;
// mcpd owns the runner images, so their entrypoints are known. A // Build the COMPLETE argv the container should run. It differs by shape:
// dockerImage server's is not introspectable — hence `entrypoint` being //
// required on the row at validation time. // package server — mcpd owns the runner image, whose ENTRYPOINT
const imageEntrypoint = server.packageName // (`npx -y` / `uvx`) is lost once we take over
? server.runtime === 'python' // `command`, so it must be prepended here.
? ['uvx'] // image + command — `command` is already a full command line; mcpd would
: ['npx', '-y'] // have run exactly it. Prepending anything breaks it.
// image only — the image's own ENTRYPOINT would run and mcpd cannot
// introspect it, so the row must declare `entrypoint`.
//
// Getting this wrong returns undefined and SILENTLY skips the wrapper: the
// agent still renders the file, nothing sources it, and the server starts
// with empty credentials. Observed on docmost and my-home-assistant, which
// carry a `command` but no `entrypoint`.
const hasPackage = server.packageName !== null && server.packageName !== undefined && server.packageName !== '';
const argv = hasPackage
? [...(server.runtime === 'python' ? ['uvx'] : ['npx', '-y']), ...(command ?? [server.packageName as string])]
: command !== undefined && command.length > 0
? command
: ((server.entrypoint as string[] | null) ?? undefined); : ((server.entrypoint as string[] | null) ?? undefined);
if (imageEntrypoint === undefined || imageEntrypoint.length === 0) return undefined;
return wrapCommandForInjector([...imageEntrypoint, ...(command ?? [])], secretNames); if (argv === undefined || argv.length === 0) return undefined;
return wrapCommandForInjector(argv, secretNames);
} }
/** /**

View File

@@ -0,0 +1,58 @@
/**
* Which argv the injector wrapper wraps, by server shape.
*
* Getting this wrong is SILENT: wrapCommand returns undefined, the wrapper is
* skipped, the agent still renders /vault/secrets/<name>, nothing sources it,
* and the server starts with empty credentials. Observed live on docmost and
* my-home-assistant, which carry a `command` but no `entrypoint`.
*/
import { describe, it, expect } from 'vitest';
import { ServerIdentityService } from '../src/services/server-identity.service.js';
import type { SecretBackendService } from '../src/services/secret-backend.service.js';
const svc = new ServerIdentityService(
{} as unknown as SecretBackendService,
{ namespace: 'mcpctl-servers', ensure: async () => undefined, remove: async () => undefined },
);
const withSecret = { env: [{ name: 'T', valueFrom: { secretRef: { name: 'creds', key: 'K' } } }] };
/** The wrapper is `sh -c <script> arg0 arg1...`; argv starts at index 3. */
const argvOf = (r: string[] | undefined): string[] | undefined => r?.slice(3);
describe('wrapCommand argv by server shape', () => {
it('prepends the node runner entrypoint for a package server', () => {
const r = svc.wrapCommand({ ...withSecret, packageName: '@leval/mcp-grafana', runtime: 'node', entrypoint: null } as never, ['@leval/mcp-grafana']);
expect(argvOf(r)).toEqual(['npx', '-y', '@leval/mcp-grafana']);
});
it('prepends uvx for a python package server', () => {
const r = svc.wrapCommand({ ...withSecret, packageName: 'mcp-searxng', runtime: 'python', entrypoint: null } as never, ['mcp-searxng']);
expect(argvOf(r)).toEqual(['uvx', 'mcp-searxng']);
});
it('uses an image server\'s command verbatim — prepending anything breaks it', () => {
// The docmost/home-assistant regression: this used to return undefined.
const r = svc.wrapCommand({ ...withSecret, packageName: null, entrypoint: null } as never, ['node', 'build/index.js']);
expect(argvOf(r)).toEqual(['node', 'build/index.js']);
});
it('falls back to the declared entrypoint for an image server with no command', () => {
const r = svc.wrapCommand({ ...withSecret, packageName: null, entrypoint: ['/usr/local/bin/gitea-mcp'] } as never, undefined);
expect(argvOf(r)).toEqual(['/usr/local/bin/gitea-mcp']);
});
it('returns undefined when there is genuinely nothing to run', () => {
expect(svc.wrapCommand({ ...withSecret, packageName: null, entrypoint: null } as never, undefined)).toBeUndefined();
});
it('returns undefined for a server with no secret refs', () => {
expect(svc.wrapCommand({ env: [], packageName: 'p', runtime: 'node', entrypoint: null } as never, ['p'])).toBeUndefined();
});
it('always sources before exec, whatever the shape', () => {
const r = svc.wrapCommand({ ...withSecret, packageName: null, entrypoint: null } as never, ['node', 'x.js']);
expect(r?.[0]).toBe('/bin/sh');
expect(r?.[2]).toContain('. /vault/secrets/creds');
expect(r?.[2]).toContain('exec "$0" "$@"');
});
});