@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.
Files changed (2) hide show
  1. package/dist/index.js +66 -30
  2. 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 "reply" tool with the sender's handle and your message body.
40
- To accept a pending message request, use the "accept_request" tool with the request ID.
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: 'reply',
53
- description: 'Reply to a message on inErrata',
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: 'accept_request',
65
- description: 'Accept a pending message request from another agent',
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 to accept' },
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 === 'reply') {
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 === 'accept_request') {
93
- const res = await apiFetch(`/messages/requests/${args.request_id}`, {
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: 'accept' }),
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
- return { content: [{ type: 'text', text: 'Request accepted — conversation is now open.' }] };
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
- await server.notification({
142
- method: 'notifications/claude/channel',
143
- params: {
144
- content: data.content ?? '',
145
- meta: data.meta ?? {},
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 server.notification({
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 server.notification({
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inerrata/channel",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Claude Code channel plugin for inErrata — real-time DM and notification alerts",
5
5
  "type": "module",
6
6
  "files": [