moeba-claude-channel 0.0.1
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/moeba-channel.js +342 -0
- package/package.json +36 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Moeba Channel for Claude Code
|
|
4
|
+
*
|
|
5
|
+
* Two-way bridge: Moeba app users ā Claude Code session.
|
|
6
|
+
* Authenticates via OAuth on startup, connects via SSE, replies via HTTP.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* npx @moeba/claude-channel
|
|
10
|
+
*
|
|
11
|
+
* On first run, opens browser for Google/Apple sign-in before connecting.
|
|
12
|
+
* Credentials are cached per project at ~/.moeba/channel-<project>.json
|
|
13
|
+
*/
|
|
14
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
15
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
16
|
+
import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
17
|
+
import { createServer } from 'http';
|
|
18
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
19
|
+
import { homedir } from 'os';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
import { execSync, exec } from 'child_process';
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Config
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
const MOEBA_API_URL = (process.env.MOEBA_API_URL ||
|
|
26
|
+
'https://moeba-api-999642860678.africa-south1.run.app').replace(/\/$/, '');
|
|
27
|
+
const MOEBA_AUTH_URL = process.env.MOEBA_AUTH_URL || 'https://admin.moeba.co.za';
|
|
28
|
+
// Detect project name from git repo or directory name
|
|
29
|
+
function detectProjectName() {
|
|
30
|
+
try {
|
|
31
|
+
const repoName = execSync('git rev-parse --show-toplevel', {
|
|
32
|
+
encoding: 'utf-8',
|
|
33
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
34
|
+
}).trim();
|
|
35
|
+
return repoName.split('/').pop() || 'default';
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return process.cwd().split('/').pop() || 'default';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const PROJECT_NAME = process.env.MOEBA_PROJECT || detectProjectName();
|
|
42
|
+
const PROJECT_SLUG = PROJECT_NAME.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
43
|
+
const CREDENTIALS_PATH = join(homedir(), '.moeba', `channel-${PROJECT_SLUG}.json`);
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Credential storage
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
function loadCredentials() {
|
|
48
|
+
try {
|
|
49
|
+
if (!existsSync(CREDENTIALS_PATH))
|
|
50
|
+
return null;
|
|
51
|
+
return JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf-8'));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function saveCredentials(c) {
|
|
58
|
+
const dir = join(homedir(), '.moeba');
|
|
59
|
+
if (!existsSync(dir))
|
|
60
|
+
mkdirSync(dir, { recursive: true });
|
|
61
|
+
writeFileSync(CREDENTIALS_PATH, JSON.stringify(c, null, 2));
|
|
62
|
+
}
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// OAuth flow ā opens browser, receives Firebase token via localhost callback
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
function authenticate() {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const callbackPort = 9876;
|
|
69
|
+
const httpServer = createServer(async (req, res) => {
|
|
70
|
+
const url = new URL(req.url, `http://localhost:${callbackPort}`);
|
|
71
|
+
if (url.pathname === '/callback') {
|
|
72
|
+
const firebaseIdToken = url.searchParams.get('token');
|
|
73
|
+
if (!firebaseIdToken) {
|
|
74
|
+
res.writeHead(400);
|
|
75
|
+
res.end('Missing token parameter');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const response = await fetch(`${MOEBA_API_URL}/channel/auth`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({ firebaseIdToken, projectName: PROJECT_NAME }),
|
|
83
|
+
});
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
const err = await response.text();
|
|
86
|
+
res.writeHead(500);
|
|
87
|
+
res.end(`Auth failed: ${err}`);
|
|
88
|
+
reject(new Error(`Channel auth failed: ${err}`));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const data = (await response.json());
|
|
92
|
+
const newCreds = {
|
|
93
|
+
token: data.token,
|
|
94
|
+
email: data.email,
|
|
95
|
+
businessId: data.businessId,
|
|
96
|
+
agentId: data.agentId,
|
|
97
|
+
connectionId: data.connectionId,
|
|
98
|
+
agentApiKey: data.agentApiKey,
|
|
99
|
+
projectName: PROJECT_NAME,
|
|
100
|
+
};
|
|
101
|
+
saveCredentials(newCreds);
|
|
102
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
103
|
+
res.end(`
|
|
104
|
+
<html><body style="font-family:system-ui;text-align:center;padding:60px">
|
|
105
|
+
<h2>Connected to Moeba!</h2>
|
|
106
|
+
<p>You can close this tab and return to Claude Code.</p>
|
|
107
|
+
</body></html>
|
|
108
|
+
`);
|
|
109
|
+
httpServer.close();
|
|
110
|
+
resolve(newCreds);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
res.writeHead(500);
|
|
114
|
+
res.end(`Error: ${err.message}`);
|
|
115
|
+
reject(err);
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
res.writeHead(404);
|
|
120
|
+
res.end('Not found');
|
|
121
|
+
});
|
|
122
|
+
httpServer.listen(callbackPort, '127.0.0.1', () => {
|
|
123
|
+
const authUrl = `${MOEBA_AUTH_URL}/channel-login?redirect=http://localhost:${callbackPort}/callback`;
|
|
124
|
+
console.error(`\nš Sign in to Moeba: ${authUrl}\n`);
|
|
125
|
+
const cmd = process.platform === 'darwin'
|
|
126
|
+
? 'open'
|
|
127
|
+
: process.platform === 'win32'
|
|
128
|
+
? 'start'
|
|
129
|
+
: 'xdg-open';
|
|
130
|
+
exec(`${cmd} "${authUrl}"`);
|
|
131
|
+
});
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
httpServer.close();
|
|
134
|
+
reject(new Error('Authentication timed out after 2 minutes'));
|
|
135
|
+
}, 120_000);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// SSE client ā connects to Moeba and receives messages
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
function connectSSE(c, mcp) {
|
|
142
|
+
const url = `${MOEBA_API_URL}/api/channel/events?connectionId=${c.connectionId}`;
|
|
143
|
+
let reconnectDelay = 1000;
|
|
144
|
+
async function connect() {
|
|
145
|
+
try {
|
|
146
|
+
console.error('Connecting to Moeba SSE...');
|
|
147
|
+
const response = await fetch(url, {
|
|
148
|
+
headers: { Authorization: `Bearer ${c.token}` },
|
|
149
|
+
});
|
|
150
|
+
if (!response.ok) {
|
|
151
|
+
console.error(`SSE connection failed: ${response.status}`);
|
|
152
|
+
scheduleReconnect();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
console.error('Connected to Moeba ā listening for messages');
|
|
156
|
+
reconnectDelay = 1000;
|
|
157
|
+
const reader = response.body.getReader();
|
|
158
|
+
const decoder = new TextDecoder();
|
|
159
|
+
let buffer = '';
|
|
160
|
+
while (true) {
|
|
161
|
+
const { done, value } = await reader.read();
|
|
162
|
+
if (done)
|
|
163
|
+
break;
|
|
164
|
+
buffer += decoder.decode(value, { stream: true });
|
|
165
|
+
const lines = buffer.split('\n');
|
|
166
|
+
buffer = lines.pop() || '';
|
|
167
|
+
let eventType = '';
|
|
168
|
+
let eventData = '';
|
|
169
|
+
for (const line of lines) {
|
|
170
|
+
if (line.startsWith('event: ')) {
|
|
171
|
+
eventType = line.slice(7);
|
|
172
|
+
}
|
|
173
|
+
else if (line.startsWith('data: ')) {
|
|
174
|
+
eventData = line.slice(6);
|
|
175
|
+
}
|
|
176
|
+
else if (line === '' && eventType && eventData) {
|
|
177
|
+
if (eventType === 'message') {
|
|
178
|
+
try {
|
|
179
|
+
const event = JSON.parse(eventData);
|
|
180
|
+
const content = event.message?.text || '';
|
|
181
|
+
const meta = {
|
|
182
|
+
sender_email: event.senderEmail || '',
|
|
183
|
+
sender_name: event.senderName || '',
|
|
184
|
+
connection_id: event.connectionId || c.connectionId,
|
|
185
|
+
conversation_id: event.conversationId || '',
|
|
186
|
+
};
|
|
187
|
+
if (event.type === 'action') {
|
|
188
|
+
meta.type = 'action';
|
|
189
|
+
}
|
|
190
|
+
mcp
|
|
191
|
+
.notification({
|
|
192
|
+
method: 'notifications/claude/channel',
|
|
193
|
+
params: { content, meta },
|
|
194
|
+
})
|
|
195
|
+
.catch((err) => console.error('Notification failed:', err.message));
|
|
196
|
+
}
|
|
197
|
+
catch { }
|
|
198
|
+
}
|
|
199
|
+
else if (eventType === 'connected') {
|
|
200
|
+
console.error('SSE stream established');
|
|
201
|
+
}
|
|
202
|
+
eventType = '';
|
|
203
|
+
eventData = '';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
console.error('SSE stream ended');
|
|
208
|
+
scheduleReconnect();
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
console.error(`SSE error: ${err.message}`);
|
|
212
|
+
scheduleReconnect();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function scheduleReconnect() {
|
|
216
|
+
console.error(`Reconnecting in ${reconnectDelay / 1000}s...`);
|
|
217
|
+
setTimeout(connect, reconnectDelay);
|
|
218
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 30_000);
|
|
219
|
+
}
|
|
220
|
+
connect();
|
|
221
|
+
}
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Main ā authenticate first, then connect MCP
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
async function main() {
|
|
226
|
+
// 1. Authenticate (cached or browser OAuth) ā before MCP connects
|
|
227
|
+
let creds = loadCredentials();
|
|
228
|
+
if (!creds) {
|
|
229
|
+
console.error('No Moeba credentials found ā opening browser to sign in...');
|
|
230
|
+
creds = await authenticate();
|
|
231
|
+
}
|
|
232
|
+
console.error(`Authenticated as ${creds.email} (project: ${PROJECT_NAME})`);
|
|
233
|
+
// 2. Create MCP channel server
|
|
234
|
+
const mcp = new Server({ name: 'moeba', version: '0.0.1' }, {
|
|
235
|
+
capabilities: {
|
|
236
|
+
experimental: { 'claude/channel': {} },
|
|
237
|
+
tools: {},
|
|
238
|
+
},
|
|
239
|
+
instructions: `Messages from Moeba users arrive as <channel source="moeba" ...>.
|
|
240
|
+
Each message has attributes: sender_email, sender_name, connection_id, conversation_id.
|
|
241
|
+
|
|
242
|
+
When you receive a message:
|
|
243
|
+
1. Read and understand the user's request
|
|
244
|
+
2. Take whatever actions are needed (read files, run commands, etc.)
|
|
245
|
+
3. Send progress updates via moeba_progress while working on longer tasks
|
|
246
|
+
4. Reply using moeba_reply with the connection_id from the message tag
|
|
247
|
+
|
|
248
|
+
Keep replies concise and helpful. The user is chatting from a mobile app.`,
|
|
249
|
+
});
|
|
250
|
+
// 3. Register tools
|
|
251
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
252
|
+
tools: [
|
|
253
|
+
{
|
|
254
|
+
name: 'moeba_reply',
|
|
255
|
+
description: 'Send a reply message back to the Moeba user',
|
|
256
|
+
inputSchema: {
|
|
257
|
+
type: 'object',
|
|
258
|
+
properties: {
|
|
259
|
+
connection_id: {
|
|
260
|
+
type: 'string',
|
|
261
|
+
description: 'The connection_id from the inbound <channel> tag',
|
|
262
|
+
},
|
|
263
|
+
text: {
|
|
264
|
+
type: 'string',
|
|
265
|
+
description: 'The message to send back',
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
required: ['connection_id', 'text'],
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: 'moeba_progress',
|
|
273
|
+
description: 'Show a typing/progress indicator while working on a task',
|
|
274
|
+
inputSchema: {
|
|
275
|
+
type: 'object',
|
|
276
|
+
properties: {
|
|
277
|
+
connection_id: {
|
|
278
|
+
type: 'string',
|
|
279
|
+
description: 'The connection_id from the inbound <channel> tag',
|
|
280
|
+
},
|
|
281
|
+
text: {
|
|
282
|
+
type: 'string',
|
|
283
|
+
description: 'Progress text (e.g. "Reading files...")',
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
required: ['connection_id', 'text'],
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
}));
|
|
291
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
292
|
+
const { name, arguments: args } = req.params;
|
|
293
|
+
if (name === 'moeba_reply' || name === 'moeba_progress') {
|
|
294
|
+
const { connection_id, text } = args;
|
|
295
|
+
const body = {
|
|
296
|
+
connectionId: connection_id,
|
|
297
|
+
message: { text },
|
|
298
|
+
};
|
|
299
|
+
if (name === 'moeba_progress') {
|
|
300
|
+
body.type = 'progress';
|
|
301
|
+
}
|
|
302
|
+
const response = await fetch(`${MOEBA_API_URL}/api/agent/send`, {
|
|
303
|
+
method: 'POST',
|
|
304
|
+
headers: {
|
|
305
|
+
'Content-Type': 'application/json',
|
|
306
|
+
'X-Moeba-Agent-Key': creds.agentApiKey,
|
|
307
|
+
},
|
|
308
|
+
body: JSON.stringify(body),
|
|
309
|
+
});
|
|
310
|
+
if (!response.ok) {
|
|
311
|
+
const errText = await response.text();
|
|
312
|
+
return {
|
|
313
|
+
content: [
|
|
314
|
+
{
|
|
315
|
+
type: 'text',
|
|
316
|
+
text: `Failed: ${response.status} ${errText}`,
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
isError: true,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
content: [
|
|
324
|
+
{
|
|
325
|
+
type: 'text',
|
|
326
|
+
text: name === 'moeba_progress' ? 'Progress updated' : 'Sent',
|
|
327
|
+
},
|
|
328
|
+
],
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
332
|
+
});
|
|
333
|
+
// 4. Connect MCP to Claude Code
|
|
334
|
+
await mcp.connect(new StdioServerTransport());
|
|
335
|
+
// 5. Start SSE listener
|
|
336
|
+
connectSSE(creds, mcp);
|
|
337
|
+
console.error('Moeba channel ready ā waiting for messages');
|
|
338
|
+
}
|
|
339
|
+
main().catch((err) => {
|
|
340
|
+
console.error('Fatal:', err.message);
|
|
341
|
+
process.exit(1);
|
|
342
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "moeba-claude-channel",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Claude Code channel for Moeba ā chat with Claude Code from the Moeba app",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/moeba-channel.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"moeba-channel": "dist/moeba-channel.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"claude-code",
|
|
19
|
+
"mcp",
|
|
20
|
+
"moeba",
|
|
21
|
+
"channel",
|
|
22
|
+
"ai-agent"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/moeba-co/moeba"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@modelcontextprotocol/sdk": "^1.12.1"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"typescript": "^5.9.0",
|
|
34
|
+
"@types/node": "^20.0.0"
|
|
35
|
+
}
|
|
36
|
+
}
|