From Function Calling to the Agent Loop gave an agent tools by writing their schemas into its prompt and running them in its own loop. That works for one agent and a few tools. It doesn’t scale to many hosts (IDEs, chat apps, agents) and many tools (databases, file systems, search, internal services): wiring each to each is an problem, and every tool described in the prompt costs context on every call. This post covers three standards that attack the problem from different sides.
MCP brings live tools, at a cost in every request; Skills bring procedures, paying only for their descriptions until one is needed; OKF brings curated knowledge with its provenance. Each puts someone else’s text in front of the model. The two token bars are separate hypothetical setups, drawn to scale.
Data: the token counts in this post- MCP turns M × N integrations into M + N: hosts and servers each speak one protocol, JSON-RPC over stdio or HTTP, offering tools, resources and prompts. Since 2026-07-28, every request is self-contained.
- Every connected tool costs context on every call: about 150 tokens each here, over 11,000 for five servers of fifteen tools.
- Skills load procedures progressively: a short description always, the instructions when relevant, files only as needed. 50 skills cost 2,000 tokens instead of 200,000.
- OKF (v0.2) stores curated knowledge as Markdown with one required
field,
type, plus provenance, and all three bring outside text into the agent: trust them like software.
Table of contents
Open Table of contents
MCP: one protocol for tools and data
The Model Context Protocol (MCP) turns integrations into : each host implements MCP once, each tool provider implements it once, and any host can use any server. It borrows the idea from the Language Server Protocol, which did the same for editors and programming languages. Three roles:
- the host, the application the user runs, which owns the model and the user’s consent;
- a client inside the host, one per connected server;
- a server, which offers capabilities.
The integration problem MCP solves, and the one the Language Server Protocol solved before it for editors and languages.
A server offers three kinds of thing, which differ in who decides to use them:
- tools, functions the model chooses to call;
- resources, data the host application chooses to read and attach as context;
- prompts, templates the user picks.
Who decides to use what: the model calls tools, the application reads resources, the user picks prompts.
The client can offer one thing back: elicitation, asking the user a
question (a missing parameter, a confirmation) in the middle of a request.
Two older client features, sampling (the server asks the host’s model
to generate) and roots (directories the server is told about, as
guidance, not enforcement), are deprecated in the current revision: the
specification suggests calling an LLM provider directly instead of
sampling, and passing paths as tool parameters or configuration instead
of roots. Servers no longer send requests to the client at all. When a
server needs more input, it returns a result marked input_required
listing what it needs, and the client retries the original request with
the answers attached (the multi-round-trip pattern).
Messages are JSON-RPC 2.0: a request is a JSON object with an id, a
method (like tools/call) and params; the response carries the same
id and either a result or an error. They travel over one of two
transports: stdio, where the host launches the server as a
subprocess and they talk over its standard input and output, and
Streamable HTTP, for remote servers. Authorization is optional and
applies to HTTP: the server acts as an OAuth 2.1 resource server, and the
client gets a token from an authorization server. A stdio server takes its
credentials from its environment instead.
In the current revision (2026-07-28) the protocol is stateless: every
request carries its protocol version and the client’s capabilities in its
_meta field, so there is no handshake or session. A client can call
server/discover, which every server must implement, to learn the
versions and capabilities a server supports before anything else. A server
that needs state across calls (an open file, a database transaction)
creates a handle and returns it, and the model passes it back as an
ordinary tool argument. With state in the arguments, any request can go
to any instance behind a load balancer.
Everything the server needs travels with each request, which is what lets any request go to any server instance.
An MCP server for this blog
Two tools and a resource, about 50 lines with the official Python SDK (version 2). The search tool ranks posts with BM25, the keyword scoring from Semantic and Hybrid Search:
import glob
import math
import os
import re
from collections import Counter
from mcp.server.mcpserver import MCPServer
# An MCP server for this blog: two tools and one resource, over stdio. Any
# MCP host (an IDE, a desktop assistant, an agent) can launch it and use
# them, without code written for that host.
POSTS = os.environ.get("POSTS", "content/posts")
def load():
out = {}
for path in glob.glob(f"{POSTS}/*.mdx"):
raw = open(path).read()
title = re.search(r'^title: "(.*)"$', raw, re.M)
body = re.sub(r"```.*?```|\$\$.*?\$\$|^import .*?$", " ", raw.split("---", 2)[-1], flags=re.S | re.M)
out[os.path.basename(path)[:-4]] = (title.group(1) if title else "", " ".join(body.split()))
return out
posts = load()
words = lambda t: re.findall(r"\w+", t.lower())
counts = {s: Counter(words(b)) for s, (_, b) in posts.items()}
df = Counter(w for c in counts.values() for w in c)
avg = sum(sum(c.values()) for c in counts.values()) / len(counts)
server = MCPServer("residual-stream-blog", instructions="Search and read the Residual Stream blog's posts.")
@server.tool()
def search_posts(query: str, k: int = 3) -> list[dict]:
"""Find the blog posts most relevant to a query (BM25 over the full text).
Returns each post's slug, title and score."""
def bm25(slug):
c, n = counts[slug], sum(counts[slug].values())
return sum(math.log((len(posts) - df[w] + 0.5) / (df[w] + 0.5) + 1) * c[w] * 2.5
/ (c[w] + 1.5 * (0.25 + 0.75 * n / avg)) for w in set(words(query)) if w in df)
best = sorted(posts, key=bm25, reverse=True)[:k]
return [{"slug": s, "title": posts[s][0], "score": round(bm25(s), 2)} for s in best]
@server.tool()
def read_post(slug: str, max_words: int = 400) -> str:
"""Read the start of one post, by slug."""
if slug not in posts:
raise ValueError(f"no post {slug!r}")
return " ".join(posts[slug][1].split()[:max_words])
@server.resource("post://{slug}")
def post_resource(slug: str) -> str:
"""A post's full text, for a host to attach as context."""
return posts[slug][1]
if __name__ == "__main__":
server.run() # stdio by default
A host’s side, through the SDK: launch it, discover its tools, call them.
import asyncio
import json
import sys
from mcp import Client, StdioServerParameters
# A host's side: launch the server as a subprocess, discover what it offers,
# and call a tool, all over JSON-RPC on the subprocess's stdin and stdout.
async def main():
params = StdioServerParameters(command=sys.executable, args=["blog_server.py"])
async with Client(params) as client:
tools = await client.list_tools()
for t in tools.tools:
print(f"tool {t.name}: {t.description.splitlines()[0]}")
print(f" input schema: {json.dumps(t.input_schema)[:160]}...")
print(f"protocol version: {client.protocol_version}")
result = await client.call_tool("search_posts", {"query": "paged attention KV cache memory", "k": 3})
print("search_posts ->", result.structured_content or result.content[0].text)
text = await client.call_tool("read_post", {"slug": "kv-cache-compression", "max_words": 25})
print("read_post ->", text.content[0].text)
# The resource: the host, not the model, decides to read it and attach it.
for t in (await client.list_resource_templates()).resource_templates:
print(f"resource template {t.uri_template}: {t.description}")
res = await client.read_resource("post://mixture-of-experts")
print("resources/read ->", res.contents[0].text[:80], "...")
asyncio.run(main())
tool search_posts: Find the blog posts most relevant to a query (BM25 over the full text).
input schema: {"type": "object", "properties": {"query": {"title": "Query", "type": "string"}, "k": {"default": 3, "title": "K", "type": "integer"}}, "required": ["query"], "...
tool read_post: Read the start of one post, by slug.
input schema: {"type": "object", "properties": {"slug": {"title": "Slug", "type": "string"}, "max_words": {"default": 400, "title": "Max Words", "type": "integer"}}, "require...
protocol version: 2026-07-28
search_posts -> {'result': [{'slug': 'paged-attention-explained', 'title': 'Paged Attention: Managing the KV Cache Like Virtual Memory', 'score': 5.75}, {'slug': 'kv-cache-explained', 'title': 'The KV Cache: How Transformers Avoid Recomputing the Past', 'score': 5.44}, {'slug': 'flashattention-explained', 'title': 'FlashAttention: Exact Attention at the Speed of Memory', 'score': 4.88}]}
read_post -> [Post 20](/posts/kv-cache-explained/) sized the KV cache, and posts 18 and 23 showed the architectural ways to shrink it: share key-value heads (GQA), or store a
resource template post://{slug}: A post's full text, for a host to attach as context.
resources/read -> [Post 13](/posts/residual-stream-explained/) found two thirds of a Transformer b ...
The read_post and resources/read lines show those posts’ text as it
was when this ran; the posts have since been reworded, so a rerun prints
different words there, and slightly different search scores.
The SDK derived each tool’s JSON Schema from the Python type hints, and its
description from the docstring, which is what the model reads to decide
when to call it. The resource is read by the host, through
resources/read, never by the model on its own. Without the SDK, the same
tool call is one line of JSON in, one line out, and the request carries
everything the server needs:
import json
import subprocess
import sys
# The same exchange without an SDK: JSON-RPC 2.0 messages, one per line, on
# the server's stdin and stdout. In the 2026-07-28 protocol every request is
# self-contained: its _meta carries the protocol version and the client's
# capabilities, so no handshake has to come first.
proc = subprocess.Popen([sys.executable, "blog_server.py"], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True)
meta = {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": {"name": "wire-demo", "version": "0.1"}}
request = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "search_posts", "arguments": {"query": "speculative decoding draft", "k": 2}, "_meta": meta}}
print("->", json.dumps(request))
proc.stdin.write(json.dumps(request) + "\n"); proc.stdin.flush()
reply = json.loads(proc.stdout.readline())
print("<-", json.dumps(reply)[:700])
proc.terminate()
-> {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "search_posts", "arguments": {"query": "speculative decoding draft", "k": 2}, "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}, "io.modelcontextprotocol/clientInfo": {"name": "wire-demo", "version": "0.1"}}}}
<- {"jsonrpc": "2.0", "id": 1, "result": {"content": [{"text": "{\n \"slug\": \"speculative-decoding\",\n \"title\": \"Speculative Decoding, Derived: Draft Models, N-grams, Medusa and EAGLE\",\n \"score\": 10.61\n}", "type": "text"}, {"text": "{\n \"slug\": \"tensorrt-llm-explained\",\n \"title\": \"TensorRT-LLM: Compiling Inference Engines Ahead of Time\",\n \"score\": 10.05\n}", "type": "text"}], "isError": false, "resultType": "complete", "structuredContent": {"result": [{"slug": "speculative-decoding", "title": "Speculative Decoding, Derived: Draft Models, N-grams, Medusa and EAGLE", "score": 10.61}, {"slug": "tensorrt-llm-explained", "title": "TensorRT-LLM: Compiling Inference Engine
The result says the same thing twice, on purpose. structuredContent is
the return value as JSON, matching an outputSchema the SDK derived from
the return type, for hosts and code that want data; content is the same
value as text blocks, for hosts that hand results straight to the model.
resultType: "complete" marks a final result, as opposed to an
input_required one.
What tools cost in context
Every connected tool is described in the prompt on every call:
n = lambda text: len(tok(text).input_ids)
async def tool_defs():
async with Client(blog_server.server) as client: # in-process, same server as before
return [t.model_dump(by_alias=True, exclude_none=True) for t in (await client.list_tools()).tools]
defs = asyncio.run(tool_defs())
per_tool = sum(n(json.dumps(d)) for d in defs) / len(defs)
print(f"this blog's 2 tools: {sum(n(json.dumps(d)) for d in defs)} tokens of definitions, {per_tool:.0f} per tool")
no_out = sum(n(json.dumps({k: v for k, v in d.items() if k != "outputSchema"})) for d in defs)
print(f" of which output schemas: {sum(n(json.dumps(d)) for d in defs) - no_out} tokens")
print(f"5 servers x 15 tools at that size: {5 * 15 * per_tool:,.0f} tokens in every request's prompt")
meta = ("name: pdf-processing\ndescription: Extract text and tables from PDF files, fill forms, merge documents. "
"Use when working with PDF files or when the user mentions PDFs, forms, or document extraction.")
print(f"\none skill's metadata (name and description): {n(meta)} tokens")
for skills in (10, 50, 200):
print(f"{skills:4d} skills: metadata always loaded {skills * n(meta):7,d} tokens; "
f"every body loaded up front at ~4,000 tokens each {skills * 4000:9,d} tokens")
n counts tokens with Qwen2.5-1.5B-Instruct’s tokenizer, as a stand-in for any model’s.
this blog's 2 tools: 295 tokens of definitions, 148 per tool
of which output schemas: 101 tokens
5 servers x 15 tools at that size: 11,062 tokens in every request's prompt
one skill's metadata (name and description): 41 tokens
10 skills: metadata always loaded 410 tokens; every body loaded up front at ~4,000 tokens each 40,000 tokens
50 skills: metadata always loaded 2,050 tokens; every body loaded up front at ~4,000 tokens each 200,000 tokens
200 skills: metadata always loaded 8,200 tokens; every body loaded up front at ~4,000 tokens each 800,000 tokens
Each of this blog’s tools costs about 150 tokens of definition, a third of it the output schema. These are counts of the raw JSON in one model’s tokenizer: a host renders tool definitions into its model’s own prompt format, which adds or drops some, but the order of magnitude holds. A host connected to five servers of fifteen tools each spends over 11,000 tokens on every request describing tools, most of which the task at hand won’t use. Hosts attack this from their side by loading tool definitions lazily: give the model a search over the available tools, and put a definition into the context only when the model asks for it. Prompt Caching makes the repeated prefix cheap but not free, and it doesn’t make the list shorter.
Tool definitions and skill descriptions are paid on every call. Skills keep the bill small by loading the bodies only when needed.
Data: the output aboveSkills: procedures loaded on demand
Skills (Anthropic’s Agent Skills) answer a different need: not a live
tool, but how to do a task: a procedure, its conventions, reference
material, helper scripts. A skill is a directory with a SKILL.md file,
whose YAML front matter gives a name (up to 64 characters, lowercase
letters, digits and hyphens) and a description (up to 1,024 characters:
what it does and when to use it), followed by instructions, plus any
other files. The example from Anthropic’s documentation, whose metadata
the token count above measured:
---
name: pdf-processing
description: Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDF files or when the user mentions PDFs, forms, or document extraction.
---
# PDF processing
(instructions, and pointers to the skill's other files and scripts)
Skills need the agent to have a filesystem and a way to run commands (in Claude’s API, a code-execution container), because that is how it reads the files and runs the scripts. Context use follows progressive disclosure, in three levels:
- Metadata, always: only the name and description sit in the system prompt, about 100 tokens per skill in Anthropic’s documentation (41 for the example above).
- Instructions, when relevant: the model reads
SKILL.md’s body when a request matches the description; the documentation’s guideline is to keep it under 5,000 tokens. - Resources and scripts, as needed: other files are read only when the instructions point to them, and scripts are run, so only their output enters the context, not their code (unless the model chooses to read the code too).
Each level is loaded only if the one above it says it is needed; the model does that retrieval itself.
The arithmetic above shows why. Assuming a body of about 4,000 tokens, near that guideline, 50 skills cost about 2,000 tokens of descriptions, against 200,000 if every body were loaded up front. The model does the retrieval itself, deciding from a description what to read, which is the same agentic retrieval as Beyond Vector RAG, applied to instructions.
OKF: curated knowledge, as files
The third need is knowledge an organization has curated: what a table
means, how a metric is computed, which source to trust. The Open
Knowledge Format (OKF, version 0.2, from Google Cloud’s knowledge-catalog
project) represents it as plainly as possible: a bundle is a directory
of Markdown files with YAML front matter. The only required field is
type; recommended ones are title, description, resource (what the
concept describes) and tags. What makes it more than notes is
provenance and trust: sources records what a concept was derived
from, generated who or what produced it, and verified who confirmed
it. The trust tier follows from verified alone: no entry means
unverified, entries only from programs mean machine-confirmed, and any
entry from a person (human:<id>) means human-reviewed. status and
stale_after mark its lifecycle. A special type,
attested computation, pairs a sanctioned query or script with code that
checks it ran as intended. A small bundle generated from this blog’s posts
(generated before the LoRA post was written, so its concept is skipped):
POSTS = "content/posts"
SITE = "https://residual-stream.lvtan71.workers.dev"
concepts = {"kv-cache": "kv-cache-explained", "lora": "parameter-efficient-fine-tuning-lora",
"speculative-decoding": "speculative-decoding", "hnsw": "vector-search-and-ann-indexes"}
os.makedirs("bundle/concepts", exist_ok=True)
index = ["---", "okf_version: \"0.2\"", "---", "", "# Residual Stream concepts", ""]
for name, slug in concepts.items():
path = f"{POSTS}/{slug}.mdx"
if not os.path.exists(path):
continue # a post not yet written (lora, here)
front = yaml.safe_load(open(path).read().split("---")[1])
title, desc = front["title"], " ".join(front["description"].split())
doc = f"""---
type: Concept
title: {json.dumps(title, ensure_ascii=False)}
description: {json.dumps(desc[:200] + "...", ensure_ascii=False)}
tags: [llm, inference]
sources:
- resource: {SITE}/posts/{slug}/
title: {json.dumps(title, ensure_ascii=False)}
generated:
by: process:make_okf
at: "2026-10-01"
status: draft
---
# Summary
{desc}
# See also
[the full post]({SITE}/posts/{slug}/)
"""
open(f"bundle/concepts/{name}.md", "w").write(doc)
index.append(f"- [{title}](concepts/{name}.md)")
open("bundle/index.md", "w").write("\n".join(index) + "\n")
print(open("bundle/index.md").read())
print(open("bundle/concepts/kv-cache.md").read()[:900])
---
okf_version: "0.2"
---
# Residual Stream concepts
- [The KV Cache: How Transformers Avoid Recomputing the Past](concepts/kv-cache.md)
- [Speculative Decoding, Derived: Draft Models, N-grams, Medusa and EAGLE](concepts/speculative-decoding.md)
- [Vector Search: Exact Search and the Approximate Indexes](concepts/hnsw.md)
---
type: Concept
title: "The KV Cache: How Transformers Avoid Recomputing the Past"
description: "Why autoregressive decoding wastes work, how caching keys and values turns it from quadratic to linear, worked through with colored matrices step by step — plus the memory cost formula and what it mea..."
tags: [llm, inference]
sources:
- resource: https://residual-stream.lvtan71.workers.dev/posts/kv-cache-explained/
title: "The KV Cache: How Transformers Avoid Recomputing the Past"
generated:
by: process:make_okf
at: "2026-10-01"
status: draft
---
# Summary
Why autoregressive decoding wastes work, how caching keys and values turns it from quadratic to linear, worked through with colored matrices step by step — plus the memory cost formula and what it means for real models.
# See also
[the full post](https://residual-stream.lvtan71.workers.dev/posts/kv-cache-explained/)
It records that a script generated it and nobody has verified it, which is exactly what an agent reading it should know before trusting it.
How the three fit
They answer different questions, and compose:
- MCP: what can I do and read right now? Live tools and data, from any provider, through one protocol.
- Skills: how should I do this task? Procedures, loaded only when a task needs them.
- OKF: what do we know, and how sure are we? Curated, versioned knowledge with its provenance.
A skill can tell the agent which MCP tools to use and in what order; an MCP server can serve an OKF bundle as resources; and the MCP project lists “Skills over MCP” among its notable extensions.
Three different questions, and the two ways the answers compose.
Security
All three put text written by someone else into the model’s context, and two of them run code. The MCP specification’s security principles say it plainly: hosts should treat tool descriptions, and tool annotations such as “read-only” or “destructive”, as untrusted unless they come from a trusted server, and should get the user’s consent before invoking a tool. A malicious server can describe a tool in words that steer the model (tool poisoning), or change a tool’s definition after the user approved it (a rug pull), and a malicious skill can instruct the agent to send data elsewhere. Secrets such as API keys shouldn’t pass through the model at all: elicitation has a URL mode for sending the user to a web page to enter them. Anthropic’s guidance for skills is to treat them like installing software: use only trusted sources, and audit everything in them. Guardrails and Prompt Injection covers prompt injection and the defenses in depth.
Where this leads
- Many Agents splits work across several agents, with their own contexts.
- Guardrails and Prompt Injection returns to injection through tools, skills and retrieved content.
Takeaways
- MCP turns integrations into : hosts and servers each implement one protocol, JSON-RPC over stdio or HTTP, offering tools, resources and prompts. Since 2026-07-28 every request is self-contained, and sampling and roots are deprecated.
- This blog’s MCP server is about 50 lines, and its two tools cost about 300 tokens of context per call; fifteen tools on each of five servers would cost about 11,000.
- Skills load procedures in three steps: a ~100-token description always, instructions when relevant, files and script output only as needed. 50 skills cost 2,000 tokens instead of 200,000.
- OKF stores curated knowledge as Markdown files with front matter, and makes provenance and verification part of the format.
- All three bring outside text, and sometimes code, into the agent: trust a server, skill or bundle only as far as its source would be trusted to install software.
References
Start with the MCP specification’s overview and its security principles, then Anthropic’s Agent Skills documentation for progressive disclosure and the OKF specification for the format itself. The Python SDK’s repository is the reference for the server and client code.
- Model Context Protocol, Specification 2026-07-28: roles, primitives, transports, the stateless revision and the security principles.
- Model Context Protocol, Python SDK (version 2): the
MCPServerandClientused for the blog’s server and host. - Anthropic, Agent Skills
(documentation) and Equipping agents for the real world with Agent Skills: the
SKILL.mdformat, progressive disclosure and its token guidance, and the advice to treat skills like installed software. - Google Cloud, Open Knowledge Format (OKF) v0.2: the bundle format, its fields and the trust tiers.