effect-webmcp
v0.1.3
Published
Effect-native tools and layers for the WebMCP browser API.
Maintainers
Readme
effect-webmcp
Effect-native tools and layers for the WebMCP browser API.
Define each browser tool once with Effect and Schema. WebMcp.layer()
registers it with document.modelContext; WebMcp.layerInMemory() runs the
same registration and execution lifecycle in tests.
Install
pnpm add effect-webmcp effect@rcThe package targets Effect 4 because its schemas, scoped runtime bridges, and
service APIs are used directly. Its supported range starts at
4.0.0-beta.47, the Effect version currently used by Sevenfall.
Examples
Two runnable counter apps show how to register a tool, update the UI from its handler, and unregister it when the UI stops:
examples/tanstack: React + TanStack Router, with a registration scope owned by the counter route.examples/foldkit: Foldkit, with a persistent subscription that sends tool calls through the normal update loop.
From a repository checkout:
pnpm install
pnpm dev:tanstack
# Or, in another terminal:
pnpm dev:foldkitThe buttons work in any supported browser. Agent access requires a host exposing
the document.modelContext API used by this package. See the
examples guide for compatibility, manual tool invocation,
and instructions for copying an example into another project.
Define and register a tool
import { Effect, Schema } from "effect";
import { WebMcp, WebMcpTool } from "effect-webmcp";
const AddTodoInput = Schema.Struct({
text: Schema.NonEmptyString.annotate({
description: "Text for the new todo item",
}),
});
const AddTodoOutput = Schema.Struct({
status: Schema.Literal("created"),
text: Schema.String,
});
const addTodo = WebMcpTool.make({
name: "add-todo",
title: "Add todo",
description: "Adds a new item to the active todo list.",
input: AddTodoInput,
output: AddTodoOutput,
annotations: {
consequentialHint: false,
},
execute: ({ text }) =>
Effect.succeed({
status: "created" as const,
text,
}),
});
const application = Effect.gen(function* () {
const webMcp = yield* WebMcp;
yield* webMcp.serve([addTodo]);
});
application.pipe(
Effect.provide(WebMcp.layer()),
Effect.scoped,
Effect.runPromise,
);input is required so every agent-supplied value is decoded before reaching
application code. Use EmptyWebMcpInput for a no-argument tool.
The optional output schema validates and encodes successful handler results.
Without it, the handler result is passed to the browser unchanged and must
still be JSON serializable.
Dependencies and lifecycle
A handler has the normal Effect.Effect<Output, Error, Requirements> shape.
Its requirements are captured when webMcp.register(tool) runs:
const program = Effect.gen(function* () {
const webMcp = yield* WebMcp;
yield* webMcp.register(toolWithApplicationServices);
});Registration requires Scope. Closing that scope aborts the browser
registration signal, unregistering the tool. A browser cancellation signal
interrupts the handler fiber, so Effect.ensuring, Effect.onInterrupt, and
handler-owned scopes run their cleanup normally.
webMcp.serve(tools) registers every tool and remains active until interrupted.
Run it inside the application's long-lived root scope. UI integrations should
fork the scoped serve effect when mounting and interrupt that fiber when
unmounting.
The lower-level webMcp.register(tool) operation completes after registering
one tool. Do not run and await a scoped effect that only calls register: the
scope then closes immediately and unregisters the tool.
Use exposedTo to share a tool with secure cross-origin frames:
yield* webMcp.register(tool, {
exposedTo: ["https://trusted-agent.example"],
});Delayed browser injection
Browser extensions may inject document.modelContext after application
startup. Use the polling Layer when the context is not guaranteed to exist yet:
application.pipe(
Effect.provide(
WebMcp.layerWhenAvailable({
timeout: "30 seconds",
interval: "500 millis",
}),
),
Effect.scoped,
Effect.runPromise,
);The Layer fails with WebMcpUnavailableError when the timeout expires. Supplying
a known WebMcpModelContext directly is typed as error-free:
WebMcp.layer({ modelContext });Use AnyWebMcpTool for a heterogeneous tool collection when preserving each
tool's individual input and output types is unnecessary:
const tools: ReadonlyArray<AnyWebMcpTool> = [addTodo, anotherTool];Test through the WebMCP interface
The in-memory layer implements registration, discovery, execution, cancellation, duplicate-name checks, JSON serialization, and scoped unregistration.
import { expect, it } from "@effect/vitest";
import { Effect } from "effect";
import { WebMcp } from "effect-webmcp";
it.effect("executes the registered tool", () =>
Effect.gen(function* () {
const webMcp = yield* WebMcp;
yield* webMcp.register(addTodo);
const tools = yield* webMcp.getTools();
const result = yield* webMcp.execute(tools[0]!, { text: "Ship it" });
expect(result).toEqual({ status: "created", text: "Ship it" });
}).pipe(Effect.provide(WebMcp.layerInMemory())),
);RegisteredWebMcpTool values are opaque capabilities. Execute the exact value
returned by getTools() on the same WebMcp service; constructing or copying
one is rejected.
Typed failures
WebMcpUnavailableError:document.modelContextis absent.WebMcpRegistrationError: the browser refused registration, the name is a duplicate, or registration was already aborted.WebMcpDiscoveryError:getTools()failed.WebMcpToolExecutionError: input decoding, the handler, output encoding, cancellation, or JSON serialization failed.
Handler failures are intentionally collapsed into
WebMcpToolExecutionError at the agent/browser boundary. Inside application
services, keep expected failures typed as usual.
