MCP, for clients and for servers

tinymachines.ai speaks the Model Context Protocol at one public URL:

https://tinymachines.ai/api/mcp

It is read-only, needs no account and no key, and answers two kinds of question. The tools say what tinymachines is and which of its pieces are answering right now, measured when you ask. The resources are a tm:// namespace: this site's documentation, data and design notes as files, and its pages as a visitor gets them, with typed links between a page and the markdown it is rendered from. The spec behind the namespace is in the repository at extern/tinypeek/spec/tm-protocol-spec.md (the tinypeek submodule); this page is the practical side.

The first half is for a client. The second half is for anyone building a server, ours included.

For clients

Connect

The endpoint is Streamable HTTP: one POST per message, JSON-RPC 2.0 in the body, no session to open and no event stream to hold. Most clients need only the URL.

claude.ai and Claude desktop. Add a custom connector and give it the URL above. No authentication is asked for, so leave that off. Desktop shares the connectors of the account it is signed in to.

Claude Code. One command registers it for the project you are in:

claude mcp add --transport http tinymachines https://tinymachines.ai/api/mcp

claude mcp list shows it, and /mcp inside a session shows what it offers. The flags are the ones at the time of writing; claude mcp add --help is the authority.

Any other client that takes a JSON configuration. The shape is the usual one:

{
  "mcpServers": {
    "tinymachines": {
      "url": "https://tinymachines.ai/api/mcp"
    }
  }
}

curl, for a look at what the client will see. Initialize, then list:

curl -s https://tinymachines.ai/api/mcp \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}'

curl -s https://tinymachines.ai/api/mcp \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

The initialize answer carries instructions, a short orientation written for the model, and capabilities, which say that tools, resources and completions are all here. A GET on the URL answers with a refusal naming POST, because there is no stream to open.

What is on offer

Four tools:

toolwhat it answers
overviewthe six pieces of the 6502 work, each with what it is, where its source is and its licence, and whether its public surface is up, measured at call time
pieceone piece by key, with the same measurement
licensingwhat may be published and under what terms, which is not one licence
resolveone tm:// URI, read, with its links in the body; the tool-shaped door into the namespace below

And the namespace, as MCP resources. The grammar is one host and five mounts:

tm://                                              the hosts this server answers for: one
tm://tinymachines/                                 the mounts
tm://tinymachines/fs/docs/nes/pile.md              a file of the site's checkout
tm://tinymachines/fs/docs/nes/                     a directory; reading it lists it
tm://tinymachines/git/nes-bench/docs/pile.md       the same file in the repository
                                                   it was pulled from, at HEAD
tm://tinymachines/git/public/docs/words.md?at=v1.0&as=blame
                                                   a file at a tag, a commit per line
tm://tinymachines/http/tinymachines.ai/docs/nes/pile?as=text
                                                   the page that file becomes, as text
tm://tinymachines/offbox/autopsy-models            where a build chain leaves this
                                                   box: named, never exposed
tm://tinymachines/entity/pattern/pad-poll          a pattern the autopsy found, with a
                                                   link to every game and routine it is in
tm://tinymachines/mcp/terrapulse/tools/get_slot    a tool of another MCP server this box
                                                   reads, as that server described it
tm://tinymachines/mcp/terrapulse/map               that server's map: which of its tools
                                                   read as addresses, and how sure each is
tm://tinymachines/kinds/pattern                    one kind of thing this host has served,
                                                   counted: its media type and its links
tm://tinymachines/http/bradley.io/                 a site this box serves and did not build,
                                                   listed from the sitemap it publishes
tm://tinymachines/manifest                         the visual language as data: a shape per
                                                   mount, a colour per link relation
tm://tinymachines/renderers/                       the module that draws each media type,
                                                   by address and sha256
tm://tinymachines/entity/pattern/pad-poll?as=blocks  anything, composed into plain blocks
                                                   for a screen with no code on it

A URI ending in / is a collection, and reading one returns its children with a cursor. Everything after the mount is the backend's own path. A representation is a query parameter, never a path suffix: ?as=stat is the metadata of the same thing, ?as=text a page's readable text (which is also what a page gives with no parameter at all), ?as=rendered the HTML with the headers a visitor gets.

