writing-model-facing-text
Skill37 starsChanged 6 days agoMITView on GitHub
Use when writing or changing a tool, parameter, resource or prompt description, a tool title or annotation, or a server-instructions snippet, and when reviewing such text: it decides which sentences the model can act on and where every other fact goes instead.
We split this skill into the rules it gives an agent
6 rules. About 0 are common, 4 are distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 11
Every docstring and description= a FastMCP server registers is sent to the client on the wire, and most of it reaches the model as a hint …
- Distinctive: line 37
Every sentence passes both or moves.
- Very specific: line 110
Use Field(description=...) instead when the entry needs a constraint the schema should also carry.
- Distinctive: line 172
Before, the same etag rule in three places:
- Distinctive: line 200
After, each fact once, where it is read:
- Distinctive: line 243
Read each description as the model: is every sentence something it can act on when choosing this call?
≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
<!-- ===== TEMPLATE-OWNED — re-rendered on template updates. ===== -->Writing model-facing text
Every docstring and description= a FastMCP server registers is sent to
the client on the wire, and most of it reaches the model as a hint at one
moment: when it chooses a tool and fills in the arguments. Text the model
cannot act on at that moment is not harmless. It costs context on every
turn, Claude Code cuts each tool description and each server's
instructions at 2,048 characters and keeps the start, and a model told
about an operator command or a resource it cannot fetch acts on it anyway.
The evidence for every rule here is in
docs/design/reference/mcp-model-facing-text.md; read it before arguing
with a rule.
Who reads each surface
| Surface | Reader | Read when | What clients do with it |
| Tool description | the model | choosing a tool, filling arguments | put in the system prompt; Claude Code truncates at 2,048; tool search indexes it |
| Parameter description | the model | filling that one argument | property in the JSON schema; tool search indexes it |
Tool title, annotations | the client | rendering a label, deciding whether to confirm | hints only; spec says clients must treat them as untrusted |
| Server instructions | the model | session start | system prompt; the one text Claude Code loads before any tool under tool search; truncated at 2,048; spec: should not duplicate tool descriptions |
| Resource description | a human in a picker, sometimes the model | browsing or attaching context | application-driven: the model may never see the list and often cannot fetch it |
| Prompt and prompt-argument descriptions | a human in a slash-command picker | picking a prompt | user-controlled |
| Tool result and error text | the model | after the call | the only surface that can say what to do next given the state |
| Skill served over MCP (SEP-2640) | the model, after the host lists its name and description and the model asks to load it | on demand, only on a host that implements the extension | skill:// resources; other clients see plain resources; hosts treat the body as untrusted data the user may refuse to load |
The two gates
Every sentence passes both or moves.
- Actionable by the model at the moment it chooses or fills in this call. A CLI command, an environment variable, a URL, a timeout default, an exception class, a framework name or how the code works fails this gate: the model cannot run, set, open or catch any of them.
- Said once, in the surface read at that moment. A fact the model needs for one argument lives in that parameter's description, not also in the tool description and again in instructions.
The contract, not the failures
A description states the contract: what the call does, what it needs and
what comes back. It never says how the call can fail. Failing is the
exception, and the error text of that call handles it. That text reaches the
model at the one moment it can act on it, and it carries its own
instructions (designing-tool-outcomes, "Writing the message"). A failure
listed in the description costs context on every turn and helps with none
of them.
The contract includes its limits. Where the tool does less than its name and signature suggest, the model would otherwise expect more, so say so up front. The line falls on why a call does not deliver:
- By design, true of every call: a limit of scope or coverage.
readopens files in the notes folder only;searchcovers titles and frontmatter, not body text. This is contract. State it positively, in the parameter it constrains (path: Path relative to the notes folder, ending in .md.) or, when no parameter carries it, as the tool's constraint sentence. - Because of what this call ran into: the file is corrupt, the version
is stale, the path does not exist, the model broke a stated limit. This
is an error. It belongs to the error text, and
fails if,raises when,returns an error whenandon a conflict, ...never appear in a description.
Absence is contract only where the return type promises it: a search that says it returns an empty list when nothing matches is stating a result, not a failure.
Where each fact goes
| Fact | Goes in |
| What the tool does and what comes back | tool description, first sentence |
| When to choose it over a sibling tool | tool description, second sentence |
| What one argument means, its format, what the default does | that parameter's description |
A value that must come from an earlier call (an etag from read) | that parameter's description, one sentence |
| A sequence that spans tools, or what this server is for | an instructions snippet, WORKFLOWS or CAPABILITIES role |
| A fact about this deployment (read-only, which instance) | instructions INSTANCE role, driven by config, never hand-written prose |
| What to do after the call given its outcome (index stale, task queued) | the result or the error text of that call |
| How the call can fail, and what to do then | the error text of that call, never the description |
| Side effects: read-only, destructive, idempotent | annotations=; plus one clause in the description only when it changes the choice |
| Operator configuration, env vars, CLI commands, limits | docs/reference/configuration.md and the operator guides |
| How it is implemented, links to framework docs | a # comment |
| What comes back and each way the call ends, for the reader of the site | the Returns: and Raises: docstring sections: FastMCP strips them from the wire, and scripts/gen_reference.py publishes them on the tool's reference page, so they are held to site quality |
| Policy the client enforces (confirm before deleting) | nowhere on the server: the client owns the human in the loop |
| A long procedure, worked examples, a workflow that spans many calls | a skill served over MCP (SEP-2640; FastMCP's SkillsDirectoryProvider), pointed at from instructions; never the only home of a fact a call depends on |
Recipe: a tool
The docstring is the description. Its parts, in order:
- One sentence: verb, object, what comes back.
Search notes by keyword and meaning; returns paths, titles and snippets ranked by score. - One sentence on when to choose it over its siblings, only if a sibling
exists.
Use read for a single known path. - One limit of scope the model would not assume from the name, or one constraint it must honour, that no parameter carries. Stated as what the tool does or needs, never as what fails otherwise. Optional.
Then an Args: section with one sentence per parameter: meaning, format,
what omitting it does. if_match: Etag from read; omit for a new file.
Use Field(description=...) instead when the entry needs a constraint the
schema should also carry. Set annotations={"title": ..., "read_only_hint":
...} on every tool.
Two things FastMCP 4.0.5 does that change how you write:
- A docstring with no
Args:entry ships verbatim,Returns:andRaises:included. A parameterless tool therefore passesdescription=on the decorator, or keeps a one-line docstring with no sections. - A resource docstring is never parsed. Keep it to one line.
Recipe: an instructions snippet
instructions_for(mcp).add(text, role=InstructionRole.WORKFLOWS,
requires_tools=(...)) in the DOMAIN-WIRING block. Two sentences at
most: the sequence that spans tools, naming them. Under tool search the
model sees this text before any tool description, so it says what the
server is for and which tool starts a task; it never restates a tool's
own description. pvl-core warns above 1,536 units for generated text and
drops the snippet when a required tool is hidden, so name the tools in
requires_tools.
Skills served over MCP
SEP-2640 (Final) ships a skill directory as skill://<name>/SKILL.md
resources, and FastMCP's SkillsDirectoryProvider does the same today
under a URI shape the working group is still reconciling. A host that
implements it shows the model the skill's name and description and loads
the body only when the model asks; any other client sees ordinary
resources, and every host treats the body as untrusted text the user may
decline to load. So a skill is where the long workflow goes, and its
frontmatter description follows the tool-description recipe above. It is
never where the one sentence a call depends on goes: that stays in the
description, the parameter or the result, and instructions may name the
skill's URI for hosts that can read it.
The test case is markdown-vault-mcp's PARA guide (about 4,500 words):
a folder layout, a frontmatter
schema per note type, a five-stage capture-to-archive loop and the tool
sequence for each stage. No call depends on any of it, so none of it
belongs in a tool description or in instructions, which already cannot
hold it; the model needs all of it the moment a user says "run my PARA
vault". That is a skill: skills/para/SKILL.md served by the provider,
one instructions sentence naming its URI, and the tool descriptions
unchanged. The same holds for a Zettelkasten or research-workflow guide.
Recipe: a resource or prompt
- Resource: one line saying what the resource contains, written for a human scanning a list. Never an instruction to the model ("read this before writing"): if the model must know it, put it where the model reads, a tool result or an instructions snippet.
- Prompt: one line saying what the prompt produces; each argument one
clause in
Args:, annotated as a barestr. FastMCP 4.0.5 appends a JSON-schema sentence to any argument annotated otherwise (Annotated,Literal,str | Noneincluded), and underfrom __future__ import annotationsto every argument, so keep that import out of the prompts module.
Worked example
Before, the same etag rule in three places:
@mcp.tool(annotations={"read_only_hint": False})
async def save_recipe(path: str, content: str, if_match: str | None = None):
"""Create a new recipe or overwrite an existing one.
To change an existing recipe: call read_recipe first, edit the text it
returned, and pass its etag as if_match. If the file changed in the
meantime the call fails with a conflict error and nothing is written;
re-read, re-apply your edit, and save again with the new etag. Never
retry a conflict by dropping if_match.
Args:
if_match: Etag from read_recipe. Required when updating an
existing recipe; omit only when creating a new file.
"""
instructions_for(mcp).add(
"To edit a recipe: read_recipe, modify, save_recipe with the etag as "
"if_match. On a conflict, re-read and re-apply; never retry without "
"if_match.",
role=InstructionRole.WORKFLOWS,
requires_tools=("read_recipe", "save_recipe"),
)After, each fact once, where it is read:
@mcp.tool(annotations={"title": "Save Recipe", "read_only_hint": False})
async def save_recipe(path: str, content: str, if_match: str | None = None):
"""Create or replace a recipe file; returns its path and new etag.
Args:
path: Vault-relative path ending in .md.
content: Complete Markdown for the file, frontmatter included.
if_match: Etag from read_recipe; omit for a new file.
"""The conflict handling left the docstring because it describes a failure, not the contract: the conflict error's text now says "re-read and retry with the new etag". The instructions snippet is gone because nothing spans tools.
Measure it
Print what the client receives, not what the source says:
mcp = make_server() # outside any event loop
async with Client(mcp) as c:
for t in await c.list_tools():
print(t.name, utf16_code_units(t.description or ""), t.description)tests/test_model_facing_text.py (template-owned) fails on a leaked
docstring section in any description, a tool with no description, a str
prompt argument carrying the JSON-schema sentence, and a tool description
or the instructions over 2,048 units. Those per-item limits are the only ones
with a client-derived source. Add a ceiling on the whole surface only once a
source derives one, stated per client class with its derivation beside the
number; none does yet, and fastmcp-pvl-core#300 is where one would be
measured. A total set from today's measurement penalises moving a fact out of
a tool description into the parameter that needs it. Until then, keep the
total down by writing each description to this skill.
Review checklist
- Read each description as the model: is every sentence something it can act on when choosing this call?
- Is each fact in exactly one surface?
- Any URL, env var, CLI command, exception class or framework name? Move it.
- Does a resource or prompt description instruct the model? Move it.
- Does an instructions snippet restate a tool description? Cut it.
- Does a description say how the call fails or what to do on an error? Move it to the error text. Keep a limit that holds for every call by design, stated positively, in the parameter it constrains.
- Does a parameterless tool, or a resource, ship a docstring section?
Other versions2 projects use a version of this.
Word for word · 2
- pvliesdonk/scholar-mcp★ 2100% same
- pvliesdonk/paperless-mcp★ 1100% same
Edited · 0
None found.
Wrote their own version · 0
None found.
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository15 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- applying-template-updatesSkill
- authoring-issues-prsSkill
- config-contractSkill
- designing-tool-outcomesSkill
- logging-standardSkill
- releasingSkill
- repository-protectionSkill
- researching-referencesSkill
- roadmappingSkill
- self-reviewingSkill
- tool-registrationSkill
- writing-documentationSkill
- writing-release-notesSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

