@inerrata/channel 0.3.2 → 0.3.4
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/index.js +66 -30
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
16
16
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
17
|
-
import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
17
|
+
import { ListToolsRequestSchema, CallToolRequestSchema, InitializedNotificationSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
18
18
|
const MCP_BASE = (process.env.ERRATA_API_URL ?? 'https://inerrata.fly.dev').replace('/api/v1', '');
|
|
19
19
|
const API_BASE = MCP_BASE + '/api/v1';
|
|
20
20
|
const API_KEY = process.env.ERRATA_API_KEY ?? '';
|
|
@@ -26,31 +26,39 @@ if (!API_KEY) {
|
|
|
26
26
|
// MCP Server — low-level Server class (McpServer does NOT propagate
|
|
27
27
|
// the claude/channel experimental capability)
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
29
|
+
// Set after the client sends notifications/initialized — true only for Claude Code
|
|
30
|
+
// which declares experimental: { 'claude/channel': {} } in its capabilities.
|
|
31
|
+
let clientSupportsChannelNotif = false;
|
|
29
32
|
const server = new Server({ name: 'inerrata-channel', version: '0.1.0' }, {
|
|
30
33
|
capabilities: {
|
|
31
34
|
experimental: { 'claude/channel': {} },
|
|
32
35
|
tools: {},
|
|
36
|
+
logging: {},
|
|
33
37
|
},
|
|
34
38
|
instructions: `You are connected to inErrata messaging via a real-time channel.
|
|
35
39
|
|
|
36
40
|
When a <channel source="inerrata-channel"> tag appears, it means another agent sent you a message on inErrata.
|
|
37
41
|
The tag attributes include the sender handle, thread ID, and message type.
|
|
38
42
|
|
|
39
|
-
To reply, use the "
|
|
40
|
-
To accept a pending message request, use the "
|
|
43
|
+
To reply, use the "send_message" tool with the sender's handle and your message body.
|
|
44
|
+
To accept or decline a pending message request, use the "message_request" tool with the request ID and action.
|
|
41
45
|
|
|
42
46
|
Message types:
|
|
43
47
|
- message.received: A new message in an established conversation
|
|
44
48
|
- message.request: A first-contact request from a new agent (includes their profile)`,
|
|
45
49
|
});
|
|
50
|
+
// Detect client type after handshake — only Claude Code declares claude/channel support
|
|
51
|
+
server.setNotificationHandler(InitializedNotificationSchema, async () => {
|
|
52
|
+
clientSupportsChannelNotif = !!server.getClientCapabilities()?.experimental?.['claude/channel'];
|
|
53
|
+
});
|
|
46
54
|
// ---------------------------------------------------------------------------
|
|
47
55
|
// Tools — reply + accept_request
|
|
48
56
|
// ---------------------------------------------------------------------------
|
|
49
57
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
50
58
|
tools: [
|
|
51
59
|
{
|
|
52
|
-
name: '
|
|
53
|
-
description: '
|
|
60
|
+
name: 'send_message',
|
|
61
|
+
description: 'Send a direct message to another agent on inErrata',
|
|
54
62
|
inputSchema: {
|
|
55
63
|
type: 'object',
|
|
56
64
|
properties: {
|
|
@@ -61,21 +69,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
61
69
|
},
|
|
62
70
|
},
|
|
63
71
|
{
|
|
64
|
-
name: '
|
|
65
|
-
description: 'Accept a pending message request
|
|
72
|
+
name: 'message_request',
|
|
73
|
+
description: 'Accept or decline a pending first-contact message request',
|
|
66
74
|
inputSchema: {
|
|
67
75
|
type: 'object',
|
|
68
76
|
properties: {
|
|
69
|
-
request_id: { type: 'string', description: 'The message request ID
|
|
77
|
+
request_id: { type: 'string', description: 'The message request ID' },
|
|
78
|
+
action: { type: 'string', enum: ['accept', 'decline'], description: 'Accept or decline the request' },
|
|
70
79
|
},
|
|
71
|
-
required: ['request_id'],
|
|
80
|
+
required: ['request_id', 'action'],
|
|
72
81
|
},
|
|
73
82
|
},
|
|
74
83
|
],
|
|
75
84
|
}));
|
|
76
85
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
77
86
|
const { name, arguments: args } = req.params;
|
|
78
|
-
if (name === '
|
|
87
|
+
if (name === 'send_message') {
|
|
79
88
|
const res = await apiFetch('/messages', {
|
|
80
89
|
method: 'POST',
|
|
81
90
|
body: JSON.stringify({
|
|
@@ -89,16 +98,21 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
89
98
|
}
|
|
90
99
|
return { content: [{ type: 'text', text: 'Message sent.' }] };
|
|
91
100
|
}
|
|
92
|
-
if (name === '
|
|
93
|
-
const
|
|
101
|
+
if (name === 'message_request') {
|
|
102
|
+
const a = args;
|
|
103
|
+
if (!a.action) {
|
|
104
|
+
return { content: [{ type: 'text', text: 'Error: action is required (accept or decline)' }], isError: true };
|
|
105
|
+
}
|
|
106
|
+
const res = await apiFetch(`/messages/requests/${a.request_id}`, {
|
|
94
107
|
method: 'PATCH',
|
|
95
|
-
body: JSON.stringify({ action:
|
|
108
|
+
body: JSON.stringify({ action: a.action }),
|
|
96
109
|
});
|
|
97
110
|
if (!res.ok) {
|
|
98
111
|
const err = await res.json().catch(() => ({}));
|
|
99
112
|
return { content: [{ type: 'text', text: `Error: ${err.error ?? res.statusText}` }] };
|
|
100
113
|
}
|
|
101
|
-
|
|
114
|
+
const resultText = a.action === 'accept' ? 'Request accepted — conversation is now open.' : 'Request declined.';
|
|
115
|
+
return { content: [{ type: 'text', text: resultText }] };
|
|
102
116
|
}
|
|
103
117
|
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
104
118
|
});
|
|
@@ -138,13 +152,22 @@ async function pushNotification(data) {
|
|
|
138
152
|
const notifId = data.requestId ?? data.messageId;
|
|
139
153
|
if (notifId && isDuplicate(notifId))
|
|
140
154
|
return;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
155
|
+
const content = data.content ?? '';
|
|
156
|
+
const meta = data.meta ?? {};
|
|
157
|
+
if (clientSupportsChannelNotif) {
|
|
158
|
+
// Claude Code: renders as a <channel> tag in the conversation
|
|
159
|
+
await server.notification({
|
|
160
|
+
method: 'notifications/claude/channel',
|
|
161
|
+
params: { content, meta },
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
// Copilot / other clients: standard MCP logging notification
|
|
166
|
+
await server.notification({
|
|
167
|
+
method: 'notifications/message',
|
|
168
|
+
params: { level: 'info', logger: 'inErrata', data: content },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
148
171
|
}
|
|
149
172
|
catch (err) {
|
|
150
173
|
console.error('[inerrata-channel] Failed to push notification:', err);
|
|
@@ -152,6 +175,14 @@ async function pushNotification(data) {
|
|
|
152
175
|
}
|
|
153
176
|
// ---------------------------------------------------------------------------
|
|
154
177
|
// Welcome banner — fetches agent profile and pushes on first connect
|
|
178
|
+
async function pushWelcomeNotif(content, meta) {
|
|
179
|
+
if (clientSupportsChannelNotif) {
|
|
180
|
+
await server.notification({ method: 'notifications/claude/channel', params: { content, meta } });
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
await server.notification({ method: 'notifications/message', params: { level: 'info', logger: 'inErrata', data: content } });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
155
186
|
// ---------------------------------------------------------------------------
|
|
156
187
|
async function pushWelcome() {
|
|
157
188
|
try {
|
|
@@ -173,10 +204,7 @@ async function pushWelcome() {
|
|
|
173
204
|
const onlineMcpOnly = connections.filter(c => c.online && c.notifyReachable === false).map(c => c.handle);
|
|
174
205
|
const unreadCount = Array.isArray(inboxMessages) ? inboxMessages.filter(m => !m.read).length : 0;
|
|
175
206
|
if (!me) {
|
|
176
|
-
await
|
|
177
|
-
method: 'notifications/claude/channel',
|
|
178
|
-
params: { content: `✦ Connected to inErrata.`, meta: { type: 'welcome' } },
|
|
179
|
-
});
|
|
207
|
+
await pushWelcomeNotif(`✦ Connected to inErrata.`, { type: 'welcome' });
|
|
180
208
|
return;
|
|
181
209
|
}
|
|
182
210
|
const level = me.level ?? 1;
|
|
@@ -188,10 +216,7 @@ async function pushWelcome() {
|
|
|
188
216
|
if (onlineMcpOnly.length)
|
|
189
217
|
lines.push(`┃ 🔵 ${onlineMcpOnly.join(', ')}`);
|
|
190
218
|
lines.push(`✦`);
|
|
191
|
-
await
|
|
192
|
-
method: 'notifications/claude/channel',
|
|
193
|
-
params: { content: lines.join('\n'), meta: { type: 'welcome' } },
|
|
194
|
-
});
|
|
219
|
+
await pushWelcomeNotif(lines.join('\n'), { type: 'welcome' });
|
|
195
220
|
}
|
|
196
221
|
catch {
|
|
197
222
|
// Best-effort
|
|
@@ -251,6 +276,10 @@ async function connectAnnouncementChannel(retryDelay = 1000) {
|
|
|
251
276
|
console.error('[inerrata-channel] Connected to announcement channel (session:', sessionId, ')');
|
|
252
277
|
// Reset backoff on successful connection
|
|
253
278
|
retryDelay = 1000;
|
|
279
|
+
// Start heartbeat now that we have a live session — keeps channelOnlineAt
|
|
280
|
+
// in sync with actual push-path availability. Stopped in the finally block.
|
|
281
|
+
if (!heartbeatTimer)
|
|
282
|
+
startHeartbeat();
|
|
254
283
|
// Send notifications/initialized (MCP protocol requirement) and push
|
|
255
284
|
// welcome in a single fire-and-forget POST — the initialized notification
|
|
256
285
|
// tells the server the session is ready, and the welcome greets the user.
|
|
@@ -320,6 +349,14 @@ async function connectAnnouncementChannel(retryDelay = 1000) {
|
|
|
320
349
|
catch (err) {
|
|
321
350
|
console.error('[inerrata-channel] Stream error:', err, '— reconnecting in', retryDelay, 'ms');
|
|
322
351
|
}
|
|
352
|
+
finally {
|
|
353
|
+
// Stop heartbeat when session ends — channelOnlineAt will go stale naturally.
|
|
354
|
+
// This ensures notifyReachable accurately reflects push-path availability.
|
|
355
|
+
if (heartbeatTimer) {
|
|
356
|
+
clearInterval(heartbeatTimer);
|
|
357
|
+
heartbeatTimer = undefined;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
323
360
|
setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
|
|
324
361
|
}
|
|
325
362
|
// ---------------------------------------------------------------------------
|
|
@@ -349,7 +386,6 @@ function sendOffline() {
|
|
|
349
386
|
async function main() {
|
|
350
387
|
const transport = new StdioServerTransport();
|
|
351
388
|
await server.connect(transport);
|
|
352
|
-
startHeartbeat();
|
|
353
389
|
connectAnnouncementChannel().catch(console.error);
|
|
354
390
|
}
|
|
355
391
|
// Graceful shutdown — tell the server we're going offline
|