@haywoodmarx/agent-pager 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/LICENSE +21 -0
- package/README.md +63 -0
- package/dist/src/broker/address.d.ts +49 -0
- package/dist/src/broker/address.js +110 -0
- package/dist/src/broker/broker.d.ts +665 -0
- package/dist/src/broker/broker.js +2270 -0
- package/dist/src/broker/database.d.ts +81 -0
- package/dist/src/broker/database.js +69 -0
- package/dist/src/broker/operator-projection.d.ts +291 -0
- package/dist/src/broker/operator-projection.js +722 -0
- package/dist/src/broker/protocol.d.ts +237 -0
- package/dist/src/broker/protocol.js +311 -0
- package/dist/src/broker/schema.d.ts +94 -0
- package/dist/src/broker/schema.js +454 -0
- package/dist/src/broker/snapshot.d.ts +171 -0
- package/dist/src/broker/snapshot.js +78 -0
- package/dist/src/configuration-defaults.d.ts +64 -0
- package/dist/src/configuration-defaults.js +48 -0
- package/dist/src/configuration.d.ts +76 -0
- package/dist/src/configuration.js +156 -0
- package/dist/src/implementations/claude/environment.d.ts +8 -0
- package/dist/src/implementations/claude/environment.js +9 -0
- package/dist/src/implementations/claude/gateway-cli.d.ts +2 -0
- package/dist/src/implementations/claude/gateway-cli.js +136 -0
- package/dist/src/implementations/claude/gateway-identity.d.ts +134 -0
- package/dist/src/implementations/claude/gateway-identity.js +103 -0
- package/dist/src/implementations/claude/gateway.d.ts +148 -0
- package/dist/src/implementations/claude/gateway.js +236 -0
- package/dist/src/implementations/claude/instructions.d.ts +33 -0
- package/dist/src/implementations/claude/instructions.js +64 -0
- package/dist/src/implementations/claude/manifest.d.ts +33 -0
- package/dist/src/implementations/claude/manifest.js +108 -0
- package/dist/src/implementations/claude/plugin-directory.d.ts +80 -0
- package/dist/src/implementations/claude/plugin-directory.js +137 -0
- package/dist/src/implementations/claude/session-continuation.d.ts +25 -0
- package/dist/src/implementations/claude/session-continuation.js +125 -0
- package/dist/src/implementations/claude/session-identity.d.ts +25 -0
- package/dist/src/implementations/claude/session-identity.js +31 -0
- package/dist/src/implementations/claude/session-start-cli.d.ts +2 -0
- package/dist/src/implementations/claude/session-start-cli.js +16 -0
- package/dist/src/implementations/claude/socket-delivery.d.ts +52 -0
- package/dist/src/implementations/claude/socket-delivery.js +159 -0
- package/dist/src/implementations/model.d.ts +162 -0
- package/dist/src/implementations/model.js +1 -0
- package/dist/src/implementations/opencode/adapter.d.ts +71 -0
- package/dist/src/implementations/opencode/adapter.js +94 -0
- package/dist/src/implementations/opencode/environment.d.ts +8 -0
- package/dist/src/implementations/opencode/environment.js +9 -0
- package/dist/src/implementations/opencode/manifest.d.ts +30 -0
- package/dist/src/implementations/opencode/manifest.js +93 -0
- package/dist/src/implementations/opencode/plugin-api.d.ts +120 -0
- package/dist/src/implementations/opencode/plugin-api.js +1 -0
- package/dist/src/implementations/opencode/plugin.d.ts +38 -0
- package/dist/src/implementations/opencode/plugin.js +303 -0
- package/dist/src/implementations/opencode/self-close-tool.d.ts +9 -0
- package/dist/src/implementations/opencode/self-close-tool.js +31 -0
- package/dist/src/implementations/orca/terminal.d.ts +48 -0
- package/dist/src/implementations/orca/terminal.js +154 -0
- package/dist/src/implementations/pi/broker-client.d.ts +56 -0
- package/dist/src/implementations/pi/broker-client.js +156 -0
- package/dist/src/implementations/pi/environment.d.ts +8 -0
- package/dist/src/implementations/pi/environment.js +9 -0
- package/dist/src/implementations/pi/extension.d.ts +57 -0
- package/dist/src/implementations/pi/extension.js +253 -0
- package/dist/src/implementations/pi/host-slots.d.ts +18 -0
- package/dist/src/implementations/pi/host-slots.js +27 -0
- package/dist/src/implementations/pi/manifest.d.ts +11 -0
- package/dist/src/implementations/pi/manifest.js +61 -0
- package/dist/src/implementations/pi/self-close-tool.d.ts +7 -0
- package/dist/src/implementations/pi/self-close-tool.js +27 -0
- package/dist/src/implementations/registry.d.ts +47 -0
- package/dist/src/implementations/registry.js +157 -0
- package/dist/src/implementations/session-key.d.ts +13 -0
- package/dist/src/implementations/session-key.js +19 -0
- package/dist/src/index.d.ts +27 -0
- package/dist/src/index.js +26 -0
- package/dist/src/launch/broker-lock.d.ts +257 -0
- package/dist/src/launch/broker-lock.js +644 -0
- package/dist/src/launch/cli.d.ts +37 -0
- package/dist/src/launch/cli.js +317 -0
- package/dist/src/launch/environment.d.ts +45 -0
- package/dist/src/launch/environment.js +48 -0
- package/dist/src/launch/launch.d.ts +235 -0
- package/dist/src/launch/launch.js +223 -0
- package/dist/src/launch/on-demand.d.ts +202 -0
- package/dist/src/launch/on-demand.js +435 -0
- package/dist/src/launch/runtime-composition.d.ts +88 -0
- package/dist/src/launch/runtime-composition.js +253 -0
- package/dist/src/launch/runtime.d.ts +79 -0
- package/dist/src/launch/runtime.js +196 -0
- package/dist/src/launch/service-system.d.ts +11 -0
- package/dist/src/launch/service-system.js +39 -0
- package/dist/src/launch/service.d.ts +173 -0
- package/dist/src/launch/service.js +492 -0
- package/dist/src/launch/skill.d.ts +57 -0
- package/dist/src/launch/skill.js +81 -0
- package/dist/src/launch/terminal.d.ts +26 -0
- package/dist/src/launch/terminal.js +43 -0
- package/dist/src/launch/workspace-branch.d.ts +10 -0
- package/dist/src/launch/workspace-branch.js +29 -0
- package/dist/src/messaging/delivery-client.d.ts +144 -0
- package/dist/src/messaging/delivery-client.js +261 -0
- package/dist/src/messaging/delivery.d.ts +75 -0
- package/dist/src/messaging/delivery.js +22 -0
- package/dist/src/messaging/inbox-socket.d.ts +98 -0
- package/dist/src/messaging/inbox-socket.js +278 -0
- package/dist/src/messaging/mcp.d.ts +53 -0
- package/dist/src/messaging/mcp.js +501 -0
- package/dist/src/messaging/notifications.d.ts +231 -0
- package/dist/src/messaging/notifications.js +489 -0
- package/dist/src/messaging/push-delivery.d.ts +77 -0
- package/dist/src/messaging/push-delivery.js +187 -0
- package/dist/src/messaging/tools.d.ts +11 -0
- package/dist/src/messaging/tools.js +102 -0
- package/dist/src/messaging/wake.d.ts +23 -0
- package/dist/src/messaging/wake.js +39 -0
- package/dist/src/operator/agent-projection.d.ts +102 -0
- package/dist/src/operator/agent-projection.js +249 -0
- package/dist/src/operator/commands.d.ts +50 -0
- package/dist/src/operator/commands.js +199 -0
- package/dist/src/operator/diagnostics.d.ts +21 -0
- package/dist/src/operator/diagnostics.js +22 -0
- package/dist/src/operator/protocol.d.ts +511 -0
- package/dist/src/operator/protocol.js +49 -0
- package/dist/src/operator/repository-system.d.ts +14 -0
- package/dist/src/operator/repository-system.js +150 -0
- package/dist/src/operator/repository.d.ts +79 -0
- package/dist/src/operator/repository.js +72 -0
- package/dist/src/operator/traffic-projection.d.ts +19 -0
- package/dist/src/operator/traffic-projection.js +229 -0
- package/dist/src/operator/transport.d.ts +87 -0
- package/dist/src/operator/transport.js +549 -0
- package/dist/src/operator/update-store.d.ts +53 -0
- package/dist/src/operator/update-store.js +162 -0
- package/dist/src/operator/updates.d.ts +94 -0
- package/dist/src/operator/updates.js +280 -0
- package/dist/src/operator/validation.d.ts +114 -0
- package/dist/src/operator/validation.js +916 -0
- package/dist/src/shared/guards.d.ts +59 -0
- package/dist/src/shared/guards.js +119 -0
- package/dist/src/shared/http.d.ts +30 -0
- package/dist/src/shared/http.js +50 -0
- package/dist/src/shared/row.d.ts +2 -0
- package/dist/src/shared/row.js +1 -0
- package/dist/src/shared/shell.d.ts +6 -0
- package/dist/src/shared/shell.js +8 -0
- package/dist/src/tui/actions.d.ts +166 -0
- package/dist/src/tui/actions.js +253 -0
- package/dist/src/tui/agent-tree.d.ts +125 -0
- package/dist/src/tui/agent-tree.js +663 -0
- package/dist/src/tui/app.d.ts +38 -0
- package/dist/src/tui/app.js +378 -0
- package/dist/src/tui/chrome.d.ts +86 -0
- package/dist/src/tui/chrome.js +85 -0
- package/dist/src/tui/client.d.ts +41 -0
- package/dist/src/tui/client.js +279 -0
- package/dist/src/tui/composition.d.ts +41 -0
- package/dist/src/tui/composition.js +146 -0
- package/dist/src/tui/message-views.d.ts +94 -0
- package/dist/src/tui/message-views.js +515 -0
- package/dist/src/tui/model.d.ts +414 -0
- package/dist/src/tui/model.js +758 -0
- package/dist/src/tui/presentation.d.ts +350 -0
- package/dist/src/tui/presentation.js +541 -0
- package/dist/src/tui/production.d.ts +79 -0
- package/dist/src/tui/production.js +253 -0
- package/dist/src/tui/run-terminal.d.ts +32 -0
- package/dist/src/tui/run-terminal.js +54 -0
- package/dist/src/tui/runtime.d.ts +113 -0
- package/dist/src/tui/runtime.js +189 -0
- package/dist/src/tui/theme.d.ts +49 -0
- package/dist/src/tui/theme.js +55 -0
- package/dist/src/tui/transport.d.ts +31 -0
- package/dist/src/tui/transport.js +255 -0
- package/dist/src/version.d.ts +11 -0
- package/dist/src/version.js +52 -0
- package/package.json +142 -0
- package/skills/pager/SKILL.md +112 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import { LOOPBACK_HOST } from "../configuration-defaults.js";
|
|
4
|
+
import { closeServer, listenServer, urlHost, writeJson } from "../shared/http.js";
|
|
5
|
+
import { isPlainObject } from "../shared/guards.js";
|
|
6
|
+
import { assertProtocolVersion, FETCH_LIMIT_DEFAULT, FETCH_LIMIT_MAX, normalizeMessageSubject, normalizeMessageType, ATTACH_HEADER, ProtocolError, validateImplementationId, validateSessionKey, } from "../broker/protocol.js";
|
|
7
|
+
import { MCP_TOOLS } from "./tools.js";
|
|
8
|
+
import { PAGER_VERSION } from "../version.js";
|
|
9
|
+
/**
|
|
10
|
+
* Names the MCP revision echoed to a client that requested none.
|
|
11
|
+
*
|
|
12
|
+
* The value is the specification revision, not a package version; it changes only when this server
|
|
13
|
+
* is verified against a newer one.
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_MCP_PROTOCOL_VERSION = "2025-03-26";
|
|
16
|
+
/** JSON-RPC error code for a failure this server reports. */
|
|
17
|
+
const JSON_RPC_SERVER_ERROR = -32000;
|
|
18
|
+
/** JSON-RPC error code for a method this server does not implement. */
|
|
19
|
+
const JSON_RPC_METHOD_NOT_FOUND = -32601;
|
|
20
|
+
/** Reports a failure the handler did not anticipate, as opposed to a declared protocol rejection. */
|
|
21
|
+
const INTERNAL_ERROR_CODE = "internal_error";
|
|
22
|
+
/**
|
|
23
|
+
* Message exposed to a caller for an unanticipated failure.
|
|
24
|
+
*
|
|
25
|
+
* Never the caught error's own message: that may carry detail about broker internals a remote caller
|
|
26
|
+
* has no standing to see, unlike a `ProtocolError`'s message, which is written to be safe to expose.
|
|
27
|
+
*/
|
|
28
|
+
const INTERNAL_ERROR_MESSAGE = "Internal error";
|
|
29
|
+
/** Names the request header an attached session presents its host delivery address in. */
|
|
30
|
+
export const DELIVERY_ADDRESS_HEADER = "x-pager-delivery-address";
|
|
31
|
+
/**
|
|
32
|
+
* Start a JSON Streamable-HTTP-compatible MCP foundation server on the host it is given, loopback by default.
|
|
33
|
+
* The server authenticates no caller, so the caller owns the choice of a non-loopback host.
|
|
34
|
+
* @param options - Broker, network binding, and optional launch-internal observers.
|
|
35
|
+
* @returns - Handle carrying the bound endpoint URL and the operation that stops the server.
|
|
36
|
+
*/
|
|
37
|
+
export async function startMcpServer(options) {
|
|
38
|
+
const { broker, port = 0, notifications, host = LOOPBACK_HOST, healthIdentity, diagnostics } = options;
|
|
39
|
+
const context = {
|
|
40
|
+
broker,
|
|
41
|
+
notifications,
|
|
42
|
+
endpoint: "",
|
|
43
|
+
...(healthIdentity ? { healthIdentity } : {}),
|
|
44
|
+
...(diagnostics ? { diagnostics } : {}),
|
|
45
|
+
};
|
|
46
|
+
const server = createServer((request, response) => {
|
|
47
|
+
void handleRequest(context, request, response);
|
|
48
|
+
});
|
|
49
|
+
await listenServer(server, port, host);
|
|
50
|
+
const address = server.address();
|
|
51
|
+
context.endpoint = `http://${urlHost(address.address)}:${String(address.port)}/mcp`;
|
|
52
|
+
return {
|
|
53
|
+
url: context.endpoint,
|
|
54
|
+
close: () => closeServer(server),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async function handleRequest(context, request, response) {
|
|
58
|
+
const { broker } = context;
|
|
59
|
+
try {
|
|
60
|
+
if (request.method === "GET" && request.url === "/health") {
|
|
61
|
+
writeJson(response, 200, { ok: true, status: "ready", ...context.healthIdentity });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (request.method === "DELETE") {
|
|
65
|
+
// Connectors end their attachments with the `detach` tool; no client may end a session this way.
|
|
66
|
+
writeJson(response, 405, {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: { code: "validation_failed", message: "Session termination over DELETE is not supported" },
|
|
69
|
+
}, { allow: "POST" });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (request.method !== "POST")
|
|
73
|
+
throw new ProtocolError("validation_failed", "POST required");
|
|
74
|
+
const body = parseRequestBody(await readBody(request));
|
|
75
|
+
if (isMcpRequest(body)) {
|
|
76
|
+
try {
|
|
77
|
+
handleMcpRequest(context, request, response, body);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof ProtocolError) {
|
|
81
|
+
writeMcpError(response, body.id, JSON_RPC_SERVER_ERROR, error.message, 200, {}, { code: error.code });
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
writeMcpError(response, body.id, JSON_RPC_SERVER_ERROR, INTERNAL_ERROR_MESSAGE, 500);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
assertProtocolVersion(body.version);
|
|
90
|
+
const call = readToolCall(body);
|
|
91
|
+
// The pre-attach tools carry no session and no identity.
|
|
92
|
+
const attached = call.tool === "attach" || call.tool === "register_agent" ? undefined : authenticate(broker, request);
|
|
93
|
+
// A detaching connector is leaving, so its push address is not taken up again.
|
|
94
|
+
if (attached && call.tool !== "detach")
|
|
95
|
+
registerDeliveryAddress(context, request, attached.agentId);
|
|
96
|
+
const result = attached
|
|
97
|
+
? callTool(context, attached, call.tool, call.args, "connector")
|
|
98
|
+
: call.tool === "attach"
|
|
99
|
+
? broker.attach(String(request.headers[ATTACH_HEADER] ?? ""), attachSessionKey(request))
|
|
100
|
+
: registerAgent(context, call.args);
|
|
101
|
+
const headers = call.tool === "attach" && typeof result === "object" && result && "sessionId" in result && "epoch" in result
|
|
102
|
+
? {
|
|
103
|
+
"mcp-session-id": String(result.sessionId),
|
|
104
|
+
"x-pager-epoch": String(result.epoch),
|
|
105
|
+
// The attach reservation is single-use, so this token is the client's only way back after a
|
|
106
|
+
// restart fences the session it just opened.
|
|
107
|
+
...("fencingToken" in result ? { "x-pager-resume-token": String(result.fencingToken) } : {}),
|
|
108
|
+
...("agentId" in result ? { "x-pager-agent-id": String(result.agentId) } : {}),
|
|
109
|
+
}
|
|
110
|
+
: refreshedSessionHeaders(attached);
|
|
111
|
+
writeJson(response, 200, { ok: true, result }, headers);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error instanceof ProtocolError) {
|
|
115
|
+
writeJson(response, error.code === "validation_failed" ? 400 : 409, {
|
|
116
|
+
ok: false,
|
|
117
|
+
error: { code: error.code, message: error.message },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
writeJson(response, 500, { ok: false, error: { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE } });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Parse a request body as JSON, refusing malformed input as a client protocol error.
|
|
127
|
+
*
|
|
128
|
+
* Kept distinct from the broker-internal failures this module answers with 500: invalid JSON, and a
|
|
129
|
+
* parse result that is not a plain object (`null`, an array, a primitive), are always the caller's
|
|
130
|
+
* fault, whatever runs after this point.
|
|
131
|
+
* @param raw - Untrusted request body text.
|
|
132
|
+
* @returns - Decoded body, shaped by the caller's own checks.
|
|
133
|
+
*/
|
|
134
|
+
function parseRequestBody(raw) {
|
|
135
|
+
let parsed;
|
|
136
|
+
try {
|
|
137
|
+
parsed = JSON.parse(raw);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
throw new ProtocolError("validation_failed", "Request body is not valid JSON");
|
|
141
|
+
}
|
|
142
|
+
if (!isPlainObject(parsed))
|
|
143
|
+
throw new ProtocolError("validation_failed", "Request body must be a JSON object");
|
|
144
|
+
return parsed;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Narrow a decoded request body to the tool call it claims to be.
|
|
148
|
+
* @param body - Decoded request body, fields untrusted.
|
|
149
|
+
* @returns - Tool name and arguments record.
|
|
150
|
+
*/
|
|
151
|
+
function readToolCall(body) {
|
|
152
|
+
const { tool, arguments: args } = body;
|
|
153
|
+
if (typeof tool !== "string" || tool.length === 0) {
|
|
154
|
+
throw new ProtocolError("validation_failed", "Request tool is required");
|
|
155
|
+
}
|
|
156
|
+
if (args !== undefined && !isPlainObject(args)) {
|
|
157
|
+
throw new ProtocolError("validation_failed", "Request arguments must be a JSON object");
|
|
158
|
+
}
|
|
159
|
+
return { tool, args: args ?? {} };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Announce a session generation the caller does not yet hold.
|
|
163
|
+
* @param attached - Authenticated session, carrying `refreshed` when this request resumed it in place,
|
|
164
|
+
* or nothing for a pre-attach call that holds no session to rotate.
|
|
165
|
+
* @returns - Rotated session headers, or an empty set when the caller's generation is still current.
|
|
166
|
+
*/
|
|
167
|
+
function refreshedSessionHeaders(attached) {
|
|
168
|
+
if (!attached?.refreshed)
|
|
169
|
+
return {};
|
|
170
|
+
return {
|
|
171
|
+
"mcp-session-id": attached.sessionId,
|
|
172
|
+
"x-pager-epoch": attached.epoch,
|
|
173
|
+
"x-pager-resume-token": attached.refreshed.fencingToken,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function authenticate(broker, request) {
|
|
177
|
+
const sessionId = String(request.headers["mcp-session-id"] ?? "");
|
|
178
|
+
const epoch = String(request.headers["x-pager-epoch"] ?? "");
|
|
179
|
+
const resumeToken = String(request.headers["x-pager-resume-token"] ?? "");
|
|
180
|
+
if (sessionId === "") {
|
|
181
|
+
throw new ProtocolError("validation_failed", "Request carries no mcp-session-id header; attach with a reservation first");
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
return { agentId: broker.resolveSession(sessionId, epoch || undefined), sessionId, epoch };
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
// A restarted service fences every session it did not issue. The caller already holds the proof
|
|
188
|
+
// needed to re-establish one, and its attach reservation was single-use, so refusing here would
|
|
189
|
+
// strand an attached agent for the rest of its run with no way back.
|
|
190
|
+
if (!(error instanceof ProtocolError) || error.code !== "expired" || !resumeToken)
|
|
191
|
+
throw error;
|
|
192
|
+
const resumed = broker.resumeSession({ sessionId: sessionId, fencingToken: resumeToken, epoch: epoch || undefined });
|
|
193
|
+
return {
|
|
194
|
+
agentId: resumed.agentId,
|
|
195
|
+
sessionId: resumed.sessionId,
|
|
196
|
+
epoch: resumed.epoch,
|
|
197
|
+
refreshed: { fencingToken: resumed.fencingToken },
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function callTool(context, attached, tool, args, surface) {
|
|
202
|
+
if (!context.diagnostics)
|
|
203
|
+
return runTool(context, attached, tool, args, surface);
|
|
204
|
+
try {
|
|
205
|
+
const output = runTool(context, attached, tool, args, surface);
|
|
206
|
+
context.diagnostics.onToolCall({ agentId: attached.agentId, tool: tool ?? "", args, outcome: { ok: true, output } });
|
|
207
|
+
return output;
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
const failure = error instanceof ProtocolError
|
|
211
|
+
? { code: error.code, message: error.message }
|
|
212
|
+
: { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE };
|
|
213
|
+
context.diagnostics.onToolCall({
|
|
214
|
+
agentId: attached.agentId,
|
|
215
|
+
tool: tool ?? "",
|
|
216
|
+
args,
|
|
217
|
+
outcome: { ok: false, error: failure },
|
|
218
|
+
});
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function runTool(context, attached, tool, args, surface) {
|
|
223
|
+
const { broker, notifications } = context;
|
|
224
|
+
// The model-facing path reaches only the advertised tools; the connector-only ones below can end or
|
|
225
|
+
// move the caller's own identity, which a model must not be able to do to its harness.
|
|
226
|
+
if (surface === "model" && !MCP_TOOLS.some((listed) => listed.name === tool)) {
|
|
227
|
+
throw new ProtocolError("validation_failed", "Unknown tool");
|
|
228
|
+
}
|
|
229
|
+
switch (tool) {
|
|
230
|
+
case "whoami":
|
|
231
|
+
return broker.agentIdentity(attached.agentId);
|
|
232
|
+
case "update_agent_alias":
|
|
233
|
+
if (typeof args.alias !== "string")
|
|
234
|
+
throw new ProtocolError("validation_failed", "Agent alias is required");
|
|
235
|
+
return broker.updateAgentAlias(attached.agentId, args.alias);
|
|
236
|
+
case "adopt_session_key": {
|
|
237
|
+
return broker.adoptSessionKey(attached.agentId, validateSessionKey(args.sessionKey));
|
|
238
|
+
}
|
|
239
|
+
case "list_team_members":
|
|
240
|
+
return { members: broker.listTeamMembers(attached.agentId) };
|
|
241
|
+
case "list_agents":
|
|
242
|
+
return { agents: broker.listAgents() };
|
|
243
|
+
case "send_agent_message": {
|
|
244
|
+
const sent = broker.send({
|
|
245
|
+
from: attached.agentId,
|
|
246
|
+
to: String(args.to),
|
|
247
|
+
subject: normalizeMessageSubject(args.subject),
|
|
248
|
+
body: args.body,
|
|
249
|
+
type: normalizeMessageType(args.type),
|
|
250
|
+
...(typeof args.idempotencyKey === "string" ? { idempotencyKey: args.idempotencyKey } : {}),
|
|
251
|
+
});
|
|
252
|
+
notifications?.notifyMessage(sent.messageId, sent.to);
|
|
253
|
+
return sent;
|
|
254
|
+
}
|
|
255
|
+
case "broadcast_agent_message": {
|
|
256
|
+
const broadcast = broker.broadcast({
|
|
257
|
+
from: attached.agentId,
|
|
258
|
+
...(args.workspace === undefined ? {} : { workspace: broadcastWorkspace(args.workspace) }),
|
|
259
|
+
subject: normalizeMessageSubject(args.subject),
|
|
260
|
+
body: args.body,
|
|
261
|
+
type: normalizeMessageType(args.type),
|
|
262
|
+
...(typeof args.idempotencyKey === "string" ? { idempotencyKey: args.idempotencyKey } : {}),
|
|
263
|
+
});
|
|
264
|
+
for (const sent of broadcast.sent)
|
|
265
|
+
notifications?.notifyMessage(sent.messageId, sent.to);
|
|
266
|
+
return broadcast;
|
|
267
|
+
}
|
|
268
|
+
case "fetch_agent_messages":
|
|
269
|
+
return { messages: broker.fetch({ agentId: attached.agentId, limit: fetchLimit(args.limit) }) };
|
|
270
|
+
case "ack_agent_messages":
|
|
271
|
+
return broker.ack(attached.agentId, ackClaimTokens(args.claimTokens));
|
|
272
|
+
case "get_message_status":
|
|
273
|
+
return broker.status(String(args.messageId), attached.agentId);
|
|
274
|
+
case "detach":
|
|
275
|
+
broker.releaseAttachments(attached.agentId);
|
|
276
|
+
notifications?.forgetDeliveryAddress?.(attached.agentId);
|
|
277
|
+
return { detached: true };
|
|
278
|
+
case undefined:
|
|
279
|
+
throw new ProtocolError("validation_failed", "Unknown tool");
|
|
280
|
+
default:
|
|
281
|
+
throw new ProtocolError("validation_failed", "Unknown tool");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Report whether a decoded body claims to be a JSON-RPC request.
|
|
286
|
+
*
|
|
287
|
+
* Recognises `jsonrpc` and `method` only.
|
|
288
|
+
* @param value - Decoded request body.
|
|
289
|
+
* @returns - Whether the body claims to be a JSON-RPC request.
|
|
290
|
+
*/
|
|
291
|
+
function isMcpRequest(value) {
|
|
292
|
+
return isPlainObject(value) && value.jsonrpc === "2.0" && typeof value.method === "string";
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Read one named member of a request's parameters.
|
|
296
|
+
* @param params - Parameters as supplied, of any JSON-RPC shape.
|
|
297
|
+
* @param name - Member to read.
|
|
298
|
+
* @returns - The member's value, or nothing when the parameters are positional or absent.
|
|
299
|
+
*/
|
|
300
|
+
function namedParameter(params, name) {
|
|
301
|
+
return isPlainObject(params) ? params[name] : undefined;
|
|
302
|
+
}
|
|
303
|
+
function handleMcpRequest(context, request, response, rpc) {
|
|
304
|
+
const { broker } = context;
|
|
305
|
+
if (rpc.method === "initialize") {
|
|
306
|
+
const declared = namedParameter(rpc.params, "protocolVersion");
|
|
307
|
+
const existingSessionId = String(request.headers["mcp-session-id"] ?? "");
|
|
308
|
+
const attached = existingSessionId
|
|
309
|
+
? broker.resumeSession({
|
|
310
|
+
sessionId: existingSessionId,
|
|
311
|
+
fencingToken: String(request.headers["x-pager-resume-token"] ?? "") || undefined,
|
|
312
|
+
epoch: String(request.headers["x-pager-epoch"] ?? "") || undefined,
|
|
313
|
+
})
|
|
314
|
+
: broker.attach(String(request.headers[ATTACH_HEADER] ?? ""), attachSessionKey(request));
|
|
315
|
+
const headers = {
|
|
316
|
+
"mcp-session-id": attached.sessionId,
|
|
317
|
+
"x-pager-epoch": attached.epoch,
|
|
318
|
+
"x-pager-resume-token": attached.fencingToken,
|
|
319
|
+
"x-pager-agent-id": attached.agentId,
|
|
320
|
+
"x-pager-lease-ttl-ms": String(broker.sessionLeaseTtlMs()),
|
|
321
|
+
};
|
|
322
|
+
// The session above is already established by the time this can throw, so an internal failure here
|
|
323
|
+
// must still carry its headers — otherwise a client that just attached or transparently resumed is
|
|
324
|
+
// left presenting credentials the server has already superseded.
|
|
325
|
+
try {
|
|
326
|
+
registerDeliveryAddress(context, request, attached.agentId);
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
if (error instanceof ProtocolError) {
|
|
330
|
+
writeMcpError(response, rpc.id, JSON_RPC_SERVER_ERROR, error.message, 200, headers, { code: error.code });
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
writeMcpError(response, rpc.id, JSON_RPC_SERVER_ERROR, INTERNAL_ERROR_MESSAGE, 500, headers);
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
writeMcpResult(response, rpc.id, {
|
|
338
|
+
protocolVersion: typeof declared === "string" ? declared : DEFAULT_MCP_PROTOCOL_VERSION,
|
|
339
|
+
capabilities: { tools: {} },
|
|
340
|
+
serverInfo: { name: "pager", version: PAGER_VERSION },
|
|
341
|
+
}, headers);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (rpc.method === "notifications/initialized") {
|
|
345
|
+
// Authenticating this notification can itself transparently resume the session, so the 202 has to
|
|
346
|
+
// carry the new generation. Dropping it leaves the client acknowledging initialization with
|
|
347
|
+
// credentials the server superseded on this very request.
|
|
348
|
+
response.writeHead(202, refreshedSessionHeaders(authenticate(broker, request)));
|
|
349
|
+
response.end();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const attached = authenticate(broker, request);
|
|
353
|
+
// A transparent resume issues a new generation the client is still unaware of; every later response
|
|
354
|
+
// must carry it. Computed before registerDeliveryAddress for the same reason as initialize's headers
|
|
355
|
+
// above: a throw there must still answer with it.
|
|
356
|
+
const refreshed = refreshedSessionHeaders(attached);
|
|
357
|
+
try {
|
|
358
|
+
registerDeliveryAddress(context, request, attached.agentId);
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
if (error instanceof ProtocolError) {
|
|
362
|
+
writeMcpError(response, rpc.id, JSON_RPC_SERVER_ERROR, error.message, 200, refreshed, { code: error.code });
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
writeMcpError(response, rpc.id, JSON_RPC_SERVER_ERROR, INTERNAL_ERROR_MESSAGE, 500, refreshed);
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (rpc.method === "tools/list") {
|
|
370
|
+
writeMcpResult(response, rpc.id, { tools: mcpTools() }, refreshed);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (rpc.method === "tools/call") {
|
|
374
|
+
const named = namedParameter(rpc.params, "name");
|
|
375
|
+
const name = typeof named === "string" ? named : undefined;
|
|
376
|
+
const supplied = namedParameter(rpc.params, "arguments");
|
|
377
|
+
const args = isPlainObject(supplied) ? supplied : {};
|
|
378
|
+
try {
|
|
379
|
+
const result = callTool(context, attached, name, args, "model");
|
|
380
|
+
writeMcpResult(response, rpc.id, { content: [{ type: "text", text: JSON.stringify(result) }] }, refreshed);
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
const failure = error instanceof ProtocolError
|
|
384
|
+
? { code: error.code, message: error.message }
|
|
385
|
+
: { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE };
|
|
386
|
+
writeMcpResult(response, rpc.id, { content: [{ type: "text", text: JSON.stringify(failure) }], isError: true }, refreshed);
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
writeMcpError(response, rpc.id, JSON_RPC_METHOD_NOT_FOUND, "Method not found");
|
|
391
|
+
}
|
|
392
|
+
function mcpTools() {
|
|
393
|
+
return MCP_TOOLS.map((tool) => ({ ...tool }));
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Hand a presented delivery address to the notification port, on every authenticated request.
|
|
397
|
+
*
|
|
398
|
+
* Presented on every request rather than once, so a service that restarted and transparently resumed
|
|
399
|
+
* the session learns the address again without the session having to notice.
|
|
400
|
+
* @param context - Server context carrying the optional notification port.
|
|
401
|
+
* @param request - Authenticated request that may carry the address header.
|
|
402
|
+
* @param agentId - Recipient the request authenticated as.
|
|
403
|
+
*/
|
|
404
|
+
function registerDeliveryAddress(context, request, agentId) {
|
|
405
|
+
const raw = request.headers[DELIVERY_ADDRESS_HEADER];
|
|
406
|
+
if (typeof raw !== "string" || raw.length === 0)
|
|
407
|
+
return;
|
|
408
|
+
context.notifications?.registerDeliveryAddress?.(agentId, raw);
|
|
409
|
+
}
|
|
410
|
+
function attachSessionKey(request) {
|
|
411
|
+
const sessionKey = String(request.headers["x-pager-session-key"] ?? "");
|
|
412
|
+
return sessionKey ? { sessionKey } : {};
|
|
413
|
+
}
|
|
414
|
+
function registerAgent(context, args) {
|
|
415
|
+
const { broker, notifications } = context;
|
|
416
|
+
const workspacePath = args.workspacePath;
|
|
417
|
+
const implementationId = args.implementationId;
|
|
418
|
+
const sessionName = args.sessionName;
|
|
419
|
+
const sessionKey = args.sessionKey;
|
|
420
|
+
const continuesSessionKey = args.continuesSessionKey;
|
|
421
|
+
if (typeof workspacePath !== "string" || !isAbsolute(workspacePath)) {
|
|
422
|
+
throw new ProtocolError("validation_failed", "registration workspacePath must be absolute");
|
|
423
|
+
}
|
|
424
|
+
const implementation = validateImplementationId(implementationId);
|
|
425
|
+
// The broker owns the session-name contract; this boundary checks only the type.
|
|
426
|
+
if (sessionName !== undefined && typeof sessionName !== "string") {
|
|
427
|
+
throw new ProtocolError("validation_failed", "registration sessionName is invalid");
|
|
428
|
+
}
|
|
429
|
+
if (sessionKey !== undefined)
|
|
430
|
+
validateSessionKey(sessionKey);
|
|
431
|
+
if (continuesSessionKey !== undefined)
|
|
432
|
+
validateSessionKey(continuesSessionKey);
|
|
433
|
+
const registered = broker.registerAgentWithoutReservation({
|
|
434
|
+
workspacePath,
|
|
435
|
+
implementation: { id: implementation },
|
|
436
|
+
...(typeof sessionName === "string" ? { sessionName } : {}),
|
|
437
|
+
...(typeof sessionKey === "string" ? { sessionKey } : {}),
|
|
438
|
+
...(typeof continuesSessionKey === "string" ? { continuesSessionKey } : {}),
|
|
439
|
+
});
|
|
440
|
+
if (registered.resumed)
|
|
441
|
+
notifications?.forgetDeliveryAddress?.(registered.agentId);
|
|
442
|
+
return {
|
|
443
|
+
...registered,
|
|
444
|
+
deliveryReservation: broker.reserveAttachment(registered.agentId, "delivery"),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Validate the claim batch size a caller asked for.
|
|
449
|
+
*
|
|
450
|
+
* A value outside the supported range is rejected, never replaced by the default.
|
|
451
|
+
* @param value - Caller-supplied limit, absent for the default.
|
|
452
|
+
* @returns - Positive integer batch size within the supported range.
|
|
453
|
+
*/
|
|
454
|
+
function fetchLimit(value) {
|
|
455
|
+
if (value === undefined)
|
|
456
|
+
return FETCH_LIMIT_DEFAULT;
|
|
457
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > FETCH_LIMIT_MAX) {
|
|
458
|
+
throw new ProtocolError("validation_failed", `fetch limit must be an integer between 1 and ${String(FETCH_LIMIT_MAX)}`);
|
|
459
|
+
}
|
|
460
|
+
return value;
|
|
461
|
+
}
|
|
462
|
+
function broadcastWorkspace(value) {
|
|
463
|
+
if (typeof value !== "string")
|
|
464
|
+
throw new ProtocolError("validation_failed", "Broadcast workspace must be a string");
|
|
465
|
+
return value;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Validate the acknowledgement batch a caller supplied.
|
|
469
|
+
* @param value - Caller-supplied claim tokens.
|
|
470
|
+
* @returns - Claim tokens, each authorising one acknowledgement.
|
|
471
|
+
*/
|
|
472
|
+
function ackClaimTokens(value) {
|
|
473
|
+
if (!Array.isArray(value))
|
|
474
|
+
throw new ProtocolError("validation_failed", "ack claimTokens must be an array");
|
|
475
|
+
return value.map((entry) => {
|
|
476
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
477
|
+
throw new ProtocolError("validation_failed", "each ack entry must be a claim token");
|
|
478
|
+
}
|
|
479
|
+
return entry;
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
function writeMcpResult(response, id, result, headers = {}) {
|
|
483
|
+
writeJson(response, 200, { jsonrpc: "2.0", id: correlationId(id), result }, headers);
|
|
484
|
+
}
|
|
485
|
+
function writeMcpError(response, id, code, message, status = 200, headers = {}, data) {
|
|
486
|
+
writeJson(response, status, { jsonrpc: "2.0", id: correlationId(id), error: { code, message, ...(data ? { data } : {}) } }, headers);
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Narrow a request's correlation id where it is written back.
|
|
490
|
+
* @param id - The `id` member as the caller sent it.
|
|
491
|
+
* @returns - The id to echo, or null when the caller's own cannot be one.
|
|
492
|
+
*/
|
|
493
|
+
function correlationId(id) {
|
|
494
|
+
return typeof id === "string" || typeof id === "number" ? id : null;
|
|
495
|
+
}
|
|
496
|
+
async function readBody(request) {
|
|
497
|
+
const chunks = [];
|
|
498
|
+
for await (const chunk of request)
|
|
499
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
500
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
501
|
+
}
|