@lelouchhe/webagent 0.8.0 → 0.10.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/README.md +43 -15
- package/config.toml +7 -27
- package/dist/index.html +21 -5
- package/dist/js/app.INIQQEGD.js +5 -0
- package/dist/js/chunk.3CLGCUHW.js +1 -0
- package/dist/js/{chunk.CT5WBNGZ.js → chunk.7WADDFJZ.js} +50 -49
- package/dist/js/chunk.AOTG3PL7.js +20 -0
- package/dist/js/{login.2WA6DTGM.js → login.WMURU4NI.js} +1 -1
- package/dist/js/viewer.RHZMFYWJ.js +1 -0
- package/dist/login.html +2 -2
- package/dist/share-viewer.html +6 -6
- package/dist/{styles.00etlpgs.css → styles.01aj0l37.css} +186 -4
- package/dist/sw.js +6 -6
- package/lib/agent-key.js +6 -0
- package/lib/attachment-dispatch.js +60 -31
- package/lib/attachment-interceptor.js +7 -7
- package/lib/attachment-labels.js +1 -1
- package/lib/attachments.js +69 -7
- package/lib/auth-middleware.js +11 -4
- package/lib/auth.js +2 -2
- package/lib/bridge.js +209 -90
- package/lib/client-registry.js +12 -12
- package/lib/config.js +2 -31
- package/lib/event-handler.js +166 -85
- package/lib/files/limits.js +15 -0
- package/lib/files/paths.js +155 -0
- package/lib/files/routes.js +232 -0
- package/lib/home-path.js +35 -0
- package/lib/http-status.js +1 -0
- package/lib/mcp/capability.js +74 -0
- package/lib/mcp/server.js +148 -0
- package/lib/mcp/task-history.js +245 -0
- package/lib/mcp/task-host.js +253 -0
- package/lib/mcp/tools.js +168 -0
- package/lib/mode-bucket.js +1 -1
- package/lib/push-service.js +33 -35
- package/lib/routes.js +1022 -475
- package/lib/server.js +84 -34
- package/lib/share/routes.js +97 -85
- package/lib/shared/task-reference.js +20 -0
- package/lib/sse-manager.js +8 -8
- package/lib/store.js +992 -284
- package/lib/task-collaboration.js +15 -0
- package/lib/task-manager.js +1409 -0
- package/lib/task-path.js +131 -0
- package/lib/{session-state.js → task-state.js} +90 -38
- package/lib/task-tree-lock.js +74 -0
- package/lib/{sessions-anchor.js → tasks-anchor.js} +8 -7
- package/lib/tokens.js +1 -1
- package/lib/types.js +2 -2
- package/package.json +8 -1
- package/dist/js/app.XBFXH37R.js +0 -2
- package/dist/js/chunk.UMQMOGWO.js +0 -1
- package/dist/js/viewer.CVWXSKJM.js +0 -1
- package/lib/session-manager.js +0 -613
- package/lib/title-service.js +0 -95
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File viewer HTTP routes — read-only access to arbitrary local files.
|
|
3
|
+
*
|
|
4
|
+
* URL space claimed: `/api/v1/files/{info,list,content}`.
|
|
5
|
+
* Task-less by design (confirmed): the caller passes an absolute or
|
|
6
|
+
* `~`-prefixed path; the server own `~` expansion + realpath canonicalization.
|
|
7
|
+
* Relative paths are rejected. Bearer auth is enforced by the shared
|
|
8
|
+
* `/api/**` gate in routes.ts — these paths are deliberately NOT in the
|
|
9
|
+
* public whitelist, with one exception: `content` IS whitelisted so
|
|
10
|
+
* `<img>` / `<a download>` can fetch without an Authorization header, and
|
|
11
|
+
* it instead requires an HMAC sig+exp signed URL (issued by `info`),
|
|
12
|
+
* mirroring the attachment scheme.
|
|
13
|
+
*
|
|
14
|
+
* Guards (see paths.ts + limits.ts): only regular files/dirs are served
|
|
15
|
+
* (no fifos/sockets/devices), directory scans and inline previews are bounded,
|
|
16
|
+
* downloads stream with backpressure, and responses carry nosniff + a
|
|
17
|
+
* restrictive CSP. The viewer renders text/markdown/image only — nothing here
|
|
18
|
+
* executes content.
|
|
19
|
+
*/
|
|
20
|
+
import { basename, dirname } from "node:path";
|
|
21
|
+
import { pipeline } from "node:stream/promises";
|
|
22
|
+
import { signAttachmentUrl, verifyAttachmentSig } from "../auth.js";
|
|
23
|
+
import { buildContentDisposition, sniffMime } from "../attachments.js";
|
|
24
|
+
import { HTTP_STATUS } from "../http-status.js";
|
|
25
|
+
import { abbreviateHomePath } from "../home-path.js";
|
|
26
|
+
import { log } from "../log.js";
|
|
27
|
+
import { MAX_IMAGE_BYTES, MAX_TEXT_PREVIEW_BYTES } from "./limits.js";
|
|
28
|
+
import { canonicalize, expandPath, FilePathError, listDirectory, openForStreaming, readHandleHead, readHead, statMeta, } from "./paths.js";
|
|
29
|
+
const flog = log.scope("files");
|
|
30
|
+
const CONTENT_TTL_SECONDS = 3600; // signed URLs live 1h, like attachments
|
|
31
|
+
const SNIFF_BYTES = 4096;
|
|
32
|
+
function fileSystemError(err) {
|
|
33
|
+
const code = err?.code;
|
|
34
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
35
|
+
return new FilePathError(HTTP_STATUS.FORBIDDEN, "Permission denied");
|
|
36
|
+
}
|
|
37
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
38
|
+
return new FilePathError(HTTP_STATUS.NOT_FOUND, "Path does not exist");
|
|
39
|
+
}
|
|
40
|
+
if (code === "ELOOP" ||
|
|
41
|
+
code === "EINVAL" ||
|
|
42
|
+
code === "ENAMETOOLONG" ||
|
|
43
|
+
code === "ERR_INVALID_ARG_VALUE") {
|
|
44
|
+
return new FilePathError(HTTP_STATUS.BAD_REQUEST, "Invalid path");
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
function json(res, status, body) {
|
|
49
|
+
res.writeHead(status, {
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
"Cache-Control": "no-store",
|
|
52
|
+
});
|
|
53
|
+
res.end(JSON.stringify(body));
|
|
54
|
+
}
|
|
55
|
+
function previewLimitFor(mime) {
|
|
56
|
+
const m = mime.toLowerCase();
|
|
57
|
+
if (m.startsWith("image/"))
|
|
58
|
+
return MAX_IMAGE_BYTES;
|
|
59
|
+
if (m.startsWith("text/"))
|
|
60
|
+
return MAX_TEXT_PREVIEW_BYTES;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
/** Expand + canonicalize a caller-supplied path; 400 on empty/relative. */
|
|
64
|
+
async function resolvePath(raw) {
|
|
65
|
+
if (!raw)
|
|
66
|
+
throw new FilePathError(400, "Missing path");
|
|
67
|
+
return canonicalize(expandPath(raw));
|
|
68
|
+
}
|
|
69
|
+
function contentBasePath(pathRaw) {
|
|
70
|
+
return `/api/v1/files/content?path=${encodeURIComponent(pathRaw)}`;
|
|
71
|
+
}
|
|
72
|
+
async function streamHandle(res, handle, size) {
|
|
73
|
+
const stream = handle.createReadStream({
|
|
74
|
+
autoClose: false,
|
|
75
|
+
start: 0,
|
|
76
|
+
...(size > 0 ? { end: size - 1 } : {}),
|
|
77
|
+
});
|
|
78
|
+
try {
|
|
79
|
+
await pipeline(stream, res);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
// Headers are already committed. A client disconnect or disk read error
|
|
83
|
+
// must terminate the stream, never fall through to a second JSON response.
|
|
84
|
+
if (!res.destroyed) {
|
|
85
|
+
res.destroy(err instanceof Error ? err : undefined);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export async function handleFileRoutes(req, res, deps) {
|
|
90
|
+
const url = req.url ?? "/";
|
|
91
|
+
if (!url.startsWith("/api/v1/files"))
|
|
92
|
+
return false;
|
|
93
|
+
const m = url.match(/^\/api\/v1\/files\/(info|list|content)(?:\?(.*))?$/);
|
|
94
|
+
if (!m)
|
|
95
|
+
return false;
|
|
96
|
+
const method = req.method ?? "GET";
|
|
97
|
+
if (method !== "GET") {
|
|
98
|
+
res.setHeader("Allow", "GET");
|
|
99
|
+
json(res, HTTP_STATUS.METHOD_NOT_ALLOWED, {
|
|
100
|
+
error: "Read-only: GET only",
|
|
101
|
+
});
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const params = new URLSearchParams(m[2]);
|
|
106
|
+
const pathRaw = params.get("path") ?? "";
|
|
107
|
+
switch (m[1]) {
|
|
108
|
+
case "info":
|
|
109
|
+
await handleInfo(res, deps, pathRaw);
|
|
110
|
+
return true;
|
|
111
|
+
case "list":
|
|
112
|
+
await handleList(res, pathRaw);
|
|
113
|
+
return true;
|
|
114
|
+
case "content":
|
|
115
|
+
await handleContent(res, deps, pathRaw, params);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
if (err instanceof FilePathError) {
|
|
121
|
+
json(res, err.status, { error: err.message });
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
const fsError = fileSystemError(err);
|
|
125
|
+
if (fsError) {
|
|
126
|
+
json(res, fsError.status, { error: fsError.message });
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
flog.error("file route failed", { url, error: String(err) });
|
|
130
|
+
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "internal_error" });
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
async function handleInfo(res, deps, pathRaw) {
|
|
136
|
+
const canonical = await resolvePath(pathRaw);
|
|
137
|
+
const meta = await statMeta(canonical);
|
|
138
|
+
const out = {
|
|
139
|
+
path: meta.path,
|
|
140
|
+
pathDisplay: abbreviateHomePath(meta.path),
|
|
141
|
+
name: meta.name,
|
|
142
|
+
kind: meta.kind,
|
|
143
|
+
size: meta.size,
|
|
144
|
+
mtime: meta.mtime,
|
|
145
|
+
};
|
|
146
|
+
if (meta.kind === "file") {
|
|
147
|
+
const mime = await sniffMime(await readHead(canonical, SNIFF_BYTES));
|
|
148
|
+
const previewLimit = previewLimitFor(mime);
|
|
149
|
+
out.mime = mime;
|
|
150
|
+
if (previewLimit !== null)
|
|
151
|
+
out.maxBytes = previewLimit;
|
|
152
|
+
if (deps.secret) {
|
|
153
|
+
const basePath = contentBasePath(canonical);
|
|
154
|
+
out.contentUrl = `${basePath}&${signAttachmentUrl(basePath, deps.secret, CONTENT_TTL_SECONDS)}`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
json(res, HTTP_STATUS.OK, out);
|
|
158
|
+
}
|
|
159
|
+
async function handleList(res, pathRaw) {
|
|
160
|
+
const canonical = await resolvePath(pathRaw);
|
|
161
|
+
const meta = await statMeta(canonical);
|
|
162
|
+
if (meta.kind !== "dir") {
|
|
163
|
+
throw new FilePathError(HTTP_STATUS.BAD_REQUEST, "Not a directory");
|
|
164
|
+
}
|
|
165
|
+
const { entries, truncated } = await listDirectory(canonical);
|
|
166
|
+
const parent = dirname(canonical);
|
|
167
|
+
json(res, HTTP_STATUS.OK, {
|
|
168
|
+
path: canonical,
|
|
169
|
+
pathDisplay: abbreviateHomePath(canonical),
|
|
170
|
+
parent,
|
|
171
|
+
parentDisplay: abbreviateHomePath(parent),
|
|
172
|
+
truncated,
|
|
173
|
+
entries,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
async function handleContent(res, deps, pathRaw, params) {
|
|
177
|
+
const basePath = contentBasePath(pathRaw);
|
|
178
|
+
const sig = params.get("sig") ?? "";
|
|
179
|
+
const exp = params.get("exp") ?? "";
|
|
180
|
+
// content is whitelisted in auth-middleware.ts (media tags / downloads
|
|
181
|
+
// cannot send Authorization headers), so it must verify its own URL.
|
|
182
|
+
// Unlike the older attachment route, this new security-sensitive route
|
|
183
|
+
// fails closed when no signing secret is wired.
|
|
184
|
+
if (!deps.secret) {
|
|
185
|
+
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
186
|
+
error: "file_content_signing_unavailable",
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!sig || !exp || !verifyAttachmentSig(basePath, exp, sig, deps.secret)) {
|
|
191
|
+
res.writeHead(HTTP_STATUS.UNAUTHORIZED, {
|
|
192
|
+
"Content-Type": "application/json",
|
|
193
|
+
});
|
|
194
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const canonical = await resolvePath(pathRaw);
|
|
198
|
+
// `info` signs the realpath-canonical string. If that path now resolves to
|
|
199
|
+
// another target (for example it was replaced by a symlink), the old
|
|
200
|
+
// capability must not silently acquire authority over the new target.
|
|
201
|
+
if (canonical !== pathRaw) {
|
|
202
|
+
json(res, HTTP_STATUS.UNAUTHORIZED, { error: "Unauthorized" });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const handle = await openForStreaming(canonical);
|
|
206
|
+
try {
|
|
207
|
+
const stats = await handle.stat();
|
|
208
|
+
if (!stats.isFile()) {
|
|
209
|
+
throw new FilePathError(HTTP_STATUS.BAD_REQUEST, "Not a regular file");
|
|
210
|
+
}
|
|
211
|
+
const mime = await sniffMime(await readHandleHead(handle, stats.size, SNIFF_BYTES));
|
|
212
|
+
const previewLimit = previewLimitFor(mime);
|
|
213
|
+
const disposition = previewLimit !== null && stats.size <= previewLimit
|
|
214
|
+
? "inline"
|
|
215
|
+
: "attachment";
|
|
216
|
+
res.writeHead(HTTP_STATUS.OK, {
|
|
217
|
+
"Content-Type": mime,
|
|
218
|
+
"X-Content-Type-Options": "nosniff",
|
|
219
|
+
// Ordinary project files change in place; never let reopening show a
|
|
220
|
+
// cached pre-edit body under the same path-bound signed URL.
|
|
221
|
+
"Cache-Control": "no-store",
|
|
222
|
+
// Belt-and-braces: even if a mime mis-sniff ever lets a browser
|
|
223
|
+
// interpret this body as HTML, it can't load any subresource.
|
|
224
|
+
"Content-Security-Policy": "default-src 'none'",
|
|
225
|
+
"Content-Disposition": buildContentDisposition(disposition, basename(canonical)),
|
|
226
|
+
});
|
|
227
|
+
await streamHandle(res, handle, stats.size);
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
await handle.close().catch(() => { });
|
|
231
|
+
}
|
|
232
|
+
}
|
package/lib/home-path.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
3
|
+
const NATIVE_PATH = { isAbsolute, join, relative, sep };
|
|
4
|
+
function portableDisplayPath(input, path) {
|
|
5
|
+
return path.sep === "\\" ? input.replace(/\\/g, "/") : input;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Expand the current user's HOME shorthand with one authoritative grammar.
|
|
9
|
+
* Both `~/` and `~\` are accepted; named-user forms stay untouched.
|
|
10
|
+
*/
|
|
11
|
+
export function expandHomePath(input, home = homedir(), path = NATIVE_PATH) {
|
|
12
|
+
if (input === "~")
|
|
13
|
+
return home;
|
|
14
|
+
if (input.startsWith("~/") || input.startsWith("~\\")) {
|
|
15
|
+
const tail = input.slice(2).replace(/[\\/]/g, path.sep);
|
|
16
|
+
return path.join(home, tail);
|
|
17
|
+
}
|
|
18
|
+
return input;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Abbreviate HOME and normalize Windows display paths to portable `/`
|
|
22
|
+
* separators. Canonical filesystem paths remain native and are stored/signed
|
|
23
|
+
* separately; this function is exclusively for UI round-tripping.
|
|
24
|
+
*/
|
|
25
|
+
export function abbreviateHomePath(input, home = homedir(), path = NATIVE_PATH) {
|
|
26
|
+
const relativePath = path.relative(home, input);
|
|
27
|
+
if (relativePath === "")
|
|
28
|
+
return "~";
|
|
29
|
+
if (relativePath === ".." ||
|
|
30
|
+
relativePath.startsWith(`..${path.sep}`) ||
|
|
31
|
+
path.isAbsolute(relativePath)) {
|
|
32
|
+
return portableDisplayPath(input, path);
|
|
33
|
+
}
|
|
34
|
+
return `~/${portableDisplayPath(relativePath, path)}`;
|
|
35
|
+
}
|
package/lib/http-status.js
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
const CAPABILITY_PREFIX = "mcp_";
|
|
3
|
+
/**
|
|
4
|
+
* Per-task MCP capability store.
|
|
5
|
+
*
|
|
6
|
+
* Holds the mapping between opaque capability tokens handed to ACP
|
|
7
|
+
* `mcpServers` definitions and the WebAgent task they were minted for.
|
|
8
|
+
* Lifecycle (minting on task create, revocation on task delete) is
|
|
9
|
+
* driven exclusively by TaskManager — this class is deliberately free of
|
|
10
|
+
* any lifecycle logic so the two tables can never drift.
|
|
11
|
+
*
|
|
12
|
+
* Tokens never touch the persistent store: they exist only in this map and
|
|
13
|
+
* die with the process, so a restart invalidates every outstanding
|
|
14
|
+
* capability by construction.
|
|
15
|
+
*/
|
|
16
|
+
export class CapabilityStore {
|
|
17
|
+
byToken = new Map();
|
|
18
|
+
byTask = new Map();
|
|
19
|
+
mintToken(taskId) {
|
|
20
|
+
const token = `${CAPABILITY_PREFIX}${randomBytes(32).toString("base64url")}`;
|
|
21
|
+
this.byToken.set(token, taskId);
|
|
22
|
+
const tokens = this.byTask.get(taskId) ?? new Set();
|
|
23
|
+
tokens.add(token);
|
|
24
|
+
this.byTask.set(taskId, tokens);
|
|
25
|
+
return token;
|
|
26
|
+
}
|
|
27
|
+
/** Mint a fresh capability for one task, replacing any prior one. */
|
|
28
|
+
mint(taskId) {
|
|
29
|
+
this.revokeByTask(taskId);
|
|
30
|
+
return this.mintToken(taskId);
|
|
31
|
+
}
|
|
32
|
+
/** Mint a replacement while keeping the current execution capability valid. */
|
|
33
|
+
mintAdditional(taskId) {
|
|
34
|
+
return this.mintToken(taskId);
|
|
35
|
+
}
|
|
36
|
+
/** Revoke one capability token (no-op when it is unknown). */
|
|
37
|
+
revoke(token) {
|
|
38
|
+
const taskId = this.byToken.get(token);
|
|
39
|
+
if (!taskId)
|
|
40
|
+
return;
|
|
41
|
+
this.byToken.delete(token);
|
|
42
|
+
const tokens = this.byTask.get(taskId);
|
|
43
|
+
tokens?.delete(token);
|
|
44
|
+
if (tokens?.size === 0)
|
|
45
|
+
this.byTask.delete(taskId);
|
|
46
|
+
}
|
|
47
|
+
/** Revoke all capabilities except the one used by a replacement execution. */
|
|
48
|
+
revokeOtherTokens(taskId, keepToken) {
|
|
49
|
+
for (const token of this.byTask.get(taskId) ?? []) {
|
|
50
|
+
if (token !== keepToken)
|
|
51
|
+
this.revoke(token);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Revoke every capability minted for a task (no-op when none exists). */
|
|
55
|
+
revokeByTask(taskId) {
|
|
56
|
+
for (const token of this.byTask.get(taskId) ?? []) {
|
|
57
|
+
this.byToken.delete(token);
|
|
58
|
+
}
|
|
59
|
+
this.byTask.delete(taskId);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Resolve a capability token to its task, or null when unknown.
|
|
63
|
+
* Fail-closed: any token that was never minted, was revoked, or whose
|
|
64
|
+
* process died resolves to null.
|
|
65
|
+
*/
|
|
66
|
+
resolve(token) {
|
|
67
|
+
return this.byToken.get(token) ?? null;
|
|
68
|
+
}
|
|
69
|
+
/** Drop every capability (server shutdown). */
|
|
70
|
+
clear() {
|
|
71
|
+
this.byToken.clear();
|
|
72
|
+
this.byTask.clear();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
3
|
+
import { registerMcpTools } from "./tools.js";
|
|
4
|
+
import { HTTP_STATUS } from "../http-status.js";
|
|
5
|
+
/**
|
|
6
|
+
* WebAgent MCP server endpoint.
|
|
7
|
+
*
|
|
8
|
+
* Serves the MCP control plane for ACP sessions over Streamable HTTP.
|
|
9
|
+
* Each request is authenticated by the capability token minted for its
|
|
10
|
+
* session (carried as `Authorization: Bearer <capability>`); the endpoint
|
|
11
|
+
* fails closed — an unknown, revoked, or out-of-scope capability never
|
|
12
|
+
* reaches the MCP protocol layer.
|
|
13
|
+
*
|
|
14
|
+
* The endpoint lives outside `/api/**`, so the shared Bearer auth gate does
|
|
15
|
+
* not apply to it (mirroring the share viewer's `/s/*` pattern): identity is
|
|
16
|
+
* the per-task capability, distinct from operator UI tokens.
|
|
17
|
+
*/
|
|
18
|
+
/** Uniquely-named WebAgent MCP server appended to an ACP session's mcpServers. */
|
|
19
|
+
export const MCP_SERVER_NAME = "webagent";
|
|
20
|
+
/**
|
|
21
|
+
* Short, transport-level usage guidance advertised through MCP initialize.
|
|
22
|
+
* Keep this generic and bounded: detailed workflow guidance belongs in tool
|
|
23
|
+
* descriptions, the Task Manual, or an on-demand skill.
|
|
24
|
+
*/
|
|
25
|
+
export const MCP_SERVER_INSTRUCTIONS = [
|
|
26
|
+
"Use task_create for a direct child, then immediately use task_send to give it its first instruction.",
|
|
27
|
+
"Use task_send for normal coordination and for continuing or resuming existing Tasks; task_send is not a lifecycle handoff. Use task_update(done|blocked) for typed lifecycle handoffs. A done Task remains available and is not deleted or permanently closed.",
|
|
28
|
+
"After dispatching work, end the current turn; do not poll with task_query.",
|
|
29
|
+
"Use task_query and task_get_record only for history recovery, diagnosis, or audit.",
|
|
30
|
+
"Omit task_id to inspect the current Task's persisted history.",
|
|
31
|
+
].join("\n");
|
|
32
|
+
const DEFAULT_PATH = "/mcp";
|
|
33
|
+
/**
|
|
34
|
+
* Build the ACP `McpServer` definition for one session: an HTTP entry whose
|
|
35
|
+
* Authorization header carries the session's freshly minted capability.
|
|
36
|
+
* `authBaseUrl` is the WebAgent's own origin (e.g. `http://127.0.0.1:6800`);
|
|
37
|
+
* the endpoint path is appended here so callers pass the base only.
|
|
38
|
+
*/
|
|
39
|
+
export function buildMcpServerEntry(capability, authBaseUrl) {
|
|
40
|
+
const base = authBaseUrl.replace(/\/$/, "");
|
|
41
|
+
return {
|
|
42
|
+
type: "http",
|
|
43
|
+
name: MCP_SERVER_NAME,
|
|
44
|
+
url: `${base}${DEFAULT_PATH}`,
|
|
45
|
+
headers: [{ name: "Authorization", value: `Bearer ${capability}` }],
|
|
46
|
+
// ACP reserves _meta for extension metadata. pi-acp translates this
|
|
47
|
+
// generic direct-tools hint into the adapter's internal setting; agents
|
|
48
|
+
// that do not understand it can safely ignore the metadata.
|
|
49
|
+
_meta: { directTools: true },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function capabilityFromRequest(req) {
|
|
53
|
+
const raw = req.headers.authorization;
|
|
54
|
+
if (typeof raw !== "string")
|
|
55
|
+
return null;
|
|
56
|
+
const match = /^Bearer\s+(\S+)\s*$/i.exec(raw.trim());
|
|
57
|
+
return match ? match[1] : null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Create an MCP request handler. Returns a function that handles requests
|
|
61
|
+
* for the endpoint path and returns `false` for everything else so the main
|
|
62
|
+
* router can continue dispatching.
|
|
63
|
+
*/
|
|
64
|
+
export function createMcpEndpoint(options) {
|
|
65
|
+
const path = options.path ?? DEFAULT_PATH;
|
|
66
|
+
const { capabilities, isTaskActive } = options;
|
|
67
|
+
return async (req, res) => {
|
|
68
|
+
const url = req.url ?? "/";
|
|
69
|
+
const pathname = url.split("?")[0] ?? url;
|
|
70
|
+
if (pathname !== path)
|
|
71
|
+
return false;
|
|
72
|
+
const method = req.method ?? "GET";
|
|
73
|
+
if (method !== "POST") {
|
|
74
|
+
// Stateless mode has no SSE stream and no session reuse, so the MCP
|
|
75
|
+
// client drives request/response over POST only — mirrors the SDK's
|
|
76
|
+
// stateless example, which rejects other methods with 405.
|
|
77
|
+
res.writeHead(HTTP_STATUS.METHOD_NOT_ALLOWED, {
|
|
78
|
+
Allow: "POST",
|
|
79
|
+
"Content-Type": "application/json",
|
|
80
|
+
});
|
|
81
|
+
res.end(JSON.stringify({
|
|
82
|
+
jsonrpc: "2.0",
|
|
83
|
+
error: { code: -32000, message: "Method not allowed." },
|
|
84
|
+
id: null,
|
|
85
|
+
}));
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
// --- Capability gate (fail closed) ---
|
|
89
|
+
const capability = capabilityFromRequest(req);
|
|
90
|
+
const taskId = capability ? capabilities.resolve(capability) : null;
|
|
91
|
+
if (!taskId || !isTaskActive(taskId)) {
|
|
92
|
+
res.writeHead(HTTP_STATUS.UNAUTHORIZED, {
|
|
93
|
+
"Content-Type": "application/json",
|
|
94
|
+
"WWW-Authenticate": "Bearer",
|
|
95
|
+
});
|
|
96
|
+
res.end(JSON.stringify({
|
|
97
|
+
jsonrpc: "2.0",
|
|
98
|
+
error: { code: -32000, message: "Unauthorized" },
|
|
99
|
+
id: null,
|
|
100
|
+
}));
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
// --- MCP protocol (stateless, one server+transport per request) ---
|
|
104
|
+
const server = new McpServer({ name: MCP_SERVER_NAME, version: "0.1.0" }, { instructions: MCP_SERVER_INSTRUCTIONS });
|
|
105
|
+
registerMcpTools(server, taskId, options.taskTools);
|
|
106
|
+
const transport = new StreamableHTTPServerTransport({
|
|
107
|
+
sessionIdGenerator: undefined,
|
|
108
|
+
// JSON responses for POST round trips (no SSE streaming needed for the
|
|
109
|
+
// agent-driven request/response shape; the SDK default SSE response is
|
|
110
|
+
// harder for clients without an event-stream consumer).
|
|
111
|
+
enableJsonResponse: true,
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
await server.connect(transport);
|
|
115
|
+
await transport.handleRequest(req, res);
|
|
116
|
+
// Transport already owns response handling for protocol errors; a
|
|
117
|
+
// thrown error here means the response was not (or only partially)
|
|
118
|
+
// written. Emit a JSON-RPC internal error instead of leaking details.
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
try {
|
|
122
|
+
if (!res.headersSent) {
|
|
123
|
+
res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR, {
|
|
124
|
+
"Content-Type": "application/json",
|
|
125
|
+
});
|
|
126
|
+
res.end(JSON.stringify({
|
|
127
|
+
jsonrpc: "2.0",
|
|
128
|
+
error: { code: -32603, message: "Internal server error" },
|
|
129
|
+
id: null,
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
res.end();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Nothing more we can do; the connection is gone.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
res.once("close", () => {
|
|
142
|
+
void transport.close().catch(() => { });
|
|
143
|
+
void server.close().catch(() => { });
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
};
|
|
148
|
+
}
|