@vanilla-game/muninn-mcp
v0.6.0
Published
Read-only MCP server for one Muninn Minecraft backend
Readme
muninn-mcp
Read-only Model Context Protocol server for one Muninn plugin Minecraft backend.
One process always represents exactly one game backend. It has one Muninn base
URL, one backend bearer token, and one discovered server_id. Run a separate
process for each backend because installed plugins and capabilities can differ.
The default MCP transport is the official stateful Streamable HTTP transport from the MCP TypeScript SDK. Stdio remains available as a fallback.
Requirements
- Node.js 20 or newer
- a reachable Muninn plugin HTTP API
- the backend bearer token from
plugins/Muninn/config.yml
This MCP server requires
Muninn plugin v0.5.0
or newer. Its
OpenAPI document
defines CoreProtect block actions and summaries, exact next_offset
pagination, and CMI player search/sort parameters. The OpenAPI document shipped
by the running plugin remains the backend contract source of truth.
HTTP-first start
npm ci
npm run build
MUNINN_BASE_URL=http://127.0.0.1:8781 \
MUNINN_AUTH_TOKEN='<Muninn backend token>' \
MUNINN_EXPECTED_SERVER_ID=survival \
MUNINN_MCP_AUTH_TOKEN='<separate MCP client token>' \
npm startDefaults:
- transport:
http - bind:
127.0.0.1 - port:
3000 - MCP endpoint:
http://127.0.0.1:3000/mcp - readiness endpoint:
http://127.0.0.1:3000/healthz
The incoming MUNINN_MCP_AUTH_TOKEN is deliberately separate from
MUNINN_AUTH_TOKEN. The first protects MCP clients → this process; the second
protects this process → the Minecraft backend. Never reuse them.
An HTTP MCP client connects to the URL and supplies the static token:
{
"mcpServers": {
"muninn-survival": {
"url": "http://127.0.0.1:3000/mcp",
"headers": {
"Authorization": "Bearer <MUNINN_MCP_AUTH_TOKEN>"
}
}
}
}The exact client configuration envelope is client-specific; the URL, standard
Authorization header, and Streamable HTTP protocol are not.
Readiness needs no token and exposes no configuration secrets:
curl http://127.0.0.1:3000/healthzThe process first discovers the backend through /health and /capabilities.
It only starts listening after backend identity and capabilities have passed
fail-fast validation.
Stdio fallback
MUNINN_TRANSPORT=stdio \
MUNINN_BASE_URL=http://127.0.0.1:8781 \
MUNINN_AUTH_TOKEN='<Muninn backend token>' \
MUNINN_EXPECTED_SERVER_ID=survival \
npm startExample stdio client entry:
{
"mcpServers": {
"muninn-survival": {
"command": "node",
"args": ["/absolute/path/to/muninn-mcp/dist/index.js"],
"env": {
"MUNINN_TRANSPORT": "stdio",
"MUNINN_BASE_URL": "http://127.0.0.1:8781",
"MUNINN_AUTH_TOKEN": "<Muninn backend token>",
"MUNINN_EXPECTED_SERVER_ID": "survival"
}
}
}
}Configuration
MCP transport
| Variable | Default | Meaning |
|---|---:|---|
| MUNINN_TRANSPORT | http | http or stdio. |
| MUNINN_MCP_BIND | 127.0.0.1 | HTTP listener hostname or IP. |
| MUNINN_MCP_PORT | 3000 | HTTP listener port, 1–65535. |
| MUNINN_MCP_PATH | /mcp | Exact Streamable HTTP endpoint path. |
| MUNINN_MCP_AUTH_TOKEN | unset | Incoming static bearer. Optional only on loopback; required for every non-loopback HTTP bind. |
| MUNINN_MCP_ALLOWED_HOSTS | loopback hosts | Comma-separated hostnames without ports. Required for wildcard binds such as 0.0.0.0. |
| MUNINN_MCP_ALLOWED_ORIGINS | none | Exact comma-separated browser origins allowed for CORS. Browser Origin requests are rejected by default. |
For a private-network listener:
MUNINN_MCP_BIND=0.0.0.0 \
MUNINN_MCP_ALLOWED_HOSTS=minecraft-admin.internal,192.0.2.20 \
MUNINN_MCP_AUTH_TOKEN='<high-entropy token>' \
npm startThe built-in listener is plain HTTP. Do not expose it directly to the public internet. Keep it on loopback/private networking or place TLS and appropriate network controls in front of it.
Muninn backend
| Variable | Required | Default | Meaning |
|---|---:|---:|---|
| MUNINN_BASE_URL | yes | — | Backend origin or API root. A bare origin gets /api/v1/ appended. |
| MUNINN_AUTH_TOKEN | yes | — | Bearer token accepted by the Muninn plugin. |
| MUNINN_EXPECTED_SERVER_ID | no | — | Fail-fast backend identity pin; strongly recommended. |
| MUNINN_TIMEOUT_MS | no | 15000 | Per-request timeout, 100–120000 ms. |
| MUNINN_DEFAULT_PAGE_SIZE | no | 100 | Explicit default for paginated tools. |
| MUNINN_MAX_PAGE_SIZE | no | 100 | MCP-side page cap, maximum 1000. |
| MUNINN_MAX_LOOKUP_SECONDS | no | 2592000 | MCP-side CoreProtect time-window cap. |
| MUNINN_MAX_RADIUS | no | 128 | MCP-side CoreProtect radius cap. |
Backend limits remain authoritative and may be stricter.
HTTP security and lifecycle
- Host validation is port-independent and deny-by-default.
- Browser requests with an
Originheader are denied unless the exact origin is allowlisted. CORS never uses*and never enables credentials. - Incoming auth uses constant-time comparison of SHA-256 token digests.
- Request bodies are parsed only after MCP authentication and are capped at 256 KiB.
- Neither backend nor incoming bearer values are logged or returned in errors, including nested backend payloads.
- Each initialize request gets a cryptographically random stateful MCP session,
its own official
StreamableHTTPServerTransport, and its ownMcpServer. - Subsequent POST/GET/DELETE requests require a valid
Mcp-Session-Id. - HTTP DELETE terminates a session.
SIGINT/SIGTERMstop accepting requests, close all active transports/SSE streams, and close the HTTP server. - Sessions are in memory and are not resumable across process restarts; clients initialize again after a restart.
Capability-aware tools
Only tools whose endpoint is present in an enabled module's capability report
are registered. CoreProtect tools also require the corresponding feature flag.
Restart the process after backend plugin/capability changes to refresh
tools/list.
All tools are annotated read-only, non-destructive, and idempotent. The Paper batch endpoint uses HTTP POST but does not mutate game state.
Core and composite
server_statusinvestigate_block— bounded Paper/CoreProtect/WorldGuard context with independent probe results
CoreProtect primitives
coreprotect_block_lookup— bounded native CoreProtect lookup for block, interaction, and entity-kill events. Include/exclude arrays become single CSV query parameters and are applied by CoreProtect before pagination. The legacy scalarplayerremains supported.coreprotect_block_summary— bounded count and optional grouping byPLAYER,MATERIAL, and/orACTION; reports the scan limit and explicitcomplete/truncatedmetadata so a partialmatchedvalue is never mistaken for a total.coreprotect_container_lookupcoreprotect_item_lookupcoreprotect_inventory_lookupcoreprotect_chat_lookupcoreprotect_command_lookupcoreprotect_session_lookupcoreprotect_sign_lookupcoreprotect_username_lookup
Paper primitives
paper_get_containerpaper_batch_containers(1–64 locations)paper_get_player_inventorypaper_get_player_ender_chestpaper_get_player_state— includes typed Unix-secondfirst_playedand ISOfirst_played_isofields for both online and known offline playerspaper_get_player_statspaper_list_playerspaper_get_server_infopaper_list_entitiespaper_get_block
CMI primitives
cmi_list_players— optional case-insensitivequery,sort(name/last_login/last_logoff), andorder(asc/desc) are applied before paginationcmi_get_player— includes the typedban.activeflag when CMI exposes a ban record, distinguishing an active ban from historical/raw metadatacmi_get_player_homescmi_list_warpscmi_list_jails
WorldGuard primitives
worldguard_list_regionsworldguard_get_regionworldguard_regions_atworldguard_flags_at
ProtectionStones primitives
protectionstones_region_at— exactworld/x/y/zlookup returning the nearest claim orregion: null; claim data includes owners, members, protection-block, home, parent, hidden, and read-only commerce stateprotectionstones_player_regions— bounded claims for an exact nickname or UUID, optionally restricted to one loaded world;include_memberdefaults totrue
| MCP tool | Backend endpoint | Required input | Optional input |
|---|---|---|---|
| protectionstones_region_at | GET /api/v1/protectionstones/region-at | world, x, y, z | — |
| protectionstones_player_regions | GET /api/v1/protectionstones/player/{player}/regions | exact nickname or UUID in player | world, include_member (default true), offset, limit |
Paginated tools always send explicit bounded offset/limit values.
CoreProtect lookup responses return the backend's exact has_more marker; when
has_more=true, pass next_offset unchanged to the follow-up call. It is
absent on the final page. Other paginated endpoints retain their documented
pagination shape.
CoreProtect block lookup filters
| MCP input | Backend query | Semantics |
|---|---|---|
| player: string | player | Legacy scalar actor include filter. Mutually exclusive with players; the only actor filter allowed with at=true. |
| players: string[] | players CSV | 1–64 included actors. Mutually exclusive with player. |
| exclude_players: string[] | exclude_players CSV | 1–64 excluded actors. Does not provide the required player/area scope by itself. |
| materials: string[] | materials CSV | 1–64 included Bukkit block materials. |
| exclude_materials: string[] | exclude_materials CSV | 1–64 excluded Bukkit block materials. Does not provide the required player/area scope by itself. |
| entities: string[] | entities CSV | 1–64 included Bukkit entity types. Implies ENTITY_KILL when actions is omitted. |
| exclude_entities: string[] | exclude_entities CSV | 1–64 excluded Bukkit entity types. Does not provide the required player/area scope by itself. |
| actions | actions CSV | One or more of BLOCK_BREAK, BLOCK_PLACE, INTERACTION, ENTITY_KILL. |
| world, x, y, z, radius | same names | Existing bounded location filter; world and all coordinates are supplied together. |
| at: true | at=true | Exact-block history. Only scalar player, location, radius, and pagination inputs are allowed. |
| offset, limit | same names | Existing bounded pagination. |
Without at=true, every request requires either concrete scalar player /
players, or complete world/x/y/z coordinates with radius.
Material, entity, action, and exclusion filters refine that player/area scope;
they never enable an unrestricted global lookup because the public CoreProtect
API v12 does not support one. The special #global actor also cannot replace
the required scope. Array values must be unique, non-empty, comma-free, and
contain at most 64 entries.
The response remains the existing BlockEvent page. entity_type can be absent
for a generic filtered entity kill when the backend uses CoreProtect API v12.
For example, this finds selected ore events for one player while keeping the material restriction server-side:
{
"seconds": 86400,
"player": "Steve",
"materials": ["DIAMOND_ORE", "DEEPSLATE_DIAMOND_ORE"],
"actions": ["BLOCK_BREAK"],
"offset": 0,
"limit": 100
}This example combines multiple actors with native exclusions and entity/action filters:
{
"seconds": 86400,
"players": ["Steve", "Alex"],
"exclude_players": ["Automation"],
"materials": ["DIAMOND_ORE"],
"exclude_materials": ["STONE"],
"entities": ["ZOMBIE", "SKELETON"],
"exclude_entities": ["ARMOR_STAND"],
"actions": ["BLOCK_BREAK", "ENTITY_KILL"],
"offset": 0,
"limit": 100
}For a smaller answer when only totals are needed, use
coreprotect_block_summary and inspect completeness before treating matched
as a total:
{
"seconds": 86400,
"player": "Steve",
"materials": ["DIAMOND_ORE", "DEEPSLATE_DIAMOND_ORE"],
"actions": ["BLOCK_BREAK"],
"group_by": ["MATERIAL", "ACTION"],
"max_scan_events": 10000,
"include_rolled_back": false
}Errors
Muninn envelopes become readable MCP tool errors with stable codes, safe
details, HTTP status, backend ID, guidance, and retryability where applicable:
UNAUTHORIZED, NOT_FOUND, BAD_REQUEST, SYNC_TIMEOUT,
MODULE_DISABLED, FEATURE_UNAVAILABLE, LIMIT_EXCEEDED,
PLAYER_OFFLINE, and INTERNAL.
Transport-side errors include TIMEOUT, NETWORK_ERROR, CANCELLED,
INVALID_RESPONSE, and SERVER_ID_MISMATCH.
HTTP routing/auth errors use bounded JSON/JSON-RPC bodies and never echo Host, Origin, authorization values, request bodies, or internal exceptions.
Verification
npm ci
npm run check
npm testThe suite covers:
- HTTP-default and stdio process startup/shutdown;
- official
StreamableHTTPClientTransportinitialize, initialized notification,tools/list,tools/call, SSE, and DELETE lifecycle; - incoming auth 401, Host/Origin/CORS policy, invalid methods and sessions;
- non-zero invalid configuration;
- recursive secret redaction;
- all 31 atomic tool-to-endpoint mappings and capability filtering.
Real plugin Docker harness
The cross-project test starts the adjacent real
Paper+CoreProtect+CMI+WorldGuard fixture, starts the built HTTP MCP process,
connects through the official Streamable HTTP client, and removes all temporary
processes/containers. Its field evaluation covers a server overview, the Paper
first_played profile contract, CMI fuzzy search/sort, an action-filtered rare
material summary, native CoreProtect actor/entity include-exclude filters, and a
combined WorldGuard-region/CoreProtect-session scenario. The test prints
MUNINN_FIELD_EVAL JSON with actual MCP tool call counts and UTF-8 output bytes
per scenario and in total:
npm run test:e2e:harnessIn this monorepo the plugin is discovered automatically at ../plugin:
MUNINN_PLUGIN_DIR=/absolute/path/to/plugin npm run test:e2e:harnessAn already-running backend can be tested directly:
MUNINN_E2E_BASE_URL=http://127.0.0.1:8781 \
MUNINN_E2E_AUTH_TOKEN='<backend token>' \
MUNINN_E2E_SERVER_ID=survival \
npm run test:e2eThe stdio fallback remains covered by the normal test suite.
Releases and npm publication
Release Please watches conventional commits on main, maintains a release PR,
updates CHANGELOG.md, package.json, package-lock.json, and the root release
manifest, then creates an mcp-vX.Y.Z GitHub Release when that release PR is merged.
The same workflow checks and tests the released commit before publishing
@vanilla-game/muninn-mcp as a public npm package with provenance.
The package is published through npm Trusted Publishing. Configure the publisher for:
- GitHub organization:
Vanilla-Game - repository:
muninn - workflow filename:
release-please.yml - allowed action:
npm publish
The workflow uses GitHub OIDC through id-token: write and does not accept a
long-lived npm token. The npm package repository URL must continue to match this
monorepo and its mcp directory.
By default Release Please uses the workflow's GITHUB_TOKEN. An optional
RELEASE_PLEASE_TOKEN GitHub secret can supply a GitHub App or fine-grained PAT
when repository policy requires release PR events to trigger other workflows.
License
MIT
