@llamaventures/cli 1.2.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.
@@ -0,0 +1,606 @@
1
+ #!/usr/bin/env node
2
+
3
+ // llama-mcp — stdio MCP server for Llama Command. Pairs with the `llama`
4
+ // CLI in the same package; both share auth + HTTP via lib/client.mjs.
5
+ //
6
+ // Wire into Claude Code / Cursor / Claude Desktop / OpenClaw via your
7
+ // agent's MCP config — see README for snippets. Auth is identical to the
8
+ // CLI: gcloud (preferred) → $LLAMA_TOKEN → ~/.llama/token.
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
+ import { getAuthHeaders, readBriefing, request } from "../lib/client.mjs";
14
+ import {
15
+ clearExternalSession,
16
+ getExternalSessionStatus,
17
+ sendExternalMessage,
18
+ startExternalSession,
19
+ uploadExternalFile,
20
+ } from "../lib/external.mjs";
21
+
22
+ // Wrap a request() call into the MCP CallToolResult shape. Catches errors
23
+ // (NO_AUTH / 401 / 5xx / network) and surfaces them as `isError: true`
24
+ // content so the calling agent sees a clean error string instead of the
25
+ // MCP transport closing.
26
+ async function callApi(method, path, body) {
27
+ try {
28
+ const result = await request(method, path, body);
29
+ const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
30
+ return { content: [{ type: "text", text }] };
31
+ } catch (err) {
32
+ return {
33
+ content: [{ type: "text", text: `Error: ${err?.message ?? String(err)}` }],
34
+ isError: true,
35
+ };
36
+ }
37
+ }
38
+
39
+ const server = new McpServer({
40
+ name: "llama-mcp",
41
+ version: "1.0.0",
42
+ });
43
+
44
+ // ============================================================
45
+ // Auth + diagnostics
46
+ // ============================================================
47
+
48
+ server.registerTool(
49
+ "auth_status",
50
+ {
51
+ description:
52
+ "Verify Llama Command credentials and return current user identity. " +
53
+ "Call this first if any other tool returns Error[NO_AUTH] or Error[UNAUTHORIZED].",
54
+ inputSchema: {},
55
+ },
56
+ async () => {
57
+ const headers = await getAuthHeaders();
58
+ if (Object.keys(headers).length === 0) {
59
+ return {
60
+ content: [
61
+ {
62
+ type: "text",
63
+ text:
64
+ "Error[NO_AUTH]: No credentials found. Mint a token at " +
65
+ "https://command.llamaventures.vc/settings/tokens, then save the " +
66
+ "llc_... value to ~/.llama/token (mode 0600), or set $LLAMA_TOKEN.",
67
+ },
68
+ ],
69
+ isError: true,
70
+ };
71
+ }
72
+ return callApi("GET", "/api/me");
73
+ }
74
+ );
75
+
76
+ // ============================================================
77
+ // Deals — read
78
+ // ============================================================
79
+
80
+ server.registerTool(
81
+ "deal_search",
82
+ {
83
+ description:
84
+ "Search the Llama Ventures deal pipeline. Fuzzy match on company name, " +
85
+ "founders, description, founder info, notes, deal owner, source, and location. " +
86
+ "Returns up to `limit` deals (default 200, cap 1000).",
87
+ inputSchema: {
88
+ q: z.string().optional().describe("fuzzy search query across all text fields"),
89
+ companyName: z.string().optional().describe("fuzzy match on companyName only"),
90
+ founder: z.string().optional().describe("fuzzy match on founders / founderInfo"),
91
+ owner: z.string().optional().describe("fuzzy match on dealOwner"),
92
+ status: z
93
+ .string()
94
+ .optional()
95
+ .describe(
96
+ "exact match on 'Our Stage' (Sourced, First Meeting, Diligence, Partner Meeting, Term Sheet, Invested, Passed, Stalled, Future, Unknown)"
97
+ ),
98
+ theirStage: z.string().optional().describe("exact match on 'Their Stage'"),
99
+ stage: z
100
+ .string()
101
+ .optional()
102
+ .describe(
103
+ "exact match on Round (Pre-Seed, Seed, Series A, Series B, Series C+, Stealth)"
104
+ ),
105
+ limit: z.number().optional().describe("max results (default 200, cap 1000)"),
106
+ offset: z.number().optional(),
107
+ },
108
+ },
109
+ async (args = {}) => {
110
+ const params = new URLSearchParams();
111
+ for (const [k, v] of Object.entries(args)) {
112
+ if (v != null && v !== "") params.set(k, String(v));
113
+ }
114
+ return callApi("GET", `/api/deals${params.toString() ? `?${params}` : ""}`);
115
+ }
116
+ );
117
+
118
+ server.registerTool(
119
+ "deal_show",
120
+ {
121
+ description:
122
+ "Get the full canonical record for one deal by uuid. Includes status, " +
123
+ "stage, founders, owner, source, valuation, all whitelisted writable fields, " +
124
+ "and the `extra` JSONB blob.",
125
+ inputSchema: {
126
+ dealId: z.string().describe("deal uuid"),
127
+ },
128
+ },
129
+ async ({ dealId }) => callApi("GET", `/api/deals/${encodeURIComponent(dealId)}`)
130
+ );
131
+
132
+ // ============================================================
133
+ // Deals — write
134
+ // ============================================================
135
+
136
+ server.registerTool(
137
+ "deal_create",
138
+ {
139
+ description:
140
+ "Create a new pipeline deal. Source defaults to the caller's user record. " +
141
+ "Owner assignment goes through the partner-approval queue (status: pending) " +
142
+ "until a partner approves it via /partner/approvals.",
143
+ inputSchema: {
144
+ companyName: z.string(),
145
+ description: z.string().optional().describe("one-liner: what they do"),
146
+ website: z.string().optional(),
147
+ founders: z.string().optional().describe("comma-separated names"),
148
+ founderInfo: z.string().optional().describe("LinkedIn URLs / background blob"),
149
+ stage: z
150
+ .string()
151
+ .optional()
152
+ .describe("Pre-Seed | Seed | Series A | Series B | Series C+ | Stealth"),
153
+ status: z.string().optional().describe("Our Stage workflow position"),
154
+ source: z.string().optional().describe("free-form sourced-by; recommend nominating a user"),
155
+ notes: z.string().optional(),
156
+ location: z.string().optional(),
157
+ },
158
+ },
159
+ async (args) => callApi("POST", "/api/deals/create", args)
160
+ );
161
+
162
+ server.registerTool(
163
+ "deal_update",
164
+ {
165
+ description:
166
+ "Update a single whitelisted field on a deal. Writable fields: status, theirStage, " +
167
+ "notes, stage, dealOwner, source, description, website, location, founders, " +
168
+ "proposedAmount, roundSize, valuation. Logs a field_change event in deal_events.",
169
+ inputSchema: {
170
+ dealId: z.string(),
171
+ field: z.string().describe("camelCase field name (see description for whitelist)"),
172
+ value: z.union([z.string(), z.number(), z.null()]).describe("new value"),
173
+ },
174
+ },
175
+ async ({ dealId, field, value }) =>
176
+ callApi("POST", "/api/deals/update", { dealId, field, value })
177
+ );
178
+
179
+ // ============================================================
180
+ // Brief blocks
181
+ // ============================================================
182
+
183
+ server.registerTool(
184
+ "brief_blocks",
185
+ {
186
+ description:
187
+ "List all brief blocks for a deal. Blocks are typed (text, link, embed, callout) " +
188
+ "and ordered. Each has stable id, optional meta (locked, by_agent, sourceSection).",
189
+ inputSchema: {
190
+ dealId: z.string(),
191
+ },
192
+ },
193
+ async ({ dealId }) =>
194
+ callApi("GET", `/api/deals/${encodeURIComponent(dealId)}/blocks`)
195
+ );
196
+
197
+ server.registerTool(
198
+ "brief_add_text",
199
+ {
200
+ description:
201
+ "Append a markdown text block to a deal brief. Supports markdown + mermaid diagrams.",
202
+ inputSchema: {
203
+ dealId: z.string(),
204
+ heading: z.string().optional().describe("optional block heading"),
205
+ body: z.string().describe("markdown body"),
206
+ },
207
+ },
208
+ async ({ dealId, heading, body }) =>
209
+ callApi("POST", `/api/deals/${encodeURIComponent(dealId)}/blocks`, {
210
+ type: "text",
211
+ heading,
212
+ body,
213
+ })
214
+ );
215
+
216
+ server.registerTool(
217
+ "brief_add_link",
218
+ {
219
+ description:
220
+ "Append a link block to a deal brief. Server fetches og:image + title via /api/link-preview.",
221
+ inputSchema: {
222
+ dealId: z.string(),
223
+ url: z.string(),
224
+ label: z.string().optional().describe("optional human-readable label"),
225
+ },
226
+ },
227
+ async ({ dealId, url, label }) =>
228
+ callApi("POST", `/api/deals/${encodeURIComponent(dealId)}/blocks`, {
229
+ type: "link",
230
+ url,
231
+ label,
232
+ })
233
+ );
234
+
235
+ server.registerTool(
236
+ "brief_add_callout",
237
+ {
238
+ description:
239
+ "Append a callout block to a deal brief. Use for emphasized insights or warnings.",
240
+ inputSchema: {
241
+ dealId: z.string(),
242
+ tone: z.string().describe("insight | warning | info | success"),
243
+ heading: z.string().optional(),
244
+ body: z.string(),
245
+ },
246
+ },
247
+ async ({ dealId, tone, heading, body }) =>
248
+ callApi("POST", `/api/deals/${encodeURIComponent(dealId)}/blocks`, {
249
+ type: "callout",
250
+ tone,
251
+ heading,
252
+ body,
253
+ })
254
+ );
255
+
256
+ // ============================================================
257
+ // Wiki (knowledge base)
258
+ // ============================================================
259
+
260
+ server.registerTool(
261
+ "wiki_search",
262
+ {
263
+ description:
264
+ "Search the Llama Ventures internal wiki — deal context, company profiles, " +
265
+ "industry frameworks, partner-curated knowledge.",
266
+ inputSchema: {
267
+ q: z.string().describe("search query"),
268
+ },
269
+ },
270
+ async ({ q }) => callApi("GET", `/api/wiki/search?q=${encodeURIComponent(q)}`)
271
+ );
272
+
273
+ server.registerTool(
274
+ "wiki_save",
275
+ {
276
+ description:
277
+ "Create or update a wiki page. Content should be markdown with attribution " +
278
+ "blocks (**[Name · YYYY-MM-DD · source · fact|opinion]**) for traceability.",
279
+ inputSchema: {
280
+ slug: z.string().describe("kebab-case slug"),
281
+ title: z.string(),
282
+ content: z.string().describe("markdown content"),
283
+ },
284
+ },
285
+ async ({ slug, title, content }) =>
286
+ callApi("POST", "/api/wiki/save", { slug, title, content })
287
+ );
288
+
289
+ // ============================================================
290
+ // Timeline + posts
291
+ // ============================================================
292
+
293
+ server.registerTool(
294
+ "timeline",
295
+ {
296
+ description:
297
+ "Get the activity timeline for a deal — field changes, owner approvals, " +
298
+ "brief edits, posts, watcher events. Append-only audit log.",
299
+ inputSchema: {
300
+ dealId: z.string(),
301
+ },
302
+ },
303
+ async ({ dealId }) =>
304
+ callApi("GET", `/api/deals/${encodeURIComponent(dealId)}/timeline`)
305
+ );
306
+
307
+ server.registerTool(
308
+ "post",
309
+ {
310
+ description:
311
+ "Post a message to a deal's timeline. Message can include @-mentions " +
312
+ "(e.g. @<first-name> or @<email@llamaventures.vc>) — the system fires " +
313
+ "email + inbox notifications to mentioned users.",
314
+ inputSchema: {
315
+ dealId: z.string(),
316
+ message: z.string(),
317
+ },
318
+ },
319
+ async ({ dealId, message }) =>
320
+ callApi("POST", `/api/deals/${encodeURIComponent(dealId)}/posts`, { message })
321
+ );
322
+
323
+ // ============================================================
324
+ // Mentions / inbox
325
+ // ============================================================
326
+
327
+ server.registerTool(
328
+ "mentions_list",
329
+ {
330
+ description:
331
+ "List @-mentions. Default scope: unresolved mentions where the caller is the " +
332
+ "recipient. Set everyone=true for team-wide visibility (mutual observability).",
333
+ inputSchema: {
334
+ everyone: z
335
+ .boolean()
336
+ .optional()
337
+ .describe("if true, list all team mentions; otherwise just for the caller"),
338
+ includeResolved: z
339
+ .boolean()
340
+ .optional()
341
+ .describe("if true, also include already-resolved mentions"),
342
+ },
343
+ },
344
+ async ({ everyone, includeResolved } = {}) => {
345
+ const params = new URLSearchParams();
346
+ if (everyone) params.set("everyone", "1");
347
+ else params.set("for_me", "1");
348
+ if (!includeResolved) params.set("unresolved", "1");
349
+ return callApi("GET", `/api/mentions?${params}`);
350
+ }
351
+ );
352
+
353
+ // ============================================================
354
+ // Escape hatch
355
+ // ============================================================
356
+
357
+ server.registerTool(
358
+ "llama_api",
359
+ {
360
+ description:
361
+ "Generic Llama Command HTTP API passthrough. Use this for endpoints that " +
362
+ "don't yet have a typed tool. Returns raw JSON. Path must start with /api/. " +
363
+ "See https://github.com/SoujiOkita98/llama-cli for the wrapped tool list.",
364
+ inputSchema: {
365
+ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
366
+ path: z.string().describe("path starting with /api/"),
367
+ body: z
368
+ .any()
369
+ .optional()
370
+ .describe(
371
+ "request body — only used on POST / PUT / PATCH; should be a JSON-serializable object"
372
+ ),
373
+ },
374
+ },
375
+ async ({ method, path, body }) => {
376
+ if (typeof path !== "string" || !path.startsWith("/api/")) {
377
+ return {
378
+ content: [{ type: "text", text: "Error: path must start with /api/" }],
379
+ isError: true,
380
+ };
381
+ }
382
+ return callApi(method, path, body);
383
+ }
384
+ );
385
+
386
+ // ============================================================
387
+ // External pitch (founder intake) — no Llama Command token required
388
+ // ============================================================
389
+ //
390
+ // These tools let an MCP-native agent (Claude Code / Cursor / OpenClaw /
391
+ // Codex / etc.) help its user pitch a company to Llama Ventures by relaying
392
+ // the conversation through our /api/external/* surface. True A2A: the
393
+ // founder's agent talks to ours, structured intake gets captured, and a
394
+ // 12-dimension verdict is returned.
395
+ //
396
+ // Anti-abuse caps are server-enforced (5 sessions/IP/day, 3/email/day,
397
+ // 30min idle, 100 msg cap, 1M token cap, global daily cap). The MCP tools
398
+ // surface those rejections as text back to the agent.
399
+
400
+ function asTextResult(text, isError = false) {
401
+ return {
402
+ content: [{ type: "text", text }],
403
+ ...(isError ? { isError: true } : {}),
404
+ };
405
+ }
406
+
407
+ server.registerTool(
408
+ "pitch_start",
409
+ {
410
+ description:
411
+ "Start a new pitch session with Llama Ventures' intake agent. Use this " +
412
+ "when a founder (the user) wants to pitch their company to Llama. " +
413
+ "Requires their name + email. Returns a session_id; the conversation " +
414
+ "is then maintained via pitch_send_message until the agent finalizes. " +
415
+ "Caps (server-enforced): 5 sessions/IP/day, 3 sessions/email/day, " +
416
+ "30min idle timeout. No Llama Command token needed.",
417
+ inputSchema: {
418
+ name: z.string().describe("the founder's full name (max 100 chars)"),
419
+ email: z.string().describe("the founder's email (deliverable, not a disposable domain)"),
420
+ },
421
+ },
422
+ async ({ name, email }) => {
423
+ try {
424
+ const session = await startExternalSession({ name, email });
425
+ return asTextResult(
426
+ JSON.stringify(
427
+ {
428
+ session_id: session.session_id,
429
+ name: session.name,
430
+ email: session.email,
431
+ started_at: session.started_at,
432
+ note: "Session active. Use pitch_send_message to relay the founder's pitch to Llama's intake agent. Use pitch_upload_file to attach decks / one-pagers. The intake agent will auto-finalize once it has enough signal.",
433
+ },
434
+ null,
435
+ 2
436
+ )
437
+ );
438
+ } catch (err) {
439
+ return asTextResult(`Error: ${err?.message ?? String(err)}`, true);
440
+ }
441
+ }
442
+ );
443
+
444
+ server.registerTool(
445
+ "pitch_send_message",
446
+ {
447
+ description:
448
+ "Relay a message from the founder to Llama Ventures' intake agent. " +
449
+ "Returns the intake agent's reply. The intake agent will ask follow-up " +
450
+ "questions, request files (use pitch_upload_file), and eventually " +
451
+ "auto-finalize the pitch — at which point the response includes " +
452
+ "`finalize_payload` with a confirmation_summary and a 12-dimension " +
453
+ "verdict (overall green/yellow/red + per-dimension notes).",
454
+ inputSchema: {
455
+ message: z.string().describe("the founder's message (max 8000 chars)"),
456
+ },
457
+ },
458
+ async ({ message }) => {
459
+ try {
460
+ const result = await sendExternalMessage(message);
461
+ const out = {
462
+ text: result.text,
463
+ finalized: result.finalized,
464
+ finalize_payload: result.finalize_payload,
465
+ };
466
+ return asTextResult(JSON.stringify(out, null, 2));
467
+ } catch (err) {
468
+ return asTextResult(`Error: ${err?.message ?? String(err)}`, true);
469
+ }
470
+ }
471
+ );
472
+
473
+ server.registerTool(
474
+ "pitch_upload_file",
475
+ {
476
+ description:
477
+ "Attach a file (deck, one-pager, deck PDF, screenshot, etc.) to the " +
478
+ "active pitch session. Server allows pdf / pptx / ppt / docx / doc / " +
479
+ "xlsx / xls / png / jpg / webp / heic / heif / txt / md, max 50 MB, " +
480
+ "10 files per session. Returns a drive_file_id; the intake agent will " +
481
+ "pick the file up via list_uploaded_files / read_uploaded_file on its " +
482
+ "next turn (so call pitch_send_message with a one-line note like " +
483
+ "'I just uploaded our pitch deck' so the agent knows to look).",
484
+ inputSchema: {
485
+ path: z.string().describe("absolute or relative filesystem path to the file"),
486
+ },
487
+ },
488
+ async ({ path: filePath }) => {
489
+ try {
490
+ const result = await uploadExternalFile(filePath);
491
+ return asTextResult(JSON.stringify(result, null, 2));
492
+ } catch (err) {
493
+ return asTextResult(`Error: ${err?.message ?? String(err)}`, true);
494
+ }
495
+ }
496
+ );
497
+
498
+ server.registerTool(
499
+ "pitch_status",
500
+ {
501
+ description:
502
+ "Show the current pitch session state — session_id, started_at, idle " +
503
+ "minutes, finalized flag. Useful when the agent isn't sure if a " +
504
+ "session is still active.",
505
+ inputSchema: {},
506
+ },
507
+ async () => {
508
+ try {
509
+ const status = getExternalSessionStatus();
510
+ return asTextResult(JSON.stringify(status, null, 2));
511
+ } catch (err) {
512
+ return asTextResult(`Error: ${err?.message ?? String(err)}`, true);
513
+ }
514
+ }
515
+ );
516
+
517
+ server.registerTool(
518
+ "pitch_finalize",
519
+ {
520
+ description:
521
+ "Clear the local pitch session state. Note: this does not force the " +
522
+ "server-side intake agent to finalize — the agent decides that on its " +
523
+ "own once the pitch is sufficient. Use this for cleanup after a session " +
524
+ "ends, or to abandon a session early. The server-side session will " +
525
+ "naturally expire after 30min of idle.",
526
+ inputSchema: {},
527
+ },
528
+ async () => {
529
+ try {
530
+ const before = getExternalSessionStatus();
531
+ clearExternalSession();
532
+ return asTextResult(
533
+ JSON.stringify(
534
+ {
535
+ cleared: before.active,
536
+ previous_session: before.active ? before : null,
537
+ note: "Local pitch session state cleared. Server-side session may still be active for ~30min until idle timeout.",
538
+ },
539
+ null,
540
+ 2
541
+ )
542
+ );
543
+ } catch (err) {
544
+ return asTextResult(`Error: ${err?.message ?? String(err)}`, true);
545
+ }
546
+ }
547
+ );
548
+
549
+ // ============================================================
550
+ // Prompts
551
+ // ============================================================
552
+ //
553
+ // MCP-native agents discover prompts via prompts/list — they can fetch
554
+ // and adopt them without any user-side prompt engineering.
555
+
556
+ server.registerPrompt(
557
+ "agent_briefing",
558
+ {
559
+ description:
560
+ "Onboard yourself as a Llama Ventures teammate. Returns the workflow " +
561
+ "contract: identity, Pipeline First rule, content capture, autonomy " +
562
+ "levels (L0/L1/L2/L3), communication style, error recovery, CLI/MCP " +
563
+ "reference, and boundaries. Read this once, internalise it, operate " +
564
+ "accordingly. Same content as `llama agent-onboard` from the CLI.",
565
+ },
566
+ async () => {
567
+ // Gate the briefing behind a /api/me check so unauthenticated MCP
568
+ // clients can't harvest internal workflow / command surface just by
569
+ // requesting the prompt. Mirrors the CLI gate in bin/llama.mjs.
570
+ const headers = await getAuthHeaders();
571
+ let stub = null;
572
+ if (Object.keys(headers).length === 0) {
573
+ stub =
574
+ "Llama Ventures team onboarding requires credentials.\n\n" +
575
+ "Team member: run `gcloud auth login` with your @llamaventures.vc " +
576
+ "account, or mint a token at " +
577
+ "https://command.llamaventures.vc/settings/tokens and run " +
578
+ "`llama token set <llc_...>`. Then re-request this prompt.\n\n" +
579
+ "Founder / external visitor: use the `pitch_*` tools — no token required.";
580
+ } else {
581
+ try {
582
+ await request("GET", "/api/me");
583
+ } catch (err) {
584
+ stub =
585
+ "Llama Ventures team onboarding requires valid credentials. " +
586
+ "Server rejected the credentials we sent. Re-mint at " +
587
+ "https://command.llamaventures.vc/settings/tokens.";
588
+ }
589
+ }
590
+ return {
591
+ messages: [
592
+ {
593
+ role: "user",
594
+ content: { type: "text", text: stub ?? readBriefing() },
595
+ },
596
+ ],
597
+ };
598
+ }
599
+ );
600
+
601
+ // ============================================================
602
+ // Boot
603
+ // ============================================================
604
+
605
+ const transport = new StdioServerTransport();
606
+ await server.connect(transport);