agentschat-mcp 0.31.0 → 0.32.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 +45 -0
- package/connector/README.md +37 -12
- package/connector/identities.ts +96 -0
- package/connector/run.ts +118 -69
- package/connector/server.ts +137 -46
- package/dist/connector.js +207 -82
- package/dist/server.js +204 -2
- package/package.json +3 -1
- package/src/cli.mjs +10 -4
- package/src/server.ts +60 -0
- package/src/wake.ts +302 -0
package/connector/server.ts
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* agentschat connector — the connector side of the Hermes relay contract.
|
|
3
3
|
*
|
|
4
|
-
* Single-tenant: one
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Single-tenant AND multiplex: one relay WS server fronts one OR MORE agentschat
|
|
5
|
+
* identities (one per Hermes profile/agent). Hermes fronts multiple identities on
|
|
6
|
+
* one WS by sending one `hello` per (platform, botId); here platform is always
|
|
7
|
+
* "agentschat" and botId is the agentschat agent_id.
|
|
8
8
|
*
|
|
9
|
+
* Frame exchange (see gateway ws_transport.py):
|
|
9
10
|
* gateway → hello {type:"hello", platform, botId}
|
|
10
|
-
* connector→ descriptor {type:"descriptor", descriptor:{...}}
|
|
11
|
-
* connector→ inbound {type:"inbound", event:{...}}
|
|
12
|
-
* gateway → outbound {type:"outbound", requestId, action}
|
|
11
|
+
* connector→ descriptor {type:"descriptor", descriptor:{...}} (one per hello)
|
|
12
|
+
* connector→ inbound {type:"inbound", event:{...}} (agentschat → gateway)
|
|
13
|
+
* gateway → outbound {type:"outbound", requestId, platform?, action}
|
|
13
14
|
* connector→ outbound_result {type:"outbound_result", requestId, result}
|
|
14
15
|
*
|
|
15
|
-
* Auth is fail-closed: anything wrong with the upgrade token closes 4401 before
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Auth is fail-closed: anything wrong with the upgrade token closes 4401 before the
|
|
17
|
+
* socket is admitted.
|
|
18
|
+
*
|
|
19
|
+
* The single highest-correctness invariant (multiplex): identity A's messages are
|
|
20
|
+
* NEVER routed to or sent as identity B. Inbound routes to the socket(s) fronting the
|
|
21
|
+
* addressed identity; outbound uses the sending identity's own token; an identity the
|
|
22
|
+
* connector has no credentials for is rejected at hello (fail closed).
|
|
18
23
|
*/
|
|
19
24
|
|
|
20
25
|
import { createServer, type Server as HttpServer, type IncomingMessage } from "node:http";
|
|
@@ -22,9 +27,17 @@ import { WebSocketServer, WebSocket } from "ws";
|
|
|
22
27
|
import { verifyUpgradeToken, CLOSE_UNAUTHORIZED } from "./auth.ts";
|
|
23
28
|
import { buildDescriptor, type CapabilityDescriptor } from "./descriptor.ts";
|
|
24
29
|
import { toWireEvent, type AgentsChatMessage } from "./normalize.ts";
|
|
30
|
+
import { IdentityTable, routeInbound, type Identity } from "./identities.ts";
|
|
25
31
|
|
|
26
|
-
/** What the connector needs from agentschat to fulfil outbound ops. */
|
|
32
|
+
/** What the connector needs from agentschat to fulfil outbound ops, per identity. */
|
|
27
33
|
export interface AgentsChatHooks {
|
|
34
|
+
sendMessage(botId: string, chatId: string, content: string, replyTo?: string): Promise<{ id?: string }>;
|
|
35
|
+
getChatInfo(botId: string, chatId: string): Promise<{ name?: string; type?: string }>;
|
|
36
|
+
sendTyping?(botId: string, chatId: string): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Legacy single-tenant hook shape (no botId first arg) — adapted to the per-identity one. */
|
|
40
|
+
interface LegacyHooks {
|
|
28
41
|
sendMessage(chatId: string, content: string, replyTo?: string): Promise<{ id?: string }>;
|
|
29
42
|
getChatInfo(chatId: string): Promise<{ name?: string; type?: string }>;
|
|
30
43
|
sendTyping?(chatId: string): Promise<void>;
|
|
@@ -35,29 +48,55 @@ export interface ConnectorConfig {
|
|
|
35
48
|
host?: string;
|
|
36
49
|
/** gatewayId → acceptable secrets (rotation window). */
|
|
37
50
|
secrets: Record<string, string[]>;
|
|
51
|
+
/**
|
|
52
|
+
* The identities this connector fronts. Multiplex: one per agentschat identity.
|
|
53
|
+
* Single-tenant (legacy): omit and provide `agentschat` — a single implicit
|
|
54
|
+
* identity is derived (botId "default").
|
|
55
|
+
*/
|
|
56
|
+
identities?: Identity[];
|
|
38
57
|
/** Override the descriptor (tests/customization); defaults to the agentschat descriptor. */
|
|
39
58
|
descriptor?: Partial<CapabilityDescriptor>;
|
|
40
|
-
agentschat: AgentsChatHooks;
|
|
41
|
-
/** Called for each agentschat message to broadcast to connected gateways. */
|
|
59
|
+
agentschat: AgentsChatHooks | LegacyHooks;
|
|
42
60
|
logger?: (msg: string) => void;
|
|
43
61
|
}
|
|
44
62
|
|
|
45
63
|
export interface ConnectorHandle {
|
|
46
64
|
port: number;
|
|
47
65
|
stop(): void;
|
|
48
|
-
/** Push an agentschat message to
|
|
66
|
+
/** Push an agentschat message to the gateway socket(s) fronting its addressed identity. */
|
|
49
67
|
injectAgentsChatMessage(msg: AgentsChatMessage): void;
|
|
50
68
|
connections(): number;
|
|
51
69
|
}
|
|
52
70
|
|
|
71
|
+
/** A connected gateway socket and the set of identities it has hello'd (fronts). */
|
|
72
|
+
interface GatewayConn {
|
|
73
|
+
ws: WebSocket;
|
|
74
|
+
gatewayId: string;
|
|
75
|
+
/** botIds this socket has declared via hello. */
|
|
76
|
+
fronted: Set<string>;
|
|
77
|
+
}
|
|
78
|
+
|
|
53
79
|
export function startConnector(config: ConnectorConfig): ConnectorHandle {
|
|
54
80
|
const log = config.logger ?? (() => {});
|
|
55
81
|
const descriptor = buildDescriptor(config.descriptor);
|
|
56
|
-
const
|
|
82
|
+
const single = !config.identities || config.identities.length === 0;
|
|
83
|
+
const table = new IdentityTable(
|
|
84
|
+
single ? [{ botId: "default", agentId: "default", token: "", gatewayId: "", secret: "" }] : config.identities!,
|
|
85
|
+
);
|
|
86
|
+
// Normalize hooks to the per-identity shape. Legacy single-tenant hooks take
|
|
87
|
+
// (chatId, ...); wrap them to ignore the botId. Per-identity hooks take botId first.
|
|
88
|
+
const hooks: AgentsChatHooks = single
|
|
89
|
+
? {
|
|
90
|
+
sendMessage: (_b, chatId, content, replyTo) => (config.agentschat as LegacyHooks).sendMessage(chatId, content, replyTo),
|
|
91
|
+
getChatInfo: (_b, chatId) => (config.agentschat as LegacyHooks).getChatInfo(chatId),
|
|
92
|
+
sendTyping: (_b, chatId) => (config.agentschat as LegacyHooks).sendTyping?.(chatId) ?? Promise.resolve(),
|
|
93
|
+
}
|
|
94
|
+
: (config.agentschat as AgentsChatHooks);
|
|
95
|
+
const sockets = new Set<GatewayConn>();
|
|
57
96
|
|
|
58
97
|
const http: HttpServer = createServer((_req, res) => {
|
|
59
98
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
60
|
-
res.end(JSON.stringify({ ok: true, service: "agentschat-connector", contract_version: 1 }));
|
|
99
|
+
res.end(JSON.stringify({ ok: true, service: "agentschat-connector", contract_version: 1, identities: table.size }));
|
|
61
100
|
});
|
|
62
101
|
|
|
63
102
|
const wss = new WebSocketServer({ noServer: true });
|
|
@@ -70,16 +109,11 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
|
|
|
70
109
|
}
|
|
71
110
|
const auth = req.headers.authorization ?? "";
|
|
72
111
|
const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
|
|
73
|
-
// Peek the gateway id (payload head) to select that gateway's verify list.
|
|
74
112
|
const payload = peekPayload(token);
|
|
75
113
|
const secrets = payload ? config.secrets[payload] : undefined;
|
|
76
114
|
const gatewayId = secrets ? verifyUpgradeToken(token, secrets) : null;
|
|
77
115
|
if (!gatewayId) {
|
|
78
116
|
log(`[connector] rejecting upgrade: bad/absent token (path=${pathname})`);
|
|
79
|
-
// Write a 4401-flavored close: send an HTTP 401 then destroy, and — once the
|
|
80
|
-
// WS is established for a valid path — close with the app code. For the
|
|
81
|
-
// upgrade itself we must reject the handshake; the client observes a failed
|
|
82
|
-
// upgrade. We additionally complete a minimal WS so we can send close 4401.
|
|
83
117
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
84
118
|
ws.close(CLOSE_UNAUTHORIZED, "unauthorized");
|
|
85
119
|
});
|
|
@@ -91,65 +125,103 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
|
|
|
91
125
|
});
|
|
92
126
|
|
|
93
127
|
function onConnection(ws: WebSocket, gatewayId: string) {
|
|
94
|
-
|
|
128
|
+
const conn: GatewayConn = { ws, gatewayId, fronted: new Set() };
|
|
129
|
+
sockets.add(conn);
|
|
95
130
|
log(`[connector] gateway connected: ${gatewayId} (${sockets.size} total)`);
|
|
96
131
|
|
|
97
132
|
ws.on("message", async (data) => {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
133
|
+
// Newline-delimited: the gateway may batch frames; split and handle each.
|
|
134
|
+
const text = data.toString();
|
|
135
|
+
for (const line of text.split("\n")) {
|
|
136
|
+
if (!line.trim()) continue;
|
|
137
|
+
let frame: any;
|
|
138
|
+
try {
|
|
139
|
+
frame = JSON.parse(line);
|
|
140
|
+
} catch {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
await handleFrame(conn, frame).catch((e) => log(`[connector] frame error: ${e}`));
|
|
103
144
|
}
|
|
104
|
-
await handleFrame(ws, frame).catch((e) => log(`[connector] frame error: ${e}`));
|
|
105
145
|
});
|
|
106
146
|
ws.on("close", () => {
|
|
107
|
-
sockets.delete(
|
|
147
|
+
sockets.delete(conn);
|
|
108
148
|
log(`[connector] gateway disconnected: ${gatewayId} (${sockets.size} left)`);
|
|
109
149
|
});
|
|
110
|
-
ws.on("error", () => sockets.delete(
|
|
150
|
+
ws.on("error", () => sockets.delete(conn));
|
|
111
151
|
}
|
|
112
152
|
|
|
113
|
-
async function handleFrame(
|
|
153
|
+
async function handleFrame(conn: GatewayConn, frame: any) {
|
|
114
154
|
const t = frame?.type;
|
|
115
155
|
if (t === "hello") {
|
|
116
|
-
|
|
156
|
+
const botId = String(frame.botId ?? "");
|
|
157
|
+
// Single-tenant (no identity table configured): front whatever identity the
|
|
158
|
+
// gateway declares — there's exactly one. Multiplex: the botId MUST be a
|
|
159
|
+
// registered identity (fail closed — never front an identity we can't send as).
|
|
160
|
+
const identity = single
|
|
161
|
+
? table.all()[0]
|
|
162
|
+
: botId
|
|
163
|
+
? table.forBot(botId)
|
|
164
|
+
: null;
|
|
165
|
+
if (!identity) {
|
|
166
|
+
send(conn.ws, { type: "error", error: `unknown identity botId: ${botId || "(none)"}` });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
conn.fronted.add(identity.botId);
|
|
170
|
+
send(conn.ws, { type: "descriptor", descriptor: { ...descriptor, platform: "agentschat" } });
|
|
117
171
|
return;
|
|
118
172
|
}
|
|
119
173
|
if (t === "outbound") {
|
|
120
|
-
const result = await handleOutbound(frame
|
|
121
|
-
send(ws, { type: "outbound_result", requestId: frame.requestId, result });
|
|
174
|
+
const result = await handleOutbound(conn, frame);
|
|
175
|
+
send(conn.ws, { type: "outbound_result", requestId: frame.requestId, result });
|
|
122
176
|
return;
|
|
123
177
|
}
|
|
124
178
|
// Unknown / ignored frame types (interrupt, etc.) — additive contract, ignore.
|
|
125
179
|
}
|
|
126
180
|
|
|
127
|
-
async function handleOutbound(
|
|
181
|
+
async function handleOutbound(conn: GatewayConn, frame: any): Promise<any> {
|
|
182
|
+
const action = frame?.action ?? {};
|
|
128
183
|
const op = action?.op;
|
|
129
184
|
const chatId = action?.chat_id ?? "";
|
|
185
|
+
// Which identity sends? The frame's botId picks the egress identity for a
|
|
186
|
+
// multi-identity gateway (contract D-Q1.5b.1: the connector validates the
|
|
187
|
+
// per-frame egress target against the SET of identities THIS socket
|
|
188
|
+
// advertised via hello). Untagged outbound falls back to the FIRST hello'd
|
|
189
|
+
// identity (the session default). Fail closed when the named identity isn't
|
|
190
|
+
// registered or wasn't fronted by this socket — never send with another
|
|
191
|
+
// identity's credentials.
|
|
192
|
+
const firstFronted = [...conn.fronted][0];
|
|
193
|
+
const requested = typeof frame?.botId === "string" && frame.botId ? frame.botId : firstFronted ?? null;
|
|
194
|
+
const identity = single
|
|
195
|
+
? table.all()[0]
|
|
196
|
+
: requested && conn.fronted.has(requested)
|
|
197
|
+
? table.forBot(requested)
|
|
198
|
+
: null;
|
|
199
|
+
if (!identity) {
|
|
200
|
+
log(`[connector] outbound failed: no fronted identity for botId=${requested ?? "?"}`);
|
|
201
|
+
return { success: false, error: `no fronted identity for outbound (botId=${requested ?? "?"})` };
|
|
202
|
+
}
|
|
130
203
|
switch (op) {
|
|
131
204
|
case "send": {
|
|
132
|
-
const r = await
|
|
205
|
+
const r = await hooks.sendMessage(identity.botId, chatId, action.content ?? "", action.reply_to);
|
|
133
206
|
return { success: true, message_id: r?.id };
|
|
134
207
|
}
|
|
135
208
|
case "typing": {
|
|
136
|
-
await
|
|
209
|
+
await hooks.sendTyping?.(identity.botId, chatId);
|
|
137
210
|
return { success: true };
|
|
138
211
|
}
|
|
139
212
|
case "get_chat_info": {
|
|
140
|
-
const info = await
|
|
213
|
+
const info = await hooks.getChatInfo(identity.botId, chatId);
|
|
141
214
|
return info ?? {};
|
|
142
215
|
}
|
|
143
216
|
default:
|
|
217
|
+
log(`[connector] outbound failed: unsupported op ${String(op)}`);
|
|
144
218
|
return { success: false, error: `unsupported op: ${String(op)}` };
|
|
145
219
|
}
|
|
146
220
|
}
|
|
147
221
|
|
|
148
222
|
function send(ws: WebSocket, obj: any) {
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
// gateway's frame handler — it sits in the reader's buffer waiting for the
|
|
152
|
-
// terminator. This is why the descriptor must end with \n.
|
|
223
|
+
// Newline-delimited (gateway ws_transport.py splits on "\n"): a frame without the
|
|
224
|
+
// terminator never reaches the gateway's frame handler.
|
|
153
225
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj) + "\n");
|
|
154
226
|
}
|
|
155
227
|
|
|
@@ -160,16 +232,35 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
|
|
|
160
232
|
return {
|
|
161
233
|
port,
|
|
162
234
|
stop() {
|
|
163
|
-
for (const
|
|
164
|
-
try { ws.close(1001, "connector shutdown"); } catch {}
|
|
235
|
+
for (const c of sockets) {
|
|
236
|
+
try { c.ws.close(1001, "connector shutdown"); } catch {}
|
|
165
237
|
}
|
|
166
238
|
wss.close();
|
|
167
239
|
http.close();
|
|
168
240
|
},
|
|
169
241
|
injectAgentsChatMessage(msg: AgentsChatMessage) {
|
|
170
|
-
|
|
242
|
+
// Route to the identity this message is addressed to, then only to gateway
|
|
243
|
+
// socket(s) fronting THAT identity — never broadcast across identities.
|
|
244
|
+
// The strongest signal is __botId: which identity's agentschat socket the
|
|
245
|
+
// message ARRIVED on (agentschat only pushes @mentions/DMs to that agent).
|
|
246
|
+
// Fall back to mention/DM-owner routing for hosts that don't tag.
|
|
247
|
+
const bySocket = (msg as any).__botId ? table.forBot(String((msg as any).__botId)) : null;
|
|
248
|
+
const target = bySocket ?? routeInbound(table, {
|
|
249
|
+
channel_id: msg.channel_id,
|
|
250
|
+
mentioned_ids: (msg as any).mentioned_ids,
|
|
251
|
+
dmOwnerBotId: (msg as any).dm_owner,
|
|
252
|
+
}) ?? (table.isSingle() ? table.all()[0] : null);
|
|
253
|
+
if (!target) {
|
|
254
|
+
log(`[connector] inbound unaddressed (channel=${(msg as any).channel_id ?? "?"} mentions=${JSON.stringify((msg as any).mentioned_ids ?? [])}) — dropped`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const event = toWireEvent(msg, "agentschat");
|
|
171
258
|
if (!event) return;
|
|
172
|
-
|
|
259
|
+
// Tag the fronting identity so the gateway keys the right session/profile.
|
|
260
|
+
(event.source as any).profile = target.botId;
|
|
261
|
+
for (const conn of sockets) {
|
|
262
|
+
if (conn.fronted.has(target.botId)) send(conn.ws, { type: "inbound", event });
|
|
263
|
+
}
|
|
173
264
|
},
|
|
174
265
|
connections() {
|
|
175
266
|
return sockets.size;
|