Register a new MCP server in workstation/agents/mcp-servers.nix using the post-refactor helpers (mkMcpServer, mkEmacsStdioServer) and the programs.mcp.servers seam. If the server needs a build-from-source derivation, drop it in workstation/agents/packages/<name>.nix.
When NOT to use this skill
- Tool-integration that happens to also expose an MCP server (e.g.,
code-review-graphis a CLI + MCP + skills bundle). Wire it through the tool submodule (agents.tools.<toolName>.mcpServers = {...}) so togglingenableremoves the whole bundle atomically. The fold indefault.nixcollects per-toolmcpServersintoprograms.mcp.serversautomatically. - HTTP / SSE-transport servers. Current helpers assume stdio. For HTTP/SSE you must hand-roll the entry with the explicit
type = "http"(or"sse") field — see the upstreamprograms.mcp.serversschema in the home-manager module.
This skill covers the common case: a stdio MCP server registered globally (not tied to a single tool integration).
Step 1: Identify the server source
Classify before touching files:
| Source | Action |
|---|---|
Already in nixpkgs (pkgs.<name>) |
No derivation needed. Reference pkgs.<name> directly. |
Already a flake output (e.g., inputs.foo.packages.${pkgs.system}.default) |
Add the flake to flake.nix inputs; reference the package via inputs.<name>. |
| Python sdist on PyPI | Build with buildPythonApplication in packages/<name>.nix. See packages/kagimcp.nix for the pattern (python312.override for transitive-dep test skips). |
| npm package on the registry | Build with buildNpmPackage + fetchurl + a checked-in package-lock.json. See packages/chrome-devtools-mcp.nix. |
| Source on GitHub (Go/Rust/etc.) | Either get it into nixpkgs upstream, or write a pkgs.callPackage derivation in packages/<name>.nix. |
| Emacs-side stdio (function in user's Emacs) | No package — use mkEmacsStdioServer (Step 4b). |
Step 2: Decide on a package derivation
If the source isn't already a usable package, create workstation/agents/packages/<name>.nix and reference it via pkgs.callPackage.
Python example (packages/kagimcp.nix shape)
{
python312,
src,
}:
let
pythonOverridden = python312.override {
packageOverrides = pyfinal: pyprev: {
# only if a transitive dep needs `doCheck = false;` or similar
# cfn-lint = pyprev.cfn-lint.overridePythonAttrs (_: { doCheck = false; });
};
};
in
pythonOverridden.pkgs.buildPythonApplication {
pname = "<name>";
version = "<version>";
pyproject = true;
inherit src;
build-system = with pythonOverridden.pkgs; [ hatchling ];
dependencies = with pythonOverridden.pkgs; [
# runtime deps
];
doCheck = false;
}
In mcp-servers.nix, call it: pkgs.callPackage ./packages/<name>.nix { src = inputs.<flakeInputName>; }.
npm example (packages/chrome-devtools-mcp.nix shape)
{ buildNpmPackage, fetchurl }:
buildNpmPackage {
pname = "<name>";
version = "<version>";
src = fetchurl {
url = "https://registry.npmjs.org/<name>/-/<name>-<version>.tgz";
hash = "sha256-..."; # get with: nix-prefetch-url --type sha256 <tgz-url>
};
sourceRoot = "package";
npmDepsFetcherVersion = 2;
npmDepsHash = "sha256-..."; # leave empty first, rebuild → nix prints expected hash
npmFlags = [ "--omit=dev" "--ignore-scripts" ];
dontNpmBuild = true;
preInstall = "mkdir -p node_modules";
postPatch = "cp ${../<name>-lock.json} package-lock.json";
}
Generate the lock file once with npm install --package-lock-only outside the Nix sandbox and check it in under workstation/agents/<name>-lock.json.
Call site: pkgs.callPackage ./packages/<name>.nix { }.
Important — git-add new files immediately
Nix flake evaluation only sees git-tracked files. After creating packages/<name>.nix (or any new lock-file alongside it), git add it before running nix build / nix flake check, or evaluation will fail with "no such file."
Step 3: Identify wrapping needs
Two orthogonal needs the helpers cover automatically:
| Need | Flag | Mechanic |
|---|---|---|
| Token compression (most chatty MCP servers benefit) | shrink = true |
Prepends caveman-shrink to the command line. |
| Secret env from a sops file | secretEnvFiles = { ENV_NAME = config.sops.secrets."path/to/secret".path; }; |
Generates a pkgs.writeShellScript wrapper that cats the file, exports the var, then execs the underlying command. |
Both can compose (see github-mcp in current mcp-servers.nix: both secretEnvFiles and shrink = true).
If the server needs neither, you get a bare { command; args; } entry — no wrapper script, no shrink.
If it needs plain env = { ... } vars (non-secret), pass env = { FOO = "bar"; }; — works in all three branches.
Step 4: Pick the helper
4a. Standard MCP server (mkMcpServer)
"<server-name>" = mkMcpServer {
name = "<server-name>"; # used as wrapper-script name
command = "<absolute-or-PATH binary>"; # e.g. "github-mcp-server" or "${pkg}/bin/foo"
args = [ "stdio" "--flag" "value" ]; # optional
env = { LOG_LEVEL = "info"; }; # optional, non-secret env
secretEnvFiles = { # optional
GITHUB_PERSONAL_ACCESS_TOKEN = config.sops.secrets."authinfo/github_pat".path;
};
shrink = true; # optional, default false
};
4b. Emacs stdio MCP server (mkEmacsStdioServer)
Use when the server is an Emacs init/stop pair driven by ${emacsConfig}/emacs-mcp-stdio.sh:
"<server-name>" = mkEmacsStdioServer {
name = "<server-name>"; # also becomes --server-id
initFunction = "<elisp-init-fn>";
stopFunction = "<elisp-stop-fn>";
shrink = true; # optional, default true for emacs servers
};
This expands to mkMcpServer under the hood with the standard launcher script and the three --init-function=, --stop-function=, --server-id= flags. Don't reimplement the launcher invocation by hand.
Step 5: Register the package in home.packages (if applicable)
If the server binary comes from a derivation built in mcp-servers.nix (Python/npm/etc. via callPackage), and the binary is a top-level path (not a ${pkg}/bin/foo already substituted into the command), add it to the home.packages list at the top of mcp-servers.nix:
home.packages = [
chrome-devtools-mcp
pkgs.mcp-nixos
pkgs.github-mcp-server
# <your new package here, if it needs to be on PATH>
];
Skip this step if you reference the binary by absolute store path (command = "${pkg}/bin/foo";) — the closure keeps it alive without exposing it on PATH.
Step 6: Wire a flake input (if from GitHub)
If you used inputs.<name> in Step 2, edit /home/oskar/projects/dot-emacs/flake.nix:
<name> = {
url = "github:<user>/<repo>";
flake = false; # or omit if upstream is a flake
};
Then nix flake lock --update-input <name> (or nix flake update <name> on older nix).
Step 7: Apply and verify
- Stage everything (Nix only evaluates git-tracked files):
git add workstation/agents/packages/<name>.nix \ workstation/agents/<name>-lock.json \ workstation/agents/mcp-servers.nix \ flake.nix flake.lock - Evaluate the server list to confirm it appears:
nix eval --impure --raw \ .#homeConfigurations."oskar@x86_64-linux".config.programs.mcp.servers \ --apply 'servers: builtins.concatStringsSep "\n" (builtins.attrNames servers)' - Build the activation package:
nix build .#homeConfigurations."oskar@x86_64-linux".activationPackage - Inspect the generated wrapper (sanity-check secret-env / shrink wiring):
nix eval --raw .#homeConfigurations."oskar@x86_64-linux".config.programs.mcp.servers."<server-name>".command # if it points to /nix/store/...-<name>-mcp, cat it to see the wrapper - Format:
nix fmt -- workstation/agents/mcp-servers.nix(treefmt). - Apply: tell the user to
home-manager switch(or whatever their activation entry is). The new server appears in Claude Code + Opencode automatically — bothprograms.claude-code.enableMcpIntegrationandprograms.opencode.enableMcpIntegrationare alreadytruein the refactoredmcp-servers.nix.
Edge cases
npmDepsHashmismatch: leave the hash as""first; the failing build prints the expected hash. Substitute, rebuild.- PyPI sdist test failures via transitive deps: use
python<NN>.override { packageOverrides = pyfinal: pyprev: { <dep> = pyprev.<dep>.overridePythonAttrs (_: { doCheck = false; }); }; }. The kagimcp derivation has the canonical example (cfn-lint chain). - Server needs more than env-from-file (e.g., a config file written to disk): the helper covers env only. For richer setup, either pre-wrap the binary in a derivation or write a small
pkgs.writeShellScriptdirectly and pass it ascommand. - Multiple servers share a wrapper pattern not covered by the helpers: extend the helper rather than duplicating wrapper logic inline. "Two adapters = real seam" — that's the whole point of the post-refactor shape.
- Stop using a server: just delete its entry from
programs.mcp.servers(and itshome.packagesentry if present). No other refactor surface to touch.
Source: ohaukeboe/dot-emacs — distributed by TomeVault.