The git mount offers the site's own repository and the ones beside it that the notebook pulls pages from, all public on GitHub under the same names. ?at= is a branch, tag or commit, or a time (an ISO date or instant, which names the newest commit made at or before it; fs takes it too, and so does entity, which reads an entity as its records had it then), ?as=log the commits touching a path (&n= how many), ?as=blame the file with a commit per line, ?as=tree a directory as JSON, and &depth= how many levels of it in one read, up to eight. For 6502 the default ref is the commit the site is serving, not the newest.

A piece of a thing has an address of its own. ?lines=FIRST-LAST reads lines of a file, or of a page's text, counted from 1 and both included; ?pointer=/a/0/b reads what a JSON pointer names in a JSON file; ?range=START-END reads bytes. The answer's uri keeps the span, so a page that quotes a piece keeps where it came from. An entity's tm:data link points into its record this way.

The entity mount is the first thing built on the other four. It gives the autopsy's patterns, games, routines and lessons addresses of their own, read from the two records the site already publishes: entity/pattern/<key>, entity/game/<key>, entity/game/<key>/routine/<bank>-<address> and entity/lesson/<key>. Each reads as JSON, its record's fields, and ?as=stat is its metadata. Only a routine that a rule named has an address, and no byte of any cartridge is in any of them. A lesson's record keeps the key of the game it was measured beside, so each lesson links to that game (tm:measures) and the game links back (tm:measured-by).

The mcp mount reads other MCP servers as mounts of this host. mcp/ lists the servers the box reads; mcp/<server>/ is one server's record (what it is, whether it answered when the box asked, what it declared) and lists its tools/, resources/ and prompts/; mcp/<server>/tools/<name> is one tool as the server described it, with its schema readable as a piece of it (?pointer=/inputSchema). A tool is listed and described, and it is run only where the server's map names it: mcp/<server>/map is the map tinyhelper's index driver derived from the server's own schemas and a person corrected one entry at a time, each entry saying how sure it is, and a path the map names (mcp/terrapulse/dex/earthquake/<id>, say) reads by calling the tool its entry names with the path's segments as arguments. The answer is the thing, typed by the kind the map gives it, linked to the tool it was read with (tm:read-with) and to what its fields name, with the server's provenance (sources, licence_basis) carried into _meta untouched. A server with no map reads as before. A server behind a token the box does not hold reads as gated, and one that does not answer as unreachable; the record says which, and when the box last asked.

