agent-room 0.2.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 +446 -0
- package/dist/core/connection-manager.d.ts +66 -0
- package/dist/core/connection-manager.d.ts.map +1 -0
- package/dist/core/connection-manager.js +205 -0
- package/dist/core/connection-manager.js.map +1 -0
- package/dist/core/logger.d.ts +99 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +204 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/message-buffer.d.ts +42 -0
- package/dist/core/message-buffer.d.ts.map +1 -0
- package/dist/core/message-buffer.js +99 -0
- package/dist/core/message-buffer.js.map +1 -0
- package/dist/core/notification-engine.d.ts +38 -0
- package/dist/core/notification-engine.d.ts.map +1 -0
- package/dist/core/notification-engine.js +78 -0
- package/dist/core/notification-engine.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +96 -0
- package/dist/index.js.map +1 -0
- package/dist/protocols/adapter-interface.d.ts +6 -0
- package/dist/protocols/adapter-interface.d.ts.map +1 -0
- package/dist/protocols/adapter-interface.js +6 -0
- package/dist/protocols/adapter-interface.js.map +1 -0
- package/dist/protocols/sse-adapter.d.ts +26 -0
- package/dist/protocols/sse-adapter.d.ts.map +1 -0
- package/dist/protocols/sse-adapter.js +138 -0
- package/dist/protocols/sse-adapter.js.map +1 -0
- package/dist/protocols/ws-adapter.d.ts +26 -0
- package/dist/protocols/ws-adapter.d.ts.map +1 -0
- package/dist/protocols/ws-adapter.js +154 -0
- package/dist/protocols/ws-adapter.js.map +1 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +903 -0
- package/dist/server.js.map +1 -0
- package/dist/service/cli.d.ts +29 -0
- package/dist/service/cli.d.ts.map +1 -0
- package/dist/service/cli.js +479 -0
- package/dist/service/cli.js.map +1 -0
- package/dist/service/http-api.d.ts +29 -0
- package/dist/service/http-api.d.ts.map +1 -0
- package/dist/service/http-api.js +122 -0
- package/dist/service/http-api.js.map +1 -0
- package/dist/service/index.d.ts +22 -0
- package/dist/service/index.d.ts.map +1 -0
- package/dist/service/index.js +80 -0
- package/dist/service/index.js.map +1 -0
- package/dist/service/protocol.d.ts +46 -0
- package/dist/service/protocol.d.ts.map +1 -0
- package/dist/service/protocol.js +81 -0
- package/dist/service/protocol.js.map +1 -0
- package/dist/service/room-manager.d.ts +88 -0
- package/dist/service/room-manager.d.ts.map +1 -0
- package/dist/service/room-manager.js +237 -0
- package/dist/service/room-manager.js.map +1 -0
- package/dist/service/test.d.ts +18 -0
- package/dist/service/test.d.ts.map +1 -0
- package/dist/service/test.js +260 -0
- package/dist/service/test.js.map +1 -0
- package/dist/service/user-manager.d.ts +68 -0
- package/dist/service/user-manager.d.ts.map +1 -0
- package/dist/service/user-manager.js +111 -0
- package/dist/service/user-manager.js.map +1 -0
- package/dist/service/ws-server.d.ts +34 -0
- package/dist/service/ws-server.d.ts.map +1 -0
- package/dist/service/ws-server.js +281 -0
- package/dist/service/ws-server.js.map +1 -0
- package/dist/test/echo-server.d.ts +12 -0
- package/dist/test/echo-server.d.ts.map +1 -0
- package/dist/test/echo-server.js +66 -0
- package/dist/test/echo-server.js.map +1 -0
- package/dist/test/integration-test.d.ts +13 -0
- package/dist/test/integration-test.d.ts.map +1 -0
- package/dist/test/integration-test.js +154 -0
- package/dist/test/integration-test.js.map +1 -0
- package/dist/test/reactive-test.d.ts +16 -0
- package/dist/test/reactive-test.d.ts.map +1 -0
- package/dist/test/reactive-test.js +128 -0
- package/dist/test/reactive-test.js.map +1 -0
- package/dist/test/send-hello.d.ts +2 -0
- package/dist/test/send-hello.d.ts.map +1 -0
- package/dist/test/send-hello.js +59 -0
- package/dist/test/send-hello.js.map +1 -0
- package/dist/test/service-mcp-test.d.ts +9 -0
- package/dist/test/service-mcp-test.d.ts.map +1 -0
- package/dist/test/service-mcp-test.js +127 -0
- package/dist/test/service-mcp-test.js.map +1 -0
- package/dist/types.d.ts +55 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -0
- package/package.json +76 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { ConnectionManager } from "./core/connection-manager.js";
|
|
4
|
+
import { MessageBuffer } from "./core/message-buffer.js";
|
|
5
|
+
import { NotificationEngine } from "./core/notification-engine.js";
|
|
6
|
+
import { Logger } from "./core/logger.js";
|
|
7
|
+
const log = Logger.create("mcp-server");
|
|
8
|
+
/**
|
|
9
|
+
* Decode a raw Service protocol JSON string into a human-readable line.
|
|
10
|
+
* Returns null if not a valid Service message.
|
|
11
|
+
*/
|
|
12
|
+
function decodeServiceMessage(raw) {
|
|
13
|
+
try {
|
|
14
|
+
const msg = JSON.parse(raw);
|
|
15
|
+
if (typeof msg !== "object" || !msg || !msg.type)
|
|
16
|
+
return null;
|
|
17
|
+
const p = msg.payload ?? {};
|
|
18
|
+
const ts = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString("en-US", { hour12: false }) : "";
|
|
19
|
+
const prefix = ts ? `[${ts}]` : "";
|
|
20
|
+
switch (msg.type) {
|
|
21
|
+
case "chat": {
|
|
22
|
+
const dm = p.dm ? " [DM]" : "";
|
|
23
|
+
const room = p.room ? ` #${p.room}` : "";
|
|
24
|
+
return `${prefix}${room}${dm} ${msg.from}: ${p.message}`;
|
|
25
|
+
}
|
|
26
|
+
case "system": {
|
|
27
|
+
switch (p.event) {
|
|
28
|
+
case "welcome":
|
|
29
|
+
return `${prefix} [system] Connected — ${p.message}`;
|
|
30
|
+
case "user.joined":
|
|
31
|
+
return `${prefix} [system] ${p.user_name} joined #${p.room_id}`;
|
|
32
|
+
case "user.left":
|
|
33
|
+
return `${prefix} [system] ${p.user_name} left #${p.room_id}`;
|
|
34
|
+
case "room.history":
|
|
35
|
+
const msgs = p.messages ?? [];
|
|
36
|
+
return `${prefix} [system] Room history: ${msgs.length} message(s)`;
|
|
37
|
+
default:
|
|
38
|
+
return `${prefix} [system:${p.event}] ${JSON.stringify(p)}`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
case "response":
|
|
42
|
+
return `${prefix} [response:${p.action}] ${p.success ? "ok" : `error: ${p.error}`}`;
|
|
43
|
+
case "error":
|
|
44
|
+
return `${prefix} [error ${p.code}] ${p.message}`;
|
|
45
|
+
default:
|
|
46
|
+
return `${prefix} [${msg.type}] ${JSON.stringify(p)}`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Create and configure the AgentRoom MCP server with all tools and resources.
|
|
55
|
+
*/
|
|
56
|
+
export function createAgentRoomServer(options = {}) {
|
|
57
|
+
const defaultServiceUrl = options.serviceUrl;
|
|
58
|
+
// ─── Core services ─────────────────────────────────────────────────
|
|
59
|
+
const connectionManager = new ConnectionManager();
|
|
60
|
+
const messageBuffer = new MessageBuffer(50);
|
|
61
|
+
const notificationEngine = new NotificationEngine(1_000);
|
|
62
|
+
/** Tracks which channels are connected via connect_service (Service protocol aware) */
|
|
63
|
+
const serviceChannels = new Map();
|
|
64
|
+
// ─── MCP Server ────────────────────────────────────────────────────
|
|
65
|
+
const mcpServer = new McpServer({
|
|
66
|
+
name: "agent-room",
|
|
67
|
+
version: "0.1.0",
|
|
68
|
+
}, {
|
|
69
|
+
capabilities: {
|
|
70
|
+
resources: { subscribe: true },
|
|
71
|
+
tools: {},
|
|
72
|
+
logging: {},
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
// Attach the low-level Server instance to the notification engine
|
|
76
|
+
notificationEngine.setServer(mcpServer.server);
|
|
77
|
+
// ─── Wire ConnectionManager events ─────────────────────────────────
|
|
78
|
+
connectionManager.on("message", (channelId, data) => {
|
|
79
|
+
// For service channels, decode protocol messages into readable text
|
|
80
|
+
const svcInfo = serviceChannels.get(channelId);
|
|
81
|
+
let bufferData = data;
|
|
82
|
+
if (svcInfo) {
|
|
83
|
+
const decoded = decodeServiceMessage(data);
|
|
84
|
+
if (decoded) {
|
|
85
|
+
bufferData = decoded;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Buffer the (possibly decoded) message
|
|
89
|
+
messageBuffer.push(channelId, bufferData);
|
|
90
|
+
// Notify AI that the stream resource has been updated
|
|
91
|
+
const recentUri = `stream://${channelId}/messages/recent`;
|
|
92
|
+
notificationEngine.notifyResourceUpdated(channelId, recentUri);
|
|
93
|
+
});
|
|
94
|
+
// ─── Tools ─────────────────────────────────────────────────────────
|
|
95
|
+
// Tool: connect_stream
|
|
96
|
+
mcpServer.tool("connect_stream", "Establish a new stream connection to a WebSocket or SSE endpoint. Returns the channel ID for subsequent operations.", {
|
|
97
|
+
url: z.string().describe("The WebSocket (ws:// or wss://) or SSE (http:// or https://) URL to connect to"),
|
|
98
|
+
protocol: z.enum(["ws", "sse"]).describe("The streaming protocol to use"),
|
|
99
|
+
channel_id: z.string().optional().describe("Optional custom channel ID. Auto-generated if not provided."),
|
|
100
|
+
auth_token: z.string().optional().describe("Optional bearer token for authentication"),
|
|
101
|
+
headers: z.record(z.string(), z.string()).optional().describe("Optional custom headers to send on connect"),
|
|
102
|
+
}, async ({ url, protocol, channel_id, auth_token, headers }) => {
|
|
103
|
+
const done = log.time("tool.connect_stream", { url, protocol });
|
|
104
|
+
try {
|
|
105
|
+
const channelId = await connectionManager.connect(url, protocol, channel_id, {
|
|
106
|
+
authToken: auth_token,
|
|
107
|
+
headers,
|
|
108
|
+
});
|
|
109
|
+
done({ channelId });
|
|
110
|
+
return {
|
|
111
|
+
content: [
|
|
112
|
+
{
|
|
113
|
+
type: "text",
|
|
114
|
+
text: `Connected to ${protocol.toUpperCase()} stream.\n\nChannel ID: ${channelId}\nURL: ${url}\nProtocol: ${protocol}\nStatus: connected\n\nUse this channel_id for send_message, disconnect_stream, and to read stream resources.`,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
121
|
+
log.error("tool.connect_stream failed", { url, protocol }, err);
|
|
122
|
+
return {
|
|
123
|
+
content: [{ type: "text", text: `Failed to connect: ${message}` }],
|
|
124
|
+
isError: true,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
// Tool: disconnect_stream
|
|
129
|
+
mcpServer.tool("disconnect_stream", "Disconnect and close a specific stream connection.", {
|
|
130
|
+
channel_id: z.string().describe("The channel ID to disconnect"),
|
|
131
|
+
}, async ({ channel_id }) => {
|
|
132
|
+
try {
|
|
133
|
+
await connectionManager.disconnect(channel_id);
|
|
134
|
+
messageBuffer.clear(channel_id);
|
|
135
|
+
serviceChannels.delete(channel_id); // Clean up service metadata if any
|
|
136
|
+
return {
|
|
137
|
+
content: [
|
|
138
|
+
{
|
|
139
|
+
type: "text",
|
|
140
|
+
text: `Channel "${channel_id}" disconnected and buffer cleared.`,
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
147
|
+
return {
|
|
148
|
+
content: [{ type: "text", text: `Failed to disconnect: ${message}` }],
|
|
149
|
+
isError: true,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
// Tool: list_connections
|
|
154
|
+
mcpServer.tool("list_connections", "List all active stream connections and their current status.", {}, async () => {
|
|
155
|
+
const connections = connectionManager.listConnections();
|
|
156
|
+
if (connections.length === 0) {
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: "text", text: "No active connections." }],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const summary = connections
|
|
162
|
+
.map((c) => {
|
|
163
|
+
return [
|
|
164
|
+
`Channel: ${c.channelId}`,
|
|
165
|
+
` URL: ${c.url}`,
|
|
166
|
+
` Protocol: ${c.protocol}`,
|
|
167
|
+
` State: ${c.state}`,
|
|
168
|
+
` Connected at: ${c.connectedAt ?? "N/A"}`,
|
|
169
|
+
` Messages received: ${c.messageCount}`,
|
|
170
|
+
].join("\n");
|
|
171
|
+
})
|
|
172
|
+
.join("\n\n");
|
|
173
|
+
return {
|
|
174
|
+
content: [{ type: "text", text: summary }],
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
// Tool: send_message
|
|
178
|
+
mcpServer.tool("send_message", "Send a message to the cloud through an active stream connection. Only works for WebSocket connections (SSE is receive-only).", {
|
|
179
|
+
channel_id: z.string().describe("The channel ID to send through"),
|
|
180
|
+
payload: z.string().describe("The message payload to send. For Service channels (connected via connect_service), this is the chat text — it will be auto-wrapped in the Service protocol."),
|
|
181
|
+
format: z.enum(["text", "json"]).optional().describe("Optional format hint. If 'json', the payload is validated as JSON before sending. For Service channels, 'json' sends raw protocol (bypass auto-wrap)."),
|
|
182
|
+
}, async ({ channel_id, payload, format }) => {
|
|
183
|
+
try {
|
|
184
|
+
// Check if this is a service channel
|
|
185
|
+
const svcInfo = serviceChannels.get(channel_id);
|
|
186
|
+
if (svcInfo && format !== "json") {
|
|
187
|
+
// Auto-wrap plain text as a Service chat message
|
|
188
|
+
const wrapped = JSON.stringify({
|
|
189
|
+
type: "chat",
|
|
190
|
+
from: svcInfo.name,
|
|
191
|
+
to: `room:${svcInfo.room}`,
|
|
192
|
+
payload: { message: payload },
|
|
193
|
+
});
|
|
194
|
+
await connectionManager.send(channel_id, wrapped);
|
|
195
|
+
return {
|
|
196
|
+
content: [{
|
|
197
|
+
type: "text",
|
|
198
|
+
text: `Chat sent to #${svcInfo.room} as "${svcInfo.name}" at ${new Date().toISOString()}.\n\nMessage: ${payload}`,
|
|
199
|
+
}],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// Raw send for non-service channels (or json format override)
|
|
203
|
+
if (format === "json") {
|
|
204
|
+
try {
|
|
205
|
+
JSON.parse(payload);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return {
|
|
209
|
+
content: [{ type: "text", text: "Invalid JSON payload. Please provide valid JSON." }],
|
|
210
|
+
isError: true,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
await connectionManager.send(channel_id, payload);
|
|
215
|
+
return {
|
|
216
|
+
content: [
|
|
217
|
+
{
|
|
218
|
+
type: "text",
|
|
219
|
+
text: `Message sent to channel "${channel_id}" at ${new Date().toISOString()}.\n\nPayload: ${payload.length > 200 ? payload.slice(0, 200) + "..." : payload}`,
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
226
|
+
return {
|
|
227
|
+
content: [{ type: "text", text: `Failed to send: ${message}` }],
|
|
228
|
+
isError: true,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
// Tool: read_history
|
|
233
|
+
mcpServer.tool("read_history", "Read buffered message history from a stream channel. Returns the most recent messages stored in the sliding window buffer (up to 50). Use this to review what has already been received without waiting for new messages.", {
|
|
234
|
+
channel_id: z.string().describe("The channel ID to read history from"),
|
|
235
|
+
count: z.number().optional().describe("Number of recent messages to return (default 20, max 50)"),
|
|
236
|
+
filter: z.string().optional().describe("Only return messages whose text contains this substring"),
|
|
237
|
+
format: z.enum(["text", "json"]).optional().describe("Output format: 'text' (default, human-readable) or 'json' (structured array)"),
|
|
238
|
+
}, async ({ channel_id, count, filter, format }) => {
|
|
239
|
+
try {
|
|
240
|
+
if (!connectionManager.has(channel_id)) {
|
|
241
|
+
return {
|
|
242
|
+
content: [{ type: "text", text: `Channel "${channel_id}" not found. Connect first using connect_stream.` }],
|
|
243
|
+
isError: true,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const n = Math.min(Math.max(count ?? 20, 1), 50);
|
|
247
|
+
let messages = messageBuffer.getRecent(channel_id, n);
|
|
248
|
+
// Apply filter if provided
|
|
249
|
+
if (filter) {
|
|
250
|
+
messages = messages.filter((m) => m.raw.includes(filter));
|
|
251
|
+
}
|
|
252
|
+
if (messages.length === 0) {
|
|
253
|
+
return {
|
|
254
|
+
content: [{
|
|
255
|
+
type: "text",
|
|
256
|
+
text: filter
|
|
257
|
+
? `No messages matching "${filter}" in channel "${channel_id}" history.`
|
|
258
|
+
: `No messages in channel "${channel_id}" history yet.`,
|
|
259
|
+
}],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
let output;
|
|
263
|
+
if (format === "json") {
|
|
264
|
+
output = JSON.stringify(messages.map((m) => ({
|
|
265
|
+
id: m.id,
|
|
266
|
+
timestamp: m.timestamp,
|
|
267
|
+
data: m.data,
|
|
268
|
+
})), null, 2);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
output = messageBuffer.formatForDisplay(messages);
|
|
272
|
+
}
|
|
273
|
+
const info = connectionManager.getConnectionInfo(channel_id);
|
|
274
|
+
const header = `Channel "${channel_id}" history — ${messages.length} message(s)${filter ? ` matching "${filter}"` : ""} (total buffered: ${messageBuffer.getAll(channel_id).length}, connection: ${info?.state ?? "unknown"})`;
|
|
275
|
+
return {
|
|
276
|
+
content: [{ type: "text", text: `${header}\n\n${output}` }],
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
281
|
+
return {
|
|
282
|
+
content: [{ type: "text", text: `Error reading history: ${message}` }],
|
|
283
|
+
isError: true,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
// Tool: wait_for_message
|
|
288
|
+
mcpServer.tool("wait_for_message", "Block until a new message arrives on a channel (or timeout). Returns the message content so you can react to it immediately. Use this in a loop to continuously monitor a stream. Only messages arriving AFTER this call are considered.", {
|
|
289
|
+
channel_id: z.string().describe("The channel ID to listen on"),
|
|
290
|
+
timeout_seconds: z.number().optional().describe("Max seconds to wait before returning (default 30, max 55)"),
|
|
291
|
+
filter: z.string().optional().describe("Only return messages whose raw text contains this substring (e.g. 'ERROR', '500')"),
|
|
292
|
+
}, async ({ channel_id, timeout_seconds, filter }) => {
|
|
293
|
+
try {
|
|
294
|
+
if (!connectionManager.has(channel_id)) {
|
|
295
|
+
return {
|
|
296
|
+
content: [{ type: "text", text: `Channel "${channel_id}" not found. Connect first using connect_stream.` }],
|
|
297
|
+
isError: true,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
// Clamp timeout to a safe range (Cursor tool calls typically timeout at ~60s)
|
|
301
|
+
const timeoutSec = Math.min(Math.max(timeout_seconds ?? 30, 1), 55);
|
|
302
|
+
const timeoutMs = timeoutSec * 1000;
|
|
303
|
+
const result = await connectionManager.waitForMessage(channel_id, timeoutMs, filter);
|
|
304
|
+
if (result.timedOut) {
|
|
305
|
+
return {
|
|
306
|
+
content: [
|
|
307
|
+
{
|
|
308
|
+
type: "text",
|
|
309
|
+
text: `No ${filter ? `messages matching "${filter}"` : "messages"} received on channel "${channel_id}" within ${timeoutSec}s.\n\nYou can call wait_for_message again to keep listening, or disconnect_stream to stop.`,
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
// Auto-format JSON for readability
|
|
315
|
+
let displayData = result.data;
|
|
316
|
+
try {
|
|
317
|
+
const parsed = JSON.parse(result.data);
|
|
318
|
+
displayData = JSON.stringify(parsed, null, 2);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
// Not JSON, display as-is
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
content: [
|
|
325
|
+
{
|
|
326
|
+
type: "text",
|
|
327
|
+
text: `New message on channel "${channel_id}" at ${new Date().toISOString()}${filter ? ` (matched filter: "${filter}")` : ""}:\n\n${displayData}`,
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
334
|
+
return {
|
|
335
|
+
content: [{ type: "text", text: `Error waiting for message: ${message}` }],
|
|
336
|
+
isError: true,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
// Tool: watch_stream
|
|
341
|
+
mcpServer.tool("watch_stream", "Convenience tool: connect to a stream (if not already connected) and wait for the next message. Combines connect_stream + wait_for_message in one step.", {
|
|
342
|
+
url: z.string().describe("The WebSocket (ws:// or wss://) or SSE (http:// or https://) URL to connect to"),
|
|
343
|
+
protocol: z.enum(["ws", "sse"]).describe("The streaming protocol to use"),
|
|
344
|
+
channel_id: z.string().optional().describe("Optional custom channel ID. Auto-generated if not provided."),
|
|
345
|
+
auth_token: z.string().optional().describe("Optional bearer token for authentication"),
|
|
346
|
+
timeout_seconds: z.number().optional().describe("Max seconds to wait for a message (default 30, max 55)"),
|
|
347
|
+
filter: z.string().optional().describe("Only return messages whose raw text contains this substring"),
|
|
348
|
+
}, async ({ url, protocol, channel_id, auth_token, timeout_seconds, filter }) => {
|
|
349
|
+
try {
|
|
350
|
+
// Connect if not already connected
|
|
351
|
+
let channelId = channel_id;
|
|
352
|
+
if (!channelId || !connectionManager.has(channelId)) {
|
|
353
|
+
channelId = await connectionManager.connect(url, protocol, channelId, {
|
|
354
|
+
authToken: auth_token,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// Wait for next message
|
|
358
|
+
const timeoutSec = Math.min(Math.max(timeout_seconds ?? 30, 1), 55);
|
|
359
|
+
const timeoutMs = timeoutSec * 1000;
|
|
360
|
+
const result = await connectionManager.waitForMessage(channelId, timeoutMs, filter);
|
|
361
|
+
if (result.timedOut) {
|
|
362
|
+
return {
|
|
363
|
+
content: [
|
|
364
|
+
{
|
|
365
|
+
type: "text",
|
|
366
|
+
text: `Connected to channel "${channelId}" at ${url}, but no ${filter ? `messages matching "${filter}"` : "messages"} received within ${timeoutSec}s.\n\nCall wait_for_message("${channelId}") to keep listening.`,
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
let displayData = result.data;
|
|
372
|
+
try {
|
|
373
|
+
const parsed = JSON.parse(result.data);
|
|
374
|
+
displayData = JSON.stringify(parsed, null, 2);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
// Not JSON
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
content: [
|
|
381
|
+
{
|
|
382
|
+
type: "text",
|
|
383
|
+
text: `Connected to channel "${channelId}" at ${url}.\n\nFirst message received at ${new Date().toISOString()}${filter ? ` (matched filter: "${filter}")` : ""}:\n\n${displayData}\n\nCall wait_for_message("${channelId}") to continue listening, or send_message("${channelId}", payload) to respond.`,
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
390
|
+
return {
|
|
391
|
+
content: [{ type: "text", text: `Failed: ${message}` }],
|
|
392
|
+
isError: true,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
// Tool: connect_service
|
|
397
|
+
mcpServer.tool("connect_service", `Connect to a remote AgentRoom Service (chat server). Handles authentication and room joining automatically. After connecting, use send_message to chat and wait_for_message to receive messages. Messages are decoded from Service protocol into human-readable format.${defaultServiceUrl ? ` Default URL: ${defaultServiceUrl}` : ""}`, {
|
|
398
|
+
url: z.string().optional().describe("Service WebSocket URL (e.g. ws://localhost:9000 or wss://my-server.com:9000)"),
|
|
399
|
+
name: z.string().optional().describe("Username for authentication (default: 'AI-Agent')"),
|
|
400
|
+
room: z.string().optional().describe("Room to auto-join (default: 'general')"),
|
|
401
|
+
channel_id: z.string().optional().describe("Optional custom channel ID"),
|
|
402
|
+
}, async ({ url, name, room, channel_id }) => {
|
|
403
|
+
const serviceUrl = url ?? defaultServiceUrl;
|
|
404
|
+
if (!serviceUrl) {
|
|
405
|
+
return {
|
|
406
|
+
content: [{ type: "text", text: "URL is required. Provide a 'url' parameter or configure AGENT_ROOM_URL environment variable." }],
|
|
407
|
+
isError: true,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
const userName = name ?? "AI-Agent";
|
|
411
|
+
const roomId = room ?? "general";
|
|
412
|
+
const done = log.time("tool.connect_service", { url: serviceUrl, userName, roomId });
|
|
413
|
+
try {
|
|
414
|
+
// 1. Connect raw WebSocket
|
|
415
|
+
const channelId = await connectionManager.connect(serviceUrl, "ws", channel_id);
|
|
416
|
+
// Helper to wait for a specific response
|
|
417
|
+
const waitForResponse = (actionName, timeoutMs = 8000) => new Promise((resolve, reject) => {
|
|
418
|
+
let settled = false;
|
|
419
|
+
const handler = (id, data) => {
|
|
420
|
+
if (settled || id !== channelId)
|
|
421
|
+
return;
|
|
422
|
+
try {
|
|
423
|
+
const msg = JSON.parse(data);
|
|
424
|
+
if (msg.type === "response" && msg.payload?.action === actionName) {
|
|
425
|
+
settled = true;
|
|
426
|
+
clearTimeout(timer);
|
|
427
|
+
connectionManager.removeListener("message", handler);
|
|
428
|
+
resolve(msg);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
catch { /* ignore non-JSON */ }
|
|
432
|
+
};
|
|
433
|
+
const timer = setTimeout(() => {
|
|
434
|
+
if (settled)
|
|
435
|
+
return;
|
|
436
|
+
settled = true;
|
|
437
|
+
connectionManager.removeListener("message", handler);
|
|
438
|
+
reject(new Error(`Timeout waiting for ${actionName} response`));
|
|
439
|
+
}, timeoutMs);
|
|
440
|
+
// Use EventEmitter.prototype.on to bypass typed overload
|
|
441
|
+
connectionManager.on("message", handler);
|
|
442
|
+
});
|
|
443
|
+
// 2. Send auth (server sends welcome first, we don't need to wait for it)
|
|
444
|
+
await connectionManager.send(channelId, JSON.stringify({
|
|
445
|
+
type: "action",
|
|
446
|
+
from: userName,
|
|
447
|
+
payload: { action: "auth", name: userName },
|
|
448
|
+
}));
|
|
449
|
+
const authMsg = await waitForResponse("auth");
|
|
450
|
+
if (!authMsg.payload?.success) {
|
|
451
|
+
await connectionManager.disconnect(channelId);
|
|
452
|
+
return {
|
|
453
|
+
content: [{ type: "text", text: `Authentication failed: ${authMsg.payload?.error ?? "unknown error"}` }],
|
|
454
|
+
isError: true,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
// 3. Join room
|
|
458
|
+
await connectionManager.send(channelId, JSON.stringify({
|
|
459
|
+
type: "action",
|
|
460
|
+
from: userName,
|
|
461
|
+
payload: { action: "room.join", room_id: roomId },
|
|
462
|
+
}));
|
|
463
|
+
const joinMsg = await waitForResponse("room.join");
|
|
464
|
+
const members = joinMsg.payload?.data?.members;
|
|
465
|
+
// 4. Register as a service channel
|
|
466
|
+
serviceChannels.set(channelId, {
|
|
467
|
+
name: userName,
|
|
468
|
+
room: roomId,
|
|
469
|
+
url: serviceUrl,
|
|
470
|
+
joinedRooms: new Set([roomId]),
|
|
471
|
+
});
|
|
472
|
+
const memberList = members && members.length > 0
|
|
473
|
+
? `\nMembers: ${members.join(", ")}`
|
|
474
|
+
: "";
|
|
475
|
+
done({ channelId });
|
|
476
|
+
return {
|
|
477
|
+
content: [{
|
|
478
|
+
type: "text",
|
|
479
|
+
text: [
|
|
480
|
+
`Connected to AgentRoom Service and joined #${roomId} as "${userName}".`,
|
|
481
|
+
``,
|
|
482
|
+
`Channel ID: ${channelId}`,
|
|
483
|
+
`URL: ${serviceUrl}`,
|
|
484
|
+
`Room: #${roomId}${memberList}`,
|
|
485
|
+
``,
|
|
486
|
+
`Usage:`,
|
|
487
|
+
`• send_message("${channelId}", "hello") → sends chat to #${roomId}`,
|
|
488
|
+
`• wait_for_message("${channelId}") → waits for next incoming message`,
|
|
489
|
+
`• read_history("${channelId}") → shows buffered messages`,
|
|
490
|
+
`• Messages are auto-decoded into human-readable format.`,
|
|
491
|
+
].join("\n"),
|
|
492
|
+
}],
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
497
|
+
log.error("tool.connect_service failed", { url: serviceUrl, userName }, err);
|
|
498
|
+
// Clean up on failure
|
|
499
|
+
if (channel_id && connectionManager.has(channel_id)) {
|
|
500
|
+
try {
|
|
501
|
+
await connectionManager.disconnect(channel_id);
|
|
502
|
+
}
|
|
503
|
+
catch { /* ignore */ }
|
|
504
|
+
}
|
|
505
|
+
return {
|
|
506
|
+
content: [{ type: "text", text: `Failed to connect to service: ${message}` }],
|
|
507
|
+
isError: true,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
// ─── Room Management Tools (require an active Service connection) ────
|
|
512
|
+
/**
|
|
513
|
+
* Helper: send an action to the service and wait for the response.
|
|
514
|
+
* Reusable for list_rooms, create_room, join_room, leave_room.
|
|
515
|
+
*/
|
|
516
|
+
async function sendServiceAction(channelId, svcInfo, action, extra = {}, timeoutMs = 8000) {
|
|
517
|
+
return new Promise((resolve) => {
|
|
518
|
+
let settled = false;
|
|
519
|
+
const handler = (id, data) => {
|
|
520
|
+
if (settled || id !== channelId)
|
|
521
|
+
return;
|
|
522
|
+
try {
|
|
523
|
+
const msg = JSON.parse(data);
|
|
524
|
+
if (msg.type === "response" && msg.payload?.action === action) {
|
|
525
|
+
settled = true;
|
|
526
|
+
clearTimeout(timer);
|
|
527
|
+
connectionManager.removeListener("message", handler);
|
|
528
|
+
if (msg.payload.success) {
|
|
529
|
+
resolve({ success: true, data: msg.payload.data });
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
resolve({ success: false, error: msg.payload.error ?? "Unknown error" });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
catch { /* ignore non-JSON */ }
|
|
537
|
+
};
|
|
538
|
+
const timer = setTimeout(() => {
|
|
539
|
+
if (settled)
|
|
540
|
+
return;
|
|
541
|
+
settled = true;
|
|
542
|
+
connectionManager.removeListener("message", handler);
|
|
543
|
+
resolve({ success: false, error: `Timeout waiting for ${action} response` });
|
|
544
|
+
}, timeoutMs);
|
|
545
|
+
connectionManager.on("message", handler);
|
|
546
|
+
// Send the action
|
|
547
|
+
connectionManager.send(channelId, JSON.stringify({
|
|
548
|
+
type: "action",
|
|
549
|
+
from: svcInfo.name,
|
|
550
|
+
payload: { action, ...extra },
|
|
551
|
+
})).catch((err) => {
|
|
552
|
+
if (settled)
|
|
553
|
+
return;
|
|
554
|
+
settled = true;
|
|
555
|
+
clearTimeout(timer);
|
|
556
|
+
connectionManager.removeListener("message", handler);
|
|
557
|
+
resolve({ success: false, error: err instanceof Error ? err.message : String(err) });
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Helper: find the active service channel. Returns [channelId, info] or an error response.
|
|
563
|
+
*/
|
|
564
|
+
function getServiceChannel(channel_id) {
|
|
565
|
+
if (channel_id) {
|
|
566
|
+
const info = serviceChannels.get(channel_id);
|
|
567
|
+
if (!info)
|
|
568
|
+
return { error: `Channel "${channel_id}" is not a Service connection. Use connect_service first.` };
|
|
569
|
+
return { channelId: channel_id, info };
|
|
570
|
+
}
|
|
571
|
+
// Auto-detect: use the first (or only) service channel
|
|
572
|
+
const entries = [...serviceChannels.entries()];
|
|
573
|
+
if (entries.length === 0)
|
|
574
|
+
return { error: "No active Service connection. Use connect_service first." };
|
|
575
|
+
if (entries.length === 1)
|
|
576
|
+
return { channelId: entries[0][0], info: entries[0][1] };
|
|
577
|
+
return { error: `Multiple Service connections active. Please specify channel_id: ${entries.map(([id]) => id).join(", ")}` };
|
|
578
|
+
}
|
|
579
|
+
// Tool: list_rooms
|
|
580
|
+
mcpServer.tool("list_rooms", "List all rooms on the connected AgentRoom Service. Shows room names, descriptions, member counts, and whether a password is required.", {
|
|
581
|
+
channel_id: z.string().optional().describe("Service channel ID. Auto-detected if only one Service connection exists."),
|
|
582
|
+
}, async ({ channel_id }) => {
|
|
583
|
+
const svc = getServiceChannel(channel_id);
|
|
584
|
+
if ("error" in svc) {
|
|
585
|
+
return { content: [{ type: "text", text: svc.error }], isError: true };
|
|
586
|
+
}
|
|
587
|
+
const result = await sendServiceAction(svc.channelId, svc.info, "room.list");
|
|
588
|
+
if (!result.success) {
|
|
589
|
+
return { content: [{ type: "text", text: `Failed to list rooms: ${result.error}` }], isError: true };
|
|
590
|
+
}
|
|
591
|
+
const rooms = (result.data?.rooms ?? []);
|
|
592
|
+
if (rooms.length === 0) {
|
|
593
|
+
return { content: [{ type: "text", text: "No rooms found." }] };
|
|
594
|
+
}
|
|
595
|
+
const lines = rooms.map((r) => {
|
|
596
|
+
const lock = r.hasPassword ? " [password]" : "";
|
|
597
|
+
const pin = r.persistent ? " [persistent]" : "";
|
|
598
|
+
return `• #${r.id} — ${r.name}${lock}${pin}\n ${r.description || "(no description)"}\n Members: ${r.memberCount}`;
|
|
599
|
+
});
|
|
600
|
+
const currentRoom = svc.info.room;
|
|
601
|
+
return {
|
|
602
|
+
content: [{
|
|
603
|
+
type: "text",
|
|
604
|
+
text: `Rooms on ${svc.info.url} (current: #${currentRoom}):\n\n${lines.join("\n\n")}`,
|
|
605
|
+
}],
|
|
606
|
+
};
|
|
607
|
+
});
|
|
608
|
+
// Tool: create_room
|
|
609
|
+
mcpServer.tool("create_room", "Create a new room on the connected AgentRoom Service. Optionally set a password to make it private.", {
|
|
610
|
+
room_id: z.string().describe("Room ID (alphanumeric, dashes, underscores)"),
|
|
611
|
+
name: z.string().optional().describe("Human-readable room name (defaults to room_id)"),
|
|
612
|
+
description: z.string().optional().describe("Room description"),
|
|
613
|
+
password: z.string().optional().describe("Room password. If set, users must provide it to join."),
|
|
614
|
+
persistent: z.boolean().optional().describe("Whether the room persists after all members leave (default: false)"),
|
|
615
|
+
channel_id: z.string().optional().describe("Service channel ID. Auto-detected if only one Service connection exists."),
|
|
616
|
+
}, async ({ room_id, name, description, password, persistent, channel_id }) => {
|
|
617
|
+
const svc = getServiceChannel(channel_id);
|
|
618
|
+
if ("error" in svc) {
|
|
619
|
+
return { content: [{ type: "text", text: svc.error }], isError: true };
|
|
620
|
+
}
|
|
621
|
+
const extra = { room_id };
|
|
622
|
+
if (name)
|
|
623
|
+
extra.name = name;
|
|
624
|
+
if (description)
|
|
625
|
+
extra.description = description;
|
|
626
|
+
if (password)
|
|
627
|
+
extra.password = password;
|
|
628
|
+
if (persistent !== undefined)
|
|
629
|
+
extra.persistent = persistent;
|
|
630
|
+
const result = await sendServiceAction(svc.channelId, svc.info, "room.create", extra);
|
|
631
|
+
if (!result.success) {
|
|
632
|
+
return { content: [{ type: "text", text: `Failed to create room: ${result.error}` }], isError: true };
|
|
633
|
+
}
|
|
634
|
+
const lock = password ? " (password-protected)" : "";
|
|
635
|
+
return {
|
|
636
|
+
content: [{
|
|
637
|
+
type: "text",
|
|
638
|
+
text: `Room #${room_id} created successfully${lock}.\n\nUse join_room("${room_id}"${password ? ', password="..."' : ""}) to enter.`,
|
|
639
|
+
}],
|
|
640
|
+
};
|
|
641
|
+
});
|
|
642
|
+
// Tool: join_room
|
|
643
|
+
mcpServer.tool("join_room", "Join a room on the connected AgentRoom Service. After joining, send_message will route to the current room.", {
|
|
644
|
+
room_id: z.string().describe("Room ID to join"),
|
|
645
|
+
password: z.string().optional().describe("Room password (required if the room is password-protected)"),
|
|
646
|
+
channel_id: z.string().optional().describe("Service channel ID. Auto-detected if only one Service connection exists."),
|
|
647
|
+
}, async ({ room_id, password, channel_id }) => {
|
|
648
|
+
const svc = getServiceChannel(channel_id);
|
|
649
|
+
if ("error" in svc) {
|
|
650
|
+
return { content: [{ type: "text", text: svc.error }], isError: true };
|
|
651
|
+
}
|
|
652
|
+
const extra = { room_id };
|
|
653
|
+
if (password)
|
|
654
|
+
extra.password = password;
|
|
655
|
+
const result = await sendServiceAction(svc.channelId, svc.info, "room.join", extra);
|
|
656
|
+
if (!result.success) {
|
|
657
|
+
return { content: [{ type: "text", text: `Failed to join room: ${result.error}` }], isError: true };
|
|
658
|
+
}
|
|
659
|
+
const members = (result.data?.members ?? []);
|
|
660
|
+
// Update the service channel's current room
|
|
661
|
+
svc.info.room = room_id;
|
|
662
|
+
svc.info.joinedRooms.add(room_id);
|
|
663
|
+
return {
|
|
664
|
+
content: [{
|
|
665
|
+
type: "text",
|
|
666
|
+
text: `Joined #${room_id}. Messages will now be sent to this room.\n\nMembers: ${members.join(", ") || "(empty)"}`,
|
|
667
|
+
}],
|
|
668
|
+
};
|
|
669
|
+
});
|
|
670
|
+
// Tool: leave_room
|
|
671
|
+
mcpServer.tool("leave_room", "Leave a room on the connected AgentRoom Service.", {
|
|
672
|
+
room_id: z.string().describe("Room ID to leave"),
|
|
673
|
+
channel_id: z.string().optional().describe("Service channel ID. Auto-detected if only one Service connection exists."),
|
|
674
|
+
}, async ({ room_id, channel_id }) => {
|
|
675
|
+
const svc = getServiceChannel(channel_id);
|
|
676
|
+
if ("error" in svc) {
|
|
677
|
+
return { content: [{ type: "text", text: svc.error }], isError: true };
|
|
678
|
+
}
|
|
679
|
+
const result = await sendServiceAction(svc.channelId, svc.info, "room.leave", { room_id });
|
|
680
|
+
if (!result.success) {
|
|
681
|
+
return { content: [{ type: "text", text: `Failed to leave room: ${result.error}` }], isError: true };
|
|
682
|
+
}
|
|
683
|
+
svc.info.joinedRooms.delete(room_id);
|
|
684
|
+
// If the user left their current room, switch to another joined room or fallback
|
|
685
|
+
if (svc.info.room === room_id) {
|
|
686
|
+
const remaining = [...svc.info.joinedRooms];
|
|
687
|
+
svc.info.room = remaining.length > 0 ? remaining[0] : "general";
|
|
688
|
+
}
|
|
689
|
+
return {
|
|
690
|
+
content: [{
|
|
691
|
+
type: "text",
|
|
692
|
+
text: `Left #${room_id}. Current room is now #${svc.info.room}.`,
|
|
693
|
+
}],
|
|
694
|
+
};
|
|
695
|
+
});
|
|
696
|
+
// Tool: open_chat_terminal
|
|
697
|
+
mcpServer.tool("open_chat_terminal", `Open an interactive chat terminal (CLI) that connects to an AgentRoom Service. This launches a separate terminal window where the user can observe real-time messages and participate in the chat room. Call this after connecting to a Service to give the user a live view.${defaultServiceUrl ? ` Default URL: ${defaultServiceUrl}` : ""}`, {
|
|
698
|
+
url: z.string().optional().describe(`Service WebSocket URL (default: ${defaultServiceUrl ?? "ws://localhost:9000"})`),
|
|
699
|
+
name: z.string().optional().describe("Username for the chat (default: current system user)"),
|
|
700
|
+
room: z.string().optional().describe("Room to auto-join (default: general)"),
|
|
701
|
+
}, async ({ url, name, room }) => {
|
|
702
|
+
const cliUrl = url ?? defaultServiceUrl ?? "ws://localhost:9000";
|
|
703
|
+
const cliName = name ?? "Observer";
|
|
704
|
+
const cliRoom = room ?? "general";
|
|
705
|
+
try {
|
|
706
|
+
const { spawn } = await import("child_process");
|
|
707
|
+
const path = await import("path");
|
|
708
|
+
const { fileURLToPath } = await import("url");
|
|
709
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
710
|
+
const cliPath = path.resolve(__dirname, "service", "cli.ts");
|
|
711
|
+
const args = ["tsx", cliPath, "--url", cliUrl, "--name", cliName, "--room", cliRoom];
|
|
712
|
+
// Try platform-appropriate terminal
|
|
713
|
+
const platform = process.platform;
|
|
714
|
+
let launched = false;
|
|
715
|
+
if (platform === "darwin") {
|
|
716
|
+
// macOS: open in Terminal.app
|
|
717
|
+
const script = `tell application "Terminal" to do script "cd ${path.resolve(__dirname, "..")} && npx ${args.join(" ")}"`;
|
|
718
|
+
spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
|
|
719
|
+
launched = true;
|
|
720
|
+
}
|
|
721
|
+
else if (platform === "linux") {
|
|
722
|
+
// Linux: try common terminal emulators
|
|
723
|
+
for (const term of ["gnome-terminal", "xterm", "konsole"]) {
|
|
724
|
+
try {
|
|
725
|
+
spawn(term, ["--", "npx", ...args], {
|
|
726
|
+
cwd: path.resolve(__dirname, ".."),
|
|
727
|
+
detached: true,
|
|
728
|
+
stdio: "ignore",
|
|
729
|
+
}).unref();
|
|
730
|
+
launched = true;
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
if (launched) {
|
|
739
|
+
return {
|
|
740
|
+
content: [{
|
|
741
|
+
type: "text",
|
|
742
|
+
text: `Chat terminal opened!\n\nConnecting to: ${cliUrl}\nUsername: ${cliName}\nRoom: #${cliRoom}\n\nThe user can now see real-time messages and participate in the chat.`,
|
|
743
|
+
}],
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
// Fallback: provide the command for the user to run manually
|
|
747
|
+
return {
|
|
748
|
+
content: [{
|
|
749
|
+
type: "text",
|
|
750
|
+
text: `Could not auto-open terminal. Ask the user to run this in a new terminal:\n\n npx tsx src/service/cli.ts --url ${cliUrl} --name ${cliName} --room ${cliRoom}\n\nOr the short form:\n\n pnpm run service:cli -- --name ${cliName} --room ${cliRoom}`,
|
|
751
|
+
}],
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
catch (err) {
|
|
755
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
756
|
+
return {
|
|
757
|
+
content: [{
|
|
758
|
+
type: "text",
|
|
759
|
+
text: `Failed to open terminal: ${errMsg}\n\nManual command:\n npx tsx src/service/cli.ts --url ${cliUrl} --name ${cliName} --room ${cliRoom}`,
|
|
760
|
+
}],
|
|
761
|
+
isError: true,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
// ─── Resources ─────────────────────────────────────────────────────
|
|
766
|
+
// Resource: connection://status (all connections summary)
|
|
767
|
+
mcpServer.resource("connection-status", "connection://status", {
|
|
768
|
+
description: "Summary of all active stream connections and their current state",
|
|
769
|
+
mimeType: "application/json",
|
|
770
|
+
}, async () => {
|
|
771
|
+
const connections = connectionManager.listConnections();
|
|
772
|
+
return {
|
|
773
|
+
contents: [
|
|
774
|
+
{
|
|
775
|
+
uri: "connection://status",
|
|
776
|
+
text: JSON.stringify(connections, null, 2),
|
|
777
|
+
mimeType: "application/json",
|
|
778
|
+
},
|
|
779
|
+
],
|
|
780
|
+
};
|
|
781
|
+
});
|
|
782
|
+
// Resource template: connection://{channel_id}/status
|
|
783
|
+
mcpServer.resource("channel-status", new ResourceTemplate("connection://{channel_id}/status", {
|
|
784
|
+
list: async () => {
|
|
785
|
+
// List all existing channel status resources
|
|
786
|
+
return {
|
|
787
|
+
resources: connectionManager.listConnections().map((c) => ({
|
|
788
|
+
uri: `connection://${c.channelId}/status`,
|
|
789
|
+
name: `Status for channel "${c.channelId}"`,
|
|
790
|
+
mimeType: "application/json",
|
|
791
|
+
})),
|
|
792
|
+
};
|
|
793
|
+
},
|
|
794
|
+
}), {
|
|
795
|
+
description: "Detailed status for a specific stream connection",
|
|
796
|
+
mimeType: "application/json",
|
|
797
|
+
}, async (uri, { channel_id }) => {
|
|
798
|
+
const info = connectionManager.getConnectionInfo(channel_id);
|
|
799
|
+
if (!info) {
|
|
800
|
+
return {
|
|
801
|
+
contents: [
|
|
802
|
+
{
|
|
803
|
+
uri: uri.href,
|
|
804
|
+
text: JSON.stringify({ error: `Channel "${channel_id}" not found` }),
|
|
805
|
+
mimeType: "application/json",
|
|
806
|
+
},
|
|
807
|
+
],
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
return {
|
|
811
|
+
contents: [
|
|
812
|
+
{
|
|
813
|
+
uri: uri.href,
|
|
814
|
+
text: JSON.stringify(info, null, 2),
|
|
815
|
+
mimeType: "application/json",
|
|
816
|
+
},
|
|
817
|
+
],
|
|
818
|
+
};
|
|
819
|
+
});
|
|
820
|
+
// Resource template: stream://{channel_id}/messages/recent
|
|
821
|
+
mcpServer.resource("stream-messages-recent", new ResourceTemplate("stream://{channel_id}/messages/recent", {
|
|
822
|
+
list: async () => {
|
|
823
|
+
return {
|
|
824
|
+
resources: connectionManager.listConnections().map((c) => ({
|
|
825
|
+
uri: `stream://${c.channelId}/messages/recent`,
|
|
826
|
+
name: `Recent messages for channel "${c.channelId}"`,
|
|
827
|
+
mimeType: "text/plain",
|
|
828
|
+
})),
|
|
829
|
+
};
|
|
830
|
+
},
|
|
831
|
+
}), {
|
|
832
|
+
description: "The most recent messages (up to 50) from a stream channel, formatted for readability",
|
|
833
|
+
mimeType: "text/plain",
|
|
834
|
+
}, async (uri, { channel_id }) => {
|
|
835
|
+
const messages = messageBuffer.getRecent(channel_id);
|
|
836
|
+
const formatted = messageBuffer.formatForDisplay(messages);
|
|
837
|
+
return {
|
|
838
|
+
contents: [
|
|
839
|
+
{
|
|
840
|
+
uri: uri.href,
|
|
841
|
+
text: formatted,
|
|
842
|
+
mimeType: "text/plain",
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
};
|
|
846
|
+
});
|
|
847
|
+
// Resource template: stream://{channel_id}/messages/latest
|
|
848
|
+
mcpServer.resource("stream-messages-latest", new ResourceTemplate("stream://{channel_id}/messages/latest", {
|
|
849
|
+
list: async () => {
|
|
850
|
+
return {
|
|
851
|
+
resources: connectionManager.listConnections().map((c) => ({
|
|
852
|
+
uri: `stream://${c.channelId}/messages/latest`,
|
|
853
|
+
name: `Latest message for channel "${c.channelId}"`,
|
|
854
|
+
mimeType: "text/plain",
|
|
855
|
+
})),
|
|
856
|
+
};
|
|
857
|
+
},
|
|
858
|
+
}), {
|
|
859
|
+
description: "Only the most recent message from a stream channel",
|
|
860
|
+
mimeType: "text/plain",
|
|
861
|
+
}, async (uri, { channel_id }) => {
|
|
862
|
+
const latest = messageBuffer.getLatest(channel_id);
|
|
863
|
+
if (!latest) {
|
|
864
|
+
return {
|
|
865
|
+
contents: [
|
|
866
|
+
{
|
|
867
|
+
uri: uri.href,
|
|
868
|
+
text: "(no messages yet)",
|
|
869
|
+
mimeType: "text/plain",
|
|
870
|
+
},
|
|
871
|
+
],
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
const formatted = messageBuffer.formatForDisplay([latest]);
|
|
875
|
+
return {
|
|
876
|
+
contents: [
|
|
877
|
+
{
|
|
878
|
+
uri: uri.href,
|
|
879
|
+
text: formatted,
|
|
880
|
+
mimeType: "text/plain",
|
|
881
|
+
},
|
|
882
|
+
],
|
|
883
|
+
};
|
|
884
|
+
});
|
|
885
|
+
// Resource: metrics://snapshot (aggregated performance & error metrics)
|
|
886
|
+
mcpServer.resource("metrics-snapshot", "metrics://snapshot", {
|
|
887
|
+
description: "Aggregated performance and error metrics: counters (connections, messages, errors) and histograms (latency, durations). Use this to diagnose performance issues.",
|
|
888
|
+
mimeType: "application/json",
|
|
889
|
+
}, async () => {
|
|
890
|
+
const snapshot = Logger.metrics.snapshot();
|
|
891
|
+
return {
|
|
892
|
+
contents: [
|
|
893
|
+
{
|
|
894
|
+
uri: "metrics://snapshot",
|
|
895
|
+
text: JSON.stringify(snapshot, null, 2),
|
|
896
|
+
mimeType: "application/json",
|
|
897
|
+
},
|
|
898
|
+
],
|
|
899
|
+
};
|
|
900
|
+
});
|
|
901
|
+
return mcpServer;
|
|
902
|
+
}
|
|
903
|
+
//# sourceMappingURL=server.js.map
|