myceliumail-mcp 1.0.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.
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Myceliumail MCP - Storage Module
3
+ *
4
+ * Local JSON storage with optional Supabase sync.
5
+ */
6
+
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { homedir } from 'os';
10
+ import { randomUUID } from 'crypto';
11
+ import { getSupabaseUrl, getSupabaseKey, hasSupabase } from './config.js';
12
+
13
+ const DATA_DIR = join(homedir(), '.myceliumail', 'data');
14
+ const MESSAGES_FILE = join(DATA_DIR, 'messages.json');
15
+
16
+ export interface Message {
17
+ id: string;
18
+ sender: string;
19
+ recipient: string;
20
+ subject: string;
21
+ body: string;
22
+ encrypted: boolean;
23
+ ciphertext?: string;
24
+ nonce?: string;
25
+ senderPublicKey?: string;
26
+ read: boolean;
27
+ archived: boolean;
28
+ createdAt: Date;
29
+ }
30
+
31
+ interface StoredMessage extends Omit<Message, 'createdAt'> {
32
+ createdAt: string;
33
+ }
34
+
35
+ function ensureDataDir(): void {
36
+ if (!existsSync(DATA_DIR)) {
37
+ mkdirSync(DATA_DIR, { recursive: true });
38
+ }
39
+ }
40
+
41
+ function loadLocalMessages(): StoredMessage[] {
42
+ if (!existsSync(MESSAGES_FILE)) return [];
43
+ try {
44
+ return JSON.parse(readFileSync(MESSAGES_FILE, 'utf-8'));
45
+ } catch {
46
+ return [];
47
+ }
48
+ }
49
+
50
+ function saveLocalMessages(messages: StoredMessage[]): void {
51
+ ensureDataDir();
52
+ writeFileSync(MESSAGES_FILE, JSON.stringify(messages, null, 2));
53
+ }
54
+
55
+ function toMessage(stored: StoredMessage): Message {
56
+ return { ...stored, createdAt: new Date(stored.createdAt) };
57
+ }
58
+
59
+ // Supabase helpers
60
+ async function supabaseRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
61
+ const url = `${getSupabaseUrl()}/rest/v1${path}`;
62
+ const response = await fetch(url, {
63
+ ...options,
64
+ headers: {
65
+ 'Content-Type': 'application/json',
66
+ 'apikey': getSupabaseKey()!,
67
+ 'Authorization': `Bearer ${getSupabaseKey()}`,
68
+ 'Prefer': options.method === 'POST' ? 'return=representation' : 'return=minimal',
69
+ ...options.headers,
70
+ },
71
+ });
72
+ if (!response.ok) throw new Error(await response.text());
73
+ if (response.status === 204) return {} as T;
74
+ return response.json() as Promise<T>;
75
+ }
76
+
77
+ export async function sendMessage(
78
+ sender: string,
79
+ recipient: string,
80
+ subject: string,
81
+ body: string,
82
+ options?: {
83
+ encrypted?: boolean;
84
+ ciphertext?: string;
85
+ nonce?: string;
86
+ senderPublicKey?: string;
87
+ }
88
+ ): Promise<Message> {
89
+ const newMessage: StoredMessage = {
90
+ id: randomUUID(),
91
+ sender,
92
+ recipient,
93
+ subject: options?.encrypted ? '' : subject,
94
+ body: options?.encrypted ? '' : body,
95
+ encrypted: options?.encrypted || false,
96
+ ciphertext: options?.ciphertext,
97
+ nonce: options?.nonce,
98
+ senderPublicKey: options?.senderPublicKey,
99
+ read: false,
100
+ archived: false,
101
+ createdAt: new Date().toISOString(),
102
+ };
103
+
104
+ if (hasSupabase()) {
105
+ try {
106
+ const [result] = await supabaseRequest<StoredMessage[]>('/agent_messages', {
107
+ method: 'POST',
108
+ body: JSON.stringify({
109
+ sender: newMessage.sender,
110
+ recipient: newMessage.recipient,
111
+ subject: newMessage.subject || null,
112
+ body: newMessage.body || null,
113
+ encrypted: newMessage.encrypted,
114
+ ciphertext: newMessage.ciphertext,
115
+ nonce: newMessage.nonce,
116
+ sender_public_key: newMessage.senderPublicKey,
117
+ }),
118
+ });
119
+ return toMessage({
120
+ ...newMessage,
121
+ id: (result as unknown as { id: string }).id
122
+ });
123
+ } catch {
124
+ // Fall through to local
125
+ }
126
+ }
127
+
128
+ // Local storage
129
+ const messages = loadLocalMessages();
130
+ messages.push(newMessage);
131
+ saveLocalMessages(messages);
132
+ return toMessage(newMessage);
133
+ }
134
+
135
+ export async function getInbox(
136
+ agentId: string,
137
+ options?: { unreadOnly?: boolean; limit?: number }
138
+ ): Promise<Message[]> {
139
+ if (hasSupabase()) {
140
+ try {
141
+ let query = `/agent_messages?recipient=eq.${agentId}&archived=eq.false&order=created_at.desc`;
142
+ if (options?.unreadOnly) query += '&read=eq.false';
143
+ if (options?.limit) query += `&limit=${options.limit}`;
144
+
145
+ const results = await supabaseRequest<Array<{
146
+ id: string; sender: string; recipient: string;
147
+ subject: string; body: string; encrypted: boolean;
148
+ ciphertext: string; nonce: string; sender_public_key: string;
149
+ read: boolean; archived: boolean; created_at: string;
150
+ }>>(query);
151
+
152
+ return results.map(r => ({
153
+ id: r.id,
154
+ sender: r.sender,
155
+ recipient: r.recipient,
156
+ subject: r.subject || '',
157
+ body: r.body || '',
158
+ encrypted: r.encrypted,
159
+ ciphertext: r.ciphertext,
160
+ nonce: r.nonce,
161
+ senderPublicKey: r.sender_public_key,
162
+ read: r.read,
163
+ archived: r.archived,
164
+ createdAt: new Date(r.created_at),
165
+ }));
166
+ } catch {
167
+ // Fall through to local
168
+ }
169
+ }
170
+
171
+ // Local storage
172
+ const messages = loadLocalMessages();
173
+ let filtered = messages.filter(m => m.recipient === agentId && !m.archived);
174
+ if (options?.unreadOnly) filtered = filtered.filter(m => !m.read);
175
+ filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
176
+ if (options?.limit) filtered = filtered.slice(0, options.limit);
177
+ return filtered.map(toMessage);
178
+ }
179
+
180
+ export async function getMessage(id: string): Promise<Message | null> {
181
+ if (hasSupabase()) {
182
+ try {
183
+ const results = await supabaseRequest<Array<{
184
+ id: string; sender: string; recipient: string;
185
+ subject: string; body: string; encrypted: boolean;
186
+ ciphertext: string; nonce: string; sender_public_key: string;
187
+ read: boolean; archived: boolean; created_at: string;
188
+ }>>(`/agent_messages?id=eq.${id}`);
189
+
190
+ if (results.length > 0) {
191
+ const r = results[0];
192
+ return {
193
+ id: r.id,
194
+ sender: r.sender,
195
+ recipient: r.recipient,
196
+ subject: r.subject || '',
197
+ body: r.body || '',
198
+ encrypted: r.encrypted,
199
+ ciphertext: r.ciphertext,
200
+ nonce: r.nonce,
201
+ senderPublicKey: r.sender_public_key,
202
+ read: r.read,
203
+ archived: r.archived,
204
+ createdAt: new Date(r.created_at),
205
+ };
206
+ }
207
+ } catch {
208
+ // Fall through
209
+ }
210
+ }
211
+
212
+ const messages = loadLocalMessages();
213
+ const found = messages.find(m => m.id === id);
214
+ return found ? toMessage(found) : null;
215
+ }
216
+
217
+ export async function markAsRead(id: string): Promise<boolean> {
218
+ if (hasSupabase()) {
219
+ try {
220
+ await supabaseRequest(`/agent_messages?id=eq.${id}`, {
221
+ method: 'PATCH',
222
+ body: JSON.stringify({ read: true }),
223
+ });
224
+ return true;
225
+ } catch {
226
+ // Fall through
227
+ }
228
+ }
229
+
230
+ const messages = loadLocalMessages();
231
+ const idx = messages.findIndex(m => m.id === id);
232
+ if (idx === -1) return false;
233
+ messages[idx].read = true;
234
+ saveLocalMessages(messages);
235
+ return true;
236
+ }
237
+
238
+ export async function archiveMessage(id: string): Promise<boolean> {
239
+ if (hasSupabase()) {
240
+ try {
241
+ await supabaseRequest(`/agent_messages?id=eq.${id}`, {
242
+ method: 'PATCH',
243
+ body: JSON.stringify({ archived: true }),
244
+ });
245
+ return true;
246
+ } catch {
247
+ // Fall through
248
+ }
249
+ }
250
+
251
+ const messages = loadLocalMessages();
252
+ const idx = messages.findIndex(m => m.id === id);
253
+ if (idx === -1) return false;
254
+ messages[idx].archived = true;
255
+ saveLocalMessages(messages);
256
+ return true;
257
+ }
package/src/server.ts ADDED
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Myceliumail MCP Server
5
+ *
6
+ * Exposes Myceliumail messaging as MCP tools for Claude Desktop
7
+ * and other MCP-compatible clients.
8
+ */
9
+
10
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
+ import { z } from 'zod';
13
+
14
+ import * as crypto from './lib/crypto.js';
15
+ import * as storage from './lib/storage.js';
16
+ import { getAgentId } from './lib/config.js';
17
+
18
+ // Create the MCP server
19
+ const server = new McpServer({
20
+ name: 'myceliumail',
21
+ version: '1.0.0',
22
+ });
23
+
24
+ // Tool: check_inbox
25
+ server.tool(
26
+ 'check_inbox',
27
+ 'Check your Myceliumail inbox for messages',
28
+ {
29
+ unread_only: z.boolean().optional().describe('Only show unread messages'),
30
+ limit: z.number().optional().describe('Maximum number of messages to return'),
31
+ },
32
+ async ({ unread_only, limit }) => {
33
+ const agentId = getAgentId();
34
+ const messages = await storage.getInbox(agentId, {
35
+ unreadOnly: unread_only,
36
+ limit: limit || 10,
37
+ });
38
+
39
+ if (messages.length === 0) {
40
+ return {
41
+ content: [{ type: 'text', text: 'šŸ“­ No messages in inbox' }],
42
+ };
43
+ }
44
+
45
+ const formatted = messages.map(msg => {
46
+ const status = msg.read ? ' ' : 'ā— ';
47
+ const encrypted = msg.encrypted ? 'šŸ” ' : '';
48
+ return `${status}${encrypted}[${msg.id.slice(0, 8)}] From: ${msg.sender} | ${msg.subject || '(no subject)'} | ${msg.createdAt.toLocaleString()}`;
49
+ }).join('\n');
50
+
51
+ return {
52
+ content: [{
53
+ type: 'text',
54
+ text: `šŸ“¬ Inbox (${messages.length} messages):\n\n${formatted}`
55
+ }],
56
+ };
57
+ }
58
+ );
59
+
60
+ // Tool: read_message
61
+ server.tool(
62
+ 'read_message',
63
+ 'Read a specific message by ID',
64
+ {
65
+ message_id: z.string().describe('Message ID (can be partial)'),
66
+ },
67
+ async ({ message_id }) => {
68
+ const agentId = getAgentId();
69
+ let message = await storage.getMessage(message_id);
70
+
71
+ // Try partial ID match
72
+ if (!message) {
73
+ const inbox = await storage.getInbox(agentId, { limit: 100 });
74
+ message = inbox.find(m => m.id.startsWith(message_id)) || null;
75
+ }
76
+
77
+ if (!message) {
78
+ return {
79
+ content: [{ type: 'text', text: `āŒ Message not found: ${message_id}` }],
80
+ };
81
+ }
82
+
83
+ // Mark as read
84
+ await storage.markAsRead(message.id);
85
+
86
+ // Decrypt if needed
87
+ let subject = message.subject;
88
+ let body = message.body;
89
+
90
+ if (message.encrypted && message.ciphertext && message.nonce && message.senderPublicKey) {
91
+ const keyPair = crypto.loadKeyPair(agentId);
92
+ if (keyPair) {
93
+ try {
94
+ const decrypted = crypto.decryptMessage({
95
+ ciphertext: message.ciphertext,
96
+ nonce: message.nonce,
97
+ senderPublicKey: message.senderPublicKey,
98
+ }, keyPair);
99
+ if (decrypted) {
100
+ const parsed = JSON.parse(decrypted);
101
+ subject = parsed.subject || subject;
102
+ body = parsed.body || body;
103
+ }
104
+ } catch {
105
+ body = '[Failed to decrypt]';
106
+ }
107
+ } else {
108
+ body = '[Cannot decrypt - no keypair]';
109
+ }
110
+ }
111
+
112
+ const encrypted = message.encrypted ? '\nšŸ” Encrypted: Yes' : '';
113
+ const text = `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
114
+ From: ${message.sender}
115
+ To: ${message.recipient}
116
+ Date: ${message.createdAt.toLocaleString()}
117
+ Subject: ${subject}${encrypted}
118
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
119
+
120
+ ${body}
121
+
122
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
123
+ ID: ${message.id}`;
124
+
125
+ return {
126
+ content: [{ type: 'text', text }],
127
+ };
128
+ }
129
+ );
130
+
131
+ // Tool: send_message
132
+ server.tool(
133
+ 'send_message',
134
+ 'Send a message to another agent',
135
+ {
136
+ recipient: z.string().describe('Recipient agent ID'),
137
+ subject: z.string().describe('Message subject'),
138
+ body: z.string().describe('Message body'),
139
+ encrypt: z.boolean().optional().describe('Encrypt the message (requires key exchange)'),
140
+ },
141
+ async ({ recipient, subject, body, encrypt }) => {
142
+ const sender = getAgentId();
143
+
144
+ if (sender === 'anonymous') {
145
+ return {
146
+ content: [{ type: 'text', text: 'āŒ Agent ID not configured. Set MYCELIUMAIL_AGENT_ID environment variable.' }],
147
+ };
148
+ }
149
+
150
+ let messageOptions;
151
+
152
+ if (encrypt) {
153
+ const senderKeyPair = crypto.loadKeyPair(sender);
154
+ if (!senderKeyPair) {
155
+ return {
156
+ content: [{ type: 'text', text: 'āŒ No keypair found. Use generate_keys first.' }],
157
+ };
158
+ }
159
+
160
+ const recipientPubKeyB64 = crypto.getKnownKey(recipient);
161
+ if (!recipientPubKeyB64) {
162
+ return {
163
+ content: [{ type: 'text', text: `āŒ No public key found for ${recipient}. Use import_key first.` }],
164
+ };
165
+ }
166
+
167
+ const recipientPubKey = crypto.decodePublicKey(recipientPubKeyB64);
168
+ const payload = JSON.stringify({ subject, body });
169
+ const encrypted = crypto.encryptMessage(payload, recipientPubKey, senderKeyPair);
170
+
171
+ messageOptions = {
172
+ encrypted: true,
173
+ ciphertext: encrypted.ciphertext,
174
+ nonce: encrypted.nonce,
175
+ senderPublicKey: encrypted.senderPublicKey,
176
+ };
177
+ }
178
+
179
+ try {
180
+ const message = await storage.sendMessage(sender, recipient, subject, body, messageOptions);
181
+ const encInfo = encrypt ? ' (šŸ” encrypted)' : '';
182
+ return {
183
+ content: [{
184
+ type: 'text',
185
+ text: `āœ… Message sent to ${recipient}${encInfo}\nID: ${message.id}`
186
+ }],
187
+ };
188
+ } catch (error) {
189
+ return {
190
+ content: [{ type: 'text', text: `āŒ Failed to send: ${error}` }],
191
+ };
192
+ }
193
+ }
194
+ );
195
+
196
+ // Tool: reply_message
197
+ server.tool(
198
+ 'reply_message',
199
+ 'Reply to a message',
200
+ {
201
+ message_id: z.string().describe('ID of message to reply to'),
202
+ body: z.string().describe('Reply message body'),
203
+ encrypt: z.boolean().optional().describe('Encrypt the reply'),
204
+ },
205
+ async ({ message_id, body, encrypt }) => {
206
+ const agentId = getAgentId();
207
+
208
+ // Find original message
209
+ let original = await storage.getMessage(message_id);
210
+ if (!original) {
211
+ const inbox = await storage.getInbox(agentId, { limit: 100 });
212
+ original = inbox.find(m => m.id.startsWith(message_id)) || null;
213
+ }
214
+
215
+ if (!original) {
216
+ return {
217
+ content: [{ type: 'text', text: `āŒ Message not found: ${message_id}` }],
218
+ };
219
+ }
220
+
221
+ // Send reply to original sender
222
+ const subject = original.subject.startsWith('Re: ')
223
+ ? original.subject
224
+ : `Re: ${original.subject}`;
225
+
226
+ let messageOptions;
227
+ if (encrypt) {
228
+ const senderKeyPair = crypto.loadKeyPair(agentId);
229
+ const recipientPubKeyB64 = crypto.getKnownKey(original.sender);
230
+
231
+ if (senderKeyPair && recipientPubKeyB64) {
232
+ const recipientPubKey = crypto.decodePublicKey(recipientPubKeyB64);
233
+ const payload = JSON.stringify({ subject, body });
234
+ const encrypted = crypto.encryptMessage(payload, recipientPubKey, senderKeyPair);
235
+ messageOptions = {
236
+ encrypted: true,
237
+ ciphertext: encrypted.ciphertext,
238
+ nonce: encrypted.nonce,
239
+ senderPublicKey: encrypted.senderPublicKey,
240
+ };
241
+ }
242
+ }
243
+
244
+ const message = await storage.sendMessage(agentId, original.sender, subject, body, messageOptions);
245
+ const encInfo = encrypt && messageOptions ? ' (šŸ” encrypted)' : '';
246
+
247
+ return {
248
+ content: [{
249
+ type: 'text',
250
+ text: `āœ… Reply sent to ${original.sender}${encInfo}\nID: ${message.id}`
251
+ }],
252
+ };
253
+ }
254
+ );
255
+
256
+ // Tool: generate_keys
257
+ server.tool(
258
+ 'generate_keys',
259
+ 'Generate encryption keypair for this agent',
260
+ {
261
+ force: z.boolean().optional().describe('Overwrite existing keypair'),
262
+ },
263
+ async ({ force }) => {
264
+ const agentId = getAgentId();
265
+
266
+ if (crypto.hasKeyPair(agentId) && !force) {
267
+ const existing = crypto.loadKeyPair(agentId);
268
+ if (existing) {
269
+ const pubKey = crypto.getPublicKeyBase64(existing);
270
+ return {
271
+ content: [{
272
+ type: 'text',
273
+ text: `āš ļø Keypair already exists for ${agentId}\n\nšŸ“§ Your public key:\n${pubKey}\n\nUse force=true to regenerate.`
274
+ }],
275
+ };
276
+ }
277
+ }
278
+
279
+ const keyPair = crypto.generateKeyPair();
280
+ crypto.saveKeyPair(agentId, keyPair);
281
+ const publicKey = crypto.getPublicKeyBase64(keyPair);
282
+
283
+ return {
284
+ content: [{
285
+ type: 'text',
286
+ text: `šŸ” Keypair generated for ${agentId}\n\nšŸ“§ Your public key (share with other agents):\n${publicKey}`
287
+ }],
288
+ };
289
+ }
290
+ );
291
+
292
+ // Tool: list_keys
293
+ server.tool(
294
+ 'list_keys',
295
+ 'List all known encryption keys',
296
+ {},
297
+ async () => {
298
+ const agentId = getAgentId();
299
+ const ownKeys = crypto.listOwnKeys();
300
+ const knownKeys = crypto.loadKnownKeys();
301
+
302
+ let output = 'šŸ” Encryption Keys\n\n── Your Keys ──\n';
303
+
304
+ if (ownKeys.length === 0) {
305
+ output += 'No keypairs. Use generate_keys to create one.\n';
306
+ } else {
307
+ for (const id of ownKeys) {
308
+ const kp = crypto.loadKeyPair(id);
309
+ if (kp) {
310
+ const marker = id === agentId ? ' (active)' : '';
311
+ output += `${id}${marker}: ${crypto.getPublicKeyBase64(kp).slice(0, 20)}...\n`;
312
+ }
313
+ }
314
+ }
315
+
316
+ output += '\n── Peer Keys ──\n';
317
+ const peers = Object.entries(knownKeys);
318
+ if (peers.length === 0) {
319
+ output += 'No peer keys. Use import_key to add one.\n';
320
+ } else {
321
+ for (const [id, key] of peers) {
322
+ output += `${id}: ${key.slice(0, 20)}...\n`;
323
+ }
324
+ }
325
+
326
+ return {
327
+ content: [{ type: 'text', text: output }],
328
+ };
329
+ }
330
+ );
331
+
332
+ // Tool: import_key
333
+ server.tool(
334
+ 'import_key',
335
+ "Import another agent's public key for encrypted messaging",
336
+ {
337
+ agent_id: z.string().describe('Agent ID to import key for'),
338
+ public_key: z.string().describe('Base64 encoded public key'),
339
+ },
340
+ async ({ agent_id, public_key }) => {
341
+ if (public_key.length < 40) {
342
+ return {
343
+ content: [{ type: 'text', text: 'āŒ Invalid key format. Expected base64 NaCl public key.' }],
344
+ };
345
+ }
346
+
347
+ crypto.saveKnownKey(agent_id, public_key);
348
+
349
+ return {
350
+ content: [{
351
+ type: 'text',
352
+ text: `āœ… Imported public key for ${agent_id}\n\nšŸ” You can now send encrypted messages to this agent.`
353
+ }],
354
+ };
355
+ }
356
+ );
357
+
358
+ // Tool: archive_message
359
+ server.tool(
360
+ 'archive_message',
361
+ 'Archive a message (remove from inbox)',
362
+ {
363
+ message_id: z.string().describe('Message ID to archive'),
364
+ },
365
+ async ({ message_id }) => {
366
+ const success = await storage.archiveMessage(message_id);
367
+
368
+ if (success) {
369
+ return {
370
+ content: [{ type: 'text', text: `āœ… Message archived: ${message_id}` }],
371
+ };
372
+ } else {
373
+ return {
374
+ content: [{ type: 'text', text: `āŒ Message not found: ${message_id}` }],
375
+ };
376
+ }
377
+ }
378
+ );
379
+
380
+ // Start the server
381
+ async function main() {
382
+ const transport = new StdioServerTransport();
383
+ await server.connect(transport);
384
+ console.error('Myceliumail MCP server running');
385
+ }
386
+
387
+ main().catch(console.error);
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "lib": [
7
+ "ES2022"
8
+ ],
9
+ "outDir": "./dist",
10
+ "rootDir": "./src",
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "declaration": true,
16
+ "sourceMap": true,
17
+ "resolveJsonModule": true
18
+ },
19
+ "include": [
20
+ "src/**/*"
21
+ ],
22
+ "exclude": [
23
+ "node_modules",
24
+ "dist"
25
+ ]
26
+ }