morselhub-mcp 1.1.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.
Files changed (2) hide show
  1. package/mcp-server.js +312 -0
  2. package/package.json +21 -0
package/mcp-server.js ADDED
@@ -0,0 +1,312 @@
1
+ #!/usr/bin/env node
2
+ // MORSELHUB_MCP_VERSION=1.0.0
3
+ /**
4
+ * MorselHub MCP Server + Channel
5
+ *
6
+ * Connects Claude to MorselHub via local HTTP API.
7
+ * Pushes incoming messages (iMessage, RetroCode, webhooks) to Claude via Channel.
8
+ *
9
+ * As Channel (recommended):
10
+ * claude --dangerously-load-development-channels server:morselhub
11
+ *
12
+ * As MCP server (tools only):
13
+ * claude mcp add morselhub node ~/morselhub-mcp-server.js
14
+ */
15
+
16
+ const http = require('http');
17
+ const API_BASE = 'http://127.0.0.1:21590';
18
+
19
+ // ── HTTP helpers ──────────────────────────────────────
20
+
21
+ function apiGet(path) {
22
+ return new Promise((resolve, reject) => {
23
+ http.get(`${API_BASE}${path}`, (res) => {
24
+ let data = '';
25
+ res.on('data', (c) => data += c);
26
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(data); } });
27
+ }).on('error', reject);
28
+ });
29
+ }
30
+
31
+ function apiPost(path, body) {
32
+ return new Promise((resolve, reject) => {
33
+ const data = JSON.stringify(body);
34
+ const req = http.request(`${API_BASE}${path}`, {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
37
+ }, (res) => {
38
+ let result = '';
39
+ res.on('data', (c) => result += c);
40
+ res.on('end', () => { try { resolve(JSON.parse(result)); } catch { resolve(result); } });
41
+ });
42
+ req.on('error', reject);
43
+ req.write(data);
44
+ req.end();
45
+ });
46
+ }
47
+
48
+ // ── MCP Tools ──────────────────────────────────────────
49
+
50
+ // ── MCP Prompts ──────────────────────────────────────────
51
+
52
+ const prompts = [
53
+ {
54
+ name: 'morselhub_instructions',
55
+ description: 'Instructions for how to handle MorselHub messages',
56
+ }
57
+ ];
58
+
59
+ const SYSTEM_INSTRUCTIONS = `You are Claude, connected to MorselHub — a message hub that receives messages from iMessage, RetroCode, and webhooks.
60
+
61
+ When you receive a channel notification with a message:
62
+ 1. Read the message content
63
+ 2. Respond helpfully using the morselhub_reply tool
64
+ 3. Set the destination to match the source (imessage, retrocode, hub, or webhook)
65
+ 4. If the source is "imessage", extract the recipient from the reply_to field (format: "imessage:address")
66
+ 5. Keep responses concise — they may be sent back via iMessage
67
+
68
+ You can also:
69
+ - Use morselhub_get_feed to see recent message history
70
+ - Use morselhub_get_settings to check configuration
71
+ - Use morselhub_send_imessage to proactively message someone
72
+
73
+ Always respond to incoming messages. Don't just acknowledge — provide a helpful answer.`;
74
+
75
+ // ── MCP Tools ──────────────────────────────────────────
76
+
77
+ const tools = [
78
+ {
79
+ name: 'morselhub_get_feed',
80
+ description: 'Get all messages from the MorselHub feed (iMessage, RetroCode, webhooks).',
81
+ inputSchema: { type: 'object', properties: {}, required: [] },
82
+ },
83
+ {
84
+ name: 'morselhub_reply',
85
+ description: 'Send a reply back through MorselHub. The reply will be routed to the original source (iMessage, RetroCode, etc).',
86
+ inputSchema: {
87
+ type: 'object',
88
+ properties: {
89
+ text: { type: 'string', description: 'Reply text' },
90
+ destination: { type: 'string', description: 'Where to send: "imessage", "retrocode", "webhook"' },
91
+ recipient: { type: 'string', description: 'Recipient address (phone/email for iMessage, or URL for webhook)' },
92
+ },
93
+ required: ['text', 'destination'],
94
+ },
95
+ },
96
+ {
97
+ name: 'morselhub_send_imessage',
98
+ description: 'Send an iMessage to a contact.',
99
+ inputSchema: {
100
+ type: 'object',
101
+ properties: {
102
+ to: { type: 'string', description: 'Recipient phone/email' },
103
+ text: { type: 'string', description: 'Message text' },
104
+ },
105
+ required: ['to', 'text'],
106
+ },
107
+ },
108
+ {
109
+ name: 'morselhub_get_settings',
110
+ description: 'Get MorselHub settings (contacts, sources, ports).',
111
+ inputSchema: { type: 'object', properties: {}, required: [] },
112
+ },
113
+ {
114
+ name: 'morselhub_health',
115
+ description: 'Check if MorselHub is running.',
116
+ inputSchema: { type: 'object', properties: {}, required: [] },
117
+ },
118
+ ];
119
+
120
+ async function handleToolCall(name, args) {
121
+ switch (name) {
122
+ case 'morselhub_get_feed': {
123
+ const feed = await apiGet('/api/feed');
124
+ return { content: [{ type: 'text', text: JSON.stringify(feed, null, 2) }] };
125
+ }
126
+ case 'morselhub_reply': {
127
+ // Post reply to hub, which routes it to the destination
128
+ await apiPost('/api/message', {
129
+ source: 'claude',
130
+ sender: 'Claude',
131
+ text: args.text,
132
+ reply_to: args.destination + ':' + (args.recipient || ''),
133
+ });
134
+ return { content: [{ type: 'text', text: `Reply sent to ${args.destination}` }] };
135
+ }
136
+ case 'morselhub_send_imessage': {
137
+ await apiPost('/api/message', {
138
+ source: 'claude',
139
+ sender: 'Claude',
140
+ text: `[iMessage to ${args.to}] ${args.text}`,
141
+ reply_to: 'imessage:' + args.to,
142
+ });
143
+ return { content: [{ type: 'text', text: `iMessage queued for ${args.to}` }] };
144
+ }
145
+ case 'morselhub_get_settings': {
146
+ const settings = await apiGet('/api/settings');
147
+ return { content: [{ type: 'text', text: JSON.stringify(settings, null, 2) }] };
148
+ }
149
+ case 'morselhub_health': {
150
+ const health = await apiGet('/api/health');
151
+ return { content: [{ type: 'text', text: JSON.stringify(health) }] };
152
+ }
153
+ default:
154
+ throw new Error(`Unknown tool: ${name}`);
155
+ }
156
+ }
157
+
158
+ // ── Channel push ───────────────────────────────────────
159
+
160
+ let channelEnabled = false;
161
+ let lastSeenMsgId = 0;
162
+
163
+ function sendNotification(method, params) {
164
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
165
+ }
166
+
167
+ function pushChannel(content, meta) {
168
+ if (!channelEnabled) return;
169
+ sendNotification('notifications/claude/channel', { content, meta: meta || {} });
170
+ }
171
+
172
+ function startEventStream() {
173
+ process.stderr.write('[channel] Connecting to MorselHub SSE push stream...\n');
174
+
175
+ http.get(`${API_BASE}/api/events`, (res) => {
176
+ process.stderr.write(`[channel] Connected to push stream (status ${res.statusCode})\n`);
177
+
178
+ let buffer = '';
179
+ res.on('data', (chunk) => {
180
+ buffer += chunk.toString();
181
+ // Parse SSE events
182
+ const parts = buffer.split('\n\n');
183
+ buffer = parts.pop() || '';
184
+
185
+ for (const part of parts) {
186
+ if (part.startsWith(': keepalive')) continue;
187
+ const dataLine = part.split('\n').find(l => l.startsWith('data: '));
188
+ if (!dataLine) continue;
189
+
190
+ try {
191
+ const msg = JSON.parse(dataLine.slice(6));
192
+ if (msg.source === 'claude' || msg.source === 'system') continue;
193
+ if (!msg.reply_to) {
194
+ process.stderr.write(`[push] Skipping non-actionable: ${msg.text?.substring(0,30)}\n`);
195
+ continue;
196
+ }
197
+
198
+ const label = msg.source === 'imessage' ? `iMessage from ${msg.sender}` :
199
+ msg.source === 'retrocode' ? `RetroCode (${msg.sender})` :
200
+ `${msg.source} (${msg.sender})`;
201
+ process.stderr.write(`[push] ${label}: ${msg.text}\n`);
202
+ pushChannel(
203
+ `MorselHub message from ${label}: "${msg.text}"\n\nPlease respond using the morselhub_reply tool. destination="${msg.source}", reply_to="${msg.reply_to}".`,
204
+ { source: msg.source, sender: msg.sender, id: msg.id, reply_to: msg.reply_to }
205
+ );
206
+ } catch (e) {
207
+ process.stderr.write(`[push] Parse error: ${e.message}\n`);
208
+ }
209
+ }
210
+ });
211
+
212
+ res.on('end', () => {
213
+ process.stderr.write('[channel] Push stream disconnected. Reconnecting in 3s...\n');
214
+ setTimeout(startEventStream, 3000);
215
+ });
216
+
217
+ res.on('error', () => {
218
+ process.stderr.write('[channel] Push stream error. Reconnecting in 3s...\n');
219
+ setTimeout(startEventStream, 3000);
220
+ });
221
+ }).on('error', () => {
222
+ process.stderr.write('[channel] Cannot connect to MorselHub. Retrying in 5s...\n');
223
+ setTimeout(startEventStream, 5000);
224
+ });
225
+
226
+ // Heartbeat every 15s
227
+ setInterval(() => apiPost('/api/heartbeat', {}).catch(() => {}), 15000);
228
+ }
229
+
230
+ // ── JSON-RPC ───────────────────────────────────────────
231
+
232
+ let buffer = '';
233
+ process.stdin.setEncoding('utf8');
234
+ process.stdin.on('data', (chunk) => {
235
+ buffer += chunk;
236
+ const lines = buffer.split('\n');
237
+ buffer = lines.pop() || '';
238
+ for (const line of lines) {
239
+ if (!line.trim()) continue;
240
+ try { handleMessage(JSON.parse(line)); } catch {}
241
+ }
242
+ });
243
+
244
+ function sendResponse(id, result) {
245
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
246
+ }
247
+
248
+ function sendError(id, code, message) {
249
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n');
250
+ }
251
+
252
+ async function handleMessage(msg) {
253
+ const { id, method, params } = msg;
254
+ switch (method) {
255
+ case 'initialize':
256
+ sendResponse(id, {
257
+ protocolVersion: '2024-11-05',
258
+ capabilities: {
259
+ tools: {},
260
+ prompts: {},
261
+ experimental: { 'claude/channel': {} },
262
+ },
263
+ serverInfo: { name: 'morselhub', version: '1.0.0' },
264
+ });
265
+ break;
266
+ case 'notifications/initialized':
267
+ channelEnabled = true;
268
+ process.stderr.write('[channel] MorselHub channel active — connecting push stream\n');
269
+ // Push instructions to Claude on connect
270
+ setTimeout(() => {
271
+ pushChannel(
272
+ SYSTEM_INSTRUCTIONS + '\n\nMorselHub is now connected. Messages will be PUSHED to you in real-time. Waiting for messages from iMessage, RetroCode, and webhooks.',
273
+ { source: 'system', mode: 'instructions' }
274
+ );
275
+ }, 1000);
276
+ startEventStream();
277
+ break;
278
+ case 'prompts/list':
279
+ sendResponse(id, { prompts });
280
+ break;
281
+ case 'prompts/get':
282
+ if (params.name === 'morselhub_instructions') {
283
+ sendResponse(id, {
284
+ description: 'Instructions for handling MorselHub messages',
285
+ messages: [{ role: 'user', content: { type: 'text', text: SYSTEM_INSTRUCTIONS } }],
286
+ });
287
+ } else {
288
+ sendError(id, -32602, 'Unknown prompt');
289
+ }
290
+ break;
291
+ case 'tools/list':
292
+ sendResponse(id, { tools });
293
+ break;
294
+ case 'tools/call':
295
+ try {
296
+ const result = await handleToolCall(params.name, params.arguments || {});
297
+ sendResponse(id, result);
298
+ } catch (e) {
299
+ sendResponse(id, { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
300
+ }
301
+ break;
302
+ case 'ping':
303
+ sendResponse(id, {});
304
+ break;
305
+ default:
306
+ if (id) sendError(id, -32601, `Method not found: ${method}`);
307
+ break;
308
+ }
309
+ }
310
+
311
+ process.stdin.resume();
312
+ process.stderr.write('MorselHub MCP server started (with Channel support)\n');
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "morselhub-mcp",
3
+ "version": "1.1.0",
4
+ "description": "MorselHub MCP server — connects Claude to iMessage, RetroCode, and webhooks via MorselHub",
5
+ "main": "mcp-server.js",
6
+ "bin": {
7
+ "morselhub-mcp": "mcp-server.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node mcp-server.js"
11
+ },
12
+ "keywords": ["mcp", "morselhub", "imessage", "claude", "anthropic"],
13
+ "author": "senzall",
14
+ "license": "MIT",
15
+ "dependencies": {
16
+ "@modelcontextprotocol/sdk": "^1.29.0"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ }
21
+ }