mikser-io-mcp
v11.4.0
Published
MCP (Model Context Protocol) substrate and tools for mikser-io. Extracted from core to iterate on its own release cadence.
Maintainers
Readme
mikser-io-mcp
MCP (Model Context Protocol) substrate and tools for mikser-io. Ships as a plugin (not in core) so MCP can iterate on its own release cadence without forcing a mikser-io version bump on every change.
What it ships
- The MCP substrate —
createMcpSubstrate, per-session McpServer + transport viamountMcpOnExpress, the pino-to-MCP log bridgewireLoggerToMcp. Other plugins compose againstruntime.options.mcpto register their own tools and resources. - Built-in resources —
mikser://config,mikser://lifecycle,mikser://logs,mikser://server. Read-only introspection any MCP client can use. - Built-in tools
- Catalog —
mikser_query_entities,mikser_read_entity,mikser_edit_entity,mikser_update_entity,mikser_delete_entity,mikser_render, over the engine's public catalog API. - Finding things —
mikser_searchlocates a string across entity meta, source files, and (within: ["output"]) the BUILT files, reporting occurrences per page — and withattribute: true, which source emitted each hit. That is how you find content you can only describe by what it says, and how you size a change to anything shared before making it. - Working backwards —
mikser_whichtakes a built destination and returns the source that produced it: the field path and line/column a value was written at, or the line in file content where it appears — each occurrence flagged by whether the string BEGINS its line, which separates a declaration from a use in any text format without a per-language grammar. Each answer is labelled by how it was reached —meta-fieldandsource-contentare RECORDED (the engine's ownrefClosuresays this render consumed that entity, and the position comes from parsing its source),scanis not. It reaches values that appear nowhere in a page's own document, which is most of a shared nav or footer. - References —
mikser_refs_inbound/mikser_refs_outbound/mikser_refs_broken/mikser_refs_rename, fromruntime.refs. - Diagnostics —
mikser_explain(why an entity did or did not re-render),mikser_build_report(what a cycle did, with history),mikser_verify(output folder vs. recorded snapshots),mikser_read_output(the bytes currently on disk for a destination). - Layouts —
mikser_layouts_inspect(template + variables + sample entities), registered bymikser-io-layoutsitself. - Liveness —
mikser_ping, which also reports how the caller is authenticated and when that credential expires.
- Catalog —
mikser_preview_render— render an entity through the pipeline and return a clickable URL serving the chain's final output (PDF for a*.html-pdf.*layout, and so on).- Endpoint scoping and
substrate.mountEndpoint— a package can own its own MCP route from this substrate, with its registrations bound only there. mikser-io-mcp-app uses it to serve MCP Apps (SEP-1865) at/apps; the interactive-UI surface used to live here under the mcp-ui vocabulary and moved there.
Also reachable from the CLI
Every tool here is registered into the engine's registry
(mikser-io's registerTool), not only into this plugin's session
surface. So an agent that runs the CLI and reads its output asks the same
questions as one speaking MCP:
npx mikser --tool which --tool-args '{"destination":"/bg/index.html","text":"Контакти"}'Names are bare on the CLI. The mikser_ prefix is this protocol's — MCP
tool names are flat across every connected server, so an unprefixed
search would collide with someone else's — and it is added when a tool
is bound into a session. A client sees exactly the names it always did;
either form works from the CLI.
npx mikser --tools lists them. stdout carries only the tool's result, so
piping into jq works; exit status is 0 / 1 (the tool reported an error) /
3 (no such tool, or bad --tool-args). See mikser-io's
docs/diagnostics.md under "The two agent workflows".
Editing content
Two tools write source files, and which one you reach for is the whole point.
mikser_edit_entity changes PART of a file: you name the text to replace and
everything else is left exactly as it is, byte for byte. Use it for any change
to an existing file.
await mikser_edit_entity({
id: '/documents/pricing.md',
find: 'price: 1200', // must match exactly once
replace: 'price: 1400',
})find must appear exactly once, or the edit is refused and told how many times
it appeared — extend it with the surrounding lines, or pass all: true for a
rename that really should hit every one. An anchor that appears nowhere is
refused too: the file is not what you read. And the RESULT must still parse in
the file's format — a broken YAML block is caught before it lands, which is the
check a whole-file write has no way to make. None of those come back as
errors; each is a result carrying what the next attempt needs.
This exists because rewriting a whole file to change one line means re-emitting
every other line, and a line dropped on the way looks downstream exactly like a
line someone deleted on purpose. ifChecksum catches a stale read; nothing
catches a lossy write.
mikser_update_entity writes the WHOLE file. Use it to CREATE a file, or when
you are genuinely replacing most of one. Three fields make that safe to do
without a shell on the box:
const page = await mikser_read_entity({ id: '/styles/tokens/buttons.css', include: ['content', 'positions'] })
// page.positions says where each meta field was written —
// { 'items[2].label': { line: 7, col: 13 } } — so a value can be cited or
// found again without scanning the file for it.
// page.contentComplete tells you whether `content` is the whole file or a
// truncated copy. Never write back from a truncated read.
// page.advisories names a file you must not edit blind — see below.
// What would this edit reach? Writes nothing.
await mikser_update_entity({ id: '/styles/tokens/buttons.css', dryRun: true })
// → wouldAffect: [{ destination: '/bg/styles/site.css', reason: 'query-matched',
// matched: { filter: {collection:'styles'}, by: '/styles/tokens/buttons.css' } }, …]
await mikser_update_entity({
id: '/styles/tokens/buttons.css', // or collection + relativePath
content: edited,
ifChecksum: page.checksum, // refuse the write if the file moved since the read
await: true, // block until the cycle picks it up, return its report
})Every tool takes and returns ids, and update_entity accepts one too — the
collection + relativePath pair still works, but a caller that just read or
searched an entity holds the id, and splitting it back into parts is a guess
(the prefix is configurable and the extension may have been stripped). Given an
id, the file location comes from the entity itself.
ifChecksummakes the write conditional. On mismatch the write is refused andcurrentChecksumcomes back, so a whole-file rewrite built from a stale copy cannot silently discard someone else's edit.await: truereturns the build report for the cycle that picked the write up, so one call answers "what did my edit change" instead of writing and guessing. The response always carriescycleId, whether or not you wait;mikser_build_report({ cycles: n })reads back the lastnfinished cycles.siblingDestinationsnames files beside this one that differ only by extension — theindex.mdsitting next toindex.yml, both rendering to/bg/index.html, one silently discarding the other. It is a heuristic and says so;mikser_explainandmikser_verifycarry the authoritative answer once a cycle has run.dryRun: truewrites nothing and returnswouldAffect: every destination the edit would re-render, each carrying the samereasonthe build report uses, so "why this one" is answered alongside "how many". Computed by running the engine's own skip rule, which is what stops the preview from disagreeing with the cycle. It cannot model a change to the file's own frontmatter, which is parsed at import and can move the destination itself.advisoriessurface a file you must not edit blind, as data rather than a comment you had to read far enough to find. Two kinds:spec-locked(the bytes answer to a document outside the repo) andgenerated(the next build overwrites this; edit its source). Declared either throughmeta.specLocked/meta.generated, or by a header line in the first 40 lines —Spec source: …,Generated by …,Do not edit— which is the only form available to a.cssor.jsfile with no meta at all. Reported on read AND echoed on write, because the caller who most needs telling is the one who never read the file.
Install
npm install mikser-io-mcpPeer dependencies: mikser-io ^9.0.0, zod ^4.0.0.
Activate
Import the mcp factory and call it first in your mikser project's plugins array — the closure runs synchronously and creates runtime.options.mcp so any plugins that register tools (api, layouts, refs, vector, etc.) can gate on it at their own onLoaded hook:
// mikser.config.js
import { mcp } from 'mikser-io-mcp'
export default {
plugins: [
mcp({
path: '/mcp', // optional; default '/mcp' (also serves as the base for `endpoints` below)
endpoints: { /* … */ } // optional; same shape as the in-core era
}),
/* … your other plugins */
],
}Calling mcp() with no options is a no-op activation — the factory runs but creates no substrate and mounts no transport. To skip MCP entirely, leave the factory call out of plugins.
Run mikser with --server; the MCP transport mounts at the configured path on the same Express server the api / preview / data plugins use.
Register with a client
Once the plugin is installed in your mikser project, the package ships a CLI you can invoke via npx to connect MCP-speaking clients. Connector name + description are read automatically from the project's package.json.
Claude Desktop
npx mikser-io-mcp register claude # default URL http://localhost:3001/mcp
npx mikser-io-mcp register claude --url http://localhost:4000/mcp # custom port
npx mikser-io-mcp register claude --dry-run # show what would change
npx mikser-io-mcp register claude --force # overwrite a different existing entry
npx mikser-io-mcp register claude --unregister # remove the entryWrites a mcpServers entry into Claude Desktop's per-OS config file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%/Claude/claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
The entry launches supergateway (npx-installed on demand) as a stdio→streamable-HTTP bridge to your running mikser server. Fully quit + reopen Claude Desktop after registering.
ChatGPT
npx mikser-io-mcp register chatgpt --url https://YOUR-TUNNEL.example/mcpChatGPT's MCP integration is server-side — OpenAI's servers connect to your MCP endpoint directly. localhost is unreachable; you must expose mikser via a public tunnel (ngrok http 3001, Cloudflare Tunnel, etc.) before running this.
The script doesn't write a file (ChatGPT has no local config). It prints the three fields (Name, Description, MCP Server URL) you paste into ChatGPT's UI: Settings → Connectors → Advanced → Developer mode → Create.
Notes:
- ChatGPT Developer mode + custom MCP connectors are beta and require a Plus / Pro / Business / Enterprise / Edu account.
- Connectors don't auto-enable per chat — toggle on each new conversation.
Sessions and restarts
Sessions live in the serving process. A restart — a dependency update, a config change, a deploy — ends every one of them, while connected clients keep the session id they were handed.
A request carrying an id the process does not hold gets 404, which is the
signal the Streamable HTTP spec defines for exactly this: on 404 a client opens
a new session with a fresh InitializeRequest. Nothing else about that client
has expired — its access token is a JWT that still verifies, and its dynamic
registration is durable — so a well-behaved client reconnects silently, with no
human in the loop and no second trip through the authorization flow.
An initialize is served even when it carries a stale id, so a client that
resends its stored id out of habit still gets a session rather than looping on
404.
Documentation
- Full MCP tour, twelve worked scenarios, every tool and resource
- MCP Apps rendering and action delivery moved to mikser-io-mcp-app (its ADR-0001)
License
MIT. See LICENSE.
