Writing the server: tools, schemas, resources, prompts
Contents
Anatomy of a tool
Input and output schemas
Annotations
Create → run → get
Resources, prompts, and context
Naming and self-diagnosis
Framework: nanohubmcp (repo nanohub-mcp). Reference servers:
padremcp/bin/padremcp.py (clean create→run→get simulation wrapper),
rappturemcp/bin/rappturemcp.py (large: apps, tasks, projects).
Anatomy of a tool
from nanohubmcp import MCPServer, ToolResult
server = MCPServer("mysolver", version="0.1.0") # `server` name is the contract
_CREATE_OUTPUT_SCHEMA = {
"type": "object",
"properties": {
"run_handle": {"type": "string"},
"device": {"type": "string"},
},
"required": ["run_handle", "device"],
}
_CREATE_INPUT_SCHEMA = {
"type": "object",
"properties": {
"length": {"type": "number", "minimum": 0.01, "maximum": 1000,
"default": 1.0, "description": "Device length in microns."},
"temperature": {"type": "number", "minimum": 1, "maximum": 2000,
"default": 300, "description": "Temperature in kelvin."},
},
"additionalProperties": False,
}
@server.tool(
input_schema=_CREATE_INPUT_SCHEMA,
output_schema=_CREATE_OUTPUT_SCHEMA,
annotations={"title": "Create PN diode simulation",
"readOnlyHint": False, "destructiveHint": False,
"idempotentHint": False, "openWorldHint": False},
)
def create_pn_diode_sim(length=1.0, p_doping=1e17, n_doping=1e17,
temperature=300, sweep_electrode=1):
"""Create a PN junction diode simulation.
Args:
length: Total device length in microns (default 1.0)
p_doping: P-side acceptor concentration in cm^-3 (default 1e17)
...
sweep_electrode: Electrode to sweep (default 1 = P side/anode, so the
forward sweep forward-biases the diode; 2 = N side/cathode)
Returns:
An opaque run_handle to pass to run_simulation and get_* tools.
"""
...
return {"run_handle": run_handle, "device": "pn_diode"}
namedefaults to the function name;descriptiondefaults to the docstring — the docstring IS the prompt the model reads. State when to call the tool, what each argument means with units and defaults, what the result contains, and which tool to call next. Be imperative about what NOT to do (“do not emit iframe markup”, “pass run_handle, not a path you invent”).Returning a dict automatically produces both a JSON text block and
structuredContent(spec-required whenoutputSchemais declared).Errors: return a short, fixed
ToolResult(content="what went wrong", is_error=True)for expected failures (unknown tool name, missing file). Raised exceptions are caught and becomeisError: truewith the message — acceptable for unexpected failures, but a deliberate ToolResult gives the model a better recovery hint. Never reflect arbitrary arguments, file contents, commands, or secrets in an error.
Read security.md before implementing filesystem access, subprocess calls, solver-deck generation, result text, or computational limits.
Input and output schemas — always explicit
If you omit input_schema the framework generates one from the signature.
Do it anyway, explicitly, because:
The schema is the model’s steering. Types, enums, defaults, and descriptions in the schema shape what the model sends. An auto-schema from an untyped Python 2-style signature is
{"type": "object"}— no guidance.Internal parameters leak. A
ctxparameter or loop-captured variable in a closure surfaces in an auto-schema and the model will try to fill it. Explicit schemas advertise exactly the public surface ({"type": "object", "properties": {}, "required": []}for zero-arg tools).output_schemais a contract clients enforce. claude.ai validatesstructuredContentagainst your declaredoutputSchemaand REJECTS the call on mismatch. Two consequences:keep a small required envelope and leave the rest open (
additionalPropertiesunset) so you can add fields later:def _object_output_schema(required, properties=None): return {"type": "object", "properties": properties or {}, "required": list(required)}
mind
[]vs{}— an empty list that gets re-encoded as an object somewhere in the proxy chain fails validation. Never round-trip your own JSON through lossy fixups.
Validate your schemas at import time so a bad schema fails loudly:
Draft202012Validator.check_schema(SCHEMA)(jsonschema package).Validating inputs against a JSON schema inside the tool (
jsonschema.validate) turns malformed model calls into precise error messages the model can self-correct from.
Annotations (behavior hints)
Emitted in tools/list; validated at decoration time (unknown keys raise).
Key |
Set it when |
|---|---|
|
always — human-readable name in host UIs |
|
pure reads — hosts may skip confirmation, cache, and allow calls from app widgets in strict mode |
|
deletes/cancels — hosts confirm with the user |
|
safe to retry (create-with-same-args, setters) |
|
tool reaches outside the session (network etc.) |
Shared constants keep this consistent across dozens of tools:
_READ_ONLY = {"readOnlyHint": True, "destructiveHint": False}
_WRITE_IDEMPOTENT = {"readOnlyHint": False, "idempotentHint": True}
Design pattern for scientific codes: create → run → get
Split by duration and side effects; never one mega-tool:
create_<device>_sim(params…) → {run_handle, inputs, estimate}— fast, validates inputs and derived work, then creates confined run state.run_simulation(run_handle)—@server.async_tool(see tasks.md); the only slow tool.get_iv_curve(run_handle),get_band_diagram(...),describe_outputs(...)— read-only result extractors returning plot-ready arrays with units.Optional helpers the model loves: parameter estimators (
estimate_threshold_voltage) that return suggested sweep ranges, anddescribe_devicescatalogs (name, params, defaults, units per device).
The run_handle is an opaque random identifier resolved beneath an
application-owned root. Never return or accept a caller-selected filesystem
path. If users need the generated deck for reproducibility, return it through a
bounded reader as structured data and label external/user-provided text as
untrusted data, not model instructions.
Defaults are physics. The zero-argument call of every create_* tool
must produce a correct, meaningful simulation — that is both the model’s
first call and your acceptance test. (Field-tested: a PN diode whose default
swept the cathode made every default “forward sweep” reverse-biased; users
report that as a bug, correctly.)
Warn, don’t silently succeed: if estimated physics says the run is
degenerate (Vt outside the sweep window, dark current below solver
resolution), return the result WITH a warnings field the model will relay.
Resources and prompts
@server.resource("config://mysolver/devices", mime_type="application/json")
def device_catalog():
"""Supported devices, parameters, units, defaults."""
return {...}
@server.prompt()
def characterize_diode():
"""Guide the model through a full IV characterization."""
return "Run create_pn_diode_sim, then ..."
Resources suit static/slowly-changing catalogs the model should browse
without a tool call; ui:// resources are the MCP Apps surface
(mcp-apps.md). Handlers returning dicts are JSON-encoded;
registered mimeType and _meta are attached automatically. Proxied URI
prefixes (the hub’s weber path) are stripped by the framework before lookup.
Context injection (ctx)
Declare a ctx=None parameter (kept OUT of the input schema) and the
framework injects a Context with session_id, request_id, server,
logging (ctx.info(...)) and progress helpers. Uses:
per-session state (which app instance is open, negotiated capabilities),
gating model-facing notes on host capabilities — never claim host-side outcomes (“the UI is rendered”) the declared capabilities don’t prove,
progress notifications from long non-async work.
Key optional per-session state by ctx.session_id with locking, expiry, and a
size cap. Never use the session ID as authentication and never store ctx
itself. See state-model.md.
For tools registered in loops/closures, bind loop variables to locals
(_captured = tool_name) so they neither leak into schemas nor late-bind.
Naming
Tools:
verb_noun, lowercase snake_case, consistent prefix per family (create_*_sim,get_*,list_*,open_*). The model picks tools by name first, description second.Keep names stable — clients cache tool lists; renames are breaking changes.
Self-diagnosis tools
Ship a couple of read-only introspection tools (validate_mcp_app,
get_app_size, get_cache_status) — they cost little and let you debug a
deployed instance entirely through the MCP connection. Pair with the
offline validator in this skill’s scripts/validate_server.py for pre-deploy
checks.