An MCP server in a plugin
This page shows a plugin called forecast-tools that packages one stdio MCP
server with one tool, describe_forecast. You will see every file in full,
test the server with a pipe before Muse Code ever runs it, install and approve
it, and watch a headless turn call the tool. Every output block was produced
by the command above it against Muse Code 1.3.0. Read
MCP servers first if you want the rules behind
each step.
What you will build
Section titled “What you will build”forecast-tools/├── .muse-plugin/│ └── plugin.json└── mcp/ ├── server.py └── smoke.py- The manifest declares a single
mcpServersentry with thestdiotransport and the commandpython3 mcp/server.py. - The server is a Python 3 script with no third-party dependencies. It
reads one JSON-RPC 2.0 message per line from stdin, writes one per line to
stdout, and implements
initialize,notifications/initialized,ping,tools/listandtools/call. - The smoke test
smoke.pydrives the server over a pipe before Muse Code ever runs it. It is not referenced by the manifest; it is just a file in the package. The messages themselves are not reproduced on this page; the driver prints only their decoded results.
You need a muse binary and a python3 on your PATH. The server was
tested with Python 3.14; any Python 3 works because it uses only the standard
library.
The manifest
Section titled “The manifest”{ "schemaVersion": 1, "name": "forecast-tools", "displayName": "Forecast tools", "version": "0.1.0", "description": "Example plugin: one stdio MCP server whose describe_forecast tool turns a place and its conditions into a short forecast.", "compat": { "source": "native", "manifestDir": ".muse-plugin" }, "capabilities": { "skills": [], "commands": [], "hooks": [], "mcpServers": [ { "id": "forecast", "transport": "stdio", "command": ["python3", "mcp/server.py"] } ], "reminders": [] }}Three things to notice:
commandis an argv array. There is no shell, so nothing is quoted or expanded.python3is looked up on thePATHthat reaches the server, andmcp/server.pyis a relative path that must exist in the plugin.- Muse Code rewrites that one relative element to the installed copy of the file when it starts the server. Nothing else in the array is touched.
- The entry has no
env,cwdorheaders. Plugin MCP server entries have noenv,headersor credentials; a server that needs a token belongs insettings.jsoninstead.
Manifest has the field table for an
mcpServers entry, including the http transport this example does not use.
The server
Section titled “The server”#!/usr/bin/env python3"""A dependency-free MCP server for the forecast-tools example plugin.
Muse Code starts this script once per session and talks to it over stdin andstdout. Each message is one JSON-RPC 2.0 object on its own line. The serveranswers initialize, ping, tools/list and tools/call, and ignores thenotifications/initialized notification. It exposes one tool, describe_forecast."""import jsonimport osimport sys
SUPPORTED_PROTOCOLS = ("2025-06-18", "2025-03-26", "2024-11-05")SERVER_INFO = {"name": "forecast-tools", "version": "0.1.0"}TOOLS = [ { "name": "describe_forecast", "description": "Turn a place name and its expected conditions into a one-sentence forecast.", "inputSchema": { "type": "object", "properties": { "place": {"type": "string", "description": "City or region, for example Lisbon."}, "conditions": {"type": "string", "description": "Expected weather, for example light rain, 14C."}, }, "required": ["place", "conditions"], }, # Read-only tools are admitted without an approval prompt once the # server itself is approved. Only set this on tools that change nothing. "annotations": {"readOnlyHint": True}, }]
def log(text): """Append one line to server.log in the plugin data directory, when Muse Code set one.
Muse Code discards the server's stderr, so a file is the only place to look when something goes wrong. The data directory is advertised but not created for you. Make it before writing. """ data_dir = os.environ.get("MUSE_PLUGIN_DATA_DIR") if not data_dir: return os.makedirs(data_dir, exist_ok=True) with open(os.path.join(data_dir, "server.log"), "a", encoding="utf-8") as handle: handle.write(text + "\n")
def send(message): """Write one message as a single line and flush, or the client waits forever.""" sys.stdout.write(json.dumps(message) + "\n") sys.stdout.flush()
def answer(request, **body): """Reply to a request with a result or an error.
A reply carries the same envelope as the request it answers (the protocol version marker and the id), so copy those two members from the request and add the body instead of spelling the envelope out again. """ reply = {key: value for key, value in request.items() if key not in ("method", "params")} reply.update(body) send(reply)
def describe_forecast(arguments): place = str(arguments.get("place", "")).strip() conditions = str(arguments.get("conditions", "")).strip() if not place or not conditions: return {"content": [{"type": "text", "text": "Both place and conditions are required."}], "isError": True} text = f"Forecast for {place}: expect {conditions}. Plan around it and check again closer to the time." return {"content": [{"type": "text", "text": text}]}
def handle(message): method = message.get("method") params = message.get("params") or {} if method == "initialize": requested = params.get("protocolVersion") version = requested if requested in SUPPORTED_PROTOCOLS else SUPPORTED_PROTOCOLS[0] answer(message, result={"protocolVersion": version, "capabilities": {"tools": {}}, "serverInfo": SERVER_INFO}) elif method == "notifications/initialized": return # a notification: no id, no reply elif method == "ping": answer(message, result={}) elif method == "tools/list": answer(message, result={"tools": TOOLS}) elif method == "tools/call": name = params.get("name") log(f"tools/call {name}") if name != "describe_forecast": answer(message, error={"code": -32602, "message": f"unknown tool: {name}"}) return answer(message, result=describe_forecast(params.get("arguments") or {})) elif message.get("id") is not None: answer(message, error={"code": -32601, "message": f"method not found: {method}"})
def main(): log("start session=%s plugin=%s root=%s" % ( os.environ.get("MUSE_SESSION_ID", "-"), os.environ.get("MUSE_PLUGIN_ID", "-"), os.environ.get("MUSE_PLUGIN_ROOT", "-"), )) for line in sys.stdin: # ends cleanly when Muse Code closes stdin line = line.strip() if not line: continue try: message = json.loads(line) except json.JSONDecodeError: log("skipped a line that was not JSON") continue if isinstance(message, dict): handle(message) log("stdin closed, exiting")
if __name__ == "__main__": main()What the script relies on:
- One message per line, flushed. Muse Code accepts newline-delimited JSON
or
Content-Lengthframing and detects which one a server uses; this script uses newlines because they are the simplest to get right. Without theflush()the reply sits in Python’s buffer and the session waits. - A reply copies the request’s envelope.
answer()keeps the request’s protocol marker andid, dropsmethodandparams, and adds aresultor anerror, so the script never spells the envelope out. - Notifications get no reply.
notifications/initializedhas noid, so the script returns without writing anything. Answering a notification is a protocol error. - Unknown methods return a
-32601error so that a client probing forresources/listorprompts/listmoves on instead of hanging. readOnlyHintis a promise. The tool only formats text, so the hint is honest. Once the server is approved, Muse Code admits calls to this tool without an approval prompt under the defaulton-requestmode.- Logging goes to a file. The server’s stderr is discarded.
log()createsMUSE_PLUGIN_DATA_DIRbefore writing because Muse Code does not create it.
Test the server with a pipe
Section titled “Test the server with a pipe”Test the protocol before involving Muse Code. smoke.py starts server.py
as a child process, writes the seven messages below one per line, prints the
id and decoded result or error of each reply, closes stdin and prints the exit
code:
#!/usr/bin/env python3"""Smoke test for server.py: drive it over a pipe the way Muse Code would.
Run it as `python3 forecast-tools/mcp/smoke.py`. It starts server.py as achild process, writes seven messages one per line, prints the id and thedecoded result or error of each reply, closes stdin and prints the exit code.No third-party dependencies."""import jsonimport osimport subprocessimport sys
SERVER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "server.py")ENVELOPE_KEY = "jsonrpc" # every message carries this marker with the value "2.0"
# (label, id, method, params). A None id makes a notification: no reply expected.MESSAGES = [ ("initialize", 1, "initialize", { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "smoke", "version": "0.1.0"}, }), ("initialized", None, "notifications/initialized", {}), ("ping", 2, "ping", {}), ("tools/list", 3, "tools/list", {}), ("tools/call", 4, "tools/call", { "name": "describe_forecast", "arguments": {"place": "Lisbon", "conditions": "light rain, 14C"}, }), ("tools/call (missing argument)", 5, "tools/call", { "name": "describe_forecast", "arguments": {"place": "Lisbon"}, }), ("resources/list", 6, "resources/list", {}),]
def main(): child = subprocess.Popen( [sys.executable, SERVER], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, ) for label, ident, method, params in MESSAGES: message = {ENVELOPE_KEY: "2.0", "method": method, "params": params} if ident is not None: message["id"] = ident child.stdin.write(json.dumps(message) + "\n") child.stdin.flush() if ident is None: continue # a notification gets no reply reply = json.loads(child.stdout.readline()) body = reply["result"] if "result" in reply else reply["error"] print(f"# {label} -> id {reply['id']}") print(json.dumps(body, indent=2)) child.stdin.close() print(f"# exit code {child.wait()}")
if __name__ == "__main__": main()It sends, in order:
- An
initializerequest (id 1) withprotocolVersion2025-06-18, empty client capabilities and aclientInfoobject. - The
notifications/initializednotification, which has no id and expects no answer. - A
pingrequest (id 2). - A
tools/listrequest (id 3). - A
tools/callrequest (id 4) fordescribe_forecastwith the argumentsplaceLisbonandconditionslight rain, 14C. - A second
tools/call(id 5) with onlyplace, to see the validation path. - A
resources/listrequest (id 6), which the server does not implement.
For each reply it prints the id and the decoded result (or error) member,
which is everything worth reading:
python3 forecast-tools/mcp/smoke.py# initialize -> id 1{ "protocolVersion": "2025-06-18", "capabilities": { "tools": {} }, "serverInfo": { "name": "forecast-tools", "version": "0.1.0" }}# ping -> id 2{}# tools/list -> id 3{ "tools": [ { "name": "describe_forecast", "description": "Turn a place name and its expected conditions into a one-sentence forecast.", "inputSchema": { "type": "object", "properties": { "place": { "type": "string", "description": "City or region, for example Lisbon." }, "conditions": { "type": "string", "description": "Expected weather, for example light rain, 14C." } }, "required": [ "place", "conditions" ] }, "annotations": { "readOnlyHint": true } } ]}# tools/call -> id 4{ "content": [ { "type": "text", "text": "Forecast for Lisbon: expect light rain, 14C. Plan around it and check again closer to the time." } ]}# tools/call (missing argument) -> id 5{ "content": [ { "type": "text", "text": "Both place and conditions are required." } ], "isError": true}# resources/list -> id 6{ "code": -32601, "message": "method not found: resources/list"}# exit code 0Exit code 0 after stdin closed confirms the shutdown path. If your own server passes a test like this, the remaining problems are about installation and approval, not the protocol.
Validate
Section titled “Validate”muse plugins validate forecast-toolsvalid forecast-tools native skills=0 commands=0 hooks=0 mcp=1 reminders=0 diagnostics=0With --json the output also lists the parsed entry. Trimmed to the server:
{ "valid": true, "plugin": { "id": "forecast-tools", "manifest_family": "native", "capabilities": { "mcp_servers": [ { "id": "forecast", "transport": "stdio", "command": ["python3", "mcp/server.py"], "url": null, "source_relative_path": "mcp/server.py" } ] }, "compatibility": { "summary": "full", "declarations": [ { "id": "mcp:forecast", "kind": "mcp", "classification": "supported" } ] } }, "diagnostics": []}source_relative_path is the one argv element Muse Code will rewrite to the
installed copy. Validation never runs the server.
Install and inspect
Section titled “Install and inspect”muse plugins install forecast-toolsinstalled forecast-tools 0.1.0 enabled=true trust=user-local provenance=native-local cache=<data dir>/plugins/cache/local/forecast-tools/a3d4a734…/packagewarning third-party plugin: MCP servers require review before activationThe warning is the point of this page: the plugin is installed and enabled,
but its server is not running anywhere yet. inspect shows why:
muse plugins inspect forecast-toolsforecast-tools 0.1.0 enabled=true active=true trust=user-local valid=true skills=0 commands=0 hooks=0 mcp=1 reminders=0 cache=<data dir>/plugins/cache/local/forecast-tools/a3d4a734…/packagewarning third-party plugin: MCP servers require review before activationruntime-capability plugin:forecast-tools:mcp_server:forecast status=review_neededThe --json form carries the definition hash the approval will bind to.
Trimmed to the runtime capability:
{ "runtime_capabilities": [ { "candidate": { "kind": "mcp_server", "plugin_id": "forecast-tools", "capability_id": "forecast", "stable_id": "plugin:forecast-tools:mcp_server:forecast", "display_path": "plugin://forecast-tools/mcp/forecast", "definition_hash": "sha256:021dcf78…", "source_digest": "sha256:a3d4a734…" }, "status": "review_needed", "diagnostic": null } ]}source_digest is the digest of the whole installed package. definition_hash
covers that digest, the server entry as Muse Code will run it (with the
relative path already rewritten to the installed copy) and the plugin
environment, so any change to any file invalidates the approval. Because the
installed path is part of it, your value will differ from the one shown.
Approve the server
Section titled “Approve the server”Approve by plugin id, kind and capability id:
muse plugins approve forecast-tools:mcp_server:forecastapprove plugin:forecast-tools:mcp_server:forecastWith --json the command reports what it recorded:
{ "decision": "approve", "runtime_capabilities": [ { "stable_id": "plugin:forecast-tools:mcp_server:forecast", "trusted_definition_hash": "sha256:021dcf78…", "enabled": true } ]}muse plugins approve forecast-tools (no capability) would have approved
every reviewable capability of the plugin at once; with one server the
result is the same. inspect now ends with:
runtime-capability plugin:forecast-tools:mcp_server:forecast status=trusted_enabledCall the tool from a headless turn
Section titled “Call the tool from a headless turn”The server loads in a new session, so any session opened after the approval
has the tool. muse exec runs one prompt without the terminal UI, which makes
it the quickest check. From an empty scratch directory:
muse exec --max-model-steps 4 'Call the describe_forecast tool with place Lisbon and conditions "light rain, 14C", then repeat the text it returns word for word.'Forecast for Lisbon: expect light rain, 14C. Plan around it and check again closer to the time.The reply is the server’s text, unchanged. (muse exec also prints a few
startup notes on stderr, such as the workspace root; they are omitted here.)
Adding --json streams the session’s event log instead of the final text,
one record per line. Two of those records show what happened; below are the
payload.event of the record with payload_type
task.lifecycle.side_effect_intent and the payload of the record with
payload_type tool.result, trimmed to the fields worth reading. The tool
was registered under its sanitised name, and the call was admitted because of
the readOnlyHint annotation:
{ "kind": "side_effect_intent", "task_id": "01a0adde-76de-7b11-835a-3ce9153aff0d", "operation": "tool:mcp__plugin_forecast_tools_forecast__describe_forecast", "idempotency_key": "tool:call_01a0adde75e175ce82a4fe1cfc6dba21", "policy_decision": "allow:mcp_read_only_hint"}{ "kind": "tool_result", "call_id": "call_01a0adde75e175ce82a4fe1cfc6dba21", "text": "Forecast for Lisbon: expect light rain, 14C. Plan around it and check again closer to the time.", "correlation_facts": { "tool_name": "mcp__plugin_forecast_tools_forecast__describe_forecast", "outcome": "success" }}Note the tool name: the plugin id forecast-tools became forecast_tools
because a hyphen is not allowed in a registered name. Write permission rules
against mcp__plugin_forecast_tools_forecast__describe_forecast, not against
the ids in the manifest.
The server’s own log confirms the environment it was given. After two
headless turns, <data dir>/plugins/data/forecast-tools/server.log contained:
start session=01a0addd-edb0-7b70-87ff-0354950fd46c plugin=forecast-tools root=<data dir>/plugins/cache/local/forecast-tools/a3d4a734…/packagetools/call describe_forecaststart session=01a0adde-6a20-7172-936a-0643862089e8 plugin=forecast-tools root=<data dir>/plugins/cache/local/forecast-tools/a3d4a734…/packagetools/call describe_forecastEach session started its own server process with a fresh MUSE_SESSION_ID,
MUSE_PLUGIN_ROOT pointed at the installed copy in the cache rather than the
directory you wrote, and the data directory existed only because log()
created it. The final stdin closed, exiting line never appeared: Muse Code
terminates the server when the session ends rather than waiting for it to
notice a closed stdin, so keep the shutdown path in your server but do not
depend on it running.
Change the server and watch the approval lapse
Section titled “Change the server and watch the approval lapse”Edit anything in the plugin, for example append a comment line to
mcp/server.py, then refresh the installed copy:
muse plugins update forecast-toolsupdated forecast-tools sha256:ece60e63… trust=user-local previous=sha256:ece60e63…warning third-party plugin: MCP servers require review before activationThe digest update prints is the manifest’s, which did not change. The
package digest did, and that is what the approval was bound to:
muse plugins inspect forecast-toolsruntime-capability plugin:forecast-tools:mcp_server:forecast status=modifiedIn the --json form the candidate carries a new definition_hash
(sha256:33b5c41f…) and source_digest (sha256:09712bdd…), and a
diagnostic explains the status:
{ "status": "modified", "diagnostic": { "code": "modified_definition_hash", "stable_id": "plugin:forecast-tools:mcp_server:forecast", "message": "runtime capability `plugin:forecast-tools:mcp_server:forecast` is inactive: definition hash changed and needs review" }}The server stays inactive in new sessions until you approve it again:
muse plugins approve forecast-toolsapprove plugin:forecast-tools:mcp_server:forecastThis is the local development loop for a plugin MCP server: edit, update,
approve, then start a new session.
What a broken command looks like
Section titled “What a broken command looks like”For contrast, the same plugin was updated with the command changed to
["no-such-forecast-binary"], approved again, and exercised with
muse exec --max-model-steps 2 'Reply with the single word ok.'. The turn
printed ok and exited 0. Nothing on stdout or stderr mentioned the server:
plugin servers are optional, so a server that cannot start leaves the session
running without its tools. If a tool you expect is missing, check the
program in command is on PATH, test the server with a pipe as above, and
look at your own log file. In the terminal UI, /mcp should list the server
as plugin:forecast-tools:forecast; if it is absent, the server failed to
start.
Clean up
Section titled “Clean up”muse plugins remove forecast-toolsremoved forecast-toolsremove also drops the recorded approval. Add --delete-data to delete
<data dir>/plugins/data/forecast-tools and the server.log in it;
without the flag the data directory survives for a later reinstall.
Common mistakes
Section titled “Common mistakes”- Forgetting to flush stdout. The session waits on
initializeuntil the startup timeout and then continues without the server. - Replying to
notifications/initialized. Notifications have no id and must not be answered. - Logging to stderr and expecting to see it. Stderr is discarded. Write
to a file under
MUSE_PLUGIN_DATA_DIR, and create that directory first. - Expecting the working directory to be the plugin directory. It is the
session’s working directory. Build paths from
MUSE_PLUGIN_ROOT. - Expecting a token in the environment. The server starts with a
cleared environment and plugin entries have no
env. Servers that need credentials belong insettings.json. - Testing in a session that was already open. Servers load at session
start; open a new one after
install,approveorupdate.
Next steps
Section titled “Next steps”- MCP servers: the rules this example follows, including tool naming, the environment table and approval behaviour.
- Plugins in SDK sessions: what this tool call looks like to an SDK client.
- Manifest: every field of an
mcpServersentry. - The weather plugin: the same lifecycle for a skill, a command and a hook.
- Plugin examples: the full list of examples.