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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export default function(pi: ExtensionAPI) {
|
|
4
|
+
pi.registerCommand("webmcp", {
|
|
5
|
+
description: "Connect to or disconnect from Chrome WebMCP tools",
|
|
6
|
+
getArgumentCompletions: (prefix) => {
|
|
7
|
+
const completions = [
|
|
8
|
+
{ value: "connect", label: "connect", detail: "Scan Chrome WebMCP tools" },
|
|
9
|
+
{ value: "disconnect", label: "disconnect", detail: "Disconnect from Chrome WebMCP" },
|
|
10
|
+
{ value: "list", label: "list", detail: "Show active WebMCP tools above the composer" },
|
|
11
|
+
];
|
|
12
|
+
const filtered = completions.filter(({ value }) => value.startsWith(prefix));
|
|
13
|
+
return filtered.length > 0 ? filtered : null;
|
|
14
|
+
},
|
|
15
|
+
handler: async (args, ctx) => {
|
|
16
|
+
const { handle } = await import("../../src/main");
|
|
17
|
+
await handle(pi, args, ctx);
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nick Breaton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# pi-webmcp
|
|
2
|
+
|
|
3
|
+
A [Pi](https://pi.dev/) extension that connects Pi to webpages that register [WebMCP](https://github.com/webmachinelearning/webmcp) tools.
|
|
4
|
+
|
|
5
|
+
> [!IMPORTANT]
|
|
6
|
+
> Both the WebMCP specification and Chrome’s implementation are in active development. Anticipate breaking changes that affect this extension.
|
|
7
|
+
|
|
8
|
+
> [!CAUTION]
|
|
9
|
+
> This extension can pose a security risk in its default operating mode once the `/webmcp` command is run. A malicious webpage could poison the running Pi session’s context via its WebMCP tool instructions.
|
|
10
|
+
>
|
|
11
|
+
> Use at your own risk. Consider setting `allowedOrigins` to restrict which webpages Pi can connect to.
|
|
12
|
+
|
|
13
|
+
## First-time Setup
|
|
14
|
+
|
|
15
|
+
1. Install this extension via npm.
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pi install npm:pi-webmcp
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
2. Enable Chrome remote debugging by visiting [`chrome://inspect/#remote-debugging`](chrome://inspect/#remote-debugging).
|
|
22
|
+
|
|
23
|
+

|
|
24
|
+
|
|
25
|
+
3. Enable the relevant Chrome flags for WebMCP.
|
|
26
|
+
|
|
27
|
+
- [`chrome://flags/#devtools-webmcp-support`](chrome://flags/#devtools-webmcp-support)
|
|
28
|
+
- [`chrome://flags/#enable-webmcp-testing`](chrome://flags/#enable-webmcp-testing)
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
1. Run `/webmcp` and accept the once-per-session confirmation prompt in Chrome.
|
|
35
|
+
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
2. Navigate to a WebMCP-capable page, such as Chrome Lab’s [WebMCP Travel](https://googlechromelabs.github.io/webmcp-tools/demos/react-flightsearch/) demo.
|
|
39
|
+
|
|
40
|
+
More can be found [here](https://github.com/GoogleChromeLabs/webmcp-tools).
|
|
41
|
+
|
|
42
|
+
### Commands
|
|
43
|
+
|
|
44
|
+
- `/webmcp` or `/webmcp connect` — Connect to Chrome and discover WebMCP tools.
|
|
45
|
+
- `/webmcp disconnect` — Disconnect from Chrome WebMCP.
|
|
46
|
+
- `/webmcp list` — Show active WebMCP tools.
|
|
47
|
+
|
|
48
|
+
## Options
|
|
49
|
+
|
|
50
|
+
Configure WebMCP options under the `webmcp` key in Pi settings, either globally in `~/.pi/agent/settings.json` or per trusted project in `.pi/settings.json`:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"webmcp": {
|
|
55
|
+
"allowedOrigins": ["googlechromelabs.github.io"]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
| Option | Description |
|
|
61
|
+
| -------------------------- | ------------------------------------------------------------------------------------- |
|
|
62
|
+
| `webmcp.allowedOrigins` | When specified, Pi will only discover and connect to WebMCP tools from these origins. |
|
|
63
|
+
| `webmcp.disallowedOrigins` | When specified, Pi will not discover or connect to WebMCP tools from these origins. |
|
|
64
|
+
|
|
65
|
+
## Browser Support
|
|
66
|
+
|
|
67
|
+
WebMCP is currently only implemented in Chrome, so this extension is scoped to Chromium-based browsers for now. We plan to support additional browsers if / when they implement WebMCP.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# WebMCP Demos
|
|
2
|
+
|
|
3
|
+
- https://googlechromelabs.github.io/webmcp-tools/demos/react-flightsearch/
|
|
4
|
+
- https://googlechromelabs.github.io/webmcp-tools/demos/pizza-maker/
|
|
5
|
+
- https://googlechromelabs.github.io/webmcp-tools/demos/doors/
|
|
6
|
+
- https://googlechromelabs.github.io/webmcp-tools/demos/webmcp-maze/
|
|
7
|
+
- https://googlechromelabs.github.io/webmcp-tools/demos/ticket-booking/
|
|
8
|
+
- https://googlechromelabs.github.io/webmcp-tools/demos/order-tracking/
|
|
9
|
+
- https://googlechromelabs.github.io/webmcp-tools/demos/hotel-chain/
|
|
10
|
+
- https://googlechromelabs.github.io/webmcp-tools/demos/sport-shop-angular/
|
|
11
|
+
- https://googlechromelabs.github.io/webmcp-tools/demos/coffee-shop
|
|
12
|
+
- https://googlechromelabs.github.io/webmcp-tools/demos/real-estate-map
|
|
13
|
+
- https://googlechromelabs.github.io/webmcp-tools/demos/leather-bag
|
|
14
|
+
- https://googlechromelabs.github.io/webmcp-tools/demos/smart-home
|
|
15
|
+
- https://googlechromelabs.github.io/webmcp-tools/demos/page-agent
|
|
16
|
+
- https://andreinwald.github.io/webmcp-demo/
|
|
17
|
+
- https://webmcp-flight-demo.netlify.app/
|
|
18
|
+
- https://webmcp-flight-demo.netlify.app/declarative.html
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-webmcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "A Pi extension that connects Pi to webpages that register WebMCP tools.",
|
|
7
|
+
"author": "Nick Breaton <nick@nickbreaton.com>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/nickbreaton/pi-webmcp.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/nickbreaton/pi-webmcp/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/nickbreaton/pi-webmcp#readme",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi-extension",
|
|
19
|
+
"webmcp"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
".pi/extensions/**",
|
|
23
|
+
"src/**",
|
|
24
|
+
"docs/**",
|
|
25
|
+
".github/*.png"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"test:watch": "vitest",
|
|
31
|
+
"fmt": "dprint fmt",
|
|
32
|
+
"fmt:check": "dprint check",
|
|
33
|
+
"prepack": "npm run typecheck && npm test && npm run fmt:check"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@effect/platform-node": "4.0.0-beta.84",
|
|
37
|
+
"chrome-remote-interface": "^0.33.3",
|
|
38
|
+
"effect": "4.0.0-beta.84",
|
|
39
|
+
"micro-memoize": "^5.1.1"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
43
|
+
"@earendil-works/pi-tui": "*",
|
|
44
|
+
"typebox": "*"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@earendil-works/pi-coding-agent": "^0.78.1",
|
|
48
|
+
"@earendil-works/pi-tui": "^0.78.1",
|
|
49
|
+
"@effect/language-service": "^0.86.2",
|
|
50
|
+
"@effect/vitest": "4.0.0-beta.84",
|
|
51
|
+
"@types/chrome-remote-interface": "^0.34.0",
|
|
52
|
+
"@types/node": "^25.9.2",
|
|
53
|
+
"dprint": "^0.54.0",
|
|
54
|
+
"typebox": "^1.2.1",
|
|
55
|
+
"typescript": "^6.0.3",
|
|
56
|
+
"vitest": "^4.1.9"
|
|
57
|
+
},
|
|
58
|
+
"pi": {
|
|
59
|
+
"extensions": [
|
|
60
|
+
".pi/extensions/pi-webmcp.ts"
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
3
|
+
import { NodeHttpServer } from "@effect/platform-node";
|
|
4
|
+
import { Layer, ManagedRuntime, Option, Schema } from "effect";
|
|
5
|
+
import { memoize } from "micro-memoize";
|
|
6
|
+
import { Type } from "typebox";
|
|
7
|
+
import { Origin, ToolId, WebMcpTools } from "./schemas/WebMcpTool";
|
|
8
|
+
import { BrowserClient } from "./services/BrowserClient";
|
|
9
|
+
import { PiApi, PiContext } from "./services/PiApi";
|
|
10
|
+
import { PiTurnRefService } from "./services/PiTurnRefService";
|
|
11
|
+
import { PiWebMcpAllowedOriginService } from "./services/PiWebMcpAllowedOriginService";
|
|
12
|
+
import { PiWebMcpCommandService } from "./services/PiWebMcpCommandService";
|
|
13
|
+
import { PiWebMcpDescribeService } from "./services/PiWebMcpDescribeService";
|
|
14
|
+
import { PiWebMcpExecuteService } from "./services/PiWebMcpExecuteService";
|
|
15
|
+
import { PiWebMcpListService } from "./services/PiWebMcpListService";
|
|
16
|
+
import { PiWebMcpServeService } from "./services/PiWebMcpServeService";
|
|
17
|
+
import { PiWebMcpSystemPromptService } from "./services/PiWebMcpSystemPromptService";
|
|
18
|
+
import { PiWebMcpToolStateService } from "./services/PiWebMcpToolStateService";
|
|
19
|
+
import { WebMcpToolDiffService } from "./services/WebMcpToolDiffService";
|
|
20
|
+
import { WebMcpToolsService } from "./services/WebMcpToolsService";
|
|
21
|
+
import { renderPiWebMcpCall, renderPiWebMcpListMessage, renderPiWebMcpMarkdownResult, renderPiWebMcpResult, renderPiWebMcpServeResult } from "./utils/renderers";
|
|
22
|
+
|
|
23
|
+
const init = memoize((pi: ExtensionAPI, ctx: ExtensionCommandContext) => {
|
|
24
|
+
const live = PiWebMcpCommandService.liveWithoutDependencies.pipe(
|
|
25
|
+
Layer.provideMerge(PiWebMcpDescribeService.live),
|
|
26
|
+
Layer.provideMerge(PiWebMcpExecuteService.live),
|
|
27
|
+
Layer.provideMerge(PiWebMcpListService.live),
|
|
28
|
+
Layer.provideMerge(PiWebMcpServeService.live),
|
|
29
|
+
Layer.provideMerge(PiWebMcpSystemPromptService.live),
|
|
30
|
+
Layer.provideMerge(PiWebMcpToolStateService.live),
|
|
31
|
+
Layer.provideMerge(PiTurnRefService.live),
|
|
32
|
+
Layer.provideMerge(WebMcpToolDiffService.live),
|
|
33
|
+
Layer.provideMerge(WebMcpToolsService.live),
|
|
34
|
+
Layer.provideMerge(PiWebMcpAllowedOriginService.live),
|
|
35
|
+
Layer.provideMerge(Layer.mergeAll(
|
|
36
|
+
Layer.succeed(PiApi, pi),
|
|
37
|
+
Layer.succeed(PiContext, ctx),
|
|
38
|
+
BrowserClient.live,
|
|
39
|
+
NodeHttpServer.layerHttpServices,
|
|
40
|
+
)),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const runtime = ManagedRuntime.make(live);
|
|
44
|
+
|
|
45
|
+
pi.registerMessageRenderer("webmcp-list", renderPiWebMcpListMessage);
|
|
46
|
+
|
|
47
|
+
pi.registerTool({
|
|
48
|
+
name: "webmcp_list",
|
|
49
|
+
label: "WebMCP List",
|
|
50
|
+
description: "List the WebMCP tools currently known to the session, grouped by origin. Does not scan the browser.",
|
|
51
|
+
promptSnippet: "List WebMCP tools currently known to the session, grouped by origin",
|
|
52
|
+
promptGuidelines: [
|
|
53
|
+
"The available WebMCP tools are already injected into your system prompt each turn; keep a running internal ledger of them and prefer it over calling webmcp_list.",
|
|
54
|
+
"Only call webmcp_list when you are genuinely confused about which tools are available or need the full grouped listing again; it does not trigger a new browser scan.",
|
|
55
|
+
],
|
|
56
|
+
parameters: Type.Object({
|
|
57
|
+
filter: Type.Optional(Type.String({ description: "Optional URL/title/target/origin filter to narrow the listed tools." })),
|
|
58
|
+
refresh: Type.Optional(Type.Boolean({ description: "Reserved; the session does not actively scan the browser on call." })),
|
|
59
|
+
origin: Type.Optional(Type.String({ description: "Optional origin/host to limit results to tools from a single WebMCP page (e.g. example.com)." })),
|
|
60
|
+
}),
|
|
61
|
+
renderCall: (_, theme) =>
|
|
62
|
+
renderPiWebMcpCall(theme, {
|
|
63
|
+
toolName: "webmcp_list",
|
|
64
|
+
}),
|
|
65
|
+
renderResult: renderPiWebMcpMarkdownResult,
|
|
66
|
+
async execute(_, params) {
|
|
67
|
+
return runtime.runPromise(PiWebMcpListService.use((service) => service.execute(params)));
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
pi.registerTool({
|
|
72
|
+
name: "webmcp_describe",
|
|
73
|
+
label: "WebMCP Describe",
|
|
74
|
+
description: "Describe a WebMCP tool's page, origin, description, and input parameters.",
|
|
75
|
+
promptSnippet: "Inspect a WebMCP page tool schema before executing it; pass the origin from webmcp_list",
|
|
76
|
+
promptGuidelines: [
|
|
77
|
+
"Use webmcp_describe with both tool and origin from webmcp_list when you need a WebMCP tool's exact parameters before execution.",
|
|
78
|
+
],
|
|
79
|
+
parameters: Type.Object({
|
|
80
|
+
tool: Type.String({ description: "Tool id from webmcp_list, or the page-provided tool name." }),
|
|
81
|
+
origin: Type.String({ description: "Origin/host where the tool is registered, without protocol (e.g. example.com)." }),
|
|
82
|
+
}),
|
|
83
|
+
renderCall: (args, theme) =>
|
|
84
|
+
renderPiWebMcpCall(theme, {
|
|
85
|
+
toolName: "webmcp_describe",
|
|
86
|
+
origin: Schema.decodeUnknownSync(Origin)(args.origin),
|
|
87
|
+
webMcpTool: Schema.decodeUnknownSync(ToolId)(args.tool),
|
|
88
|
+
}),
|
|
89
|
+
renderResult: renderPiWebMcpResult,
|
|
90
|
+
async execute(_, params) {
|
|
91
|
+
return runtime.runPromise(PiWebMcpDescribeService.use((service) => service.execute(params)));
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
pi.registerTool({
|
|
96
|
+
name: "webmcp_execute",
|
|
97
|
+
label: "WebMCP Execute",
|
|
98
|
+
description: "Execute a WebMCP tool exposed by an open Chrome tab.",
|
|
99
|
+
promptSnippet: "Execute a selected WebMCP page tool with JSON arguments",
|
|
100
|
+
promptGuidelines: [
|
|
101
|
+
"Before using webmcp_execute, ask the user to run /webmcp if WebMCP is not connected or no matching tool is known.",
|
|
102
|
+
"When calling webmcp_execute, pass the page-provided tool name or the safe tool id, and always include the origin from webmcp_list.",
|
|
103
|
+
],
|
|
104
|
+
parameters: Type.Object({
|
|
105
|
+
tool: Type.String({ description: "Tool id or page-provided WebMCP tool name." }),
|
|
106
|
+
origin: Type.String({ description: "Origin/host where the tool is registered, without protocol (e.g. example.com)." }),
|
|
107
|
+
args: Type.Optional(Type.String({ description: "Arguments as a JSON object string for the WebMCP tool." })),
|
|
108
|
+
}),
|
|
109
|
+
renderCall: (args, theme) =>
|
|
110
|
+
renderPiWebMcpCall(theme, {
|
|
111
|
+
toolName: "webmcp_execute",
|
|
112
|
+
origin: Schema.decodeUnknownSync(Origin)(args.origin),
|
|
113
|
+
webMcpTool: Schema.decodeUnknownSync(ToolId)(args.tool),
|
|
114
|
+
}),
|
|
115
|
+
renderResult: renderPiWebMcpResult,
|
|
116
|
+
async execute(_toolCallId, params) {
|
|
117
|
+
return runtime.runPromise(PiWebMcpExecuteService.use((service) => service.execute(params)));
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
pi.registerTool({
|
|
122
|
+
name: "webmcp_serve",
|
|
123
|
+
label: "WebMCP Serve",
|
|
124
|
+
description: "Serve a local file or folder over HTTP so WebMCP pages can reference it.",
|
|
125
|
+
promptSnippet: "Serve a local file or folder and return a browser-accessible URL",
|
|
126
|
+
promptGuidelines: [
|
|
127
|
+
"Use webmcp_serve when a WebMCP page needs to fetch or embed a local file or folder from this session.",
|
|
128
|
+
"Pass a file or directory path. Relative paths resolve from the current Pi working directory.",
|
|
129
|
+
"The returned URL remains available for the normal Pi application lifecycle.",
|
|
130
|
+
],
|
|
131
|
+
parameters: Type.Object({
|
|
132
|
+
path: Type.String({ description: "File or directory path to serve. Relative paths resolve from the current Pi working directory." }),
|
|
133
|
+
}),
|
|
134
|
+
renderCall: (args, theme) =>
|
|
135
|
+
renderPiWebMcpCall(theme, {
|
|
136
|
+
toolName: "webmcp_serve",
|
|
137
|
+
target: args.path,
|
|
138
|
+
}),
|
|
139
|
+
renderResult: renderPiWebMcpServeResult,
|
|
140
|
+
async execute(_, params) {
|
|
141
|
+
return runtime.runPromise(PiWebMcpServeService.use((service) => service.execute(params)));
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
pi.on("turn_end", async () => {
|
|
146
|
+
await runtime.runPromise(PiTurnRefService.use((service) => service.reset()));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
150
|
+
ctx.ui.setWidget("webmcp-list", undefined);
|
|
151
|
+
|
|
152
|
+
const prompt = await runtime.runPromise(PiWebMcpSystemPromptService.use((service) => service.getSystemPrompt()));
|
|
153
|
+
|
|
154
|
+
if (!prompt) return;
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
systemPrompt: event.systemPrompt + `\n\n${prompt}`,
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
pi.on("message_end", async (event) => {
|
|
162
|
+
if (event.message.role !== "user") return;
|
|
163
|
+
|
|
164
|
+
const tools = await runtime.runPromise(PiWebMcpToolStateService.use((service) => service.commit()));
|
|
165
|
+
if (Option.isNone(tools)) return;
|
|
166
|
+
|
|
167
|
+
const messageWithDetails = event.message as typeof event.message & { details?: Record<string, unknown>; };
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
message: {
|
|
171
|
+
...event.message,
|
|
172
|
+
details: {
|
|
173
|
+
...messageWithDetails.details,
|
|
174
|
+
webmcp: { tools: JSON.parse(JSON.stringify(Schema.encodeSync(WebMcpTools)(tools.value))) },
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
pi.on("agent_end", async () => {
|
|
181
|
+
await runtime.runPromise(PiWebMcpCommandService.use((service) => service.nudge()));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
pi.on("session_tree", async () => {
|
|
185
|
+
await runtime.runPromise(PiWebMcpCommandService.use((service) => service.nudge()));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
pi.on("session_shutdown", async () => {
|
|
189
|
+
await runtime.dispose();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (ctx.mode === "tui") {
|
|
193
|
+
ctx.ui.onTerminalInput((data) => {
|
|
194
|
+
if (matchesKey(data, Key.escape)) {
|
|
195
|
+
ctx.ui.setWidget("webmcp-list", undefined);
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { runtime };
|
|
202
|
+
}, { transformKey: () => ["pi-webmcp-runtime"] });
|
|
203
|
+
|
|
204
|
+
export async function handle(pi: ExtensionAPI, args: string, ctx: ExtensionCommandContext) {
|
|
205
|
+
const { runtime } = init(pi, ctx);
|
|
206
|
+
|
|
207
|
+
const effect = PiWebMcpCommandService.use((service) => {
|
|
208
|
+
return service.handle(args);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await runtime.runPromise(effect);
|
|
212
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Hash, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
export class WebMcpToolAnnotation extends Schema.Class<WebMcpToolAnnotation>("WebMcpToolAnnotation")({
|
|
4
|
+
readOnly: Schema.optionalKey(Schema.Boolean),
|
|
5
|
+
untrustedContent: Schema.optionalKey(Schema.Boolean),
|
|
6
|
+
autosubmit: Schema.optionalKey(Schema.Boolean),
|
|
7
|
+
}) {}
|
|
8
|
+
|
|
9
|
+
export class WebMcpToolMetadata extends Schema.Class<WebMcpToolMetadata>("WebMcpToolMetadata")({
|
|
10
|
+
targetId: Schema.String,
|
|
11
|
+
title: Schema.String,
|
|
12
|
+
url: Schema.String,
|
|
13
|
+
}) {}
|
|
14
|
+
|
|
15
|
+
export const Origin = Schema.String.pipe(Schema.brand("Origin"));
|
|
16
|
+
export type Origin = typeof Origin.Type;
|
|
17
|
+
|
|
18
|
+
export const ToolId = Schema.String.pipe(Schema.brand("ToolId"));
|
|
19
|
+
export type ToolId = typeof ToolId.Type;
|
|
20
|
+
|
|
21
|
+
export class WebMcpTool extends Schema.Class<WebMcpTool>("WebMcpTool")({
|
|
22
|
+
name: Schema.String,
|
|
23
|
+
origin: Origin,
|
|
24
|
+
sessionId: Schema.optionalKey(Schema.String),
|
|
25
|
+
description: Schema.optionalKey(Schema.String),
|
|
26
|
+
inputSchema: Schema.optionalKey(Schema.Json),
|
|
27
|
+
outputSchema: Schema.optionalKey(Schema.Json),
|
|
28
|
+
annotations: Schema.optionalKey(WebMcpToolAnnotation),
|
|
29
|
+
frameId: Schema.String,
|
|
30
|
+
backendNodeId: Schema.optionalKey(Schema.Number),
|
|
31
|
+
stackTrace: Schema.optionalKey(Schema.Unknown),
|
|
32
|
+
}) {
|
|
33
|
+
get id(): ToolId {
|
|
34
|
+
const transformed = this.name.toLowerCase()
|
|
35
|
+
.replace(/[^a-z0-9_]+/g, "_")
|
|
36
|
+
.replace(/^_+|_+$/g, "")
|
|
37
|
+
.slice(0, 48);
|
|
38
|
+
return Schema.decodeSync(ToolId)(transformed);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get hash(): number {
|
|
42
|
+
return Hash.hash({
|
|
43
|
+
name: this.name,
|
|
44
|
+
origin: this.origin,
|
|
45
|
+
description: this.description,
|
|
46
|
+
inputSchema: this.inputSchema,
|
|
47
|
+
outputSchema: this.outputSchema,
|
|
48
|
+
annotations: this.annotations,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class WebMcpToolContainer extends Schema.Class<WebMcpToolContainer>("WebMcpToolContainer")({
|
|
54
|
+
metadata: WebMcpToolMetadata,
|
|
55
|
+
tool: WebMcpTool,
|
|
56
|
+
}) {}
|
|
57
|
+
|
|
58
|
+
export const WebMcpTools = Schema.Array(WebMcpTool);
|
|
59
|
+
export const WebMcpToolContainers = Schema.Array(WebMcpToolContainer);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import CDP from "chrome-remote-interface";
|
|
2
|
+
import type { Client } from "chrome-remote-interface";
|
|
3
|
+
import { Context, Effect, Layer, Option, Ref, Schema, Scope } from "effect";
|
|
4
|
+
|
|
5
|
+
// The Chrome type package only includes the standard protocol schema, but WebMCP
|
|
6
|
+
// exposes experimental `WebMCP.*` commands/events through the same CDP client.
|
|
7
|
+
// Keep this local widening until we add proper generated protocol typings for
|
|
8
|
+
// the WebMCP domain.
|
|
9
|
+
export type CdpClient = Client & {
|
|
10
|
+
send(method: string, params?: any, sessionId?: string): Promise<any>;
|
|
11
|
+
on(method: string, cb: (params: any, sessionId?: string) => void): void;
|
|
12
|
+
off?(method: string, cb: (params: any, sessionId?: string) => void): void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const DEFAULT_HOST = process.env.CDP_HOST ?? "127.0.0.1";
|
|
16
|
+
const DEFAULT_PORT = Number(process.env.CDP_PORT ?? 9222);
|
|
17
|
+
const DEFAULT_WS = process.env.CDP_WS ?? `ws://${DEFAULT_HOST}:${DEFAULT_PORT}/devtools/browser`;
|
|
18
|
+
|
|
19
|
+
export class BrowserClientError extends Schema.TaggedErrorClass<BrowserClientError>()("BrowserClientError", {
|
|
20
|
+
operation: Schema.Union([Schema.Literal("connect"), Schema.Literal("disconnect")]),
|
|
21
|
+
cause: Schema.Unknown,
|
|
22
|
+
}) {}
|
|
23
|
+
|
|
24
|
+
export class BrowserClient extends Context.Service<BrowserClient, {
|
|
25
|
+
readonly connect: (options?: { readonly force?: boolean; }) => Effect.Effect<CdpClient, BrowserClientError>;
|
|
26
|
+
readonly get: Effect.Effect<Option.Option<CdpClient>>;
|
|
27
|
+
readonly disconnect: () => Effect.Effect<void, BrowserClientError>;
|
|
28
|
+
}>()("pi-webmcp/BrowserClient") {
|
|
29
|
+
static readonly live = Layer.effect(
|
|
30
|
+
BrowserClient,
|
|
31
|
+
Effect.gen(function*() {
|
|
32
|
+
const clientRef = yield* Ref.make<Option.Option<CdpClient>>(Option.none());
|
|
33
|
+
const scope = yield* Effect.scope;
|
|
34
|
+
const context = yield* Effect.context();
|
|
35
|
+
|
|
36
|
+
const clear = Ref.set(clientRef, Option.none());
|
|
37
|
+
|
|
38
|
+
const connect = Effect.fn("BrowserClient.connect")(function*(options?: { readonly force?: boolean; }) {
|
|
39
|
+
const existing = yield* Ref.get(clientRef);
|
|
40
|
+
|
|
41
|
+
if (Option.isSome(existing)) {
|
|
42
|
+
if (!options?.force) return existing.value;
|
|
43
|
+
|
|
44
|
+
yield* Ref.set(clientRef, Option.none());
|
|
45
|
+
yield* Effect.tryPromise({
|
|
46
|
+
try: () => existing.value.close(),
|
|
47
|
+
catch: (cause) => new BrowserClientError({ operation: "disconnect", cause }),
|
|
48
|
+
}).pipe(Effect.ignore);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const client = yield* Effect.acquireRelease(
|
|
52
|
+
Effect.tryPromise({
|
|
53
|
+
try: () => CDP({ target: DEFAULT_WS, local: true }),
|
|
54
|
+
catch: (cause) => new BrowserClientError({ operation: "connect", cause }),
|
|
55
|
+
}),
|
|
56
|
+
(client) =>
|
|
57
|
+
Effect.gen(function*() {
|
|
58
|
+
const existing = yield* Ref.get(clientRef);
|
|
59
|
+
if (Option.isNone(existing) || existing.value !== client) return;
|
|
60
|
+
yield* Ref.set(clientRef, Option.none());
|
|
61
|
+
yield* Effect.tryPromise({
|
|
62
|
+
try: () => client.close(),
|
|
63
|
+
catch: (cause) => new BrowserClientError({ operation: "disconnect", cause }),
|
|
64
|
+
}).pipe(Effect.ignore);
|
|
65
|
+
}),
|
|
66
|
+
).pipe(Scope.provide(scope));
|
|
67
|
+
|
|
68
|
+
client.on("disconnect", () => Effect.runSyncWith(context)(clear));
|
|
69
|
+
yield* Ref.set(clientRef, Option.some(client));
|
|
70
|
+
return client;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const disconnect = Effect.fn("BrowserClient.disconnect")(function*() {
|
|
74
|
+
const existing = yield* Ref.get(clientRef);
|
|
75
|
+
if (Option.isNone(existing)) return;
|
|
76
|
+
yield* Ref.set(clientRef, Option.none());
|
|
77
|
+
yield* Effect.tryPromise({
|
|
78
|
+
try: () => existing.value.close(),
|
|
79
|
+
catch: (cause) => new BrowserClientError({ operation: "disconnect", cause }),
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return BrowserClient.of({
|
|
84
|
+
connect,
|
|
85
|
+
get: Ref.get(clientRef),
|
|
86
|
+
disconnect,
|
|
87
|
+
});
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Context } from "effect";
|
|
3
|
+
|
|
4
|
+
export class PiApi extends Context.Service<PiApi, ExtensionAPI>()("pi-webmcp/PiApi") {}
|
|
5
|
+
|
|
6
|
+
export class PiContext extends Context.Service<PiContext, ExtensionCommandContext>()("pi-webmcp/PiContext") {}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option, Ref, Scope } from "effect";
|
|
2
|
+
|
|
3
|
+
export class PiTurnRefService extends Context.Service<PiTurnRefService, {
|
|
4
|
+
readonly make: <A>(value?: Option.Option<A>) => Effect.Effect<Ref.Ref<Option.Option<A>>, never, Scope.Scope>;
|
|
5
|
+
readonly reset: () => Effect.Effect<void>;
|
|
6
|
+
}>()("pi-webmcp/PiTurnRefService") {
|
|
7
|
+
static readonly live = Layer.effect(
|
|
8
|
+
PiTurnRefService,
|
|
9
|
+
Effect.gen(function*() {
|
|
10
|
+
const refs = new Set<Ref.Ref<Option.Option<any>>>();
|
|
11
|
+
|
|
12
|
+
return PiTurnRefService.of({
|
|
13
|
+
make: Effect.fn("PiTurnRefService.make")(function*<A>(value: Option.Option<A> = Option.none()) {
|
|
14
|
+
const ref = yield* Ref.make(value);
|
|
15
|
+
|
|
16
|
+
refs.add(ref);
|
|
17
|
+
|
|
18
|
+
yield* Effect.addFinalizer(() => {
|
|
19
|
+
return Effect.sync(() => refs.delete(ref));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
return ref;
|
|
23
|
+
}),
|
|
24
|
+
reset: Effect.fn("PiTurnRefService.reset")(function*() {
|
|
25
|
+
for (const ref of refs) {
|
|
26
|
+
yield* Ref.set(ref, Option.none());
|
|
27
|
+
}
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option } from "effect";
|
|
2
|
+
import { Origin } from "../schemas/WebMcpTool";
|
|
3
|
+
import { PiWebMcpSettingsService } from "./PiWebMcpSettingsService";
|
|
4
|
+
|
|
5
|
+
export class PiWebMcpAllowedOriginService extends Context.Service<PiWebMcpAllowedOriginService, {
|
|
6
|
+
readonly isAllowed: (origin: Origin) => boolean;
|
|
7
|
+
}>()("pi-webmcp/PiWebMcpAllowedOriginService") {
|
|
8
|
+
static readonly liveWithoutDependencies = Layer.effect(
|
|
9
|
+
PiWebMcpAllowedOriginService,
|
|
10
|
+
Effect.gen(function*() {
|
|
11
|
+
const settings = yield* PiWebMcpSettingsService;
|
|
12
|
+
|
|
13
|
+
const isAllowed = (origin: Origin): boolean => {
|
|
14
|
+
const allowed = Option.match(settings.allowedOrigins, {
|
|
15
|
+
onNone: () => true,
|
|
16
|
+
onSome: (origins) => origins.has(origin),
|
|
17
|
+
});
|
|
18
|
+
const disallowed = Option.match(settings.disallowedOrigins, {
|
|
19
|
+
onNone: () => false,
|
|
20
|
+
onSome: (origins) => origins.has(origin),
|
|
21
|
+
});
|
|
22
|
+
return allowed && !disallowed;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return PiWebMcpAllowedOriginService.of({ isAllowed });
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
static readonly live = PiWebMcpAllowedOriginService.liveWithoutDependencies.pipe(
|
|
30
|
+
Layer.provide(PiWebMcpSettingsService.live),
|
|
31
|
+
);
|
|
32
|
+
}
|