Skip to content
Residual Stream
Go back

Giving Agents Tools and Knowledge: MCP, Skills and OKF

Updated:
Tan Le
ENVI

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 M×NM \times N 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.

Three ways in, and what each costs the context the agent’s context MCP server live tools and data JSON-RPC Skill procedure, on demand loaded OKF bundle curated knowledge read 5 × 15 tools at 148 each: 11,062 tokens bodies only when needed 50 descriptions at 41 each: 2,050 tokens front matter: type (+ sources, verified) concept files, read as needed all three bring outside text into the context: trust them like installed software

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
At a glance
  • 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 M×NM \times N integrations into M+NM + N: 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.
without a protocol: M × N = 12 IDE chat app agent database files search service with MCP: M + N = 7 IDE chat app agent database files search service MCP each line is an integration someone has to build and maintain

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.
host owns the model and the user’s consent model client 1 one per server client 2 one per server server: this blog tools the model calls resources the host reads prompts the user picks server: a database JSON-RPC 2.0 stdio or HTTP

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.

a stateless tools/call request (2026-07-28) "jsonrpc": "2.0", "id": 1 pairs the reply "method": "tools/call" what to do "params": {"name": "search_posts", which tool, with what "arguments": {"query": …, "k": 2}, "_meta": {protocolVersion, everything a server needs, clientCapabilities, clientInfo}} in every request: no session reply: "result": {"structuredContent": … JSON …, "content": [ … text … ], "resultType": "complete"} the same value twice: for code, and for the model

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

Full script: blog_server.py

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())

Full script: mcp_client.py

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()

Full script: wire.py

-> {"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")

Full script: tool_cost.py

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.

tokens in every request’s prompt (log scale) this blog’s 2 MCP tools 295 5 servers × 15 tools 11,062 50 skills, metadata only 2,050 50 skills, every body up front 200,000

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 above

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

  1. 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).
  2. 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.
  3. 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).
1. metadata name + description always, ~100 tokens (41 here) 2. instructions SKILL.md body when a request matches, < 5,000 tokens 3. files and scripts references, helpers as needed; a script’s output, not its code the model reads the description and decides what to load: retrieval over instructions

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])

Full script: okf.py

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

MCP what can I do and read right now? live tools and data Skills how should I do this task? procedures, loaded on demand OKF what do we know, and how sure? curated files with provenance a skill says which MCP tools to use an MCP server can serve an OKF bundle as resources

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

Takeaways

  • MCP turns M×NM \times N integrations into M+NM + N: 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.


Written by

Tan Le

AI engineer. I write these notes to learn LLM internals and AI engineering in depth, and to keep what I learn where I can find it again.