Extracting a Provider Contract from a Multi-Provider WebMCP Implementation
The hard part wasn't registering tools — it was making independently built provider surfaces predictable enough for a consumer to reason about.
The problem wasn't registration
Over the last stretch I built several independent WebMCP providers, and then a single journey that strung four of them together end to end. Registering tools with document.modelContext was never the hard part — that API is small, and it works.
The hard part showed up the moment a consumer — a model, or another provider's orchestration — had to reason about a surface it didn't write. Each provider worked fine in isolation. Put four of them together, authored at different times against different implicit assumptions, and all the friction lived in the seams:
- Is this tool safe to call speculatively, or will it change state and charge a card?
- What arguments does it actually accept — and what happens when I get them slightly wrong?
- After a state-changing call, how do I know it happened, and get a handle to what it did?
- Is there even a WebMCP runtime here, or am I calling into the void?
None of those are registration questions. They're contract questions. And every provider answered them differently, or not at all.
So I extracted the recurring requirements into a small, open, zero-dependency package: @zioladev/provider-tools. It doesn't replace document.modelContext — it's the thin layer of discipline on top that makes a provider surface predictable enough for someone else to build against.
The design choices
Every rule below exists because its absence cost me something concrete across those four providers.
Explicit read vs. state-changing effects. Every tool declares effect: "read" | "state-changing". There's no inference, no guessing from the name. A consumer can look at a tool and know whether calling it is consequence-free or whether it moves money and state. This single distinction is what makes speculative exploration safe.
Required input schemas. Every tool must ship an inputSchema, validated against a supported JSON-Schema subset at registration time — not on first call. A provider can't register a tool whose inputs it never described. The surface is self-describing by construction.
Reject, don't coerce. Invalid input is rejected, never coerced. Send the wrong shape and you get a structured error — not a silently "fixed" value that limps forward and does something subtly wrong three layers down. Coercion hides bugs; rejection surfaces them at the boundary, where they're cheap to fix.
Structured execution evidence. State-changing tools must return a structured ExecutionResult — whether it executed, a confirmation id, and the resulting data — rather than a freeform string. "It worked, probably" is not a contract. A consumer gets a machine-checkable handle to what actually happened.
Runtime diagnostics. The kit detects the WebMCP runtime (document.modelContext, falling back to navigator.modelContext) and degrades to a no-op when there's nothing there, instead of throwing from deep in a call stack. You can tell up front whether you're in a WebMCP-capable context.
No coupling — to my stack or to any model. Provider Tools has zero runtime dependencies and no knowledge of Refraktor, Selvage, or any particular model family. It's provider-side vocabulary only. The contract describes the shape of a provider surface, not who consumes it or what sits downstream. That's deliberate: a contract that only works with my stack isn't a contract, it's a coupling.
The whole thing is Apache-2.0, TypeScript → ESM, and verified live against a WebMCP-enabled Chrome — registration, live tool surface, read and state-changing execution, input rejection, and structured evidence all green. (One useful thing that fell out of the live run: the runtime hands execute its arguments as an object, and executeTool wants a registered-tool handle rather than a name string. Good to know the difference between the mental model and the metal.)
What comes next
Provider Tools is Phase 1: the contract. Writing the contract down is what unlocks everything after it — you can't conform to, or interoperate against, something that was only ever implicit.
Each layer stands on the one above it, and inherits its guarantees — or its gaps. That's the whole reason to nail the contract first.
Provider Tools is deliberately small. The ambition isn't in the package; it's in what a predictable provider surface makes possible once you can actually count on it.
Apache-2.0 · zero-dependency · github.com/zioladev/provider-tools · npm