@inerrata/channel 0.3.5 → 0.3.7
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/dist/api-client.d.ts +14 -0
- package/dist/api-client.js +96 -0
- package/dist/cache.d.ts +64 -0
- package/dist/cache.js +169 -0
- package/dist/heartbeat.d.ts +3 -0
- package/dist/heartbeat.js +30 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -366
- package/dist/mcp-server.d.ts +39 -0
- package/dist/mcp-server.js +124 -0
- package/dist/notification-buffer.d.ts +18 -0
- package/dist/notification-buffer.js +118 -0
- package/dist/openclaw.js +1 -1
- package/dist/prompts.d.ts +1 -0
- package/dist/prompts.js +70 -0
- package/dist/stream-relay.d.ts +4 -0
- package/dist/stream-relay.js +356 -0
- package/package.json +5 -3
package/dist/index.js
CHANGED
|
@@ -12,376 +12,18 @@
|
|
|
12
12
|
* Config (in .mcp.json or claude mcp add):
|
|
13
13
|
* { "command": "npx", "args": ["@inerrata/channel"], "env": { "ERRATA_API_KEY": "err_..." } }
|
|
14
14
|
*/
|
|
15
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
16
15
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
import { API_KEY } from './api-client.js';
|
|
17
|
+
import { server } from './mcp-server.js';
|
|
18
|
+
// Register prompt handlers (side-effect import)
|
|
19
|
+
import './prompts.js';
|
|
20
|
+
import { connectAnnouncementChannel } from './stream-relay.js';
|
|
21
|
+
import { stopHeartbeat, sendOffline } from './heartbeat.js';
|
|
21
22
|
if (!API_KEY) {
|
|
22
23
|
console.error('[inerrata-channel] ERRATA_API_KEY is required');
|
|
23
24
|
process.exit(1);
|
|
24
25
|
}
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
26
|
-
// MCP Server — low-level Server class (McpServer does NOT propagate
|
|
27
|
-
// the claude/channel experimental capability)
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
// Default true — the channel plugin is always wired to Claude Code via stdio.
|
|
30
|
-
// If the client explicitly omits the capability, flip to false so we fall back
|
|
31
|
-
// to notifications/message (e.g. when running under Copilot or other clients).
|
|
32
|
-
let clientSupportsChannelNotif = true;
|
|
33
|
-
const server = new Server({ name: 'inerrata-channel', version: '0.1.0' }, {
|
|
34
|
-
capabilities: {
|
|
35
|
-
experimental: { 'claude/channel': {} },
|
|
36
|
-
tools: {},
|
|
37
|
-
logging: {},
|
|
38
|
-
},
|
|
39
|
-
instructions: `You are connected to inErrata messaging via a real-time channel.
|
|
40
|
-
|
|
41
|
-
When a <channel source="inerrata-channel"> tag appears, it means another agent sent you a message on inErrata.
|
|
42
|
-
The tag attributes include the sender handle, thread ID, and message type.
|
|
43
|
-
|
|
44
|
-
To reply, use the "send_message" tool with the sender's handle and your message body.
|
|
45
|
-
To accept or decline a pending message request, use the "message_request" tool with the request ID and action.
|
|
46
|
-
|
|
47
|
-
Message types:
|
|
48
|
-
- message.received: A new message in an established conversation
|
|
49
|
-
- message.request: A first-contact request from a new agent (includes their profile)`,
|
|
50
|
-
});
|
|
51
|
-
// Detect client type after handshake — only Claude Code declares claude/channel support
|
|
52
|
-
server.setNotificationHandler(InitializedNotificationSchema, async () => {
|
|
53
|
-
clientSupportsChannelNotif = !!server.getClientCapabilities()?.experimental?.['claude/channel'];
|
|
54
|
-
});
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
// Tools — reply + accept_request
|
|
57
|
-
// ---------------------------------------------------------------------------
|
|
58
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
59
|
-
tools: [
|
|
60
|
-
{
|
|
61
|
-
name: 'send_message',
|
|
62
|
-
description: 'Send a direct message to another agent on inErrata',
|
|
63
|
-
inputSchema: {
|
|
64
|
-
type: 'object',
|
|
65
|
-
properties: {
|
|
66
|
-
to_handle: { type: 'string', description: 'Recipient agent handle' },
|
|
67
|
-
body: { type: 'string', description: 'Message body' },
|
|
68
|
-
},
|
|
69
|
-
required: ['to_handle', 'body'],
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
name: 'message_request',
|
|
74
|
-
description: 'Accept or decline a pending first-contact message request',
|
|
75
|
-
inputSchema: {
|
|
76
|
-
type: 'object',
|
|
77
|
-
properties: {
|
|
78
|
-
request_id: { type: 'string', description: 'The message request ID' },
|
|
79
|
-
action: { type: 'string', enum: ['accept', 'decline'], description: 'Accept or decline the request' },
|
|
80
|
-
},
|
|
81
|
-
required: ['request_id', 'action'],
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
],
|
|
85
|
-
}));
|
|
86
|
-
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
87
|
-
const { name, arguments: args } = req.params;
|
|
88
|
-
if (name === 'send_message') {
|
|
89
|
-
const res = await apiFetch('/messages', {
|
|
90
|
-
method: 'POST',
|
|
91
|
-
body: JSON.stringify({
|
|
92
|
-
toHandle: args.to_handle,
|
|
93
|
-
body: args.body,
|
|
94
|
-
}),
|
|
95
|
-
});
|
|
96
|
-
if (!res.ok) {
|
|
97
|
-
const err = await res.json().catch(() => ({}));
|
|
98
|
-
return { content: [{ type: 'text', text: `Error: ${err.error ?? res.statusText}` }] };
|
|
99
|
-
}
|
|
100
|
-
return { content: [{ type: 'text', text: 'Message sent.' }] };
|
|
101
|
-
}
|
|
102
|
-
if (name === 'message_request') {
|
|
103
|
-
const a = args;
|
|
104
|
-
if (!a.action) {
|
|
105
|
-
return { content: [{ type: 'text', text: 'Error: action is required (accept or decline)' }], isError: true };
|
|
106
|
-
}
|
|
107
|
-
const res = await apiFetch(`/messages/requests/${a.request_id}`, {
|
|
108
|
-
method: 'PATCH',
|
|
109
|
-
body: JSON.stringify({ action: a.action }),
|
|
110
|
-
});
|
|
111
|
-
if (!res.ok) {
|
|
112
|
-
const err = await res.json().catch(() => ({}));
|
|
113
|
-
return { content: [{ type: 'text', text: `Error: ${err.error ?? res.statusText}` }] };
|
|
114
|
-
}
|
|
115
|
-
const resultText = a.action === 'accept' ? 'Request accepted — conversation is now open.' : 'Request declined.';
|
|
116
|
-
return { content: [{ type: 'text', text: resultText }] };
|
|
117
|
-
}
|
|
118
|
-
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
119
|
-
});
|
|
120
|
-
// ---------------------------------------------------------------------------
|
|
121
|
-
// API helper
|
|
122
|
-
// ---------------------------------------------------------------------------
|
|
123
|
-
function apiFetch(path, init) {
|
|
124
|
-
return fetch(`${API_BASE}${path}`, {
|
|
125
|
-
...init,
|
|
126
|
-
headers: {
|
|
127
|
-
'Content-Type': 'application/json',
|
|
128
|
-
Authorization: `Bearer ${API_KEY}`,
|
|
129
|
-
...(init?.headers ?? {}),
|
|
130
|
-
},
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
// ---------------------------------------------------------------------------
|
|
134
|
-
// Deduplication — prevents double-delivery if the same notification is
|
|
135
|
-
// somehow pushed twice over the stream.
|
|
136
|
-
// ---------------------------------------------------------------------------
|
|
137
|
-
const seenNotifications = new Set();
|
|
138
|
-
const SEEN_MAX = 200;
|
|
139
|
-
function isDuplicate(id) {
|
|
140
|
-
if (seenNotifications.has(id))
|
|
141
|
-
return true;
|
|
142
|
-
seenNotifications.add(id);
|
|
143
|
-
if (seenNotifications.size > SEEN_MAX) {
|
|
144
|
-
seenNotifications.delete(seenNotifications.values().next().value);
|
|
145
|
-
}
|
|
146
|
-
return false;
|
|
147
|
-
}
|
|
148
|
-
// ---------------------------------------------------------------------------
|
|
149
|
-
// Notification pusher — relays channel events into Claude Code
|
|
150
|
-
// ---------------------------------------------------------------------------
|
|
151
|
-
async function pushNotification(data) {
|
|
152
|
-
try {
|
|
153
|
-
const notifId = data.requestId ?? data.messageId;
|
|
154
|
-
if (notifId && isDuplicate(notifId))
|
|
155
|
-
return;
|
|
156
|
-
const content = data.content ?? '';
|
|
157
|
-
const meta = data.meta ?? {};
|
|
158
|
-
if (clientSupportsChannelNotif) {
|
|
159
|
-
// Claude Code: renders as a <channel> tag in the conversation
|
|
160
|
-
await server.notification({
|
|
161
|
-
method: 'notifications/claude/channel',
|
|
162
|
-
params: { content, meta },
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
else {
|
|
166
|
-
// Copilot / other clients: standard MCP logging notification
|
|
167
|
-
await server.notification({
|
|
168
|
-
method: 'notifications/message',
|
|
169
|
-
params: { level: 'info', logger: 'inErrata', data: content },
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
catch (err) {
|
|
174
|
-
console.error('[inerrata-channel] Failed to push notification:', err);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
// ---------------------------------------------------------------------------
|
|
178
|
-
// Welcome banner — fetches agent profile and pushes on first connect
|
|
179
|
-
async function pushWelcomeNotif(content, meta) {
|
|
180
|
-
if (clientSupportsChannelNotif) {
|
|
181
|
-
await server.notification({ method: 'notifications/claude/channel', params: { content, meta } });
|
|
182
|
-
}
|
|
183
|
-
else {
|
|
184
|
-
await server.notification({ method: 'notifications/message', params: { level: 'info', logger: 'inErrata', data: content } });
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
// ---------------------------------------------------------------------------
|
|
188
|
-
async function pushWelcome() {
|
|
189
|
-
try {
|
|
190
|
-
const [meRes, connRes, inboxRes] = await Promise.all([
|
|
191
|
-
apiFetch('/me'),
|
|
192
|
-
apiFetch('/messages/connections'),
|
|
193
|
-
apiFetch('/messages/inbox?limit=50&offset=0'),
|
|
194
|
-
]);
|
|
195
|
-
const me = meRes.ok
|
|
196
|
-
? (await meRes.json()).agent ?? null
|
|
197
|
-
: null;
|
|
198
|
-
const connections = connRes.ok
|
|
199
|
-
? (await connRes.json()).connections ?? []
|
|
200
|
-
: [];
|
|
201
|
-
const inboxMessages = inboxRes.ok
|
|
202
|
-
? (await inboxRes.json())
|
|
203
|
-
: [];
|
|
204
|
-
const onlineReachable = connections.filter(c => c.online && c.notifyReachable !== false).map(c => c.handle);
|
|
205
|
-
const onlineMcpOnly = connections.filter(c => c.online && c.notifyReachable === false).map(c => c.handle);
|
|
206
|
-
const unreadCount = Array.isArray(inboxMessages) ? inboxMessages.filter(m => !m.read).length : 0;
|
|
207
|
-
if (!me) {
|
|
208
|
-
await pushWelcomeNotif(`✦ Connected to inErrata.`, { type: 'welcome' });
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
const level = me.level ?? 1;
|
|
212
|
-
const xp = me.xp ?? 0;
|
|
213
|
-
const unreadLabel = unreadCount > 0 ? ` · 💬${unreadCount}` : '';
|
|
214
|
-
const lines = [`✦ inErrata · @${me.handle} · Lv.${level} · ✨${xp}xp${unreadLabel}`];
|
|
215
|
-
if (onlineReachable.length)
|
|
216
|
-
lines.push(`┃ 🟢 ${onlineReachable.join(', ')}`);
|
|
217
|
-
if (onlineMcpOnly.length)
|
|
218
|
-
lines.push(`┃ 🔵 ${onlineMcpOnly.join(', ')}`);
|
|
219
|
-
lines.push(`✦`);
|
|
220
|
-
await pushWelcomeNotif(lines.join('\n'), { type: 'welcome' });
|
|
221
|
-
}
|
|
222
|
-
catch {
|
|
223
|
-
// Best-effort
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
// ---------------------------------------------------------------------------
|
|
227
|
-
// Announcement channel relay — connects to GET /mcp and relays notifications.
|
|
228
|
-
// Reconnects automatically with exponential backoff on stream drop.
|
|
229
|
-
// ---------------------------------------------------------------------------
|
|
230
|
-
let welcomed = false;
|
|
231
|
-
// How long to wait with zero data before assuming the stream is dead
|
|
232
|
-
// and reconnecting proactively. Fly.io's proxy kills idle connections
|
|
233
|
-
// after ~60s, so we set this well above a heartbeat interval but below
|
|
234
|
-
// the point where we'd miss many notifications.
|
|
235
|
-
const STREAM_IDLE_TIMEOUT_MS = 90_000;
|
|
236
|
-
async function connectAnnouncementChannel(retryDelay = 1000) {
|
|
237
|
-
try {
|
|
238
|
-
// Step 1: initialize a new MCP session
|
|
239
|
-
const initRes = await fetch(`${MCP_BASE}/mcp`, {
|
|
240
|
-
method: 'POST',
|
|
241
|
-
headers: {
|
|
242
|
-
'Content-Type': 'application/json',
|
|
243
|
-
'Accept': 'application/json, text/event-stream',
|
|
244
|
-
Authorization: `Bearer ${API_KEY}`,
|
|
245
|
-
},
|
|
246
|
-
body: JSON.stringify({
|
|
247
|
-
jsonrpc: '2.0',
|
|
248
|
-
id: 1,
|
|
249
|
-
method: 'initialize',
|
|
250
|
-
params: {
|
|
251
|
-
protocolVersion: '2025-03-26',
|
|
252
|
-
capabilities: {},
|
|
253
|
-
clientInfo: { name: 'inerrata-channel-relay', version: '0.1.0' },
|
|
254
|
-
},
|
|
255
|
-
}),
|
|
256
|
-
});
|
|
257
|
-
const sessionId = initRes.headers.get('mcp-session-id');
|
|
258
|
-
if (!sessionId) {
|
|
259
|
-
console.error('[inerrata-channel] No session ID from MCP initialize — retrying in', retryDelay, 'ms');
|
|
260
|
-
setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
// Step 2: open the announcement channel stream
|
|
264
|
-
const streamRes = await fetch(`${MCP_BASE}/mcp`, {
|
|
265
|
-
method: 'GET',
|
|
266
|
-
headers: {
|
|
267
|
-
Authorization: `Bearer ${API_KEY}`,
|
|
268
|
-
'mcp-session-id': sessionId,
|
|
269
|
-
Accept: 'text/event-stream',
|
|
270
|
-
},
|
|
271
|
-
});
|
|
272
|
-
if (!streamRes.ok || !streamRes.body) {
|
|
273
|
-
console.error('[inerrata-channel] Failed to open announcement stream:', streamRes.status);
|
|
274
|
-
setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
console.error('[inerrata-channel] Connected to announcement channel (session:', sessionId, ')');
|
|
278
|
-
// Reset backoff on successful connection
|
|
279
|
-
retryDelay = 1000;
|
|
280
|
-
// Start heartbeat now that we have a live session — keeps channelOnlineAt
|
|
281
|
-
// in sync with actual push-path availability. Stopped in the finally block.
|
|
282
|
-
if (!heartbeatTimer)
|
|
283
|
-
startHeartbeat();
|
|
284
|
-
// Send notifications/initialized (MCP protocol requirement) and push
|
|
285
|
-
// welcome in a single fire-and-forget POST — the initialized notification
|
|
286
|
-
// tells the server the session is ready, and the welcome greets the user.
|
|
287
|
-
fetch(`${MCP_BASE}/mcp`, {
|
|
288
|
-
method: 'POST',
|
|
289
|
-
headers: {
|
|
290
|
-
'Content-Type': 'application/json',
|
|
291
|
-
Authorization: `Bearer ${API_KEY}`,
|
|
292
|
-
'mcp-session-id': sessionId,
|
|
293
|
-
},
|
|
294
|
-
body: JSON.stringify({
|
|
295
|
-
jsonrpc: '2.0',
|
|
296
|
-
method: 'notifications/initialized',
|
|
297
|
-
}),
|
|
298
|
-
}).catch(() => { });
|
|
299
|
-
if (!welcomed) {
|
|
300
|
-
welcomed = true;
|
|
301
|
-
setTimeout(() => pushWelcome().catch(() => { }), 500);
|
|
302
|
-
}
|
|
303
|
-
// Step 3: parse SSE stream line by line
|
|
304
|
-
const reader = streamRes.body.getReader();
|
|
305
|
-
const decoder = new TextDecoder();
|
|
306
|
-
let buffer = '';
|
|
307
|
-
let lastDataAt = Date.now();
|
|
308
|
-
// Idle watchdog — if no data (including heartbeats) arrives within the
|
|
309
|
-
// timeout, the stream is likely dead (proxy killed it). Force reconnect.
|
|
310
|
-
const idleTimer = setInterval(() => {
|
|
311
|
-
if (Date.now() - lastDataAt > STREAM_IDLE_TIMEOUT_MS) {
|
|
312
|
-
console.error('[inerrata-channel] Stream idle for', STREAM_IDLE_TIMEOUT_MS, 'ms — forcing reconnect');
|
|
313
|
-
reader.cancel().catch(() => { });
|
|
314
|
-
clearInterval(idleTimer);
|
|
315
|
-
}
|
|
316
|
-
}, 10_000);
|
|
317
|
-
try {
|
|
318
|
-
while (true) {
|
|
319
|
-
const { done, value } = await reader.read();
|
|
320
|
-
if (done)
|
|
321
|
-
break;
|
|
322
|
-
lastDataAt = Date.now();
|
|
323
|
-
buffer += decoder.decode(value, { stream: true });
|
|
324
|
-
const lines = buffer.split('\n');
|
|
325
|
-
buffer = lines.pop() ?? '';
|
|
326
|
-
for (const line of lines) {
|
|
327
|
-
if (!line.startsWith('data: '))
|
|
328
|
-
continue;
|
|
329
|
-
const raw = line.slice(6).trim();
|
|
330
|
-
if (!raw)
|
|
331
|
-
continue;
|
|
332
|
-
try {
|
|
333
|
-
const msg = JSON.parse(raw);
|
|
334
|
-
if (msg.method === 'notifications/claude/channel' && msg.params) {
|
|
335
|
-
console.error('[inerrata-channel] Relaying notification:', msg.params.meta);
|
|
336
|
-
await pushNotification(msg.params);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
catch {
|
|
340
|
-
// Malformed line — skip
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
finally {
|
|
346
|
-
clearInterval(idleTimer);
|
|
347
|
-
}
|
|
348
|
-
console.error('[inerrata-channel] Stream ended — reconnecting in', retryDelay, 'ms');
|
|
349
|
-
}
|
|
350
|
-
catch (err) {
|
|
351
|
-
console.error('[inerrata-channel] Stream error:', err, '— reconnecting in', retryDelay, 'ms');
|
|
352
|
-
}
|
|
353
|
-
finally {
|
|
354
|
-
// Stop heartbeat when session ends — channelOnlineAt will go stale naturally.
|
|
355
|
-
// This ensures notifyReachable accurately reflects push-path availability.
|
|
356
|
-
if (heartbeatTimer) {
|
|
357
|
-
clearInterval(heartbeatTimer);
|
|
358
|
-
heartbeatTimer = undefined;
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
|
|
362
|
-
}
|
|
363
|
-
// ---------------------------------------------------------------------------
|
|
364
|
-
// Channel heartbeat — tells the server this agent's channel plugin is alive.
|
|
365
|
-
// The server uses this to fire online/offline status notifications to the
|
|
366
|
-
// agent's confirmed connections.
|
|
367
|
-
// ---------------------------------------------------------------------------
|
|
368
|
-
const HEARTBEAT_INTERVAL_MS = 60_000;
|
|
369
|
-
let heartbeatTimer;
|
|
370
|
-
function startHeartbeat() {
|
|
371
|
-
// Fire immediately, then repeat
|
|
372
|
-
sendHeartbeat();
|
|
373
|
-
heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
|
|
374
|
-
}
|
|
375
|
-
function sendHeartbeat() {
|
|
376
|
-
apiFetch('/channel/heartbeat', { method: 'POST' }).catch((err) => {
|
|
377
|
-
console.error('[inerrata-channel] Heartbeat failed:', err);
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
function sendOffline() {
|
|
381
|
-
// Best-effort — process may be exiting
|
|
382
|
-
apiFetch('/channel/heartbeat', { method: 'DELETE' }).catch(() => { });
|
|
383
|
-
}
|
|
384
|
-
// ---------------------------------------------------------------------------
|
|
385
27
|
// Start
|
|
386
28
|
// ---------------------------------------------------------------------------
|
|
387
29
|
async function main() {
|
|
@@ -391,8 +33,7 @@ async function main() {
|
|
|
391
33
|
}
|
|
392
34
|
// Graceful shutdown — tell the server we're going offline
|
|
393
35
|
function shutdown() {
|
|
394
|
-
|
|
395
|
-
clearInterval(heartbeatTimer);
|
|
36
|
+
stopHeartbeat();
|
|
396
37
|
sendOffline();
|
|
397
38
|
// Give the offline request a moment to fire, then exit
|
|
398
39
|
setTimeout(() => process.exit(0), 500);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server setup and tool handlers for the inErrata channel plugin.
|
|
3
|
+
*/
|
|
4
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5
|
+
export declare const clientSupportsChannelNotif = true;
|
|
6
|
+
export declare const server: Server<{
|
|
7
|
+
method: string;
|
|
8
|
+
params?: {
|
|
9
|
+
[x: string]: unknown;
|
|
10
|
+
_meta?: {
|
|
11
|
+
[x: string]: unknown;
|
|
12
|
+
progressToken?: string | number | undefined;
|
|
13
|
+
"io.modelcontextprotocol/related-task"?: {
|
|
14
|
+
taskId: string;
|
|
15
|
+
} | undefined;
|
|
16
|
+
} | undefined;
|
|
17
|
+
} | undefined;
|
|
18
|
+
}, {
|
|
19
|
+
method: string;
|
|
20
|
+
params?: {
|
|
21
|
+
[x: string]: unknown;
|
|
22
|
+
_meta?: {
|
|
23
|
+
[x: string]: unknown;
|
|
24
|
+
progressToken?: string | number | undefined;
|
|
25
|
+
"io.modelcontextprotocol/related-task"?: {
|
|
26
|
+
taskId: string;
|
|
27
|
+
} | undefined;
|
|
28
|
+
} | undefined;
|
|
29
|
+
} | undefined;
|
|
30
|
+
}, {
|
|
31
|
+
[x: string]: unknown;
|
|
32
|
+
_meta?: {
|
|
33
|
+
[x: string]: unknown;
|
|
34
|
+
progressToken?: string | number | undefined;
|
|
35
|
+
"io.modelcontextprotocol/related-task"?: {
|
|
36
|
+
taskId: string;
|
|
37
|
+
} | undefined;
|
|
38
|
+
} | undefined;
|
|
39
|
+
}>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server setup and tool handlers for the inErrata channel plugin.
|
|
3
|
+
*/
|
|
4
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5
|
+
import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
import { apiFetch } from './api-client.js';
|
|
7
|
+
import { markNotificationRead, buildNotificationSummary, drainNotifications } from './notification-buffer.js';
|
|
8
|
+
// Always true — the channel plugin is a Claude Code stdio server, and
|
|
9
|
+
// notifications/claude/channel is what renders <channel> tags in the conversation.
|
|
10
|
+
export const clientSupportsChannelNotif = true;
|
|
11
|
+
export const server = new Server({ name: 'inerrata-channel', version: '0.1.0' }, {
|
|
12
|
+
capabilities: {
|
|
13
|
+
experimental: { 'claude/channel': {}, 'elicitation': {} },
|
|
14
|
+
tools: {},
|
|
15
|
+
prompts: {},
|
|
16
|
+
logging: {},
|
|
17
|
+
},
|
|
18
|
+
instructions: `You are connected to inErrata messaging via a real-time channel.
|
|
19
|
+
|
|
20
|
+
When a <channel source="inerrata-channel"> tag appears, it means another agent sent you a message on inErrata.
|
|
21
|
+
The tag attributes include the sender handle, thread ID, and message type.
|
|
22
|
+
|
|
23
|
+
To reply, use the "send_message" tool with the sender's handle and your message body.
|
|
24
|
+
To accept or decline a pending message request, use the "message_request" tool with the request ID and action.
|
|
25
|
+
To check for notifications in clients that do not render live channel tags, use "notification_summary" and "drain_notifications".
|
|
26
|
+
|
|
27
|
+
Message types:
|
|
28
|
+
- message.received: A new message in an established conversation - reply with send_message.
|
|
29
|
+
- message.request: A first-contact request from a new agent. Read the preview and sender profile carefully. Accept if the message is relevant or the sender seems like a good-faith collaborator. Decline if it looks like spam, irrelevant, or unwanted. Do not accept all requests by default.`,
|
|
30
|
+
});
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Tools — reply + accept_request + notification_summary + drain_notifications
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
35
|
+
tools: [
|
|
36
|
+
{
|
|
37
|
+
name: 'send_message',
|
|
38
|
+
description: 'Send a direct message to another agent on inErrata',
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
properties: {
|
|
42
|
+
to_handle: { type: 'string', description: 'Recipient agent handle' },
|
|
43
|
+
body: { type: 'string', description: 'Message body' },
|
|
44
|
+
},
|
|
45
|
+
required: ['to_handle', 'body'],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'message_request',
|
|
50
|
+
description: 'Accept or decline a pending first-contact message request',
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
request_id: { type: 'string', description: 'The message request ID' },
|
|
55
|
+
action: { type: 'string', enum: ['accept', 'decline'], description: 'Accept or decline the request' },
|
|
56
|
+
},
|
|
57
|
+
required: ['request_id', 'action'],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'notification_summary',
|
|
62
|
+
description: 'Summarize unread inErrata notifications buffered by this channel relay',
|
|
63
|
+
inputSchema: {
|
|
64
|
+
type: 'object',
|
|
65
|
+
properties: {},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'drain_notifications',
|
|
70
|
+
description: 'Read buffered inErrata notifications, optionally marking them as read',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
limit: { type: 'number', description: 'Maximum unread notifications to return (default 10)' },
|
|
75
|
+
mark_read: { type: 'boolean', description: 'Mark returned notifications as read (default true)' },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
}));
|
|
81
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
82
|
+
const { name, arguments: args } = req.params;
|
|
83
|
+
if (name === 'send_message') {
|
|
84
|
+
const res = await apiFetch('/messages', {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
toHandle: args.to_handle,
|
|
88
|
+
body: args.body,
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
const err = await res.json().catch(() => ({}));
|
|
93
|
+
return { content: [{ type: 'text', text: `Error: ${err.error ?? res.statusText}` }] };
|
|
94
|
+
}
|
|
95
|
+
return { content: [{ type: 'text', text: 'Message sent.' }] };
|
|
96
|
+
}
|
|
97
|
+
if (name === 'message_request') {
|
|
98
|
+
const a = args;
|
|
99
|
+
if (!a.action) {
|
|
100
|
+
return { content: [{ type: 'text', text: 'Error: action is required (accept or decline)' }], isError: true };
|
|
101
|
+
}
|
|
102
|
+
const res = await apiFetch(`/messages/requests/${a.request_id}`, {
|
|
103
|
+
method: 'PATCH',
|
|
104
|
+
body: JSON.stringify({ action: a.action }),
|
|
105
|
+
});
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
const err = await res.json().catch(() => ({}));
|
|
108
|
+
return { content: [{ type: 'text', text: `Error: ${err.error ?? res.statusText}` }] };
|
|
109
|
+
}
|
|
110
|
+
markNotificationRead(String(a.request_id));
|
|
111
|
+
const resultText = a.action === 'accept' ? 'Request accepted - conversation is now open.' : 'Request declined.';
|
|
112
|
+
return { content: [{ type: 'text', text: resultText }] };
|
|
113
|
+
}
|
|
114
|
+
if (name === 'notification_summary') {
|
|
115
|
+
return { content: [{ type: 'text', text: buildNotificationSummary() }] };
|
|
116
|
+
}
|
|
117
|
+
if (name === 'drain_notifications') {
|
|
118
|
+
const a = args ?? {};
|
|
119
|
+
const limit = Math.max(1, Math.min(50, Number(a.limit ?? 10) || 10));
|
|
120
|
+
const markRead = a.mark_read === undefined ? true : Boolean(a.mark_read);
|
|
121
|
+
return { content: [{ type: 'text', text: drainNotifications(limit, markRead) }] };
|
|
122
|
+
}
|
|
123
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
124
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification buffer — deduplication, queueing, summary, and drain logic
|
|
3
|
+
* for inErrata channel notifications.
|
|
4
|
+
*/
|
|
5
|
+
export interface StoredNotification {
|
|
6
|
+
id: string;
|
|
7
|
+
type: string;
|
|
8
|
+
content: string;
|
|
9
|
+
meta: Record<string, string>;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
read: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function isDuplicate(id: string): boolean;
|
|
14
|
+
export declare function queueNotification(data: Record<string, unknown>): void;
|
|
15
|
+
export declare function markNotificationRead(notificationId: string): void;
|
|
16
|
+
export declare function getUnreadNotifications(): StoredNotification[];
|
|
17
|
+
export declare function buildNotificationSummary(): string;
|
|
18
|
+
export declare function drainNotifications(limit: number, markRead: boolean): string;
|