Every read says what it is in its mimeType. A thing the server composes is application/vnd.tm.<kind>+json (listing, stat, record, pattern, tool, and so on) or text/vnd.tm.<kind> (a page's text is text/vnd.tm.page); a file the server merely holds keeps its own type, so a markdown file is still text/markdown. A client never has to guess a kind from a path. kinds/ lists every kind this host has served since its record began, and kinds/<kind> says how often, as which media types and with which link relations; it is measured, so a kind never served is not there. A link may carry its target's type. On the entity mount ?as=set&rel=<rel> reads the things behind one link relation inlined, a page at a time, which saves a client one read a member.

A site this box serves but did not build is listed from the sitemap.xml it publishes, and its robots.txt is the fence: only a listed page is ever fetched, a path the sitemap does not name is not-found before any request leaves, and a path under a Disallow is denied with the rule. Its pages read as pages. Where the site publishes a link manifest at /.well-known/tm-manifest.json, a page takes its title from it and its stat names the files that make it; the manifest can describe a listed page, never add one.

Two addresses serve a host with no modules at all, a terminal or a print page. tm://tinymachines/manifest is the visual language as data, a shape per mount, a colour family per link relation, the handles a kind offers, read from one file so every viewer draws the same one. ?as=blocks on any address composes it into an ordered list of blocks from a closed vocabulary (heading, note, kv, list, table, timeline, diff, code, media, question, finding, trail, link), every block naming the address it came from and the body naming every read it was built from, so a page that renders blocks and nothing else shows every kind without looking at a body.

Every read carries links. A notebook page links to the markdown it is rendered from (tm:source), the markdown links back to its page (tm:renders-as); a page the build pulled from another repository, and its copy here, link to the file they came from (tm:generated-by) and that file links forward (tm:generates); a file here and the same file in git are joined both ways (tm:repository, tm:working-copy); any page links to the route file that renders it (tm:source) and to the records its own code reads (tm:data), a record links back to its readers (tm:read-by) and to the script that writes it (tm:generated-by); a page links to the same page in the other language and on the other site (alternate), a submodule to the repository it pins, at the commit it pins (tm:pins), and a trail, a JSON file under fs/notes/trails/ that is a named, ordered list of addresses, to each of its steps (tm:step), every step linking back to the trails it is on (tm:step-of); a pattern links to each game it was found in (tm:found-in), each routine a rule named (tm:instance) and each lesson that uses it (tm:demonstrated-by), and those link back (tm:exhibits, tm:instance-of, tm:demonstrates); a lesson links to its cartridge's source (tm:cartridge) and a game to its page (tm:renders-as); the root links to the documents to read first (help); and up, collection, alternate, describedby, version-history and latest-version mean what the IANA registry says. A link that stands for several occurrences says how many in count. Links come in one order: the IANA ones first, then the exact, then the inferred, and by relation and address within each. No read carries more than a hundred of them: one with more ends in a tm:links link to ?as=links, which gives the whole set and no content, a page at a time, each page ending in next. The set can be narrowed: ?as=links&rel=tm:found-in&min=2 is one relation only, and only the links that count at least twice, and the narrowed set is an address of its own. Through resources the links sit in the result's _meta under tinymachines.ai/links; through resolve they are in the body.

A URI that does not resolve is refused with the nearest ones that do, so a wrong guess teaches rather than dead-ends. Reads are limited by caller: a burst, then a steady rate, with a page counting for more than a file because the server fetches it. A caller over the rate is answered rate-limited with retryAfter in seconds, and waiting that long is enough. Someone reading never meets it; something crawling does.

The server keeps a record of each request: what was asked for, when, how it ended and how much came back. It does not record who asked: no address is kept, and no stand-in for one. The record is kept for thirty days at most and is not part of anything the namespace serves. A file over the size cap comes back with its size and a ?range= and a ?lines= template. The resource templates say the grammar and completions fill in live values, so a client can compose a URI it was never shown and try it.

A first session

Six exchanges take a client from a cold connection to the source file behind a page, without guessing a path:

  1. initialize: the capabilities and the orientation, which names the documents to read first.
  2. resources/templates/list: the templates, one for the list of hosts, one for the root and one or two for each mount.
  3. Read tm://tinymachines/http/tinymachines.ai/docs/nes/: the notebook's NES section, page by page.
  4. completion/complete on the fs template's path with docs/nes/pi typed: the files that start that way.
  5. Read tm://tinymachines/http/tinymachines.ai/docs/nes/pile?as=text: the page's text, and a tm:source link.
  6. Read that link: the markdown, with its own link back to the page, to the directory it sits in, and to the file in the bench repository it was pulled from, whose ?as=log is its history.

Through the resolve tool, the same six steps are calls with a uri argument, and the links come back in the body.

The other endpoint

The chip itself is not behind this URL. Running 6502 code, assembling and minting cartridges is the 6502 API's job, which has its own endpoint at https://6502.tinymachines.ai/api/mcp and its own tools. The overview tool here names it, and the two are designed to be connected together.

For servers

This section is how ours is built, so that a server of your own speaks the same way, and so that a change to ours lands in the right place. The code is api/mcp_server.py (the transport and the tools) and api/tm.py (the namespace), with api/test_tm.py beside them.

The transport is one POST

A conforming Streamable HTTP server needs very little:

  • POST takes one JSON-RPC 2.0 message or a batch, and answers in kind. A batch comes back as a batch, in order.
  • A notification (a message with no id, such as notifications/initialized) is answered with status 202 and no body.
  • GET is where a client would open a server-to-client stream. With no server-initiated messages to send, ours answers 405 with Allow: POST and a sentence saying why.
  • initialize echoes the client's protocolVersion when it is one the server supports and otherwise answers with the newest it speaks. Capabilities are declared here and nowhere else.
  • A protocol error (unknown method, bad params) is a JSON-RPC error. A tool that refuses is a normal result with isError: true and a reason the model can read, because a JSON-RPC error is for the client, not the model.

The transport here is hand-written rather than an SDK. It is a short, dependency-free thing with little in it to be wrong about, which matters for a service whose promise is that nothing goes stale underneath it. An SDK is the right choice when the server will do more than this one does (subscriptions, sampling, a session).

A tool is a description and an implementation, checked against each other

Each tool is a dictionary in TOOLS (name, title, description, input schema) and a function in a table the handler is built with. The handler asserts at construction that every advertised tool has an implementation and every implementation is advertised, so the two cannot drift. The description is written for the model: what the tool answers, what to call first, and what an unknown argument gets (a refusal naming the valid ones, never a guess).

The tools call the same functions the REST routes call. A tool that computed its own answer would be a second surface pretending to be one.

A mount is three operations

The namespace is a set of mounts behind one grammar, and adding a backend is adding a mount; the protocol, the templates and the client do not change. A mount implements:

operationinputoutput
lista collection path and a cursorchildren as URIs, each with a name, a type, a size where there is one, and whether it is a collection; a nextCursor when truncated
reada leaf path and its representationthe content with its type, and the links
completea template argument and what was typedcandidate values, capped, with a hasMore flag

and every mount inherits the same controls: paths are resolved before anything touches the filesystem and refused when they land outside the mount's roots; a read is capped in bytes and offers ranges beyond the cap; a listing is paged; a deny-list of filename patterns is a second net, listed as redacted so a client knows the file exists and does not get it.

Three of ours, and the reason each is shaped the way it is:

  • fs offers three directories of the checkout that serves the site, the ones that are public on GitHub anyway. The roots are an allowlist, never the host, because this box holds a sign-in database and a gitignored hosting runbook, and no deny-list of names would catch them.
  • git runs git itself, read-only, on repositories the server was told about, with every argument passed as an argument and never through a shell. A ref is checked against a pattern before it is used, so nothing a client names can be read as an option; a tree path is normalised and refused if it walks up. Which repository a pulled page came from is read from the pull scripts, which are the one copy of that fact, rather than kept as a second list.
  • http fetches a page through nginx on loopback, with the site's name as SNI and as the Host header, so the bytes are what a visitor gets: the real certificate, the redirect map, the policy headers. The site has to be one this box serves, which is what keeps the mount from being an open proxy. ?as=text takes the page's main element and leaves navigation out.

Every read attaches links of the form {rel, href, confidence?, title?}. Where the IANA registry has a relation, it is used and carries no confidence, because it is exact by construction. The rest are tm: relations and must say exact, inferred or low; the constructor asserts that, so a link cannot get it wrong quietly. Inverse pairs are both emitted, so a traversal can be walked in either direction, and every href is a complete URI, never a relative path.

The page-to-source join is the one place a server needs to know how the site is built. Ours restates the notebook loader's rule (a markdown path under docs/ becomes a /docs/ URL, an index names its directory, ja/ is the language prefix), and the tests check the restatement against the loader itself and against the last crawl of the served site. That check is how two edge cases were found rather than assumed.

The data links are read off the tree the same way. A URL's route file follows the app framework's convention; the records a page reads are the data files named in its file and in the library modules it imports, followed through the library only, so the site frame's menu and the language tables are not counted as the page's reading; the writer of a record is the script that names it. All of that is static reading of source, so those links say inferred, and a record whose own note names its writer is the one case that earns exact. A link's confidence is for the client to weigh, which is why it is on every one of ours.

What a refusal carries

A refusal is a JSON-RPC error whose data.reason a client can switch on:

reasonwhat comes with it
not-foundup to five nearest URIs that do resolve
out-of-rootthe roots that are offered
deniednothing; the file exists and is not offered
too-largethe size, a ?range= and a ?lines= template, a ?as=stat link
binarythe type and a ?as=stat link
redirectthe status; the visitor would be sent on
no-such-refthe refs that start the same way
git-errorgit's own last line
bad-facet, bad-cursorwhat was expected

Through the resolve tool the same refusal is an isError result with the same fields in its text, so a model sees the nearest URIs too.

Testing a server

Ours runs under pytest in api/, and the tests that matter most are the ones a fresh server should copy: a path can spell nothing that leaves the roots (dotted segments, percent-encoded ones, a symlink out); a denied file is listed as redacted and refused on read; the page rule agrees with the thing that actually builds the pages; a wrong URI returns its nearest siblings; and a handler built without the namespace advertises none of it. A check that can pass on nothing is not a check, so each of these asserts on a real refusal or a real list.

What is not here yet

Not yet: subscriptions, and anything that writes. Writes are not planned. The direction the spec reserves is proposals: a tool that takes a URI a client wishes existed and stages a reviewable change, with nothing landing without the owner's approval.