A minimal Rust MCP server that loads WebAssembly components while running, reads their WIT interfaces, and dynamically invokes their exported functions. No plugin manifest, generated host bindings, or server restart is required.
Requires Rust 1.93 or newer.
cargo build --locked
./target/debug/hyperlocal-agent-appsThe server speaks newline-delimited MCP JSON-RPC on stdin/stdout. It starts with an empty, in-memory plugin registry. Relative component paths resolve against the server's working directory. stdout is reserved for MCP messages.
Configure your harness with its MCP server settings, for example:
{
"mcpServers": {
"hyperlocal": {
"command": "/absolute/path/to/hyperlocal-agent-apps/target/debug/hyperlocal-agent-apps"
}
}
}For an optimized binary, use cargo build --release --locked and point the
harness at target/release/hyperlocal-agent-apps.
For ChatGPT plugin connections, just chatgpt starts a loopback Streamable HTTP
MCP endpoint at http://127.0.0.1:47832/mcp. See the
ChatGPT connection guide for Secure MCP Tunnel
and private plugin registration. This is separate from Codex's stdio settings.
The eleven MCP tools stay fixed as components are connected:
| Tool | Arguments | Result |
|---|---|---|
connect_plugin |
id, path |
Plugin id, function count, and optional web_url |
list_plugins |
none | Connected ids, paths, function counts, and web URLs |
inspect_plugin |
id |
Reconstructed WIT and typed function descriptions |
call_plugin |
id, function, arguments |
A results array |
discover_peers |
optional wait_ms (100–10000, default 2000) |
IPv4 LAN peer URLs and mDNS diagnostics |
list_peer_plugins |
peer_url |
Catalog with ids, sizes, and SHA-256 digests |
download_plugin |
peer_url, plugin_id, sha256 |
Verified local component path; nothing is instantiated |
inspect_peer_plugin |
peer_url, plugin_id |
Named peer's WIT, function paths, and component digest |
call_peer_plugin |
peer_url, plugin_id, sha256, function, arguments |
Named peer and hosted function result |
open_peer_web |
peer_url, plugin_id |
Named connection and local proxy web_url |
close_peer_web |
web_url |
Closes your proxy; leaves the hosted plugin running |
Connect a component using an arbitrary local id:
{"id":"math","path":"/absolute/path/to/fixtures/calculator.wat"}Inspect it:
{"id":"math"}Invoke its interface export:
{
"id": "math",
"function": ["example:math/calculator@0.1.0", "add"],
"arguments": {"a": 20, "b": 22}
}Result:
{"results":[42]}Function paths are arrays of exact export names, so interface names containing slashes and versions are unambiguous. Root functions use a one-element array. Named arguments must match the WIT parameters exactly.
Connect another file at any time. Reconnect an existing id to atomically replace its component; compilation or validation failures preserve the previous version. Calls already in flight retain their original component. Connections are explicit: the server does not watch directories or automatically reload changed files.
Discovery uses mDNS/DNS-SD service type _hyperlocal._tcp.local.. The default
server does not advertise or expose a LAN listener. Discovery runs only when
discover_peers is called.
On a machine that should share plugins, start the server with --share:
./target/debug/hyperlocal-agent-apps \
--share --name "Studio Mac" \
--plugin counter=fixtures/web-demo.watKeep this process attached to your MCP harness. --plugin id=path is a repeatable
startup preload; components connected later through MCP appear in the catalog
automatically. --port 47831 fixes the sharing port if needed; otherwise an
available port is chosen and printed to stderr. --help lists all options.
The same flags can be added to your harness's MCP command arguments.
With --share, all currently connected component binaries are readable by
other devices that can reach the sharing port. The binary includes any assets
or static data embedded by its author. With plain --share, the service exposes only the component
catalog and original binaries, not local paths, MCP operations, browser APIs,
credentials, or live WASM memory. Live interfaces require the separate
--share-live opt-in described below. Browser listeners remain on loopback. Normal
MCP startup without --share keeps sharing disabled.
On the receiving machine, call these MCP tools in order:
-
discover_peerswith{"wait_ms":3000}. Select one of the returnedurls. -
list_peer_pluginswith{"peer_url":"http://192.168.1.20:47831/"}. -
Select an id and digest from the catalog, then call:
{ "peer_url": "http://192.168.1.20:47831/", "plugin_id": "counter", "sha256": "<64-character lowercase digest from the catalog>" }Use
download_pluginfor this step. It returns an absolute localpath. -
Call
connect_pluginwith a chosen localidand the downloadedpath. This explicitly instantiates your own copy, including its local browser mount.
A suitable agent prompt is:
Discover Hyperlocal peers on my network and list their shared plugins.
Download the counter component from the peer I select, then connect it locally
as peer-counter and show its web_url.
Downloads go to .hyperlocal/plugins/<sha256>.wasm relative to the server's
working directory. Use --download-dir /absolute/path to choose a stable cache.
The directory is created only after a download has passed validation. Writes
are atomic, existing valid files are reused, and corrupt cache entries are
reported rather than overwritten. A peer changing its advertised version
requires selecting the new digest explicitly. Source .wat files are converted
to binary components; sharing serves the exact bytes retained when connected,
even if the original source file is subsequently edited or removed.
Downloads verify the catalog's size and SHA-256, require a valid binary component, and never run guest code (including start functions). Connecting starts at the component's initial state: downloading another machine's counter does not copy its current count. Peer names, metadata, and mDNS announcements are untrusted; the checksum checks byte integrity against the chosen catalog, not publisher identity. This first version uses unauthenticated HTTP on the LAN and has no signatures or account pairing.
The discovery and download client currently support numeric IPv4 addresses on
private, link-local, or loopback networks. Client requests bypass HTTP proxies,
refuse redirects, have bounded timeouts, and limit catalogs to 256 KiB / 256
entries and components to 32 MiB. Multicast may be unavailable on guest Wi-Fi,
VPNs, or networks with client isolation. In that case you can use a known LAN
IP and sharing port directly with list_peer_plugins. Discovery does not cross
routers or connect to a public registry.
The distribution HTTP API is versioned separately from plugin WIT:
GET /v1/catalogreturns protocol version 1, the advertised peer name, component metadata, andweb_available/calls_availableflags.GET /v1/components/<sha256>returns immutable binary component bytes.
This metadata is generated by the host. Plugin authors still need no manifest.
Use this workflow when you want the peer's current state. Downloads create a separate local instance; remote calls and web proxies use the peer's existing instance, shared with its own agent and browser.
The publishing machine must explicitly opt in:
./target/debug/hyperlocal-agent-apps \
--share-live --name "Studio Mac" \
--plugin counter=fixtures/web-demo.wat--share-live implies binary sharing and additionally exposes all connected
plugins' hosted functions and web interfaces to reachable LAN clients. Plain
--share keeps these endpoints disabled. This v0.1 LAN service is unauthenticated;
only enable live sharing for peers you intend to let read and change that state.
There is no remote connect/install/reset-runtime administration API.
Peers announce --name in both mDNS and the catalog. Discovery returns the name
alongside addresses. Remote inspection, function results, and web connection
results also include peer_name, so the agent can identify Studio Mac / counter.
Names are display labels, not authenticated identities; addresses distinguish
peers with duplicate names.
On the receiving machine:
-
discover_peersand select the named peer. -
list_peer_pluginsshows which plugins have live calls or web access enabled. -
inspect_peer_pluginwithpeer_urlandplugin_idreturns WIT, exact function paths, the currentsha256, and the peer name. -
call_peer_plugininvokes a function on that same hosted instance:{ "peer_url": "http://192.168.1.20:47831/", "plugin_id": "counter", "sha256": "<digest from inspect_peer_plugin>", "function": ["example:counter/state@0.1.0", "increment"], "arguments": {"amount": 41} }The result contains
peer_name,plugin_id,sha256, andresult: {"results":[41]}. -
open_peer_webwith the same peer and plugin returns a named connection and a local loopbackweb_url. Open it in the browser. Clicking Add 1 from browser changes the remote counter to 42. A remotegetthen returns 42.
For example, ask your agent:
Discover Hyperlocal peers and connect to Studio Mac's hosted counter.
Inspect its functions, open its web UI, and increment the remote counter by 41.
Use its current state; do not download or reconnect the component.
The proxy preserves root-relative paths and queries, so /app.js and browser
fetch('/api/...') work. It creates no local component instance and writes no
download files. Calling open_peer_web again for the same peer, plugin and
digest reuses the proxy. close_peer_web closes only your local proxy. The remote
instance continues running. All proxies close when your receiving server exits.
Web proxies expose GET /.hyperlocal/connection locally, containing the peer
name, address, plugin id and digest. The demo uses this to show the peer name in
its badge and browser tab title. Other plugins may use it as optional UI context.
This path is reserved on peer proxies; ordinary local plugins own their own paths.
Calls and proxies pin the inspected component digest. If the publisher replaces a plugin with different code, old calls/proxies fail with a version-change error. Inspect again and reopen the proxy to accept the replacement. Reconnecting the same component on the publisher can still reset its state without changing its digest. A failed remote request is not a transaction rollback; mutations may have completed before a timeout or dropped response. Automatic retries are disabled.
The machine gateway rejects browser Origin/Fetch-Metadata headers and requires JSON POSTs. Browser access uses the origin-checked loopback proxy, which strips ambient cookies, Authorization, Origin, Referer and transport headers before forwarding; remote Set-Cookie responses are dropped. Remote HTML/JavaScript is still untrusted web content. UI responses remain buffered, with no WebSockets or streaming. Remote function requests are limited to 1 MiB and replies to 8 MiB; web requests retain the existing 1 MiB input / 8 MiB output body limits.
The additional HTTP endpoints exist only with --share-live:
GET /v1/plugins/<id>: WIT and function metadata, without local filesystem paths.POST /v1/plugins/<id>/call: pinned function path and named arguments.POST /v1/plugins/<id>/web: pinned WIT request/response envelope, used by proxies.
A plugin is a Component Model .wasm binary with embedded interface types.
Component text (.wat) is also accepted for easy experimentation. Core Wasm
modules alone are insufficient. No adjacent .wit or TOML file is read at runtime.
inspect_plugin reconstructs WIT from the component and returns function paths,
parameter names, type descriptions, results, and a callable flag. This does not
recover source comments or examples absent from the component. Functions inside
exported interfaces and root exported functions are both supported.
For example, the calculator fixture exposes this interface:
package example:math@0.1.0;
interface calculator {
record pair {
first: u32,
second: u32,
}
add: func(a: u32, b: u32) -> u32;
second: func(pair: pair) -> u32;
}The readable fixture in fixtures/calculator.wat needs no guest-language
compiler. The tests also turn it into a binary component and connect that binary.
For your own plugins, build with a Component Model toolchain and export your
application's typed WIT functions directly.
A component can also export agent:web/handler@0.1.0 with the handle function
specified in wit/web.wit. This is an optional WIT convention;
no manifest or host import is needed.
handle: func(request: request) -> response;Requests contain method, path (including the query), headers, and a byte
body. Responses contain status, headers, and a byte body. Return HTML,
JavaScript, CSS, images, or API responses with the appropriate content-type.
The component owns routing and embeds or generates its assets. The host never
serves files from the plugin's directory.
As soon as connect_plugin succeeds, a web-capable component is mounted on its
own http://127.0.0.1:<port>/ origin. The result includes its actual web_url;
list_plugins and inspect_plugin expose it too. Root-relative asset paths and
browser fetch('/api/...') work normally. Ordinary components return null for
web_url and do not open listeners.
Try the self-contained demo:
{"id":"web-demo","path":"/absolute/path/to/fixtures/web-demo.wat"}Open the returned web_url in your browser. The component serves a page at /,
JavaScript at /app.js, and a shared counter through /api/counter,
/api/increment, and /api/reset. It also retains /api/info and /api/echo
for basic HTTP experiments.
The editable assets live in examples/web-demo/; run
python3 examples/web-demo/build.py to regenerate the fixture after editing them.
Python is only needed to rebuild the fixture, not to run it.
The mount remains available between MCP calls for the life of the MCP server. Replacing a web plugin with another web plugin under the same id preserves its URL and serves the new component. A failed connection leaves the existing mount intact. Replacing it with a component without the web export closes its listener. Stopping the MCP server closes all mounts. A new server session gets new ports.
Run just foodcourt for a multi-vendor food court:
Kiez Tacos, Mitte Ramen, Spree Spritz, and Kreuzberg Taproom each advertise as
independent peers with their own menus, websites, and live order ledgers.
Use just run guest to discover them and compare menus or place simulated orders.
For a second named peer, run just mcp shoes (or just run shoes for its own
Codex agent). Kiez Kicks hosts a sneaker catalog,
live stock by size, and pickup reservations on port 47833, alongside Forno Pizza
on port 47831. Discover either business by name from the same guest agent.
With just installed, use just mcp restaurant to launch the restaurant and
just run guest to open Codex CLI with the guest MCP server configured for that
session. just mcp guest provides the plain guest MCP stdio server for other
harnesses; just demo runs the interactive two-process demo.
Use just run restaurant instead of just mcp restaurant to host the restaurant
inside a Codex CLI session whose agent can read incoming orders from that same
live component.
Run cargo build --locked followed by python3 examples/pizzeria/demo.py to
start a named “Forno Pizza” restaurant peer and a separate guest MCP server.
Open the printed menu URL, then type order in the launcher to place a
simulated Hawaii order through the peer function. Its receipt appears in the
browser using the same live WASM state. The menu also includes Berghain, a
black-dough pizza. No payment or real kitchen dispatch takes place.
See the pizzeria guide for a two-device Wi-Fi demo, the WIT API, and component build instructions.
Each connected plugin owns one live Wasmtime store and component instance. MCP calls and HTTP requests acquire the same per-plugin lock before invoking exports. Guest globals and linear memory survive calls and page reloads. Calls to different plugins remain independent; calls to the same plugin run one at a time. The state is in the WASM guest, not copied into a host-side counter.
The demo exports example:counter/state@0.1.0
alongside its web handler:
get: func() -> u32;
increment: func(amount: u32) -> u32;
reset: func() -> u32;To demonstrate both directions:
-
Connect
fixtures/web-demo.watascounterand open itsweb_url. -
Call
call_pluginwith:{ "id": "counter", "function": ["example:counter/state@0.1.0", "increment"], "arguments": {"amount": 41} } -
The page displays 41 automatically (it polls once per second).
-
Click Add 1 from browser. The page displays 42.
-
Call the same interface's
getwith empty arguments{}. It returns{"results":[42]}. Browser Reset and the typedresetfunction both reset the same counter to zero. The counter uses u32 wrapping arithmetic.
For the installed Codex MCP server, restart your CLI session to load the rebuilt binary, then ask:
Connect fixtures/web-demo.wat as counter using hyperlocal. Show its web_url,
inspect its counter interface, and increment the counter by 41. After I change
it in the browser, read the counter again without reconnecting the plugin.
Connecting compiles, validates, and instantiates the new component before it replaces the old one. Reconnecting under the same id deliberately resets guest state, while preserving a web-to-web replacement's URL. A failed replacement preserves both the old state and mount. Already admitted calls finish on their original instance. State does not survive a server restart and is not saved to disk.
Fuel is refilled for every call; memory limits apply to the retained instance. Guest allocators must free transient allocations. The demo uses a canonical post-return hook to reclaim HTTP request memory without resetting the counter.
A guest trap, fuel exhaustion, or another failure during Func::call marks only
that plugin instance as faulted. Later calls report that reconnection is required;
the host never silently reinitializes it. Reconnect explicitly to recover and
reset state. Invalid arguments rejected before execution do not fault the plugin,
and a normal WIT result::err remains an ordinary return value. Guest mutations
before a failure are not rolled back.
Listeners bind only to loopback. Host and browser-origin checks reject unrelated
origins; each plugin has its own origin, although loopback ports are not a cookie
isolation boundary. Request bodies are limited to 1 MiB and buffered for at most
10 seconds; response bodies are limited to 8 MiB. Headers are UTF-8 strings.
Transport-framing and CORS response headers are host-controlled. Requests and
responses are buffered; this interface does not support streaming or WebSockets.
This custom interface is not wasi:http/incoming-handler.
| WIT value | JSON encoding |
|---|---|
bool, small integers, finite floats, string |
Corresponding JSON scalar |
s64, u64 |
Decimal strings on output; strings or integer JSON numbers on input |
char |
String containing one Unicode scalar |
list<T>, tuple |
Array |
| record | Object with exactly its declared fields |
| enum | Case name string |
| flags | Array of unique flag names |
option<T> |
{"none":null} or {"some":value} |
| result | {"ok":value} or {"err":value}; absent payload is null |
| variant | {"case-name":value}; absent payload is null |
For example, some(none) becomes {"some":{"none":null}}. Lists of bytes
are integer arrays. Non-finite float results produce a tool error rather than
silently becoming JSON null. Use strings when sending 64-bit integers from a
JavaScript client to avoid rounding before the request reaches the server.
- Self-contained components only. All host imports, including WASI imports, are rejected on connection with their names in the error. No outbound HTTP, filesystem, credentials, sockets, or process bindings are installed.
- Each plugin retains one live instance shared by MCP and HTTP. State is in-memory only.
- Resources, maps, futures, streams, and error-context values are not callable.
Functions using them remain discoverable with
callable: false. - Each call gets 10 million fuel units. Each retained instance has a 64 MiB limit per guest linear memory, at most 10 memories, and bounded tables/instances. Instantiation has its own 10 million fuel unit budget. Component input files are limited to 32 MiB. These limits do not bound compilation time, host-side JSON allocation, or total process memory.
- No JavaScript orchestration tool, host capability API, plugin dependency linking, disk persistence of guest state, signatures, pairing, or public registries yet.
Loading, inspecting, and calling run on blocking workers; registry locks are only
held during lookup or publication. MCP errors from plugin operations are returned
as tool results with isError: true, leaving the server available for further calls.
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
cargo fmt --checkTests cover dynamic interface discovery, binary and text components, named record arguments, Unicode strings and byte lists, memory limits, connecting multiple plugins, atomic replacement, invalid arguments, unbound imports, traps, fuel exhaustion, composite value conversion, and a real stdio MCP session. HTTP tests cover mounted assets and APIs, POST bodies, queries, separate origins, hot replacement, origin checks, explicit recovery after traps, and listener cleanup. Shared-state tests mix MCP and HTTP updates, verify concurrent increments, refill fuel without losing globals, reclaim transient HTTP memory, and check that reconnection resets state while failed replacement preserves it. Tests need permission to bind and connect to local sockets. Peer tests cover immutable byte transfer, digest/version failures, corrupt caches, non-component rejection, explicit execution, and a full MCP download/connect flow. Live-peer tests verify named inspection and results, remote/browser shared state, default-off access, version pinning, browser gateway rejection, credential stripping, and proxy cleanup. The actual multicast test is opt-in because it needs a suitable LAN interface:
cargo test --locked --test peers mdns_discovers_a_live_advertised_peer -- --ignored