Skip to content
Developer Preview

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.

forecast-tools/
├── .muse-plugin/
│ └── plugin.json
└── mcp/
├── server.py
└── smoke.py
  • The manifest declares a single mcpServers entry with the stdio transport and the command python3 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/list and tools/call.
  • The smoke test smoke.py drives 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.

forecast-tools/.muse-plugin/plugin.json
{
"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:

  • command is an argv array. There is no shell, so nothing is quoted or expanded. python3 is looked up on the PATH that reaches the server, and mcp/server.py is 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, cwd or headers. Plugin MCP server entries have no env, headers or credentials; a server that needs a token belongs in settings.json instead.

Manifest has the field table for an mcpServers entry, including the http transport this example does not use.

forecast-tools/mcp/server.py
#!/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 and
stdout. Each message is one JSON-RPC 2.0 object on its own line. The server
answers initialize, ping, tools/list and tools/call, and ignores the
notifications/initialized notification. It exposes one tool, describe_forecast.
"""
import json
import os
import 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-Length framing and detects which one a server uses; this script uses newlines because they are the simplest to get right. Without the flush() 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 and id, drops method and params, and adds a result or an error, so the script never spells the envelope out.
  • Notifications get no reply. notifications/initialized has no id, so the script returns without writing anything. Answering a notification is a protocol error.
  • Unknown methods return a -32601 error so that a client probing for resources/list or prompts/list moves on instead of hanging.
  • readOnlyHint is 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 default on-request mode.
  • Logging goes to a file. The server’s stderr is discarded. log() creates MUSE_PLUGIN_DATA_DIR before writing because Muse Code does not create it.

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:

forecast-tools/mcp/smoke.py
#!/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 a
child process, writes seven messages one per line, prints the id and the
decoded result or error of each reply, closes stdin and prints the exit code.
No third-party dependencies.
"""
import json
import os
import subprocess
import 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:

  1. An initialize request (id 1) with protocolVersion 2025-06-18, empty client capabilities and a clientInfo object.
  2. The notifications/initialized notification, which has no id and expects no answer.
  3. A ping request (id 2).
  4. A tools/list request (id 3).
  5. A tools/call request (id 4) for describe_forecast with the arguments place Lisbon and conditions light rain, 14C.
  6. A second tools/call (id 5) with only place, to see the validation path.
  7. A resources/list request (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:

Terminal window
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 0

Exit 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.

Terminal window
muse plugins validate forecast-tools
valid forecast-tools native skills=0 commands=0 hooks=0 mcp=1 reminders=0 diagnostics=0

With --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.

Terminal window
muse plugins install forecast-tools
installed forecast-tools 0.1.0 enabled=true trust=user-local provenance=native-local cache=<data dir>/plugins/cache/local/forecast-tools/a3d4a734…/package
warning third-party plugin: MCP servers require review before activation

The warning is the point of this page: the plugin is installed and enabled, but its server is not running anywhere yet. inspect shows why:

Terminal window
muse plugins inspect forecast-tools
forecast-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…/package
warning third-party plugin: MCP servers require review before activation
runtime-capability plugin:forecast-tools:mcp_server:forecast status=review_needed

The --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 by plugin id, kind and capability id:

Terminal window
muse plugins approve forecast-tools:mcp_server:forecast
approve plugin:forecast-tools:mcp_server:forecast

With --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_enabled

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:

Terminal window
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…/package
tools/call describe_forecast
start session=01a0adde-6a20-7172-936a-0643862089e8 plugin=forecast-tools root=<data dir>/plugins/cache/local/forecast-tools/a3d4a734…/package
tools/call describe_forecast

Each 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:

Terminal window
muse plugins update forecast-tools
updated forecast-tools sha256:ece60e63… trust=user-local previous=sha256:ece60e63…
warning third-party plugin: MCP servers require review before activation

The digest update prints is the manifest’s, which did not change. The package digest did, and that is what the approval was bound to:

Terminal window
muse plugins inspect forecast-tools
runtime-capability plugin:forecast-tools:mcp_server:forecast status=modified

In 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:

Terminal window
muse plugins approve forecast-tools
approve plugin:forecast-tools:mcp_server:forecast

This is the local development loop for a plugin MCP server: edit, update, approve, then start a new session.

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.

Terminal window
muse plugins remove forecast-tools
removed forecast-tools

remove 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.

  • Forgetting to flush stdout. The session waits on initialize until 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 in settings.json.
  • Testing in a session that was already open. Servers load at session start; open a new one after install, approve or update.