Compare commits
5 Commits
feat/injec
...
7716e424f9
| Author | SHA1 | Date | |
|---|---|---|---|
| 7716e424f9 | |||
|
|
f097c0f4d5 | ||
| c79bdab51b | |||
|
|
913c0fbdc6 | ||
| beb57baf58 |
36
deploy/Dockerfile.gitea-mcp
Normal file
36
deploy/Dockerfile.gitea-mcp
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# 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/*
|
||||||
|
|
||||||
|
COPY --from=docker.gitea.com/gitea-mcp-server:latest /app/gitea-mcp /usr/local/bin/gitea-mcp
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Kept as ENTRYPOINT so the plain (non-injected) path behaves exactly like
|
||||||
|
# upstream. mcpd REPLACES this with the sourcing wrapper when the server opts
|
||||||
|
# into injected delivery — that is why a shell has to exist in the image.
|
||||||
|
ENTRYPOINT ["/usr/local/bin/gitea-mcp"]
|
||||||
36
scripts/build-gitea-mcp.sh
Executable file
36
scripts/build-gitea-mcp.sh
Executable 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"
|
||||||
@@ -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.
|
||||||
: ((server.entrypoint as string[] | null) ?? undefined);
|
// image only — the image's own ENTRYPOINT would run and mcpd cannot
|
||||||
if (imageEntrypoint === undefined || imageEntrypoint.length === 0) return undefined;
|
// 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);
|
||||||
|
|
||||||
return wrapCommandForInjector([...imageEntrypoint, ...(command ?? [])], secretNames);
|
if (argv === undefined || argv.length === 0) return undefined;
|
||||||
|
return wrapCommandForInjector(argv, secretNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
58
src/mcpd/tests/injector-argv.test.ts
Normal file
58
src/mcpd/tests/injector-argv.test.ts
Normal 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" "$@"');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user