tuna-agent 0.1.96 → 0.1.97

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,11 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Idea Bank MCP Server for Tuna Agent
4
+ *
5
+ * Stdio-based MCP server that exposes idea bank tools to Claude Code.
6
+ * Communicates with the Tuna API using agent token auth.
7
+ *
8
+ * Usage:
9
+ * node idea-server.js --api-url https://api.tuna.ai --token xxx --agent-id abc123
10
+ */
11
+ export {};
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Idea Bank MCP Server for Tuna Agent
4
+ *
5
+ * Stdio-based MCP server that exposes idea bank tools to Claude Code.
6
+ * Communicates with the Tuna API using agent token auth.
7
+ *
8
+ * Usage:
9
+ * node idea-server.js --api-url https://api.tuna.ai --token xxx --agent-id abc123
10
+ */
11
+ function parseArgs() {
12
+ const args = process.argv.slice(2);
13
+ let apiUrl = '';
14
+ let token = '';
15
+ let agentId = '';
16
+ for (let i = 0; i < args.length; i++) {
17
+ if (args[i] === '--api-url' && args[i + 1])
18
+ apiUrl = args[++i];
19
+ else if (args[i] === '--token' && args[i + 1])
20
+ token = args[++i];
21
+ else if (args[i] === '--agent-id' && args[i + 1])
22
+ agentId = args[++i];
23
+ }
24
+ if (!apiUrl || !token || !agentId) {
25
+ process.stderr.write('Usage: idea-server --api-url URL --token TOKEN --agent-id ID\n');
26
+ process.exit(1);
27
+ }
28
+ return { apiUrl, token, agentId };
29
+ }
30
+ // ===== API Client =====
31
+ async function apiCall(config, method, path, body) {
32
+ const url = `${config.apiUrl}${path}`;
33
+ const headers = {
34
+ 'Authorization': `Bearer ${config.token}`,
35
+ 'Content-Type': 'application/json',
36
+ };
37
+ const options = { method, headers };
38
+ if (body)
39
+ options.body = JSON.stringify(body);
40
+ const res = await fetch(url, options);
41
+ const json = await res.json();
42
+ if (!res.ok || (json.code && json.code >= 400)) {
43
+ throw new Error(json.message || `API error: ${res.status}`);
44
+ }
45
+ return json.data;
46
+ }
47
+ function sendResponse(res) {
48
+ // Claude Code uses newline-delimited JSON (not Content-Length framing)
49
+ process.stdout.write(JSON.stringify(res) + '\n');
50
+ }
51
+ function sendResult(id, result) {
52
+ sendResponse({ jsonrpc: '2.0', id: id ?? null, result });
53
+ }
54
+ function sendError(id, code, message) {
55
+ sendResponse({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
56
+ }
57
+ // ===== Tool Definitions =====
58
+ const TOOLS = [
59
+ {
60
+ name: 'create_idea',
61
+ description: 'Create a new idea in the idea bank. Captures app/product ideas from research, trends, or social media.',
62
+ inputSchema: {
63
+ type: 'object',
64
+ properties: {
65
+ name: { type: 'string', description: 'Name/title of the idea' },
66
+ category: { type: 'string', description: 'Category of the idea (e.g. SaaS, Mobile App, AI Tool, etc.)' },
67
+ source: { type: 'string', enum: ['trending', 'research', 'social'], description: 'Where the idea came from' },
68
+ strategy: { type: 'string', description: 'High-level strategy or approach for this idea (optional)' },
69
+ url: { type: 'string', description: 'Reference URL related to the idea (optional)' },
70
+ score: { type: 'number', description: 'Idea score/rating (optional)' },
71
+ estimated_difficulty: { type: 'string', enum: ['easy', 'medium', 'hard'], description: 'Estimated difficulty to build (optional)' },
72
+ monetization_model: { type: 'string', description: 'How this idea could make money (optional)' },
73
+ tags: { type: 'string', description: 'Comma-separated tags (optional, e.g. "ai,saas,b2b")' },
74
+ competitors: { type: 'string', description: 'Comma-separated competitor names (optional, e.g. "Notion,Coda,Airtable")' },
75
+ notes: { type: 'string', description: 'Additional notes about the idea (optional)' },
76
+ },
77
+ required: ['name', 'category', 'source'],
78
+ },
79
+ },
80
+ {
81
+ name: 'update_idea',
82
+ description: 'Update an existing idea. Can update any field including status, score, notes, tags, etc.',
83
+ inputSchema: {
84
+ type: 'object',
85
+ properties: {
86
+ idea_id: { type: 'string', description: 'The ID of the idea to update' },
87
+ name: { type: 'string', description: 'New name (optional)' },
88
+ category: { type: 'string', description: 'New category (optional)' },
89
+ status: { type: 'string', enum: ['new', 'watching', 'validated', 'archived'], description: 'New status (optional)' },
90
+ score: { type: 'number', description: 'New score (optional)' },
91
+ notes: { type: 'string', description: 'New notes (optional)' },
92
+ strategy: { type: 'string', description: 'New strategy (optional)' },
93
+ url: { type: 'string', description: 'New URL (optional)' },
94
+ monetization_model: { type: 'string', description: 'New monetization model (optional)' },
95
+ estimated_difficulty: { type: 'string', enum: ['easy', 'medium', 'hard'], description: 'New estimated difficulty (optional)' },
96
+ tags: { type: 'string', description: 'Comma-separated tags (optional)' },
97
+ competitors: { type: 'string', description: 'Comma-separated competitor names (optional)' },
98
+ times_seen: { type: 'number', description: 'Number of times this idea has been seen (optional)' },
99
+ },
100
+ required: ['idea_id'],
101
+ },
102
+ },
103
+ {
104
+ name: 'list_ideas',
105
+ description: 'List ideas in the idea bank with optional filters. Returns ideas with status emoji indicators.',
106
+ inputSchema: {
107
+ type: 'object',
108
+ properties: {
109
+ status: { type: 'string', enum: ['new', 'watching', 'validated', 'archived'], description: 'Filter by status (optional)' },
110
+ category: { type: 'string', description: 'Filter by category (optional)' },
111
+ source: { type: 'string', enum: ['trending', 'research', 'social'], description: 'Filter by source (optional)' },
112
+ agent_id: { type: 'string', description: 'Filter by agent ID (optional, defaults to current agent)' },
113
+ page: { type: 'number', description: 'Page number (optional, default 1)' },
114
+ limit: { type: 'number', description: 'Items per page (optional, default 20)' },
115
+ sort_by: { type: 'string', description: 'Field to sort by (optional, e.g. "score", "created_at")' },
116
+ sort_order: { type: 'string', enum: ['asc', 'desc'], description: 'Sort order (optional)' },
117
+ },
118
+ },
119
+ },
120
+ {
121
+ name: 'search_ideas',
122
+ description: 'Search ideas by keyword query. Searches across name, notes, tags, and other text fields.',
123
+ inputSchema: {
124
+ type: 'object',
125
+ properties: {
126
+ query: { type: 'string', description: 'Search query string' },
127
+ status: { type: 'string', enum: ['new', 'watching', 'validated', 'archived'], description: 'Filter by status (optional)' },
128
+ category: { type: 'string', description: 'Filter by category (optional)' },
129
+ page: { type: 'number', description: 'Page number (optional)' },
130
+ limit: { type: 'number', description: 'Items per page (optional)' },
131
+ },
132
+ required: ['query'],
133
+ },
134
+ },
135
+ {
136
+ name: 'archive_idea',
137
+ description: 'Archive an idea by setting its status to archived.',
138
+ inputSchema: {
139
+ type: 'object',
140
+ properties: {
141
+ idea_id: { type: 'string', description: 'The ID of the idea to archive' },
142
+ },
143
+ required: ['idea_id'],
144
+ },
145
+ },
146
+ {
147
+ name: 'bulk_update_ideas',
148
+ description: 'Update multiple ideas at once. Useful for batch status changes, scoring, or categorization.',
149
+ inputSchema: {
150
+ type: 'object',
151
+ properties: {
152
+ idea_ids: { type: 'string', description: 'Comma-separated list of idea IDs to update' },
153
+ status: { type: 'string', enum: ['new', 'watching', 'validated', 'archived'], description: 'New status for all ideas (optional)' },
154
+ score: { type: 'number', description: 'New score for all ideas (optional)' },
155
+ category: { type: 'string', description: 'New category for all ideas (optional)' },
156
+ },
157
+ required: ['idea_ids'],
158
+ },
159
+ },
160
+ ];
161
+ // ===== Status Emoji Helper =====
162
+ function statusEmoji(status) {
163
+ switch (status) {
164
+ case 'new': return '\u2B50'; // ⭐
165
+ case 'watching': return '\uD83D\uDC41'; // 👁
166
+ case 'validated': return '\u2705'; // ✅
167
+ case 'archived': return '\uD83D\uDCE6'; // 📦
168
+ default: return '\u2753'; // ❓
169
+ }
170
+ }
171
+ // ===== Request Handler =====
172
+ async function handleRequest(config, req) {
173
+ try {
174
+ switch (req.method) {
175
+ case 'initialize':
176
+ sendResult(req.id, {
177
+ protocolVersion: '2024-11-05',
178
+ capabilities: { tools: {} },
179
+ serverInfo: { name: 'tuna-idea-bank', version: '1.0.0' },
180
+ });
181
+ break;
182
+ case 'notifications/initialized':
183
+ // No response needed for notifications
184
+ break;
185
+ case 'tools/list':
186
+ sendResult(req.id, { tools: TOOLS });
187
+ break;
188
+ case 'tools/call': {
189
+ const toolName = req.params?.name ?? '';
190
+ const args = req.params?.arguments ?? {};
191
+ const result = await handleToolCall(config, toolName, args);
192
+ sendResult(req.id, result);
193
+ break;
194
+ }
195
+ case 'ping':
196
+ sendResult(req.id, {});
197
+ break;
198
+ default:
199
+ if (req.id !== undefined) {
200
+ sendError(req.id, -32601, `Method not found: ${req.method}`);
201
+ }
202
+ }
203
+ }
204
+ catch (err) {
205
+ const message = err instanceof Error ? err.message : String(err);
206
+ if (req.id !== undefined) {
207
+ sendError(req.id, -32603, message);
208
+ }
209
+ }
210
+ }
211
+ async function handleToolCall(config, toolName, args) {
212
+ try {
213
+ switch (toolName) {
214
+ case 'create_idea': {
215
+ if (!args.name || !args.category || !args.source) {
216
+ return { content: [{ type: 'text', text: 'Error: name, category, and source are required' }], isError: true };
217
+ }
218
+ const body = {
219
+ name: args.name,
220
+ category: args.category,
221
+ source: args.source,
222
+ agent_id: config.agentId,
223
+ };
224
+ if (args.strategy)
225
+ body.strategy = args.strategy;
226
+ if (args.url)
227
+ body.url = args.url;
228
+ if (args.score)
229
+ body.score = Number(args.score);
230
+ if (args.estimated_difficulty)
231
+ body.estimated_difficulty = args.estimated_difficulty;
232
+ if (args.monetization_model)
233
+ body.monetization_model = args.monetization_model;
234
+ if (args.tags)
235
+ body.tags = args.tags.split(',').map((t) => t.trim());
236
+ if (args.competitors)
237
+ body.competitors = args.competitors.split(',').map((c) => c.trim());
238
+ if (args.notes)
239
+ body.notes = args.notes;
240
+ const data = await apiCall(config, 'POST', '/agent-idea', body);
241
+ return { content: [{ type: 'text', text: `Idea "${data.name}" created (ID: ${data._id})` }] };
242
+ }
243
+ case 'update_idea': {
244
+ if (!args.idea_id) {
245
+ return { content: [{ type: 'text', text: 'Error: idea_id is required' }], isError: true };
246
+ }
247
+ const body = {};
248
+ if (args.name)
249
+ body.name = args.name;
250
+ if (args.category)
251
+ body.category = args.category;
252
+ if (args.status)
253
+ body.status = args.status;
254
+ if (args.score)
255
+ body.score = Number(args.score);
256
+ if (args.notes)
257
+ body.notes = args.notes;
258
+ if (args.strategy)
259
+ body.strategy = args.strategy;
260
+ if (args.url)
261
+ body.url = args.url;
262
+ if (args.monetization_model)
263
+ body.monetization_model = args.monetization_model;
264
+ if (args.estimated_difficulty)
265
+ body.estimated_difficulty = args.estimated_difficulty;
266
+ if (args.tags)
267
+ body.tags = args.tags.split(',').map((t) => t.trim());
268
+ if (args.competitors)
269
+ body.competitors = args.competitors.split(',').map((c) => c.trim());
270
+ if (args.times_seen)
271
+ body.times_seen = Number(args.times_seen);
272
+ const data = await apiCall(config, 'PUT', `/agent-idea/${args.idea_id}`, body);
273
+ return { content: [{ type: 'text', text: `Idea "${data.name}" updated (ID: ${data._id})` }] };
274
+ }
275
+ case 'list_ideas': {
276
+ const params = new URLSearchParams();
277
+ params.set('agent_id', args.agent_id || config.agentId);
278
+ if (args.status)
279
+ params.set('status', args.status);
280
+ if (args.category)
281
+ params.set('category', args.category);
282
+ if (args.source)
283
+ params.set('source', args.source);
284
+ if (args.page)
285
+ params.set('page', args.page);
286
+ if (args.limit)
287
+ params.set('limit', args.limit);
288
+ if (args.sort_by)
289
+ params.set('sort_by', args.sort_by);
290
+ if (args.sort_order)
291
+ params.set('sort_order', args.sort_order);
292
+ const data = await apiCall(config, 'GET', `/agent-idea?${params.toString()}`);
293
+ const ideas = data.ideas || [];
294
+ if (ideas.length === 0) {
295
+ return { content: [{ type: 'text', text: 'No ideas found.' }] };
296
+ }
297
+ const listing = ideas.map((idea) => {
298
+ const emoji = statusEmoji(idea.status);
299
+ const score = idea.score != null ? ` | Score: ${idea.score}` : '';
300
+ const tags = idea.tags && idea.tags.length > 0 ? ` | Tags: ${idea.tags.join(', ')}` : '';
301
+ return `- ${emoji} **${idea.name}** (ID: ${idea._id})\n Category: ${idea.category} | Source: ${idea.source}${score}${tags}\n Created: ${idea.created_at}`;
302
+ }).join('\n\n');
303
+ const total = data.total != null ? ` (total: ${data.total})` : '';
304
+ return { content: [{ type: 'text', text: `Found ${ideas.length} idea(s)${total}:\n\n${listing}` }] };
305
+ }
306
+ case 'search_ideas': {
307
+ if (!args.query) {
308
+ return { content: [{ type: 'text', text: 'Error: query is required' }], isError: true };
309
+ }
310
+ const params = new URLSearchParams();
311
+ params.set('q', args.query);
312
+ if (args.status)
313
+ params.set('status', args.status);
314
+ if (args.category)
315
+ params.set('category', args.category);
316
+ if (args.page)
317
+ params.set('page', args.page);
318
+ if (args.limit)
319
+ params.set('limit', args.limit);
320
+ const data = await apiCall(config, 'GET', `/agent-idea/search?${params.toString()}`);
321
+ const ideas = data.ideas || [];
322
+ if (ideas.length === 0) {
323
+ return { content: [{ type: 'text', text: `No ideas found matching "${args.query}".` }] };
324
+ }
325
+ const listing = ideas.map((idea) => {
326
+ const emoji = statusEmoji(idea.status);
327
+ const score = idea.score != null ? ` | Score: ${idea.score}` : '';
328
+ const tags = idea.tags && idea.tags.length > 0 ? ` | Tags: ${idea.tags.join(', ')}` : '';
329
+ return `- ${emoji} **${idea.name}** (ID: ${idea._id})\n Category: ${idea.category} | Source: ${idea.source}${score}${tags}`;
330
+ }).join('\n\n');
331
+ const total = data.total != null ? ` (total: ${data.total})` : '';
332
+ return { content: [{ type: 'text', text: `Search results for "${args.query}" — ${ideas.length} idea(s)${total}:\n\n${listing}` }] };
333
+ }
334
+ case 'archive_idea': {
335
+ if (!args.idea_id) {
336
+ return { content: [{ type: 'text', text: 'Error: idea_id is required' }], isError: true };
337
+ }
338
+ const data = await apiCall(config, 'PUT', `/agent-idea/${args.idea_id}/archive`, {});
339
+ return { content: [{ type: 'text', text: `Idea "${data.name}" archived (ID: ${data._id})` }] };
340
+ }
341
+ case 'bulk_update_ideas': {
342
+ if (!args.idea_ids) {
343
+ return { content: [{ type: 'text', text: 'Error: idea_ids is required' }], isError: true };
344
+ }
345
+ const body = {
346
+ idea_ids: args.idea_ids.split(',').map((id) => id.trim()),
347
+ };
348
+ if (args.status)
349
+ body.status = args.status;
350
+ if (args.score)
351
+ body.score = Number(args.score);
352
+ if (args.category)
353
+ body.category = args.category;
354
+ if (!args.status && !args.score && !args.category) {
355
+ return { content: [{ type: 'text', text: 'Error: at least one of status, score, or category is required' }], isError: true };
356
+ }
357
+ const data = await apiCall(config, 'PUT', '/agent-idea/bulk', body);
358
+ return { content: [{ type: 'text', text: `Bulk update complete: ${data.updated_count} idea(s) updated.` }] };
359
+ }
360
+ default:
361
+ return { content: [{ type: 'text', text: `Unknown tool: ${toolName}` }], isError: true };
362
+ }
363
+ }
364
+ catch (err) {
365
+ const message = err instanceof Error ? err.message : String(err);
366
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
367
+ }
368
+ }
369
+ // ===== Stdio Transport =====
370
+ function startServer(config) {
371
+ process.stderr.write(`[idea-mcp] Starting with agent ${config.agentId}\n`);
372
+ let buffer = '';
373
+ process.stdin.setEncoding('utf-8');
374
+ process.stdin.on('data', (chunk) => {
375
+ buffer += chunk;
376
+ // Claude Code sends newline-delimited JSON (one JSON object per line)
377
+ const lines = buffer.split('\n');
378
+ buffer = lines.pop() ?? ''; // Keep incomplete last line in buffer
379
+ for (const line of lines) {
380
+ const trimmed = line.trim();
381
+ if (!trimmed)
382
+ continue;
383
+ try {
384
+ const req = JSON.parse(trimmed);
385
+ handleRequest(config, req).catch((err) => {
386
+ process.stderr.write(`[idea-mcp] Error handling ${req.method}: ${err}\n`);
387
+ });
388
+ }
389
+ catch {
390
+ process.stderr.write(`[idea-mcp] Failed to parse JSON: ${trimmed.slice(0, 100)}\n`);
391
+ }
392
+ }
393
+ });
394
+ process.stdin.on('end', () => {
395
+ process.stderr.write('[idea-mcp] stdin closed, shutting down\n');
396
+ process.exit(0);
397
+ });
398
+ }
399
+ // ===== Main =====
400
+ const config = parseArgs();
401
+ startServer(config);
402
+ export {};
package/dist/mcp/setup.js CHANGED
@@ -378,6 +378,7 @@ function buildMem0McpConfig(agentName) {
378
378
  */
379
379
  export function setupMcpConfig(config) {
380
380
  const knowledgeServerPath = path.join(__dirname, 'knowledge-server.js');
381
+ const ideaServerPath = path.join(__dirname, 'idea-server.js');
381
382
  const browserServerPath = path.join(__dirname, 'browser-server.js');
382
383
  const mcpServers = {
383
384
  'tuna-knowledge': {
@@ -389,6 +390,15 @@ export function setupMcpConfig(config) {
389
390
  '--agent-id', config.agentId,
390
391
  ],
391
392
  },
393
+ 'tuna-idea': {
394
+ command: process.execPath,
395
+ args: [
396
+ ideaServerPath,
397
+ '--api-url', config.apiUrl,
398
+ '--token', config.agentToken,
399
+ '--agent-id', config.agentId,
400
+ ],
401
+ },
392
402
  'tuna-browser': {
393
403
  command: process.execPath,
394
404
  args: [browserServerPath, '--user-data-dir', path.join(process.env.HOME || '', '.config', 'tuna-browser', 'chrome-profile')],
@@ -420,6 +430,7 @@ export function getMcpConfigPath() {
420
430
  */
421
431
  export function writeAgentFolderMcpConfig(agentFolderPath, config) {
422
432
  const knowledgeServerPath = path.join(__dirname, 'knowledge-server.js');
433
+ const ideaServerPath = path.join(__dirname, 'idea-server.js');
423
434
  const browserServerPath = path.join(__dirname, 'browser-server.js');
424
435
  // Derive agent name from folder path (e.g. ~/agents/co-founder → "co-founder")
425
436
  // config.name is the machine name, NOT the agent name
@@ -443,6 +454,15 @@ export function writeAgentFolderMcpConfig(agentFolderPath, config) {
443
454
  '--agent-id', config.agentId,
444
455
  ],
445
456
  },
457
+ 'tuna-idea': {
458
+ command: process.execPath,
459
+ args: [
460
+ ideaServerPath,
461
+ '--api-url', config.apiUrl,
462
+ '--token', config.agentToken,
463
+ '--agent-id', config.agentId,
464
+ ],
465
+ },
446
466
  'tuna-browser': {
447
467
  command: process.execPath,
448
468
  args: [browserServerPath, '--user-data-dir', path.join(process.env.HOME || '', '.config', 'tuna-browser', 'chrome-profile')],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuna-agent",
3
- "version": "0.1.96",
3
+ "version": "0.1.97",
4
4
  "description": "Tuna Agent - Run AI coding tasks on your machine",
5
5
  "bin": {
6
6
  "tuna-agent": "dist/cli/index.js"