pi-webmcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,176 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
+ import { NodeHttpServer } from "@effect/platform-node";
3
+ import { Context, Effect, FileSystem, Layer, Path, Ref, Scope } from "effect";
4
+ import { HttpPlatform, HttpServer, HttpServerRequest, HttpServerRespondable, HttpServerResponse, HttpStaticServer } from "effect/unstable/http";
5
+ import { PiContext } from "./PiApi";
6
+ import { PiWebMcpToolStateService } from "./PiWebMcpToolStateService";
7
+
8
+ export type PiWebMcpServeParams = {
9
+ readonly path: string;
10
+ };
11
+
12
+ type Mount = {
13
+ readonly root: string;
14
+ readonly kind: "file" | "directory";
15
+ readonly fileName?: string;
16
+ };
17
+
18
+ function withCorsOrigins(
19
+ request: HttpServerRequest.HttpServerRequest,
20
+ response: HttpServerResponse.HttpServerResponse,
21
+ allowedOrigins: ReadonlySet<string>,
22
+ ) {
23
+ const requestOrigin = request.headers.origin;
24
+
25
+ if (!requestOrigin || !allowedOrigins.has(requestOrigin)) {
26
+ return response;
27
+ }
28
+
29
+ return HttpServerResponse.setHeaders(response, {
30
+ "Access-Control-Allow-Origin": requestOrigin,
31
+ "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
32
+ "Access-Control-Allow-Headers": "Range, Content-Type, Accept, Origin",
33
+ "Access-Control-Expose-Headers": "Content-Length, Content-Range, Accept-Ranges, ETag, Last-Modified",
34
+ "Vary": "Origin",
35
+ });
36
+ }
37
+
38
+ export class PiWebMcpServeService extends Context.Service<PiWebMcpServeService, {
39
+ readonly execute: (params: PiWebMcpServeParams) => Effect.Effect<AgentToolResult<unknown>, never, PiContext>;
40
+ }>()("pi-webmcp/PiWebMcpServeService") {
41
+ static readonly live = Layer.effect(
42
+ PiWebMcpServeService,
43
+ Effect.gen(function*() {
44
+ const scope = yield* Scope.Scope;
45
+ const fileSystem = yield* FileSystem.FileSystem;
46
+ const path = yield* Path.Path;
47
+ const httpPlatform = yield* HttpPlatform.HttpPlatform;
48
+ const piContext = yield* PiContext;
49
+ const toolState = yield* PiWebMcpToolStateService;
50
+ const mountsRef = yield* Ref.make<Mount[]>([]);
51
+
52
+ const app = Effect.gen(function*() {
53
+ const request = yield* HttpServerRequest.HttpServerRequest;
54
+ const stagedTools = yield* toolState.staged;
55
+ const committedTools = yield* toolState.committed;
56
+ const allowedOrigins = new Set([...committedTools, ...stagedTools].map((tool) => `https://${tool.origin}`));
57
+
58
+ if (request.method === "OPTIONS") {
59
+ return withCorsOrigins(request, HttpServerResponse.empty({ status: 204 }), allowedOrigins);
60
+ }
61
+
62
+ if (request.method !== "GET" && request.method !== "HEAD") {
63
+ return HttpServerResponse.text("Method not allowed", { status: 405 });
64
+ }
65
+
66
+ const url = new URL(request.url, "http://localhost");
67
+ const index = Number(url.pathname.split("/").filter(Boolean)[0]);
68
+ const mounts = yield* Ref.get(mountsRef);
69
+ const mount = Number.isInteger(index) && index >= 0 ? mounts[index] : undefined;
70
+
71
+ if (!mount) {
72
+ return withCorsOrigins(request, HttpServerResponse.text("Not found", { status: 404 }), allowedOrigins);
73
+ }
74
+
75
+ const prefix = `/${index}`;
76
+ const suffix = url.pathname === prefix ? "/" : url.pathname.slice(prefix.length);
77
+ let rewrittenUrl: string;
78
+
79
+ if (mount.kind === "directory") {
80
+ rewrittenUrl = `${suffix}${url.search}`;
81
+ } else if (suffix === "/" || suffix === "") {
82
+ rewrittenUrl = `/${encodeURIComponent(mount.fileName!)}${url.search}`;
83
+ } else if (decodeURIComponent(suffix.startsWith("/") ? suffix.slice(1) : suffix) === mount.fileName) {
84
+ rewrittenUrl = `${suffix}${url.search}`;
85
+ } else {
86
+ return withCorsOrigins(request, HttpServerResponse.text("Forbidden", { status: 403 }), allowedOrigins);
87
+ }
88
+
89
+ const staticApp = yield* HttpStaticServer.make({ root: mount.root });
90
+ const rewrittenRequest = request.modify({ url: rewrittenUrl });
91
+ const response = yield* staticApp.pipe(
92
+ Effect.provideService(HttpServerRequest.HttpServerRequest, rewrittenRequest),
93
+ Effect.catch((error: unknown) => HttpServerRespondable.toResponseOrElse(error, HttpServerResponse.text("Not found", { status: 404 }))),
94
+ );
95
+
96
+ return withCorsOrigins(request, response, allowedOrigins);
97
+ });
98
+
99
+ const startServer = yield* Effect.cached(Effect.gen(function*() {
100
+ const serverContext = yield* Layer.buildWithScope(NodeHttpServer.layerTest, scope);
101
+ const server = Context.get(serverContext, HttpServer.HttpServer);
102
+
103
+ yield* server.serve(app).pipe(
104
+ Effect.provideService(FileSystem.FileSystem, fileSystem),
105
+ Effect.provideService(Path.Path, path),
106
+ Effect.provideService(HttpPlatform.HttpPlatform, httpPlatform),
107
+ Effect.provideService(PiContext, piContext),
108
+ Scope.provide(scope),
109
+ );
110
+
111
+ if (server.address._tag === "UnixAddress") {
112
+ return yield* Effect.die("webmcp_serve requires a TCP HTTP server address");
113
+ }
114
+
115
+ return { origin: `http://localhost:${server.address.port}` };
116
+ }));
117
+
118
+ return PiWebMcpServeService.of({
119
+ execute: Effect.fn("PiWebMcpServeService.execute")(
120
+ function*(params: PiWebMcpServeParams) {
121
+ const ctx = yield* PiContext;
122
+ const server = yield* startServer;
123
+ const resolvedPath = path.isAbsolute(params.path)
124
+ ? params.path
125
+ : path.resolve(ctx.cwd, params.path);
126
+ const stat = yield* fileSystem.stat(resolvedPath);
127
+
128
+ if (stat.type !== "File" && stat.type !== "Directory") {
129
+ return {
130
+ content: [{ type: "text" as const, text: `Cannot serve ${resolvedPath}: path is not a file or directory.` }],
131
+ details: {},
132
+ };
133
+ }
134
+
135
+ const mount: Mount = stat.type === "File"
136
+ ? {
137
+ root: path.dirname(resolvedPath),
138
+ kind: "file",
139
+ fileName: path.basename(resolvedPath),
140
+ }
141
+ : {
142
+ root: resolvedPath,
143
+ kind: "directory",
144
+ };
145
+
146
+ const id = yield* Ref.modify(mountsRef, (mounts) => {
147
+ const next = [...mounts, mount];
148
+ return [String(next.length - 1), next] as const;
149
+ });
150
+
151
+ return {
152
+ content: [{
153
+ type: "text" as const,
154
+ text: `Serving ${mount.kind} at ${
155
+ mount.kind === "file"
156
+ ? `${server.origin}/${id}/${encodeURIComponent(mount.fileName!)}`
157
+ : `${server.origin}/${id}/`
158
+ }`,
159
+ }],
160
+ details: {},
161
+ };
162
+ },
163
+ (effect) =>
164
+ effect.pipe(
165
+ Effect.catch((cause: unknown) =>
166
+ Effect.succeed({
167
+ content: [{ type: "text" as const, text: `Failed to serve path: ${cause instanceof Error ? cause.message : String(cause)}` }],
168
+ details: {},
169
+ })
170
+ ),
171
+ ),
172
+ ),
173
+ });
174
+ }),
175
+ );
176
+ }
@@ -0,0 +1,57 @@
1
+ import { getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
2
+ import { Context, Effect, Layer, Option, Schema } from "effect";
3
+ import { Origin } from "../schemas/WebMcpTool";
4
+ import { PiContext } from "./PiApi";
5
+
6
+ export class PiWebMcpSettings extends Schema.Class<PiWebMcpSettings>("pi-webmcp/PiWebMcpSettings")({
7
+ allowedOrigins: Schema.optionalKey(Schema.Array(Schema.String)),
8
+ disallowedOrigins: Schema.optionalKey(Schema.Array(Schema.String)),
9
+ }) {}
10
+
11
+ class PiSettings extends Schema.Class<PiSettings>("pi-webmcp/PiSettings")({
12
+ webmcp: Schema.optionalKey(PiWebMcpSettings),
13
+ }) {}
14
+
15
+ export class PiWebMcpSettingsService extends Context.Service<PiWebMcpSettingsService, {
16
+ readonly allowedOrigins: Option.Option<ReadonlySet<Origin>>;
17
+ readonly disallowedOrigins: Option.Option<ReadonlySet<Origin>>;
18
+ }>()("pi-webmcp/PiWebMcpSettingsService") {
19
+ static readonly live = Layer.effect(
20
+ PiWebMcpSettingsService,
21
+ Effect.gen(function*() {
22
+ const ctx = yield* PiContext;
23
+ const manager = SettingsManager.create(ctx.cwd, getAgentDir());
24
+
25
+ const normalizeOrigin = (origin: string): Origin => {
26
+ try {
27
+ const url = new URL(origin.includes("://") ? origin : `https://${origin}`);
28
+ return Schema.decodeSync(Origin)(url.host);
29
+ } catch {
30
+ return Schema.decodeSync(Origin)(origin);
31
+ }
32
+ };
33
+
34
+ const normalizeOrigins = (origins: ReadonlyArray<string>): ReadonlySet<Origin> => {
35
+ return new Set(origins.map(normalizeOrigin));
36
+ };
37
+
38
+ const globalSettings = yield* Schema.decodeUnknownEffect(PiSettings)(manager.getGlobalSettings());
39
+ const projectSettings = yield* Schema.decodeUnknownEffect(PiSettings)(manager.getProjectSettings());
40
+
41
+ const globalWebMcp = globalSettings.webmcp;
42
+ const projectWebMcp = projectSettings.webmcp;
43
+
44
+ const allowedOrigins = Option.map(
45
+ Option.fromUndefinedOr(projectWebMcp?.allowedOrigins ?? globalWebMcp?.allowedOrigins),
46
+ (origins) => normalizeOrigins(origins),
47
+ );
48
+
49
+ const disallowedOrigins = Option.map(
50
+ Option.fromUndefinedOr(projectWebMcp?.disallowedOrigins ?? globalWebMcp?.disallowedOrigins),
51
+ (origins) => normalizeOrigins(origins),
52
+ );
53
+
54
+ return PiWebMcpSettingsService.of({ allowedOrigins, disallowedOrigins });
55
+ }),
56
+ );
57
+ }
@@ -0,0 +1,67 @@
1
+ import { Context, Effect, Layer, Option } from "effect";
2
+ import { WebMcpTool } from "../schemas/WebMcpTool";
3
+ import { BrowserClient } from "./BrowserClient";
4
+ import { PiContext } from "./PiApi";
5
+ import { PiWebMcpToolStateService } from "./PiWebMcpToolStateService";
6
+ import { WebMcpToolDiffService } from "./WebMcpToolDiffService";
7
+
8
+ function formatTools(tools: WebMcpTool[], options: { readonly includeDescription: boolean; readonly unavailableOriginLabel?: string; }) {
9
+ if (tools.length === 0) return "none";
10
+
11
+ const groups: WebMcpTool[][] = [];
12
+ for (const tool of tools) {
13
+ const group = groups.find((group) => group[0]?.origin === tool.origin);
14
+ if (group) {
15
+ group.push(tool);
16
+ } else {
17
+ groups.push([tool]);
18
+ }
19
+ }
20
+
21
+ return groups.map((list) => {
22
+ const origin = list[0]!.origin;
23
+ const body = list
24
+ .sort((a, b) => a.name.localeCompare(b.name))
25
+ .map((tool) => {
26
+ const description = options.includeDescription && tool.description ? ` ${tool.description}` : "";
27
+ return `- **${tool.name}**${description}`;
28
+ })
29
+ .join("\n");
30
+
31
+ const originLabel = options.unavailableOriginLabel ? `${origin} (${options.unavailableOriginLabel})` : origin;
32
+ return `${originLabel}\n${body}`;
33
+ }).join("\n\n");
34
+ }
35
+
36
+ export class PiWebMcpSystemPromptService extends Context.Service<PiWebMcpSystemPromptService, {
37
+ readonly getSystemPrompt: () => Effect.Effect<string | undefined, never, PiContext>;
38
+ }>()("pi-webmcp/PiWebMcpSystemPromptService") {
39
+ static readonly live = Layer.effect(
40
+ PiWebMcpSystemPromptService,
41
+ Effect.gen(function*() {
42
+ const browser = yield* BrowserClient;
43
+ const toolState = yield* PiWebMcpToolStateService;
44
+ const toolDiff = yield* WebMcpToolDiffService;
45
+
46
+ return PiWebMcpSystemPromptService.of({
47
+ getSystemPrompt: Effect.fn("PiWebMcpSystemPromptService.getSystemPrompt")(function*() {
48
+ const cdp = yield* browser.get;
49
+
50
+ if (Option.isNone(cdp)) {
51
+ return "WebMCP connection is not live. No WebMCP tools are currently available.";
52
+ }
53
+
54
+ const staged = yield* toolState.staged;
55
+ const committed = yield* toolState.committed;
56
+ const diff = toolDiff.diff(committed, staged);
57
+
58
+ if (!toolDiff.hasDiff(diff)) return undefined;
59
+
60
+ return `WebMCP connection is live.\n\nNew tools available:\n\n${formatTools(diff.added, { includeDescription: true })}\n\nTools no longer available:\n\n${
61
+ formatTools(diff.removed, { includeDescription: false, unavailableOriginLabel: "none remain" })
62
+ }\n\nKeep a running internal ledger of the WebMCP tool changes listed above and prefer it over calling webmcp_list. Only call webmcp_list when you are genuinely confused about which tools are available or need the full grouped listing; it does not actively scan the browser.`;
63
+ }),
64
+ });
65
+ }),
66
+ );
67
+ }
@@ -0,0 +1,49 @@
1
+ import { Context, Effect, Layer, Option, Ref, Result, Schema } from "effect";
2
+ import { WebMcpTool, WebMcpTools } from "../schemas/WebMcpTool";
3
+ import { PiContext } from "./PiApi";
4
+
5
+ export class PiWebMcpToolStateService extends Context.Service<PiWebMcpToolStateService, {
6
+ readonly stage: (tools: WebMcpTool[]) => Effect.Effect<void>;
7
+ readonly staged: Effect.Effect<WebMcpTool[]>;
8
+ readonly commit: () => Effect.Effect<Option.Option<WebMcpTool[]>>;
9
+ readonly committed: Effect.Effect<WebMcpTool[], never, PiContext>;
10
+ }>()("pi-webmcp/PiWebMcpToolStateService") {
11
+ static readonly live = Layer.effect(
12
+ PiWebMcpToolStateService,
13
+ Effect.gen(function*() {
14
+ const stagedRef = yield* Ref.make<Option.Option<WebMcpTool[]>>(Option.none());
15
+
16
+ return PiWebMcpToolStateService.of({
17
+ stage: Effect.fn("PiWebMcpToolStateService.stage")(function*(tools: WebMcpTool[]) {
18
+ yield* Ref.set(stagedRef, Option.some(tools));
19
+ }),
20
+ staged: Effect.gen(function*() {
21
+ const tools = yield* Ref.get(stagedRef);
22
+ return Option.getOrElse(tools, () => []);
23
+ }),
24
+ commit: Effect.fn("PiWebMcpToolStateService.commit")(function*() {
25
+ return yield* Ref.getAndSet(stagedRef, Option.none());
26
+ }),
27
+ committed: Effect.gen(function*() {
28
+ const ctx = yield* PiContext;
29
+ const branch = ctx.sessionManager.getBranch();
30
+
31
+ for (let index = branch.length - 1; index >= 0; index--) {
32
+ const entry = branch[index];
33
+ if (entry.type !== "message") continue;
34
+ if (entry.message?.role !== "user") continue;
35
+
36
+ const details = entry.message as typeof entry.message & { details?: { webmcp?: { tools?: unknown; }; }; };
37
+ const result = Schema.decodeUnknownResult(WebMcpTools)(details.details?.webmcp?.tools);
38
+
39
+ if (Result.isSuccess(result)) {
40
+ return [...result.success];
41
+ }
42
+ }
43
+
44
+ return [];
45
+ }),
46
+ });
47
+ }),
48
+ );
49
+ }
@@ -0,0 +1,157 @@
1
+ import { Context, Effect, Layer, Queue, Result, Schema, Stream } from "effect";
2
+ import { WebMcpTool } from "../schemas/WebMcpTool";
3
+ import { BrowserClient } from "./BrowserClient";
4
+
5
+ export const WebMcpEventToolAdded = Schema.TaggedStruct("WebMcpEventToolAdded", {
6
+ sessionId: Schema.String,
7
+ tool: WebMcpTool,
8
+ });
9
+
10
+ export const WebMcpEventToolRemoved = Schema.TaggedStruct("WebMcpEventToolRemoved", {
11
+ sessionId: Schema.String,
12
+ frameId: Schema.String,
13
+ name: Schema.String,
14
+ });
15
+
16
+ export const WebMcpEventSessionCleared = Schema.TaggedStruct("WebMcpEventSessionCleared", {
17
+ sessionId: Schema.String,
18
+ });
19
+
20
+ export const WebMcpEvent = Schema.Union([
21
+ WebMcpEventToolAdded,
22
+ WebMcpEventToolRemoved,
23
+ WebMcpEventSessionCleared,
24
+ ]);
25
+
26
+ export type WebMcpEvent = typeof WebMcpEvent.Type;
27
+
28
+ class TargetInfo extends Schema.Class<TargetInfo>("TargetInfo")({
29
+ targetId: Schema.String,
30
+ title: Schema.String,
31
+ url: Schema.URLFromString,
32
+ type: Schema.String,
33
+ attached: Schema.optionalKey(Schema.Boolean),
34
+ }) {}
35
+
36
+ function isPageTarget(target: TargetInfo) {
37
+ return target.type === "page" && target.url.protocol !== "chrome:" && target.url.protocol !== "devtools:";
38
+ }
39
+
40
+ export class WebMcpEventService extends Context.Service<WebMcpEventService, {
41
+ readonly changes: Stream.Stream<WebMcpEvent, never, never>;
42
+ }>()("pi-webmcp/WebMcpEventService") {
43
+ static readonly liveWithoutDependencies = Layer.effect(
44
+ WebMcpEventService,
45
+ Effect.gen(function*() {
46
+ const browser = yield* BrowserClient;
47
+
48
+ const setup = Effect.gen(function*() {
49
+ const cdp = yield* browser.connect();
50
+ const targetBySession = new Map<string, TargetInfo>();
51
+
52
+ return Stream.callback<WebMcpEvent>((queue) => {
53
+ return Effect.gen(function*() {
54
+ const attachTarget = async (target: TargetInfo) => {
55
+ if (!isPageTarget(target) || target.attached) return;
56
+ try {
57
+ const { sessionId } = await cdp.send("Target.attachToTarget", { targetId: target.targetId, flatten: true });
58
+ targetBySession.set(sessionId, target);
59
+ await cdp.send("Page.enable", {}, sessionId);
60
+ await cdp.send("WebMCP.enable", {}, sessionId);
61
+ } catch {
62
+ // Tab may have closed between discovery and attach
63
+ }
64
+ };
65
+
66
+ const onToolsAdded = (ev: any, sessionId?: string) => {
67
+ if (!sessionId) return;
68
+ const target = targetBySession.get(sessionId);
69
+ if (!target) throw new Error(`Missing target info for WebMCP session: ${sessionId}`);
70
+ const origin = target.url.host;
71
+ for (const tool of ev.tools ?? []) {
72
+ const result = Schema.decodeUnknownResult(WebMcpTool)({ ...tool, origin, sessionId });
73
+ if (Result.isFailure(result)) continue;
74
+ Queue.offerUnsafe(queue, WebMcpEventToolAdded.make({ sessionId, tool: result.success }));
75
+ }
76
+ };
77
+
78
+ const onToolsRemoved = (ev: any, sessionId?: string) => {
79
+ if (!sessionId) return;
80
+ for (const removed of ev.tools ?? []) {
81
+ const name = removed?.name;
82
+ const frameId = removed?.frameId;
83
+ if (!name || !frameId) continue;
84
+ Queue.offerUnsafe(queue, WebMcpEventToolRemoved.make({ sessionId, frameId, name }));
85
+ }
86
+ };
87
+
88
+ const onFrameNavigated = (ev: any, sessionId?: string) => {
89
+ if (!sessionId || ev.frame?.parentId) return;
90
+ Queue.offerUnsafe(queue, WebMcpEventSessionCleared.make({ sessionId }));
91
+ cdp.send("WebMCP.enable", {}, sessionId).catch(() => {});
92
+ };
93
+
94
+ const onDetachedFromTarget = ({ sessionId }: { sessionId?: string; }) => {
95
+ if (!sessionId) return;
96
+ targetBySession.delete(sessionId);
97
+ Queue.offerUnsafe(queue, WebMcpEventSessionCleared.make({ sessionId }));
98
+ };
99
+
100
+ const onTargetCreated = ({ targetInfo }: { targetInfo?: unknown; }) => {
101
+ if (!targetInfo) return;
102
+ const result = Schema.decodeUnknownResult(TargetInfo)(targetInfo);
103
+ if (Result.isFailure(result)) return;
104
+ void attachTarget(result.success);
105
+ };
106
+
107
+ const onTargetInfoChanged = ({ targetInfo }: { targetInfo?: unknown; }) => {
108
+ if (!targetInfo) return;
109
+ const result = Schema.decodeUnknownResult(TargetInfo)(targetInfo);
110
+ if (Result.isFailure(result)) return;
111
+ void attachTarget(result.success);
112
+ };
113
+
114
+ yield* Effect.acquireRelease(
115
+ Effect.gen(function*() {
116
+ yield* Effect.sync(() => cdp.on("WebMCP.toolsAdded", onToolsAdded));
117
+ yield* Effect.sync(() => cdp.on("WebMCP.toolsRemoved", onToolsRemoved));
118
+ yield* Effect.sync(() => cdp.on("Page.frameNavigated", onFrameNavigated));
119
+ yield* Effect.sync(() => cdp.on("Target.detachedFromTarget", onDetachedFromTarget));
120
+ yield* Effect.sync(() => cdp.on("Target.targetCreated", onTargetCreated));
121
+ yield* Effect.sync(() => cdp.on("Target.targetInfoChanged", onTargetInfoChanged));
122
+
123
+ yield* Effect.tryPromise(() => cdp.send("Target.setDiscoverTargets", { discover: true }));
124
+
125
+ const { targetInfos } = yield* Effect.tryPromise(() => cdp.send("Target.getTargets"));
126
+ const pages = targetInfos?.flatMap((targetInfo: unknown) => {
127
+ const result = Schema.decodeUnknownResult(TargetInfo)(targetInfo);
128
+ return Result.isSuccess(result) && isPageTarget(result.success) ? [result.success] : [];
129
+ }) ?? [];
130
+ for (const target of pages) {
131
+ yield* Effect.tryPromise(() => attachTarget(target)).pipe(Effect.ignore);
132
+ }
133
+ }).pipe(Effect.ignore),
134
+ () =>
135
+ Effect.sync(() => {
136
+ cdp.off!("WebMCP.toolsAdded", onToolsAdded);
137
+ cdp.off!("WebMCP.toolsRemoved", onToolsRemoved);
138
+ cdp.off!("Page.frameNavigated", onFrameNavigated);
139
+ cdp.off!("Target.detachedFromTarget", onDetachedFromTarget);
140
+ cdp.off!("Target.targetCreated", onTargetCreated);
141
+ cdp.off!("Target.targetInfoChanged", onTargetInfoChanged);
142
+ }),
143
+ );
144
+ }).pipe(Effect.ignore);
145
+ });
146
+ });
147
+
148
+ return WebMcpEventService.of({
149
+ changes: Stream.unwrap(setup.pipe(Effect.orDie)),
150
+ });
151
+ }),
152
+ );
153
+
154
+ static readonly live = WebMcpEventService.liveWithoutDependencies.pipe(
155
+ Layer.provide(BrowserClient.live),
156
+ );
157
+ }
@@ -0,0 +1,28 @@
1
+ import { Context, Layer } from "effect";
2
+ import { WebMcpTool } from "../schemas/WebMcpTool";
3
+
4
+ export type WebMcpToolDiff = {
5
+ readonly added: WebMcpTool[];
6
+ readonly removed: WebMcpTool[];
7
+ };
8
+
9
+ export class WebMcpToolDiffService extends Context.Service<WebMcpToolDiffService, {
10
+ readonly diff: (previous: WebMcpTool[], next: WebMcpTool[]) => WebMcpToolDiff;
11
+ readonly hasDiff: (diff: WebMcpToolDiff) => boolean;
12
+ }>()("pi-webmcp/WebMcpToolDiffService") {
13
+ static readonly live = Layer.succeed(
14
+ WebMcpToolDiffService,
15
+ WebMcpToolDiffService.of({
16
+ diff: (previous, next) => {
17
+ const previousHashes = new Set(previous.map((tool) => tool.hash));
18
+ const nextHashes = new Set(next.map((tool) => tool.hash));
19
+
20
+ return {
21
+ added: next.filter((tool) => !previousHashes.has(tool.hash)),
22
+ removed: previous.filter((tool) => !nextHashes.has(tool.hash)),
23
+ };
24
+ },
25
+ hasDiff: (diff) => diff.added.length > 0 || diff.removed.length > 0,
26
+ }),
27
+ );
28
+ }
@@ -0,0 +1,46 @@
1
+ import { Context, Effect, Layer, Match, Stream } from "effect";
2
+ import { WebMcpTool } from "../schemas/WebMcpTool";
3
+ import { WebMcpEventService } from "./WebMcpEventService";
4
+
5
+ export class WebMcpToolsService extends Context.Service<WebMcpToolsService, {
6
+ readonly changes: Stream.Stream<WebMcpTool[], never, never>;
7
+ }>()("pi-webmcp/WebMcpToolsService") {
8
+ static readonly liveWithoutDependencies = Layer.effect(
9
+ WebMcpToolsService,
10
+ Effect.gen(function*() {
11
+ const events = yield* WebMcpEventService;
12
+
13
+ return WebMcpToolsService.of({
14
+ changes: events.changes.pipe(
15
+ Stream.scan(new Map<string, WebMcpTool>(), (registry: Map<string, WebMcpTool>, event) =>
16
+ Match.valueTags(event, {
17
+ WebMcpEventToolAdded: ({ sessionId, tool }) => {
18
+ const next = new Map(registry);
19
+ next.set(`${sessionId}::${tool.frameId}::${tool.name}`, tool);
20
+ return next;
21
+ },
22
+ WebMcpEventToolRemoved: ({ sessionId, frameId, name }) => {
23
+ const next = new Map(registry);
24
+ next.delete(`${sessionId}::${frameId}::${name}`);
25
+ return next;
26
+ },
27
+ WebMcpEventSessionCleared: ({ sessionId }) => {
28
+ const next = new Map(registry);
29
+ for (const key of registry.keys()) {
30
+ if (key.startsWith(`${sessionId}::`)) {
31
+ next.delete(key);
32
+ }
33
+ }
34
+ return next;
35
+ },
36
+ })),
37
+ Stream.map((registry) => [...registry.values()]),
38
+ ),
39
+ });
40
+ }),
41
+ );
42
+
43
+ static readonly live = WebMcpToolsService.liveWithoutDependencies.pipe(
44
+ Layer.provide(WebMcpEventService.live),
45
+ );
46
+ }
@@ -0,0 +1 @@
1
+ export const agentConnectInstruction = "WebMCP is not connected. Ask the user to run `/webmcp` before using WebMCP tools.";
@@ -0,0 +1,70 @@
1
+ import { type AgentToolResult, getMarkdownTheme, keyText, type Theme, type ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
2
+ import { Markdown, Text } from "@earendil-works/pi-tui";
3
+ import type { Origin, ToolId } from "../schemas/WebMcpTool";
4
+
5
+ export type PiWebMcpRenderCallOptions = {
6
+ readonly toolName: string;
7
+ readonly origin?: Origin;
8
+ readonly webMcpTool?: ToolId;
9
+ readonly target?: string;
10
+ };
11
+
12
+ export const renderPiWebMcpCall = (
13
+ theme: Theme,
14
+ { toolName, origin, webMcpTool, target }: PiWebMcpRenderCallOptions,
15
+ ) => {
16
+ let text = theme.fg("toolTitle", theme.bold(toolName));
17
+
18
+ if (target) {
19
+ text += ` ${theme.fg("toolOutput", target)}`;
20
+ text += ` ${theme.fg("dim", `(${keyText("app.tools.expand")} to expand)`)}`;
21
+ return new Text(text, 0, 0);
22
+ }
23
+
24
+ if (!origin) {
25
+ text += ` ${theme.fg("dim", `(${keyText("app.tools.expand")} to expand)`)}`;
26
+ return new Text(text, 0, 0);
27
+ }
28
+
29
+ text += ` ${theme.fg("accent", origin)}`;
30
+
31
+ if (webMcpTool) {
32
+ text += ` ${theme.fg("dim", ":")} ${theme.fg("toolOutput", webMcpTool)}`;
33
+ }
34
+
35
+ text += ` ${theme.fg("dim", `(${keyText("app.tools.expand")} to expand)`)}`;
36
+
37
+ return new Text(text, 0, 0);
38
+ };
39
+
40
+ const getTextResult = (result: AgentToolResult<unknown>): string => {
41
+ return result.content?.find((c) => c.type === "text")?.text ?? "";
42
+ };
43
+
44
+ export const renderPiWebMcpResult = (
45
+ result: AgentToolResult<unknown>,
46
+ { expanded }: ToolRenderResultOptions,
47
+ ): Text => {
48
+ if (!expanded) return new Text("", 0, 0);
49
+ return new Text(getTextResult(result), 0, 0);
50
+ };
51
+
52
+ export const renderPiWebMcpMarkdownResult = (
53
+ result: AgentToolResult<unknown>,
54
+ { expanded }: ToolRenderResultOptions,
55
+ ) => {
56
+ if (!expanded) return new Text("", 0, 0);
57
+ return new Markdown(getTextResult(result), 0, 0, getMarkdownTheme());
58
+ };
59
+
60
+ export const renderPiWebMcpServeResult = (
61
+ result: AgentToolResult<unknown>,
62
+ { expanded }: ToolRenderResultOptions,
63
+ ): Text => {
64
+ if (!expanded) return new Text("", 0, 0);
65
+ return new Text(`\n${getTextResult(result)}`, 0, 0);
66
+ };
67
+
68
+ export const renderPiWebMcpListMessage = (message: { readonly content: unknown; }) => {
69
+ return new Markdown(typeof message.content === "string" ? message.content : "", 0, 0, getMarkdownTheme());
70
+ };