Files
flake-hub/packages/metamcp.nix
T
Oleks cb369d3be3
ci/woodpecker/push/arm64 Pipeline failed
ci/woodpecker/push/amd64 Pipeline failed
metamcp: observability patch — W3C traceparent + Prometheus /metrics (cluster#49, cluster#50)
Adds a postPatch step that applies metamcp-observability.patch to
the upstream metatool-ai/metamcp v2.4.22 source before pnpm build.
The patch:

  * Drops in apps/backend/src/lib/observability/{trace,metrics}.ts:
    AsyncLocalStorage trace context + parser/synthesizer per the W3C
    contract (Specs/mcp-request-id), plus a hand-rolled
    Counter/Histogram so we don't have to touch pnpm-lock.yaml /
    pnpmDeps hash (no new npm dependency).

  * Wires a top-level express middleware in apps/backend/src/index.ts
    that binds trace context, observes mcp_hop_duration_seconds on
    response close, and counts mcp_cancellation_total when the
    downstream client hangs up mid-response.

  * Adds /metrics to the Express app and last-resort process traps
    (unhandledRejection / uncaughtException) feeding
    mcp_uncaught_throw_total — the smoking-gun signal from
    cluster#44.

  * Patches process-managed-transport.send() to inject
    params._meta.traceparent on every outbound JSON-RPC bound for a
    stdio child (MCP _meta convention, Specs/mcp-request-id).

Retire when this lands upstream.
2026-05-24 05:34:47 +03:00

210 lines
7.0 KiB
Nix

{
lib,
stdenv,
fetchFromGitHub,
makeWrapper,
nodejs_20,
pnpm_10,
fetchPnpmDeps,
pnpmConfigHook,
postgresql,
}:
# MetaMCP — MCP aggregator/orchestrator.
#
# Upstream is a pnpm monorepo (Turbo) with two apps:
# - apps/backend : Express/tRPC API, built with tsup -> dist/index.js
# port 12009 (internal), runs drizzle-kit migrations at boot
# - apps/frontend : Next.js 15, port 12008 (public), launched via `next start`
#
# Both require runtime node_modules (Next.js non-standalone; drizzle-kit is
# a devDep invoked at runtime by docker-entrypoint.sh). We ship the whole
# built tree under $out/lib/metamcp and provide launcher scripts.
#
# The upstream Dockerfile patches Next.js' proxy timeout (30s -> 600s) by
# sed-editing two files inside node_modules/.pnpm/next@.../...; we replicate
# that in postBuild so the behaviour matches the official image.
#
# Build is single-derivation: pnpm fetch -> pnpm build -> install. First
# build prints the right pnpmDeps hash; paste it back here and rebuild.
let
pname = "metamcp";
version = "2.4.22";
src = fetchFromGitHub {
owner = "metatool-ai";
repo = "metamcp";
rev = "v${version}";
hash = "sha256-EEb3RUjsaJ5ZSHSIkAxfdV/BAjZEAvw3rtjALM4RpSc=";
};
in
stdenv.mkDerivation (finalAttrs: {
inherit pname version src;
pnpmDeps = fetchPnpmDeps {
inherit pname version src;
fetcherVersion = 3;
hash = "sha256-nHHLLu5wBzzP4i/oTnOkuIiPQvvvBAIIVtKdfpDiXQw=";
};
nativeBuildInputs = [
nodejs_20
pnpm_10
pnpmConfigHook
makeWrapper
];
# Upstream pins `packageManager: pnpm@9.0.0`; nixpkgs ships pnpm 10. The
# lockfile is v9 (forward-compatible). Rewrite the pin to the pnpm we
# actually have so pnpm 10 doesn't try to network-fetch pnpm@9 and Turbo
# (which *requires* the field) still finds it.
postPatch = ''
${nodejs_20}/bin/node -e '
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
pkg.packageManager = "pnpm@${pnpm_10.version}";
if (pkg.engines) delete pkg.engines.pnpm;
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n");
'
# cluster#49, cluster#50 observability patch: W3C traceparent
# propagation (HTTP header params._meta.traceparent on stdio
# children) + hand-rolled Prometheus metrics on /metrics, no new
# dependency so pnpmDeps stays untouched. Two new source files
# under apps/backend/src/lib/observability/ and minimal edits to
# apps/backend/src/index.ts and process-managed-transport.ts.
# Retire when this lands upstream.
patch -p1 < ${./metamcp-observability.patch}
'';
# pnpmConfigHook places node_modules; build the workspace with Turbo.
buildPhase = ''
runHook preBuild
# Match the upstream Dockerfile sed-patch on Next.js proxy timeout.
# Files live under the pnpm virtual store; glob to tolerate minor
# next/react version bumps on later tags.
for f in \
node_modules/.pnpm/next@*/node_modules/next/dist/server/lib/router-utils/proxy-request.js \
node_modules/.pnpm/next@*/node_modules/next/dist/esm/server/lib/router-utils/proxy-request.js; do
if [ -f "$f" ]; then
sed -i -e "s/30000/600000/" "$f"
fi
done
pnpm build
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/lib/metamcp $out/bin
# Ship the whole built workspace. This is bulky but reliable:
# `next start` needs .next + node_modules + package.json side-by-side,
# and the backend's runtime invokes `pnpm exec drizzle-kit migrate`.
cp -r \
apps \
packages \
node_modules \
package.json \
pnpm-workspace.yaml \
pnpm-lock.yaml \
turbo.json \
$out/lib/metamcp/
# Next.js standalone post-processing: `output: "standalone"` produces
# apps/frontend/.next/standalone/apps/frontend/server.js but does NOT
# copy .next/static or public/ into the standalone tree. Do it here
# so the runtime UI has its CSS, JS chunks and static assets.
SA=$out/lib/metamcp/apps/frontend/.next/standalone/apps/frontend
cp -r $out/lib/metamcp/apps/frontend/.next/static $SA/.next/static
cp -r $out/lib/metamcp/apps/frontend/public $SA/public
# Launcher: re-implementation of upstream's docker-entrypoint.sh.
# Cleaner than substituteInPlace-ing the upstream script (the original
# has overlapping `/app` substrings that break naive replacement) and
# gives us a single place to keep the orchestration logic in sync.
cat > $out/bin/metamcp <<EOF
#!${stdenv.shell}
set -e
export PATH=${
lib.makeBinPath [
nodejs_20
pnpm_10
postgresql # for pg_isready
]
}:\$PATH
ROOT=$out/lib/metamcp
: "\''${POSTGRES_HOST:=127.0.0.1}"
: "\''${POSTGRES_PORT:=5432}"
: "\''${POSTGRES_USER:=postgres}"
echo "Waiting for PostgreSQL at \$POSTGRES_HOST:\$POSTGRES_PORT..."
until pg_isready -h "\$POSTGRES_HOST" -p "\$POSTGRES_PORT" -U "\$POSTGRES_USER"; do
sleep 2
done
echo "Running drizzle migrations..."
cd "\$ROOT/apps/backend"
pnpm exec drizzle-kit migrate
echo "Starting backend on :12009..."
PORT=12009 node "\$ROOT/apps/backend/dist/index.js" &
BACKEND_PID=\$!
sleep 3
kill -0 \$BACKEND_PID 2>/dev/null || { echo "Backend died"; exit 1; }
echo "Starting frontend on :12008..."
cd "\$ROOT/apps/frontend/.next/standalone/apps/frontend"
# Next.js standalone uses \$HOSTNAME as the bind address. The shell
# inherits HOSTNAME=<system-hostname>, leaving the server unreachable
# on 127.0.0.1 force 0.0.0.0 unless overridden via METAMCP_HOSTNAME.
PORT=12008 HOSTNAME="\''${METAMCP_HOSTNAME:-0.0.0.0}" node server.js &
FRONTEND_PID=\$!
sleep 3
kill -0 \$FRONTEND_PID 2>/dev/null || { kill \$BACKEND_PID 2>/dev/null; echo "Frontend died"; exit 1; }
trap 'kill \$BACKEND_PID \$FRONTEND_PID 2>/dev/null || true' TERM INT
wait \$BACKEND_PID \$FRONTEND_PID
EOF
chmod +x $out/bin/metamcp
# Direct sub-launchers (handy for systemd "two-unit" deployments if
# you ever want to skip the bundled orchestrator).
makeWrapper ${nodejs_20}/bin/node $out/bin/metamcp-backend \
--add-flags "$out/lib/metamcp/apps/backend/dist/index.js" \
--set NODE_ENV production
cat > $out/bin/metamcp-frontend <<EOF
#!${stdenv.shell}
cd $out/lib/metamcp/apps/frontend/.next/standalone/apps/frontend
exec env HOSTNAME="\''${METAMCP_HOSTNAME:-0.0.0.0}" ${nodejs_20}/bin/node server.js "\$@"
EOF
chmod +x $out/bin/metamcp-frontend
runHook postInstall
'';
# The pnpm/turbo build writes into $HOME; give it a writable one.
preBuild = ''
export HOME=$TMPDIR
'';
meta = {
description = "MetaMCP MCP aggregator, orchestrator, middleware, gateway";
homepage = "https://github.com/metatool-ai/metamcp";
license = lib.licenses.mit;
mainProgram = "metamcp";
platforms = [
"x86_64-linux"
"aarch64-linux"
];
};
})