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.
- package/.github/chrome_allow_remote_debugging.png +0 -0
- package/.github/chrome_enable_remote_debugging.png +0 -0
- package/.github/chrome_webmcp_flags.png +0 -0
- package/.pi/extensions/pi-webmcp.ts +20 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/docs/webmcp-demos.md +18 -0
- package/package.json +63 -0
- package/src/main.ts +212 -0
- package/src/schemas/WebMcpTool.ts +59 -0
- package/src/services/BrowserClient.ts +90 -0
- package/src/services/PiApi.ts +6 -0
- package/src/services/PiTurnRefService.ts +32 -0
- package/src/services/PiWebMcpAllowedOriginService.ts +32 -0
- package/src/services/PiWebMcpCommandService.ts +184 -0
- package/src/services/PiWebMcpDescribeService.ts +93 -0
- package/src/services/PiWebMcpExecuteService.ts +153 -0
- package/src/services/PiWebMcpListService.ts +107 -0
- package/src/services/PiWebMcpServeService.ts +176 -0
- package/src/services/PiWebMcpSettingsService.ts +57 -0
- package/src/services/PiWebMcpSystemPromptService.ts +67 -0
- package/src/services/PiWebMcpToolStateService.ts +49 -0
- package/src/services/WebMcpEventService.ts +157 -0
- package/src/services/WebMcpToolDiffService.ts +28 -0
- package/src/services/WebMcpToolsService.ts +46 -0
- package/src/utils/copy.ts +1 -0
- package/src/utils/renderers.ts +70 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, Markdown, Spacer } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Context, Effect, Layer, Option, Ref, Result, Schema, SchemaTransformation, Stream, SubscriptionRef } from "effect";
|
|
4
|
+
import { BrowserClient } from "./BrowserClient";
|
|
5
|
+
import { PiContext } from "./PiApi";
|
|
6
|
+
import { PiTurnRefService } from "./PiTurnRefService";
|
|
7
|
+
import { PiWebMcpAllowedOriginService } from "./PiWebMcpAllowedOriginService";
|
|
8
|
+
import { PiWebMcpListService } from "./PiWebMcpListService";
|
|
9
|
+
import { PiWebMcpToolStateService } from "./PiWebMcpToolStateService";
|
|
10
|
+
import { WebMcpToolDiff, WebMcpToolDiffService } from "./WebMcpToolDiffService";
|
|
11
|
+
import { WebMcpToolsService } from "./WebMcpToolsService";
|
|
12
|
+
|
|
13
|
+
const Subcommand = Schema.Literals([
|
|
14
|
+
"connect",
|
|
15
|
+
"disconnect",
|
|
16
|
+
"list",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const CommandArgs = Schema.String.pipe(
|
|
20
|
+
Schema.decode(SchemaTransformation.trim().compose(SchemaTransformation.toLowerCase())),
|
|
21
|
+
Schema.decodeTo(Schema.Literals(["", ...Subcommand.literals])),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
function formatAddedOrigins(diff: WebMcpToolDiff) {
|
|
25
|
+
const counts = new Map<string, number>();
|
|
26
|
+
|
|
27
|
+
for (const tool of diff.added) {
|
|
28
|
+
counts.set(tool.origin, (counts.get(tool.origin) ?? 0) + 1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return [...counts.entries()]
|
|
32
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
33
|
+
.map(([origin, count]) => `${origin} [${count}]`)
|
|
34
|
+
.join(", ");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class PiWebMcpCommandService extends Context.Service<PiWebMcpCommandService, {
|
|
38
|
+
readonly handle: (args: string) => Effect.Effect<void, never, PiContext>;
|
|
39
|
+
readonly nudge: () => Effect.Effect<void>;
|
|
40
|
+
}>()("pi-webmcp/PiWebMcpCommandService") {
|
|
41
|
+
static readonly liveWithoutDependencies = Layer.effect(
|
|
42
|
+
PiWebMcpCommandService,
|
|
43
|
+
Effect.gen(function*() {
|
|
44
|
+
const browser = yield* BrowserClient;
|
|
45
|
+
const toolState = yield* PiWebMcpToolStateService;
|
|
46
|
+
const listService = yield* PiWebMcpListService;
|
|
47
|
+
const tools = yield* WebMcpToolsService;
|
|
48
|
+
const allowedOrigin = yield* PiWebMcpAllowedOriginService;
|
|
49
|
+
const toolDiff = yield* WebMcpToolDiffService;
|
|
50
|
+
const turnRefService = yield* PiTurnRefService;
|
|
51
|
+
const notificationShownRef = yield* turnRefService.make(Option.some(true));
|
|
52
|
+
const nudges = yield* SubscriptionRef.make<unknown>(null);
|
|
53
|
+
|
|
54
|
+
const disconnect = Effect.fn("PiWebMcpCommandService.disconnect")(function*() {
|
|
55
|
+
const ctx = yield* PiContext;
|
|
56
|
+
|
|
57
|
+
ctx.ui.setWidget("webmcp-list", undefined);
|
|
58
|
+
// TODO: detach active CDP target sessions before disconnecting.
|
|
59
|
+
yield* browser.disconnect().pipe(Effect.ignore);
|
|
60
|
+
yield* toolState.stage([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const list = Effect.fn("PiWebMcpCommandService.list")(function*() {
|
|
64
|
+
const ctx = yield* PiContext;
|
|
65
|
+
const cdp = yield* browser.get;
|
|
66
|
+
|
|
67
|
+
if (Option.isNone(cdp)) {
|
|
68
|
+
ctx.ui.notify("WebMCP: Not connected. Run `/webmcp` first.", "error");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const text = yield* listService.markdown({});
|
|
73
|
+
|
|
74
|
+
if (Option.isNone(text)) {
|
|
75
|
+
ctx.ui.notify("WebMCP: No tools discovered.", "info");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const markdownTheme = getMarkdownTheme();
|
|
80
|
+
|
|
81
|
+
ctx.ui.setWidget("webmcp-list", () => {
|
|
82
|
+
const widget = new Container();
|
|
83
|
+
widget.addChild(new Markdown(text.value, 0, 0, markdownTheme));
|
|
84
|
+
widget.addChild(new Spacer(1));
|
|
85
|
+
return widget;
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const connect = Effect.fn("PiWebMcpCommandService.connect")(function*() {
|
|
90
|
+
const ctx = yield* PiContext;
|
|
91
|
+
|
|
92
|
+
ctx.ui.setWidget("webmcp-list", undefined);
|
|
93
|
+
|
|
94
|
+
const connected = yield* browser.connect({ force: true }).pipe(
|
|
95
|
+
Effect.as(true),
|
|
96
|
+
Effect.catchTag(
|
|
97
|
+
"BrowserClientError",
|
|
98
|
+
Effect.fn("PiWebMcpCommandService.connect.handleBrowserClientError")(function*() {
|
|
99
|
+
ctx.ui.notify("WebMCP: Failed to connect to Chrome. Make sure Chrome is open with remote debugging enabled.", "error");
|
|
100
|
+
return false;
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (!connected) return;
|
|
106
|
+
|
|
107
|
+
yield* tools.changes.pipe(
|
|
108
|
+
Stream.map((tools) => tools.filter((tool) => allowedOrigin.isAllowed(tool.origin))),
|
|
109
|
+
Stream.zipLatestWith(SubscriptionRef.changes(nudges), (tools) => tools),
|
|
110
|
+
// Stage every change immediately so the registry stays current.
|
|
111
|
+
Stream.tap((active) => toolState.stage(active)),
|
|
112
|
+
// Only notify for the latest change once the agent is idle. If a
|
|
113
|
+
// newer change arrives while we're waiting, `switchMap` interrupts
|
|
114
|
+
// the pending notification and restarts with the latest state,
|
|
115
|
+
// preventing a backlog of queued notifications.
|
|
116
|
+
Stream.switchMap((active) =>
|
|
117
|
+
Stream.fromEffectDrain(Effect.gen(function*() {
|
|
118
|
+
yield* Effect.promise(() => ctx.waitForIdle());
|
|
119
|
+
|
|
120
|
+
const committed = yield* toolState.committed;
|
|
121
|
+
const diff = toolDiff.diff(committed, active);
|
|
122
|
+
|
|
123
|
+
const notificationShown = yield* Ref.get(notificationShownRef).pipe(
|
|
124
|
+
Effect.map(Option.getOrElse(() => false)),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (!toolDiff.hasDiff(diff) && notificationShown) {
|
|
128
|
+
ctx.ui.notify("");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (diff.added.length === 0) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
yield* Ref.set(notificationShownRef, Option.some(true));
|
|
137
|
+
|
|
138
|
+
ctx.ui.notify(`WebMCP: New tool(s) discovered for ${formatAddedOrigins(diff)}.`, "info");
|
|
139
|
+
}))
|
|
140
|
+
),
|
|
141
|
+
Stream.runDrain,
|
|
142
|
+
Effect.forkDetach,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return PiWebMcpCommandService.of({
|
|
147
|
+
handle: Effect.fn("PiWebMcpCommandService.handle")(function*(args: string) {
|
|
148
|
+
const ctx = yield* PiContext;
|
|
149
|
+
|
|
150
|
+
const result = Schema.decodeUnknownResult(CommandArgs)(args);
|
|
151
|
+
|
|
152
|
+
if (Result.isFailure(result)) {
|
|
153
|
+
ctx.ui.notify(`Usage: /webmcp [${Subcommand.literals.join("|")}]`, "error");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const command = result.success;
|
|
158
|
+
|
|
159
|
+
if (command === "disconnect") {
|
|
160
|
+
return yield* disconnect();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (command === "list") {
|
|
164
|
+
return yield* list();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
yield* connect();
|
|
168
|
+
}),
|
|
169
|
+
nudge: Effect.fn("PiWebMcpCommandService.nudge")(function*() {
|
|
170
|
+
yield* SubscriptionRef.set(nudges, Symbol());
|
|
171
|
+
}),
|
|
172
|
+
});
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
static readonly live = PiWebMcpCommandService.liveWithoutDependencies.pipe(
|
|
177
|
+
Layer.provide(PiWebMcpListService.live),
|
|
178
|
+
Layer.provide(PiWebMcpToolStateService.live),
|
|
179
|
+
Layer.provide(WebMcpToolsService.live),
|
|
180
|
+
Layer.provide(PiTurnRefService.live),
|
|
181
|
+
Layer.provide(PiWebMcpAllowedOriginService.live),
|
|
182
|
+
Layer.provide(WebMcpToolDiffService.live),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type AgentToolResult, highlightCode } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Context, Effect, Formatter, Layer, Option, Schema } from "effect";
|
|
3
|
+
import { Origin, ToolId, WebMcpTool } from "../schemas/WebMcpTool";
|
|
4
|
+
import { agentConnectInstruction } from "../utils/copy";
|
|
5
|
+
import { BrowserClient } from "./BrowserClient";
|
|
6
|
+
import { PiContext } from "./PiApi";
|
|
7
|
+
import { PiWebMcpToolStateService } from "./PiWebMcpToolStateService";
|
|
8
|
+
|
|
9
|
+
export type PiWebMcpDescribeParams = {
|
|
10
|
+
readonly tool: string;
|
|
11
|
+
readonly origin: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type PiWebMcpDescribeDetails = {
|
|
15
|
+
readonly connected?: boolean;
|
|
16
|
+
readonly id?: ToolId;
|
|
17
|
+
readonly error?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export class PiWebMcpDescribeService extends Context.Service<PiWebMcpDescribeService, {
|
|
21
|
+
readonly execute: (params: PiWebMcpDescribeParams) => Effect.Effect<AgentToolResult<PiWebMcpDescribeDetails>, never, PiContext>;
|
|
22
|
+
}>()("pi-webmcp/PiWebMcpDescribeService") {
|
|
23
|
+
static readonly live = Layer.effect(
|
|
24
|
+
PiWebMcpDescribeService,
|
|
25
|
+
Effect.gen(function*() {
|
|
26
|
+
const browser = yield* BrowserClient;
|
|
27
|
+
const toolState = yield* PiWebMcpToolStateService;
|
|
28
|
+
|
|
29
|
+
// TODO: refactor `listToolsText` into an Effect-native formatter (e.g. a
|
|
30
|
+
// `Schema` representation / `Formatter`-based renderer) instead of a
|
|
31
|
+
// plain string builder. Needs investigation into SchemaRepresentation
|
|
32
|
+
// / Formatter.formatJson usage and whether grouping by origin belongs
|
|
33
|
+
// in a dedicated service.
|
|
34
|
+
const listToolsText = (tools: WebMcpTool[]) => {
|
|
35
|
+
if (tools.length === 0) return "No WebMCP tools found. Ask the user to run `/webmcp` first.";
|
|
36
|
+
|
|
37
|
+
return tools
|
|
38
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
39
|
+
.map((tool) => {
|
|
40
|
+
const id = tool.id;
|
|
41
|
+
const name = id === tool.name ? id : `${id} (${tool.name})`;
|
|
42
|
+
const description = tool.description ? `\n ${tool.description}` : "";
|
|
43
|
+
return ` - ${name} @ ${tool.origin}${description}`;
|
|
44
|
+
})
|
|
45
|
+
.join("\n");
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// TODO: refactor `resolveTool` into an Effect-native lookup, e.g.
|
|
49
|
+
// Schema-validated selection / `Effect.gen` returning a typed
|
|
50
|
+
// `Result`/`Option` instead of a `{ candidates }` discriminated object.
|
|
51
|
+
// Needs investigation into whether this and PiWebMcpExecuteService's
|
|
52
|
+
// resolveTool should share one service.
|
|
53
|
+
const resolveTool = (tools: WebMcpTool[], id: ToolId, origin: Origin) => {
|
|
54
|
+
const candidates = tools.filter((tool) => (tool.id === id || tool.name === id) && tool.origin === origin);
|
|
55
|
+
|
|
56
|
+
return candidates.length === 1 ? candidates[0] : { candidates };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return PiWebMcpDescribeService.of({
|
|
60
|
+
execute: Effect.fn("PiWebMcpDescribeService.execute")(function*(params: PiWebMcpDescribeParams) {
|
|
61
|
+
const cdpOption = yield* browser.get;
|
|
62
|
+
if (Option.isNone(cdpOption)) {
|
|
63
|
+
return {
|
|
64
|
+
content: [{ type: "text", text: agentConnectInstruction }],
|
|
65
|
+
details: {},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const activeTools = [...yield* toolState.committed, ...yield* toolState.staged];
|
|
70
|
+
const origin = Schema.decodeUnknownSync(Origin)(params.origin);
|
|
71
|
+
const toolId = Schema.decodeUnknownSync(ToolId)(params.tool);
|
|
72
|
+
const resolved = resolveTool(activeTools, toolId, origin);
|
|
73
|
+
if ("candidates" in resolved) {
|
|
74
|
+
return {
|
|
75
|
+
content: [{
|
|
76
|
+
type: "text",
|
|
77
|
+
text: resolved.candidates.length > 0
|
|
78
|
+
? `Ambiguous tool. Provide origin.\n\n${listToolsText(resolved.candidates)}`
|
|
79
|
+
: `Tool not found: ${params.tool}. Try webmcp_list first.`,
|
|
80
|
+
}],
|
|
81
|
+
details: {},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const inputSchema = Formatter.formatJson(Schema.encodeSync(Schema.Json)(resolved.inputSchema ?? {}), { space: 2 });
|
|
86
|
+
const highlightedInputSchema = highlightCode(inputSchema, "json").join("\n");
|
|
87
|
+
const text = `\n${resolved.description ?? "(no description)"}\n\n${highlightedInputSchema}`;
|
|
88
|
+
return { content: [{ type: "text", text }], details: {} };
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { type AgentToolResult, highlightCode } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Context, Effect, Layer, Option, Schema } from "effect";
|
|
3
|
+
import { Origin, ToolId, WebMcpTool } from "../schemas/WebMcpTool";
|
|
4
|
+
import { agentConnectInstruction } from "../utils/copy";
|
|
5
|
+
import { BrowserClient, type CdpClient } from "./BrowserClient";
|
|
6
|
+
import { PiContext } from "./PiApi";
|
|
7
|
+
import { PiWebMcpToolStateService } from "./PiWebMcpToolStateService";
|
|
8
|
+
|
|
9
|
+
export type PiWebMcpExecuteParams = {
|
|
10
|
+
readonly tool: string;
|
|
11
|
+
readonly origin: string;
|
|
12
|
+
readonly args?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type PiWebMcpExecuteDetails = {
|
|
16
|
+
readonly connected?: boolean;
|
|
17
|
+
readonly id?: ToolId;
|
|
18
|
+
readonly origin?: Origin;
|
|
19
|
+
readonly input?: Record<string, unknown>;
|
|
20
|
+
readonly result?: unknown;
|
|
21
|
+
readonly error?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export class PiWebMcpExecuteError extends Schema.TaggedErrorClass<PiWebMcpExecuteError>()("PiWebMcpExecuteError", {
|
|
25
|
+
operation: Schema.Union([Schema.Literal("parseInput"), Schema.Literal("invokeTool")]),
|
|
26
|
+
cause: Schema.Unknown,
|
|
27
|
+
}) {}
|
|
28
|
+
|
|
29
|
+
function listToolsText(tools: WebMcpTool[]) {
|
|
30
|
+
if (tools.length === 0) return "No WebMCP tools found. Ask the user to run `/webmcp` first.";
|
|
31
|
+
|
|
32
|
+
return tools
|
|
33
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
34
|
+
.map((tool) => {
|
|
35
|
+
const id = tool.id;
|
|
36
|
+
const name = id === tool.name ? id : `${id} (${tool.name})`;
|
|
37
|
+
const description = tool.description ? `\n ${tool.description}` : "";
|
|
38
|
+
return ` - ${name} @ ${tool.origin}${description}`;
|
|
39
|
+
})
|
|
40
|
+
.join("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveTool(tools: WebMcpTool[], id: ToolId, origin?: Origin) {
|
|
44
|
+
const candidates = tools.filter((tool) => (tool.id === id || tool.name === id) && (!origin || tool.origin === origin));
|
|
45
|
+
|
|
46
|
+
return candidates.length === 1 ? candidates[0] : { candidates };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseInput(args: string | undefined) {
|
|
50
|
+
return Effect.try({
|
|
51
|
+
try: () => {
|
|
52
|
+
if (!args) return {};
|
|
53
|
+
const input = JSON.parse(args) as unknown;
|
|
54
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
55
|
+
throw new Error("args must be a JSON object string");
|
|
56
|
+
}
|
|
57
|
+
return input as Record<string, unknown>;
|
|
58
|
+
},
|
|
59
|
+
catch: (cause) => new PiWebMcpExecuteError({ operation: "parseInput", cause }),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function invokeWebMcpTool(cdp: CdpClient, tool: WebMcpTool, input: Record<string, unknown>) {
|
|
64
|
+
return Effect.tryPromise({
|
|
65
|
+
try: async () => {
|
|
66
|
+
if (!tool.sessionId) throw new Error("WebMCP tool is missing its CDP session id. Re-run `/webmcp` and try again.");
|
|
67
|
+
|
|
68
|
+
let invocationId: string | undefined;
|
|
69
|
+
const responsePromise = new Promise<unknown>((resolve, reject) => {
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
reject(
|
|
72
|
+
new Error(
|
|
73
|
+
"Timed out waiting for WebMCP.toolResponded. The page accepted the invocation but did not respond; declarative form tools may require the page/form to opt into toolautosubmit or otherwise call event.respondWith(...).",
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
}, 60_000);
|
|
77
|
+
|
|
78
|
+
cdp.on("WebMCP.toolResponded", (ev: any, evSessionId?: string) => {
|
|
79
|
+
if (evSessionId !== tool.sessionId) return;
|
|
80
|
+
if (!invocationId || ev.invocationId === invocationId) {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
resolve(ev);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const invokeResult = await cdp.send("WebMCP.invokeTool", {
|
|
88
|
+
frameId: tool.frameId,
|
|
89
|
+
toolName: tool.name,
|
|
90
|
+
input,
|
|
91
|
+
}, tool.sessionId);
|
|
92
|
+
invocationId = invokeResult.invocationId;
|
|
93
|
+
|
|
94
|
+
const response = await responsePromise;
|
|
95
|
+
return { invokeResult, response };
|
|
96
|
+
},
|
|
97
|
+
catch: (cause) => new PiWebMcpExecuteError({ operation: "invokeTool", cause }),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function textResult(text: string, details: PiWebMcpExecuteDetails): AgentToolResult<PiWebMcpExecuteDetails> {
|
|
102
|
+
return { content: [{ type: "text", text }], details };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class PiWebMcpExecuteService extends Context.Service<PiWebMcpExecuteService, {
|
|
106
|
+
readonly execute: (params: PiWebMcpExecuteParams) => Effect.Effect<AgentToolResult<PiWebMcpExecuteDetails>, never, PiContext>;
|
|
107
|
+
}>()("pi-webmcp/PiWebMcpExecuteService") {
|
|
108
|
+
static readonly live = Layer.effect(
|
|
109
|
+
PiWebMcpExecuteService,
|
|
110
|
+
Effect.gen(function*() {
|
|
111
|
+
const browser = yield* BrowserClient;
|
|
112
|
+
const toolState = yield* PiWebMcpToolStateService;
|
|
113
|
+
|
|
114
|
+
return PiWebMcpExecuteService.of({
|
|
115
|
+
execute: Effect.fn("PiWebMcpExecuteService.execute")(
|
|
116
|
+
function*(params: PiWebMcpExecuteParams) {
|
|
117
|
+
const cdpOption = yield* browser.get;
|
|
118
|
+
if (Option.isNone(cdpOption)) {
|
|
119
|
+
return textResult(agentConnectInstruction, { connected: false });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const activeTools = [...yield* toolState.committed, ...yield* toolState.staged];
|
|
123
|
+
const origin = Schema.decodeUnknownSync(Origin)(params.origin);
|
|
124
|
+
const toolId = Schema.decodeUnknownSync(ToolId)(params.tool);
|
|
125
|
+
const resolved = resolveTool(activeTools, toolId, origin);
|
|
126
|
+
if ("candidates" in resolved) {
|
|
127
|
+
return textResult(
|
|
128
|
+
resolved.candidates.length > 0
|
|
129
|
+
? `Ambiguous tool. Retry with origin.\n\n${listToolsText(resolved.candidates)}`
|
|
130
|
+
: `Tool not found: ${params.tool}. Try /webmcp first.`,
|
|
131
|
+
{ error: "tool_not_found_or_ambiguous" },
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const input = yield* parseInput(params.args);
|
|
136
|
+
const result = yield* invokeWebMcpTool(cdpOption.value, resolved, input);
|
|
137
|
+
const inputJson = highlightCode(JSON.stringify(input, null, 2), "json").join("\n");
|
|
138
|
+
const responseJson = highlightCode(JSON.stringify((result as any).response, null, 2), "json").join("\n");
|
|
139
|
+
const text = `\n→\n\n${inputJson}\n\n←\n\n${responseJson}`;
|
|
140
|
+
|
|
141
|
+
return textResult(text, {
|
|
142
|
+
id: resolved.id,
|
|
143
|
+
origin: resolved.origin,
|
|
144
|
+
input,
|
|
145
|
+
result,
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
(effect) => effect.pipe(Effect.catch((cause: unknown) => Effect.succeed(textResult(String(cause instanceof Error ? cause.message : cause), { error: "execute_failed" })))),
|
|
149
|
+
),
|
|
150
|
+
});
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Context, Effect, Layer, Option, Schema } from "effect";
|
|
3
|
+
import { Origin, WebMcpTool } from "../schemas/WebMcpTool";
|
|
4
|
+
import { agentConnectInstruction } from "../utils/copy";
|
|
5
|
+
import { BrowserClient } from "./BrowserClient";
|
|
6
|
+
import { PiContext } from "./PiApi";
|
|
7
|
+
import { PiWebMcpToolStateService } from "./PiWebMcpToolStateService";
|
|
8
|
+
|
|
9
|
+
export type PiWebMcpListParams = {
|
|
10
|
+
readonly filter?: string;
|
|
11
|
+
readonly refresh?: boolean;
|
|
12
|
+
readonly origin?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export class PiWebMcpListService extends Context.Service<PiWebMcpListService, {
|
|
16
|
+
readonly markdown: (params: PiWebMcpListParams) => Effect.Effect<Option.Option<string>, never, PiContext>;
|
|
17
|
+
readonly execute: (params: PiWebMcpListParams) => Effect.Effect<AgentToolResult<unknown>, never, PiContext>;
|
|
18
|
+
}>()("pi-webmcp/PiWebMcpListService") {
|
|
19
|
+
static readonly live = Layer.effect(
|
|
20
|
+
PiWebMcpListService,
|
|
21
|
+
Effect.gen(function*() {
|
|
22
|
+
const browser = yield* BrowserClient;
|
|
23
|
+
const toolState = yield* PiWebMcpToolStateService;
|
|
24
|
+
|
|
25
|
+
const uniqueTools = (tools: WebMcpTool[]): WebMcpTool[] => {
|
|
26
|
+
return [...new Map(tools.map((tool) => [`${tool.origin}:${tool.name}:${tool.frameId}`, tool])).values()];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const groupToolsByOrigin = (tools: WebMcpTool[]): WebMcpTool[][] => {
|
|
30
|
+
const groups: WebMcpTool[][] = [];
|
|
31
|
+
for (const tool of uniqueTools(tools)) {
|
|
32
|
+
const group = groups.find((group) => group[0]?.origin === tool.origin);
|
|
33
|
+
if (group) {
|
|
34
|
+
group.push(tool);
|
|
35
|
+
} else {
|
|
36
|
+
groups.push([tool]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return groups.sort((a, b) => a[0]!.origin.localeCompare(b[0]!.origin));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const formatToolList = (tools: WebMcpTool[]): Option.Option<string> => {
|
|
44
|
+
if (tools.length === 0) {
|
|
45
|
+
return Option.none();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sections = groupToolsByOrigin(tools).map((list) => {
|
|
49
|
+
const origin = list[0]!.origin;
|
|
50
|
+
const body = list
|
|
51
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
52
|
+
.map((tool) => {
|
|
53
|
+
const description = tool.description ? ` ${tool.description}` : "";
|
|
54
|
+
return `- **${tool.name}**${description}`;
|
|
55
|
+
})
|
|
56
|
+
.join("\n\n");
|
|
57
|
+
return `${origin}\n\n${body}`;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return Option.some(`\n${sections.join("\n\n")}`);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const markdown = Effect.fn("PiWebMcpListService.markdown")(function*(params: PiWebMcpListParams) {
|
|
64
|
+
const cdp = yield* browser.get;
|
|
65
|
+
|
|
66
|
+
if (Option.isNone(cdp)) {
|
|
67
|
+
return Option.none<string>();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const staged = yield* toolState.staged;
|
|
71
|
+
const committed = yield* toolState.committed;
|
|
72
|
+
|
|
73
|
+
let tools = [...staged, ...committed];
|
|
74
|
+
|
|
75
|
+
if (params.origin) {
|
|
76
|
+
const targetOrigin = Schema.decodeUnknownSync(Origin)(params.origin);
|
|
77
|
+
tools = tools.filter((tool) => tool.origin === targetOrigin);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return formatToolList(tools);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const fallbackMessage = Effect.fn("PiWebMcpListService.fallbackMessage")(function*() {
|
|
84
|
+
const cdp = yield* browser.get;
|
|
85
|
+
|
|
86
|
+
if (Option.isNone(cdp)) {
|
|
87
|
+
return agentConnectInstruction;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return "No WebMCP tools found.";
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return PiWebMcpListService.of({
|
|
94
|
+
markdown,
|
|
95
|
+
execute: Effect.fn("PiWebMcpListService.execute")(function*(params: PiWebMcpListParams) {
|
|
96
|
+
const maybeMarkdown = yield* markdown(params);
|
|
97
|
+
const text = Option.isSome(maybeMarkdown) ? maybeMarkdown.value : yield* fallbackMessage();
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
content: [{ type: "text", text }],
|
|
101
|
+
details: {},
|
|
102
|
+
};
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
}
|