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.
- package/mcp-server.js +312 -0
- 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
|
+
}
|