@trylighthouse/mcp-server 0.2.1 → 0.2.6
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/README.md +5 -1
- package/build/api.d.ts +34 -0
- package/build/api.js +146 -12
- package/build/server.d.ts +4 -1
- package/build/server.js +207 -36
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -86,7 +86,7 @@ Records support built-in types (`company`, `person`, `deal`) and custom object t
|
|
|
86
86
|
|
|
87
87
|
| Tool | Description |
|
|
88
88
|
|------|-------------|
|
|
89
|
-
| `lighthouse_crm_list_lists` | Get
|
|
89
|
+
| `lighthouse_crm_list_lists` | Get the CRM lists in your workspace, or find one by its name |
|
|
90
90
|
| `lighthouse_crm_create_list` | Create a new list |
|
|
91
91
|
| `lighthouse_crm_get_list` | Get a single list |
|
|
92
92
|
| `lighthouse_crm_update_list` | Update a list |
|
|
@@ -201,6 +201,10 @@ Files and folders live in the workspace, not on a record. Linking one to a recor
|
|
|
201
201
|
| `lighthouse_reports_share_dashboard_with_teams` | Share a dashboard with teams |
|
|
202
202
|
| `lighthouse_reports_revoke_dashboard_from_teams` | Revoke team access to a dashboard |
|
|
203
203
|
|
|
204
|
+
## Rate limits
|
|
205
|
+
|
|
206
|
+
A `429` with the code `rate_limit_exceeded` is retried for you, up to 4 times, before the tool reports the `429` exactly as the API sent it. Before each retry the server waits at least the seconds in the `Retry-After` header (1 second if there is none, at most 30). Each retry also draws a random wait that can grow with every attempt: up to 2 seconds before the first retry, then up to 4, 8 and 16 (never more than 30), and the server waits the longer of the two. Calls refused at the same moment therefore spread out instead of all coming back together and being refused again. It never starts a retry more than 40 seconds after the first attempt, so the API still has time to answer within the 60 seconds many MCP clients allow for a tool call. When the next wait would end past those 40 seconds, the tool reports the `429` straight away. A tool call your client cancels stops waiting and is not sent again. The API refuses such a request before running it, so a refused attempt changes nothing and only the attempt it accepts is applied. No other error is retried, `credit_limit_exceeded` included, and neither is a limit set by a connected email or LinkedIn account (`rate_limited`).
|
|
207
|
+
|
|
204
208
|
## Environment Variables
|
|
205
209
|
|
|
206
210
|
| Variable | Required | Description |
|
package/build/api.d.ts
CHANGED
|
@@ -1,9 +1,34 @@
|
|
|
1
|
+
export declare const RATE_LIMIT_RETRY: Readonly<{
|
|
2
|
+
retriesMax: 4;
|
|
3
|
+
fallbackWaitMs: 1000;
|
|
4
|
+
waitMaxMs: 30000;
|
|
5
|
+
backoffBaseMs: 1000;
|
|
6
|
+
retryWindowMs: 40000;
|
|
7
|
+
}>;
|
|
8
|
+
export declare const RATE_LIMIT_CODE = "rate_limit_exceeded";
|
|
9
|
+
export interface RateLimitRetryOptions {
|
|
10
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
11
|
+
random?: () => number;
|
|
12
|
+
now?: () => number;
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
}
|
|
15
|
+
export declare function withCallSignal<T>(signal: AbortSignal | undefined, run: () => T): T;
|
|
16
|
+
export declare function callSignalOf(args: unknown[]): AbortSignal | undefined;
|
|
17
|
+
export declare function retryAfterMs(header: string | null | undefined, now?: number): number;
|
|
18
|
+
export declare function backoffCeilingMs(retry: number): number;
|
|
19
|
+
export declare function rateLimitWaitMs(header: string | null | undefined, retry: number, random?: () => number, now?: number): number;
|
|
20
|
+
export declare function isRateLimitRefusal(res: Response): Promise<boolean>;
|
|
21
|
+
export declare function fetchWithRateLimitRetry(send: () => Promise<Response>, options?: RateLimitRetryOptions): Promise<Response>;
|
|
1
22
|
export declare class LighthouseAPI {
|
|
2
23
|
private baseUrl;
|
|
3
24
|
private apiKey;
|
|
4
25
|
private currentTool;
|
|
5
26
|
constructor(apiKey?: string);
|
|
6
27
|
setCurrentTool(toolName: string | null): void;
|
|
28
|
+
private url;
|
|
29
|
+
private headers;
|
|
30
|
+
private query;
|
|
31
|
+
private send;
|
|
7
32
|
private request;
|
|
8
33
|
get(path: string, params?: Record<string, unknown>): Promise<{
|
|
9
34
|
status: number;
|
|
@@ -11,6 +36,15 @@ export declare class LighthouseAPI {
|
|
|
11
36
|
error: unknown;
|
|
12
37
|
meta?: unknown;
|
|
13
38
|
}>;
|
|
39
|
+
download(path: string, params: Record<string, unknown> | undefined, maxBytes: number): Promise<{
|
|
40
|
+
bytes: Buffer<ArrayBuffer>;
|
|
41
|
+
contentType: string;
|
|
42
|
+
} | null>;
|
|
43
|
+
downloadSigned(url: string, maxBytes: number): Promise<{
|
|
44
|
+
bytes: Buffer<ArrayBuffer>;
|
|
45
|
+
contentType: string;
|
|
46
|
+
} | null>;
|
|
47
|
+
private fetchBytes;
|
|
14
48
|
post(path: string, body?: Record<string, unknown>): Promise<{
|
|
15
49
|
status: number;
|
|
16
50
|
data: unknown;
|
package/build/api.js
CHANGED
|
@@ -1,4 +1,103 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
const DEFAULT_BASE_URL = "https://api.trylighthouse.vc";
|
|
3
|
+
export const RATE_LIMIT_RETRY = Object.freeze({
|
|
4
|
+
retriesMax: 4,
|
|
5
|
+
fallbackWaitMs: 1_000,
|
|
6
|
+
waitMaxMs: 30_000,
|
|
7
|
+
backoffBaseMs: 1_000,
|
|
8
|
+
retryWindowMs: 40_000,
|
|
9
|
+
});
|
|
10
|
+
export const RATE_LIMIT_CODE = "rate_limit_exceeded";
|
|
11
|
+
const toolCall = new AsyncLocalStorage();
|
|
12
|
+
export function withCallSignal(signal, run) {
|
|
13
|
+
return toolCall.run({ signal }, run);
|
|
14
|
+
}
|
|
15
|
+
export function callSignalOf(args) {
|
|
16
|
+
for (let i = args.length - 1; i >= 0; i -= 1) {
|
|
17
|
+
const arg = args[i];
|
|
18
|
+
const signal = arg != null && typeof arg === "object" ? arg.signal : undefined;
|
|
19
|
+
if (signal instanceof AbortSignal)
|
|
20
|
+
return signal;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const pause = (ms, signal) => new Promise((resolve) => {
|
|
25
|
+
if (signal?.aborted)
|
|
26
|
+
return resolve();
|
|
27
|
+
const done = () => {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
signal?.removeEventListener("abort", done);
|
|
30
|
+
resolve();
|
|
31
|
+
};
|
|
32
|
+
const timer = setTimeout(done, ms);
|
|
33
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
34
|
+
});
|
|
35
|
+
function cancelled(signal) {
|
|
36
|
+
const reason = signal.reason;
|
|
37
|
+
if (reason instanceof Error)
|
|
38
|
+
return reason;
|
|
39
|
+
const error = new Error("The tool call was cancelled.");
|
|
40
|
+
error.name = "AbortError";
|
|
41
|
+
return error;
|
|
42
|
+
}
|
|
43
|
+
export function retryAfterMs(header, now = Date.now()) {
|
|
44
|
+
const value = typeof header === "string" ? header.trim() : "";
|
|
45
|
+
if (/^\d+(\.\d+)?$/.test(value))
|
|
46
|
+
return Number(value) * 1000;
|
|
47
|
+
if (/[a-z]/i.test(value)) {
|
|
48
|
+
const at = Date.parse(value);
|
|
49
|
+
if (Number.isFinite(at))
|
|
50
|
+
return Math.max(0, at - now);
|
|
51
|
+
}
|
|
52
|
+
return RATE_LIMIT_RETRY.fallbackWaitMs;
|
|
53
|
+
}
|
|
54
|
+
export function backoffCeilingMs(retry) {
|
|
55
|
+
return Math.min(RATE_LIMIT_RETRY.waitMaxMs, RATE_LIMIT_RETRY.backoffBaseMs * 2 ** retry);
|
|
56
|
+
}
|
|
57
|
+
export function rateLimitWaitMs(header, retry, random = Math.random, now = Date.now()) {
|
|
58
|
+
const floor = Math.min(retryAfterMs(header, now), RATE_LIMIT_RETRY.waitMaxMs);
|
|
59
|
+
const draw = Number(random());
|
|
60
|
+
const spread = Number.isFinite(draw) ? Math.min(Math.max(draw, 0), 1) : 0;
|
|
61
|
+
return Math.ceil(Math.max(floor, spread * backoffCeilingMs(retry)));
|
|
62
|
+
}
|
|
63
|
+
export async function isRateLimitRefusal(res) {
|
|
64
|
+
if (res.status !== 429)
|
|
65
|
+
return false;
|
|
66
|
+
try {
|
|
67
|
+
const body = (await res.clone().json());
|
|
68
|
+
if (body == null || typeof body !== "object")
|
|
69
|
+
return false;
|
|
70
|
+
const error = body.error;
|
|
71
|
+
const code = error != null && typeof error === "object" ? error.code : undefined;
|
|
72
|
+
return (typeof code === "string" ? code : body.error_code) === RATE_LIMIT_CODE;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function fetchWithRateLimitRetry(send, options = {}) {
|
|
79
|
+
const sleep = options.sleep ?? pause;
|
|
80
|
+
const random = options.random ?? Math.random;
|
|
81
|
+
const now = options.now ?? Date.now;
|
|
82
|
+
const signal = options.signal;
|
|
83
|
+
const startedAt = now();
|
|
84
|
+
let res = await send();
|
|
85
|
+
for (let retry = 1; retry <= RATE_LIMIT_RETRY.retriesMax; retry += 1) {
|
|
86
|
+
if (signal?.aborted)
|
|
87
|
+
return res;
|
|
88
|
+
if (!(await isRateLimitRefusal(res)))
|
|
89
|
+
return res;
|
|
90
|
+
const wait = rateLimitWaitMs(res.headers.get("retry-after"), retry, random, now());
|
|
91
|
+
if (now() - startedAt + wait > RATE_LIMIT_RETRY.retryWindowMs)
|
|
92
|
+
return res;
|
|
93
|
+
await res.body?.cancel().catch(() => undefined);
|
|
94
|
+
await sleep(wait, signal);
|
|
95
|
+
if (signal?.aborted)
|
|
96
|
+
throw cancelled(signal);
|
|
97
|
+
res = await send();
|
|
98
|
+
}
|
|
99
|
+
return res;
|
|
100
|
+
}
|
|
2
101
|
export class LighthouseAPI {
|
|
3
102
|
baseUrl;
|
|
4
103
|
apiKey;
|
|
@@ -15,9 +114,11 @@ export class LighthouseAPI {
|
|
|
15
114
|
setCurrentTool(toolName) {
|
|
16
115
|
this.currentTool = toolName;
|
|
17
116
|
}
|
|
18
|
-
|
|
117
|
+
url(path) {
|
|
19
118
|
const versioned = (/^\/v\d+\//.test(path) || path.startsWith("/mcp/")) ? path : `/v1${path}`;
|
|
20
|
-
|
|
119
|
+
return `${this.baseUrl}${versioned}`;
|
|
120
|
+
}
|
|
121
|
+
headers() {
|
|
21
122
|
const headers = {
|
|
22
123
|
Authorization: `Bearer ${this.apiKey}`,
|
|
23
124
|
"Content-Type": "application/json",
|
|
@@ -26,23 +127,56 @@ export class LighthouseAPI {
|
|
|
26
127
|
if (this.currentTool) {
|
|
27
128
|
headers["X-Lighthouse-MCP-Tool"] = this.currentTool;
|
|
28
129
|
}
|
|
29
|
-
|
|
30
|
-
method,
|
|
31
|
-
headers,
|
|
32
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
33
|
-
});
|
|
34
|
-
const json = await res.json();
|
|
35
|
-
return json;
|
|
130
|
+
return headers;
|
|
36
131
|
}
|
|
37
|
-
|
|
38
|
-
|
|
132
|
+
query(params) {
|
|
133
|
+
return params
|
|
39
134
|
? "?" +
|
|
40
135
|
Object.entries(params)
|
|
41
136
|
.filter(([, v]) => v !== undefined && v !== null)
|
|
42
137
|
.map(([k, v]) => `${k}=${encodeURIComponent(typeof v === "object" ? JSON.stringify(v) : String(v))}`)
|
|
43
138
|
.join("&")
|
|
44
139
|
: "";
|
|
45
|
-
|
|
140
|
+
}
|
|
141
|
+
send(url, init) {
|
|
142
|
+
const signal = toolCall.getStore()?.signal;
|
|
143
|
+
const request = signal ? { ...init, signal } : init;
|
|
144
|
+
return fetchWithRateLimitRetry(() => fetch(url, request), { signal });
|
|
145
|
+
}
|
|
146
|
+
async request(method, path, body) {
|
|
147
|
+
const res = await this.send(this.url(path), {
|
|
148
|
+
method,
|
|
149
|
+
headers: this.headers(),
|
|
150
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
151
|
+
});
|
|
152
|
+
const json = await res.json();
|
|
153
|
+
return json;
|
|
154
|
+
}
|
|
155
|
+
async get(path, params) {
|
|
156
|
+
return this.request("GET", path + this.query(params));
|
|
157
|
+
}
|
|
158
|
+
async download(path, params, maxBytes) {
|
|
159
|
+
return this.fetchBytes(this.url(path) + this.query(params), this.headers(), maxBytes);
|
|
160
|
+
}
|
|
161
|
+
async downloadSigned(url, maxBytes) {
|
|
162
|
+
return this.fetchBytes(url, {}, maxBytes);
|
|
163
|
+
}
|
|
164
|
+
async fetchBytes(url, headers, maxBytes) {
|
|
165
|
+
try {
|
|
166
|
+
const res = await this.send(url, { headers });
|
|
167
|
+
if (!res.ok)
|
|
168
|
+
return null;
|
|
169
|
+
const declared = Number(res.headers.get("content-length"));
|
|
170
|
+
if (Number.isFinite(declared) && declared > maxBytes)
|
|
171
|
+
return null;
|
|
172
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
173
|
+
if (bytes.length > maxBytes)
|
|
174
|
+
return null;
|
|
175
|
+
return { bytes, contentType: (res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase() };
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
46
180
|
}
|
|
47
181
|
async post(path, body) {
|
|
48
182
|
return this.request("POST", path, body);
|
package/build/server.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
export
|
|
2
|
+
export interface CreateServerOptions {
|
|
3
|
+
privateEquity?: boolean;
|
|
4
|
+
}
|
|
5
|
+
export declare function createServer(apiKey?: string, options?: CreateServerOptions): McpServer;
|
package/build/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { LighthouseAPI } from "./api.js";
|
|
3
|
+
import { LighthouseAPI, callSignalOf, withCallSignal } from "./api.js";
|
|
4
4
|
import { leanDiscovery } from "./leanDiscovery.js";
|
|
5
5
|
function text(data) {
|
|
6
6
|
const failed = data != null && typeof data === "object" && data.error != null;
|
|
@@ -29,6 +29,44 @@ function profileSection(res, keys) {
|
|
|
29
29
|
return { ...envelope, data: section };
|
|
30
30
|
}
|
|
31
31
|
const TRANSCRIPT_WINDOW_CHARS = 40_000;
|
|
32
|
+
const FILE_CONTENT_WINDOW_CHARS = TRANSCRIPT_WINDOW_CHARS;
|
|
33
|
+
const IMAGE_BYTES_MAX = 5 * 1024 * 1024;
|
|
34
|
+
const IMAGE_CONTENT_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
35
|
+
const IMAGE_LIMIT_WORDS = `${IMAGE_BYTES_MAX / (1024 * 1024)} MB`;
|
|
36
|
+
function viewableImageType(res, pick) {
|
|
37
|
+
if (res == null || typeof res !== "object")
|
|
38
|
+
return null;
|
|
39
|
+
const envelope = res;
|
|
40
|
+
if (envelope.error != null || envelope.data == null || typeof envelope.data !== "object")
|
|
41
|
+
return null;
|
|
42
|
+
const data = envelope.data;
|
|
43
|
+
if (data.kind !== "image")
|
|
44
|
+
return null;
|
|
45
|
+
const { contentType, size } = pick(data);
|
|
46
|
+
const type = typeof contentType === "string" ? contentType.toLowerCase() : "";
|
|
47
|
+
if (!IMAGE_CONTENT_TYPES.has(type))
|
|
48
|
+
return null;
|
|
49
|
+
if (typeof size === "number" && size > IMAGE_BYTES_MAX)
|
|
50
|
+
return null;
|
|
51
|
+
return type;
|
|
52
|
+
}
|
|
53
|
+
function textWithImage(res, image, declaredType) {
|
|
54
|
+
const base = text(res);
|
|
55
|
+
if (!image)
|
|
56
|
+
return base;
|
|
57
|
+
const mimeType = IMAGE_CONTENT_TYPES.has(image.contentType) ? image.contentType : declaredType;
|
|
58
|
+
return { ...base, content: [...base.content, { type: "image", data: image.bytes.toString("base64"), mimeType }] };
|
|
59
|
+
}
|
|
60
|
+
const FILE_CONTENT_RULES = "kind is pdf, document, spreadsheet, presentation, html, csv, markdown, text or image (null for a kind that cannot be read); pages_total is a PDF's pages or a presentation's slides. " +
|
|
61
|
+
"A spreadsheet reads as one \"Sheet: <name>\" block per sheet, a row per line with its cells tab separated (a date shows as its stored serial number); a presentation as one \"Slide <n>\" block per slide. " +
|
|
62
|
+
`A long file comes back in windows of max_chars (default ${FILE_CONTENT_WINDOW_CHARS}): meta.has_more true means the file continues, so pass meta.next_offset_chars as offset_chars to read on, and never treat a window with has_more as the end of the file. ` +
|
|
63
|
+
"meta.complete false means the file is longer than can be read here and the text stops before its end. " +
|
|
64
|
+
"When text is null, reason says why (unsupported_type, image, too_large, no_text for a scan or an empty file, unreadable for a damaged or password protected one, timed_out) and note says it in words: relay the note, never guess the content. " +
|
|
65
|
+
`An image (PNG, JPEG, GIF or WebP up to ${IMAGE_LIMIT_WORDS}) comes back as the image itself after the JSON, so you can look at it. ` +
|
|
66
|
+
"Older .doc, .xls and .ppt files, archives, audio and video cannot be read.";
|
|
67
|
+
const MEETING_ACCESS_RULE = "The API key's user may read a recorded meeting (its notes and transcript) when they recorded it, were invited to it (as a guest, declined included, or as its organizer), or a teammate who recorded it shares their meeting notes with them; each occurrence of a recurring meeting is judged on its own.";
|
|
68
|
+
const MEETING_KEY_SOURCES = "from a lighthouse_inbox_calendar_meetings row, or from the meeting object of a meeting note read with lighthouse_crm_get_note, lighthouse_crm_list_notes or lighthouse_crm_search_notes";
|
|
69
|
+
const NOTE_MEETING_KEY_RULE = "meeting.meeting_key is the recorded meeting's handle, the same one a lighthouse_inbox_calendar_meetings row carries: when meeting.has_transcript is true, pass it to lighthouse_inbox_calendar_meeting_transcript for what was actually said. meeting.has_transcript and meeting.has_recording are answered for the API key's user: each is true only when that transcript or recording belongs to this note's occurrence and they may read it, which needs no access to the recorder's calendar (their role permitting). A recurring meeting shares one key and keeps only the transcript of its latest recorded occurrence, so the note of an earlier occurrence says false. lighthouse_inbox_calendar_meeting describes that same latest occurrence, so it answers for the key when either flag is true; lighthouse_inbox_calendar_meeting_notes returns the newest notes of that meeting the user may read. The API returns no recording, only whether one exists.";
|
|
32
70
|
const THREAD_MESSAGES_PAGE_DEFAULT = 15;
|
|
33
71
|
const RETIRED_INPUTS = {
|
|
34
72
|
email: "email is no longer a parameter. Use emails, a comma separated list of email addresses.",
|
|
@@ -43,18 +81,18 @@ const ADDRESS_INPUT = z.union([
|
|
|
43
81
|
}),
|
|
44
82
|
]);
|
|
45
83
|
const RECIPIENTS = z.union([ADDRESS_INPUT, z.array(ADDRESS_INPUT)]);
|
|
46
|
-
export function createServer(apiKey) {
|
|
84
|
+
export function createServer(apiKey, options = {}) {
|
|
47
85
|
const api = new LighthouseAPI(apiKey);
|
|
48
86
|
const server = new McpServer({
|
|
49
87
|
name: "lighthouse",
|
|
50
|
-
version: "0.2.
|
|
88
|
+
version: "0.2.6",
|
|
51
89
|
});
|
|
52
90
|
const originalRegister = server.registerTool.bind(server);
|
|
53
91
|
server.registerTool = (name, schemaOrConfig, handler) => {
|
|
54
92
|
const wrappedHandler = async (...args) => {
|
|
55
93
|
api.setCurrentTool(name);
|
|
56
94
|
try {
|
|
57
|
-
return await handler(...args);
|
|
95
|
+
return await withCallSignal(callSignalOf(args), () => handler(...args));
|
|
58
96
|
}
|
|
59
97
|
finally {
|
|
60
98
|
api.setCurrentTool(null);
|
|
@@ -78,7 +116,7 @@ export function createServer(apiKey) {
|
|
|
78
116
|
"investors (a company's investors, a read-only multi_record of the GLOBAL DATABASE's companies and people): contains_any, not_contains_any, contains_all, empty, not_empty; the value is a list of investor ids from lighthouse_discovery_search_entities with record_type 'investor', never CRM record ids; " +
|
|
79
117
|
"experience / education (a person's work history and schools, read-only multi_records of the GLOBAL DATABASE's companies and schools): contains_any, not_contains_any, contains_all, empty, not_empty; the value is a list of ids from lighthouse_discovery_search_entities with record_type 'company' (record_type 'school' for education), never CRM record ids; " +
|
|
80
118
|
"domain/email/phone: contains_any, not_contains_any, contains_all, empty, not_empty; " +
|
|
81
|
-
"list_entries (the record's list memberships; the value is an array of list ids from lighthouse_crm_list_lists, and the list's record_type must be this record type): contains_any, not_contains_any, contains_all, empty, not_empty (empty = in no list, not_empty = in at least one list; both take no value). A custom field can belong to ONE list: lighthouse_crm_list_attributes
|
|
119
|
+
"list_entries (the record's list memberships; the value is an array of list ids from lighthouse_crm_list_lists, and the list's record_type must be the type of the record the condition is on: this record type, or the linked record's type on a dotted path): contains_any, not_contains_any, contains_all, empty, not_empty (empty = in no list, not_empty = in at least one list; both take no value). On a dotted path it filters by the linked records' lists, quantified like every dotted field: associated_people.list_entries contains_any [<people list id>] on a company = the companies with at least one person in that list, associated_people.list_entries empty = the companies none of whose people is in a list, companies with no people included; a custom record-link field works the same way (<link field>.list_entries, the lists of the record type it links to). A custom field can belong to ONE list: lighthouse_crm_list_attributes gives it that list's list_id, it can be set only on a record already in that list, its value sits inside that list's entry under the record's list_entries rather than at the top level, and naming it in a condition or a sort answers 'List not found' when you cannot open the list. A question about the records IN a list with further conditions or an order (\"companies in list X created this month, newest first\") is ONE call: either lighthouse_crm_list_list_records with the same filters and sort, or this search with a list_entries contains_any condition on that list's id beside the other conditions; never read the whole list and narrow it by hand; " +
|
|
82
120
|
"checkbox: eq (value is true or false); " +
|
|
83
121
|
"interaction (first/last email & linkedin interaction, overall interaction-with): eq, neq, empty, not_empty; " +
|
|
84
122
|
"interaction-type (first/last interaction type): contains_any, not_contains_any, empty, not_empty. " +
|
|
@@ -109,7 +147,7 @@ export function createServer(apiKey) {
|
|
|
109
147
|
"Multiple entries sort by the first, then tie-break by the next. " +
|
|
110
148
|
"NOT every field is sortable. A field is sortable ONLY when its type (the `data_type` returned by lighthouse_crm_list_attributes) is one of: " +
|
|
111
149
|
"text, url, number, currency, percentage, rating, date, datetime, datetimez, select, status, user, checkbox, " +
|
|
112
|
-
"record, record_company_name, record_person_name, record_deal_name, growth, note, duration. " +
|
|
150
|
+
"record, record_company_name, record_person_name, record_deal_name, record_custom_object_name, growth, note, duration. " +
|
|
113
151
|
"growth (headcount_growth) sorts by ONE window: add \"timeframe\":\"1m\"|\"3m\"|\"6m\"|\"1y\" to the sort item ({\"field\":\"headcount_growth\",\"direction\":\"desc\",\"timeframe\":\"6m\"}; 3m when omitted; an unknown window comes back in invalid_sort_fields). " +
|
|
114
152
|
"note (the notes field) orders by the newest note's date, records without notes last. " +
|
|
115
153
|
"duration (time_in_stage, time_in_status, every time_in_<status field> twin) orders by the time in the current status: asc = fewest days first, records whose status is empty last; the sort item is the plain {\"field\",\"direction\"} with no extra key. " +
|
|
@@ -146,8 +184,8 @@ export function createServer(apiKey) {
|
|
|
146
184
|
"json: contains, not_contains, empty, not_empty. " +
|
|
147
185
|
"SPECIAL COMPOUND TYPES, " +
|
|
148
186
|
"signal_select (company_signals): operators contains_any/not_contains_any/contains_all, value is array of signal strings e.g. [\"new_funding_round\",\"growth_headcount\"]; " +
|
|
149
|
-
"signal_select (person_signals): operators contains_any/not_contains_any/contains_all, value is {\"signals\":[\"company_registration\",...],\"registration_countries\":[\"
|
|
150
|
-
"(every key besides signals is optional: registration_countries only filters the company_registration signal by country; " +
|
|
187
|
+
"signal_select (person_signals): operators contains_any/not_contains_any/contains_all, value is {\"signals\":[\"company_registration\",...],\"registration_countries\":[\"DE\",\"GB\",...],\"signal_dates\":{\"stealth\":\"2024-03-01\"},\"stealth_predicted_areas\":[\"fintech\",...]} " +
|
|
188
|
+
"(every key besides signals is optional: registration_countries only filters the company_registration signal by country, as UPPERCASE ISO codes, and the country is where the company was registered, and registrations exist only for the countries whose company registers we read, which lighthouse_discovery_list_locations returns as `registration_countries`, so any other country returns no people; " +
|
|
151
189
|
"signal_dates maps a signal to a 'started on/after' ISO date and applies to stealth / out_of_stealth / founded_new_company; " +
|
|
152
190
|
"stealth_predicted_areas refines the stealth signal by predicted category group, same values as person_stealth_predicted_area); " +
|
|
153
191
|
"language_select: operators contains_any/not_contains_any/contains_all, value is {\"languages\":[<ISO 639-1 two-letter codes, e.g. \"en\",\"de\",\"fr\">],\"proficiency\":[<one or more of exactly \"NATIVE_BILINGUAL\",\"EXPERT\",\"ADVANCED\",\"INTERMEDIATE\",\"BASIC\">]} " +
|
|
@@ -182,7 +220,7 @@ export function createServer(apiKey) {
|
|
|
182
220
|
"Socials: company_linkedin, company_twitter, company_facebook, company_github, company_producthunt (url; matches the profile handle/permalink with text operators). " +
|
|
183
221
|
"Investors (who invested IN this company): company_investor (investor_multi_select; ids from lighthouse_discovery_search_entities record_type 'investor'), company_investor_country (multi_select; list_locations countries), company_investor_region (multi_select; list_locations regions). " +
|
|
184
222
|
"Investments (the company AS an investor; the investments_top_* keys match the investor's TOP-RANKED preference only, e.g. their #1 country, not every investment they ever made): company_is_investor (checkbox; eq true = the company is itself an investor such as a fund or CVC, eq false = exclude investors), company_num_investments (number; total recorded investments), company_investments_top_category (multi_select; list_industries categories), company_investments_top_sdg (multi_select; list_industries sdg_categories), company_investments_top_country (multi_select; list_locations countries), company_investments_top_region (multi_select; list_locations regions), company_investments_top_stage (funding_type_multi_select; lighthouse_discovery_list_funding_types `value`, no_funding not applicable). " +
|
|
185
|
-
"Team: company_team_past_companies (work_experience_multi_select; ids from lighthouse_discovery_search_entities record_type 'company'), company_team_education (education_multi_select; ids from lighthouse_discovery_search_entities record_type 'school'),
|
|
223
|
+
"Team: company_team_past_companies (work_experience_multi_select; ids from lighthouse_discovery_search_entities record_type 'company'), company_team_education (education_multi_select; ids from lighthouse_discovery_search_entities record_type 'school'), company_team_tags (multi_select; same values as person_tags; matches a company whose current founders or executives carry the tags, and contains_all may be met by different members), company_team_language (language_select), company_team_language_proficiency (multi_select; use with company_team_language to require a proficiency level), company_team_name_origin (multi_select; list_locations countries), company_team_location_country, company_team_location_region, company_team_work_country, company_team_work_region, company_team_edu_country, company_team_edu_region (multi_select; list_locations). " +
|
|
186
224
|
"CRM (filter the global database by YOUR workspace CRM): company_internal_crm_status (checkbox; eq true = already in your CRM, false = not), company_crm_list_entries (multi_select; companies that are in specific CRM company lists, list ids from lighthouse_crm_list_lists), company_crm_deals_list_entries (multi_select; companies whose deals are in specific deal lists), company_crm_team_network (multi_user; companies whose team members are connected to specific workspace users, ids from lighthouse_workspace_list_users), company_crm_team_list_entries (multi_select; companies whose team members are in specific people lists), company_external_crm_status (checkbox; needs a connected external CRM), company_external_crm_lists (multi_select; needs a connected external CRM). " +
|
|
187
225
|
'NESTED-GROUP EXAMPLE for "headcount 10-50 AND (HQ in US or UK) AND raised Series A": ' +
|
|
188
226
|
'{"operator":"AND","conditions":[{"field":"company_headcount","operator":"between","value":{"min":10,"max":50}},{"field":"company_last_funding_type","operator":"contains_any","value":["series_a"]}],"groups":[{"operator":"OR","conditions":[{"field":"company_hq_country","operator":"contains_any","value":["US","GB"]}],"groups":[]}]} ' +
|
|
@@ -191,8 +229,8 @@ export function createServer(apiKey) {
|
|
|
191
229
|
"Basics: person_name (short_text), person_created_at (date). " +
|
|
192
230
|
"Investments (the person AS an investor, e.g. an angel; the investments_top_* keys match the investor's TOP-RANKED preference only, e.g. their #1 country, not every investment they ever made): person_is_investor (checkbox; eq true = only investors, eq false = exclude investors), person_num_investments (number; total recorded investments), person_investments_top_category (multi_select; list_industries categories), person_investments_top_sdg (multi_select; list_industries sdg_categories), person_investments_top_country (multi_select; list_locations countries), person_investments_top_region (multi_select; list_locations regions), person_investments_top_stage (funding_type_multi_select; lighthouse_discovery_list_funding_types `value`, no_funding not applicable). " +
|
|
193
231
|
"Industry: person_current_categories, person_past_categories (multi_select; values from lighthouse_discovery_list_industries → categories), person_current_sdg_impact, person_past_sdg_impact (multi_select; list_industries → sdg_categories). " +
|
|
194
|
-
"Signals: person_signals (signal_select; value {\"signals\":[...],\"registration_countries\"?:[...],\"signal_dates\"?:{<signal>:\"YYYY-MM-DD\"},\"stealth_predicted_areas\"?:[...]}), person_registration_country (multi_select; filters company_registration signals by country,
|
|
195
|
-
"Tags: person_tags (multi_select; values: phd_doctorate, 2nd_time_founder, serial_founder, has_exit, raised_funding, sales_exp, co_exp, eng_exp, ai_and_data_exp, product_exp, hr_exp). " +
|
|
232
|
+
"Signals: person_signals (signal_select; value {\"signals\":[...],\"registration_countries\"?:[...],\"signal_dates\"?:{<signal>:\"YYYY-MM-DD\"},\"stealth_predicted_areas\"?:[...]}), person_registration_country (multi_select; filters company_registration signals by country, values from lighthouse_discovery_list_locations → registration_countries, the countries whose company registers we read; the country is where the company was registered, so any other country returns no people). " +
|
|
233
|
+
"Tags: person_tags (multi_select; values: phd_doctorate, master, bachelor, technical, 2nd_time_founder, serial_founder, has_exit, raised_funding, sales_exp, co_exp, eng_exp, ai_and_data_exp, product_exp, hr_exp). " +
|
|
196
234
|
"Language & origin: person_language (language_select), person_language_proficiency (multi_select; use with person_language to require a proficiency level), person_name_origin (multi_select; list_locations countries). " +
|
|
197
235
|
"Current location: person_country (multi_select; UPPERCASE ISO alpha-2 codes e.g. \"US\", \"GB\" from lighthouse_discovery_list_locations → countries), person_region (multi_select; list_locations → regions), person_german_state (multi_select; German federal states). " +
|
|
198
236
|
"Experience: person_past_companies (work_experience_multi_select; ids from lighthouse_discovery_search_entities record_type 'company'), person_education (education_multi_select; ids from lighthouse_discovery_search_entities record_type 'school'), person_experience_start_date, person_experience_end_date (date; matches people with at least one work experience whose start/end date passes the bound, independent of the other experience conditions), person_work_country, person_work_region, person_edu_country, person_edu_region (multi_select; list_locations). " +
|
|
@@ -204,13 +242,13 @@ export function createServer(apiKey) {
|
|
|
204
242
|
"(an outer AND of two OR groups; country codes from list_locations).";
|
|
205
243
|
server.registerTool("lighthouse_crm_list_attributes", {
|
|
206
244
|
title: "List CRM Attributes",
|
|
207
|
-
description: "Get a lightweight catalog of the fields for a CRM record type: each field's permalink, label, data_type, links_to, read_only, is_custom, has_options, and
|
|
245
|
+
description: "Get a lightweight catalog of the fields for a CRM record type: each field's permalink, label, data_type, links_to, read_only, is_custom, has_options, source_field and list_id. " +
|
|
208
246
|
"`source_field` names the status attribute a `duration` attribute is derived from (null on every other attribute); such attributes (time_in_stage on deals, time_in_status on tasks, time_in_<status> beside a custom status field) are read-only and computed, never written. " +
|
|
209
247
|
"ALWAYS call this before building filters for search_records / list_records or creating/updating views. Use the exact permalinks it returns. " +
|
|
210
248
|
"To keep the response small it does NOT include select/multi_select option values: when a field has has_options=true, call lighthouse_crm_list_options with that permalink to get its allowed values before filtering or setting it. " +
|
|
211
249
|
"Supports built-in types (company, person, deal, task) and custom objects (use the object's permalink, e.g. 'lp', 'project'). " +
|
|
212
250
|
"When called without record_type, returns all types including custom objects as top-level keys. " +
|
|
213
|
-
"A custom field that belongs to a single list
|
|
251
|
+
"A custom field that belongs to a single list carries that list's list_id: it can be set only on a record already in that list (add the record with lighthouse_crm_add_to_list first), and its value sits in that list's entry under the record's list_entries. Without list_id every list's fields are listed; passing list_id keeps the fields of every record plus that list's own. A list you cannot open answers 'List not found', and so does filtering or sorting on a field of such a list.",
|
|
214
252
|
inputSchema: {
|
|
215
253
|
record_type: z
|
|
216
254
|
.string()
|
|
@@ -518,7 +556,7 @@ export function createServer(apiKey) {
|
|
|
518
556
|
description: "The AI reports generated on one CRM record (any record type: company, person, deal, or a custom object permalink), newest first: the memos, SWOTs, teardowns, pass memos and briefs a member generated from the record's AI Reports tab in Lighthouse. " +
|
|
519
557
|
"WHEN TO USE: the user asks whether a memo or report already exists on a record, what the last one said, wants to compare or build on an earlier report, or asks about a company's \"memo\" / \"analysis\" / \"write-up\" (check here before writing one from scratch). Not for dashboards or charts (those are lighthouse_reports_*), and nothing here generates a report: that happens in the app. " +
|
|
520
558
|
"Each row: id, name, status (processing / success / error), depth (short / standard / extensive), template, partial (true when the run hit a cap), summary (one paragraph of the report's own prose), created_by ({ id, first_name, last_name, full_name, avatar_url, email }, the same member object every other row carries), created_at, completed_at. Only a success report has a document: read it with lighthouse_crm_get_ai_report. " +
|
|
521
|
-
"A report quoting a mailbox, calendar, LinkedIn account or file the API key's user cannot read is left out of the list (its author always sees it).",
|
|
559
|
+
"A report quoting a mailbox, calendar, meeting notes, LinkedIn account or file the API key's user cannot read is left out of the list (its author always sees it).",
|
|
522
560
|
inputSchema: {
|
|
523
561
|
record_type: z.string().describe("Record type: company, person, deal, or a custom object permalink"),
|
|
524
562
|
record_id: z.string().uuid().optional().describe("Record ID whose reports to list"),
|
|
@@ -540,7 +578,7 @@ export function createServer(apiKey) {
|
|
|
540
578
|
title: "Get AI Report",
|
|
541
579
|
description: "One AI report by id (from lighthouse_crm_list_ai_reports): the same metadata plus record_type / record_id and content, the full document as Markdown with its section headings (citation markers removed). " +
|
|
542
580
|
"WHEN TO USE: the user wants what an earlier memo or report actually says (a section, a number, a recommendation), or a comparison with it. A report can be long: quote or summarise the part the question is about rather than echoing it whole. " +
|
|
543
|
-
"content is null while the report is still processing or failed. Answers 403 naming the channel when the report draws on a shared mailbox, calendar, LinkedIn account or document the API key's user cannot read, and 404 for an unknown id.",
|
|
581
|
+
"content is null while the report is still processing or failed. Answers 403 naming the channel when the report draws on a shared mailbox, calendar, meeting notes, LinkedIn account or document the API key's user cannot read, and 404 for an unknown id.",
|
|
544
582
|
inputSchema: {
|
|
545
583
|
id: z.string().uuid().describe("Report ID from lighthouse_crm_list_ai_reports"),
|
|
546
584
|
},
|
|
@@ -790,14 +828,17 @@ export function createServer(apiKey) {
|
|
|
790
828
|
server.registerTool("lighthouse_crm_list_lists", {
|
|
791
829
|
title: "List CRM Lists",
|
|
792
830
|
description: "Get all CRM lists in your workspace. Each list's record_type is 'company', 'person', 'deal', or a custom object permalink (e.g. 'lp', 'project'), and that is the record type of everything in it. " +
|
|
793
|
-
"Each row carries owner (the member who created it) and is_owner (true when that member is the API key's user), so 'my lists' and 'lists shared with me' can be told apart without a second call."
|
|
831
|
+
"Each row carries owner (the member who created it) and is_owner (true when that member is the API key's user), so 'my lists' and 'lists shared with me' can be told apart without a second call. " +
|
|
832
|
+
"WHEN TO USE: to find a list someone names (\"the Deals Reviewed list\"), pass search with the words of its name and read the id off the matching row, instead of paging through every list; leave search out to page through all of them. meta.total counts the lists that matched.",
|
|
794
833
|
inputSchema: {
|
|
834
|
+
search: z.string().optional().describe("Only lists whose name contains every one of these words, in any case. A list with a similar name is a different list."),
|
|
835
|
+
query: z.string().optional().describe("Same as search. Either name works; search wins when both are sent."),
|
|
795
836
|
limit: z.number().min(1).max(100).default(25),
|
|
796
837
|
offset: z.number().min(0).default(0),
|
|
797
838
|
},
|
|
798
839
|
}, async (params) => {
|
|
799
840
|
try {
|
|
800
|
-
const res = await api.
|
|
841
|
+
const res = await api.post("/lists/search", params);
|
|
801
842
|
return text(res);
|
|
802
843
|
}
|
|
803
844
|
catch (e) {
|
|
@@ -1016,7 +1057,7 @@ export function createServer(apiKey) {
|
|
|
1016
1057
|
title: "List Notes",
|
|
1017
1058
|
description: "Get notes for a CRM record. Each note in the response includes a `records` map, every record (company/person/deal/custom object) the note is currently linked to, plus a `tags` array of any workspace tags attached to the note (each with id, label, color). " +
|
|
1018
1059
|
"`tagged_users` is the teammates the note MENTIONS, each the member object this API answers for a person ({ id, first_name, last_name, full_name, avatar_url, email }), and [] when it mentions nobody. It is read from the mention spans in `content`, not from the ids a write sent, so a note written with tagged_users ids and no mention span reads back []: always write both, as lighthouse_crm_create_note and lighthouse_crm_update_note say. " +
|
|
1019
|
-
"Every note also carries `type` ('standard' for a note a member wrote, 'meeting' for meeting notes written from a recorded meeting) and `meeting` (null on a standard note; on a meeting note { title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording }, where attendees is [{ email, name }] with name possibly null, call_type is a short category from a fixed list such as 'First founder call' or 'Internal discussion',
|
|
1060
|
+
"Every note also carries `type` ('standard' for a note a member wrote, 'meeting' for meeting notes written from a recorded meeting) and `meeting` (null on a standard note; on a meeting note { meeting_key, title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording }, where attendees is [{ email, name }] with name possibly null, call_type is a short category from a fixed list such as 'First founder call' or 'Internal discussion', duration_seconds and ical_uid are null when unknown and attendees is [] when there are none). " + NOTE_MEETING_KEY_RULE + " Meeting notes are included only when the API key's user may read the meeting. " + MEETING_ACCESS_RULE,
|
|
1020
1061
|
inputSchema: {
|
|
1021
1062
|
record_id: z.string().uuid().describe("Record ID to list notes for"),
|
|
1022
1063
|
limit: z.number().min(1).max(100).default(25),
|
|
@@ -1043,7 +1084,7 @@ export function createServer(apiKey) {
|
|
|
1043
1084
|
"type (select: contains_any / not_contains_any ONLY, with an array of \"standard\" and / or \"meeting\"; [\"meeting\"] selects meeting notes written from a recorded meeting, [\"standard\"] the notes members wrote; any other value is rejected). " +
|
|
1044
1085
|
"An unknown field or invalid operator is rejected with a clear error naming it. " +
|
|
1045
1086
|
"`tagged_users` is the teammates the note MENTIONS, each the member object this API answers for a person ({ id, first_name, last_name, full_name, avatar_url, email }), and [] when it mentions nobody. It is read from the mention spans in `content`, not from the ids a write sent, so a note written with tagged_users ids and no mention span reads back []: always write both, as lighthouse_crm_create_note and lighthouse_crm_update_note say. " +
|
|
1046
|
-
"Every returned note carries `type` ('standard' | 'meeting') and `meeting` (null on a standard note; on a meeting note { title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording }; attendees is [{ email, name }] with name possibly null; duration_seconds
|
|
1087
|
+
"Every returned note carries `type` ('standard' | 'meeting') and `meeting` (null on a standard note; on a meeting note { meeting_key, title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording }; attendees is [{ email, name }] with name possibly null; duration_seconds and ical_uid are null when unknown and attendees is [] when there are none). " + NOTE_MEETING_KEY_RULE + " Meeting notes are included only when the API key's user may read the meeting. " + MEETING_ACCESS_RULE,
|
|
1047
1088
|
inputSchema: {
|
|
1048
1089
|
search: z.string().optional().describe("Free-text search on the note title"),
|
|
1049
1090
|
filters: z.record(z.any()).optional().describe("CRM-style filter object for notes (see tool description for fields + operators)"),
|
|
@@ -1103,7 +1144,7 @@ export function createServer(apiKey) {
|
|
|
1103
1144
|
title: "Get Note",
|
|
1104
1145
|
description: "Get a single note by ID. Response includes a `records` object keyed by record type (or custom object permalink) with every linked record, plus a `tags` array of any workspace tags attached to the note (each with permalink, label, color). " +
|
|
1105
1146
|
"`tagged_users` is the teammates the note MENTIONS, each the member object this API answers for a person ({ id, first_name, last_name, full_name, avatar_url, email }), and [] when it mentions nobody. It is read from the mention spans in `content`, not from the ids a write sent, so a note written with tagged_users ids and no mention span reads back []: always write both, as lighthouse_crm_create_note and lighthouse_crm_update_note say. " +
|
|
1106
|
-
"`type` is 'standard' for a note a member wrote and 'meeting' for meeting notes written from a recorded meeting; `meeting` is null on a standard note and { title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording } on a meeting note: title and occurred_at come from the meeting itself, attendees is [{ email, name }] with name possibly null, call_type is one of 'First founder call', 'Follow-up founder call', 'Diligence call', 'Re-engagement', 'Portfolio check-in', 'Board meeting', 'Investment committee', 'LP meeting', 'Fundraising call', 'Co-investor or syndication call', 'Internal discussion', 'Hiring or reference call', 'Partnership or vendor call' or 'Other',
|
|
1147
|
+
"`type` is 'standard' for a note a member wrote and 'meeting' for meeting notes written from a recorded meeting; `meeting` is null on a standard note and { meeting_key, title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording } on a meeting note: title and occurred_at come from the meeting itself, attendees is [{ email, name }] with name possibly null, call_type is one of 'First founder call', 'Follow-up founder call', 'Diligence call', 'Re-engagement', 'Portfolio check-in', 'Board meeting', 'Investment committee', 'LP meeting', 'Fundraising call', 'Co-investor or syndication call', 'Internal discussion', 'Hiring or reference call', 'Partnership or vendor call' or 'Other', duration_seconds and ical_uid are null when unknown, and attendees is [] when there are none. " + NOTE_MEETING_KEY_RULE + " A meeting note is returned only when the API key's user may read the meeting; otherwise it is reported as not found. " + MEETING_ACCESS_RULE,
|
|
1107
1148
|
inputSchema: {
|
|
1108
1149
|
note_id: z.string().uuid().optional().describe("Note ID"),
|
|
1109
1150
|
id: z.string().uuid().optional().describe("Same as note_id, the name this tool has always taken. Send either one."),
|
|
@@ -1575,7 +1616,7 @@ export function createServer(apiKey) {
|
|
|
1575
1616
|
});
|
|
1576
1617
|
server.registerTool("lighthouse_documents_get_file", {
|
|
1577
1618
|
title: "Get File",
|
|
1578
|
-
description: "Get file metadata. Response includes my_role ('owner' | 'editor' | 'viewer'), is_owner, owner (the member who owns it: { id, first_name, last_name, full_name, avatar_url, email }), sharing ('private' or 'workspace'), linked_records_count, linked_records[] and shared_with_teams[] (each entry has { team_id, team_name, role, granted_at }). Pass download_url=true to also receive a short-lived signed read URL (expires in 15 minutes).",
|
|
1619
|
+
description: "Get file metadata. Response includes my_role ('owner' | 'editor' | 'viewer'), is_owner, owner (the member who owns it: { id, first_name, last_name, full_name, avatar_url, email }), sharing ('private' or 'workspace'), linked_records_count, linked_records[] and shared_with_teams[] (each entry has { team_id, team_name, role, granted_at }). Pass download_url=true to also receive a short-lived signed read URL (expires in 15 minutes). To read what the file SAYS, use lighthouse_documents_get_file_content.",
|
|
1579
1620
|
inputSchema: {
|
|
1580
1621
|
file_id: z.string().uuid().describe("File UUID"),
|
|
1581
1622
|
download_url: z.boolean().optional().describe("Include a short-lived signed download URL"),
|
|
@@ -1590,6 +1631,39 @@ export function createServer(apiKey) {
|
|
|
1590
1631
|
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1591
1632
|
}
|
|
1592
1633
|
});
|
|
1634
|
+
server.registerTool("lighthouse_documents_get_file_content", {
|
|
1635
|
+
title: "Read File",
|
|
1636
|
+
description: "What one stored file SAYS, as text: PDFs, Word, Excel and PowerPoint files (and their OpenDocument versions), text, CSV and HTML. " +
|
|
1637
|
+
"WHEN TO USE: the answer is inside a document the workspace keeps (\"what are the conditions in the term sheet\", \"summarise the memo on this deal\", \"what does the deck say about revenue\"). " +
|
|
1638
|
+
"Take the file id from lighthouse_documents_list or lighthouse_documents_list_for_record. " +
|
|
1639
|
+
"Returns the file's row (the one lighthouse_documents_get_file returns: id, name, folder_id, size_bytes, content_type, sharing, owner, ...) followed by kind, pages_total, text, reason and note. " +
|
|
1640
|
+
FILE_CONTENT_RULES +
|
|
1641
|
+
" A file the user may not open answers forbidden, one that does not exist not found.",
|
|
1642
|
+
inputSchema: {
|
|
1643
|
+
file_id: z.string().uuid().optional().describe("File UUID. The name every other file tool takes."),
|
|
1644
|
+
id: z.string().uuid().optional().describe("Same as file_id. Send either one."),
|
|
1645
|
+
offset_chars: z.number().min(0).default(0).describe("Character offset to start from (meta.next_offset_chars of the previous window)"),
|
|
1646
|
+
max_chars: z.number().min(1).default(FILE_CONTENT_WINDOW_CHARS).describe("Characters per window"),
|
|
1647
|
+
},
|
|
1648
|
+
}, async (params) => {
|
|
1649
|
+
try {
|
|
1650
|
+
const fileId = requireId(params.file_id, params.id, "file_id");
|
|
1651
|
+
const res = await api.get(`/v1/documents/files/${fileId}/content`, {
|
|
1652
|
+
offset_chars: params.offset_chars,
|
|
1653
|
+
max_chars: params.max_chars,
|
|
1654
|
+
});
|
|
1655
|
+
const imageType = viewableImageType(res, (data) => ({ contentType: data.content_type, size: data.size_bytes }));
|
|
1656
|
+
if (!imageType)
|
|
1657
|
+
return text(res);
|
|
1658
|
+
const signed = await api.get(`/v1/documents/files/${fileId}`, { download_url: "1" });
|
|
1659
|
+
const url = signed?.data?.download_url;
|
|
1660
|
+
const image = typeof url === "string" ? await api.downloadSigned(url, IMAGE_BYTES_MAX) : null;
|
|
1661
|
+
return textWithImage(res, image, imageType);
|
|
1662
|
+
}
|
|
1663
|
+
catch (e) {
|
|
1664
|
+
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1665
|
+
}
|
|
1666
|
+
});
|
|
1593
1667
|
server.registerTool("lighthouse_documents_update_file", {
|
|
1594
1668
|
title: "Update File",
|
|
1595
1669
|
description: "Rename, move, or change sharing on a file. Editors can rename/move-between-folders and delete; only the owner can move to root, change sharing, or grant team access. " +
|
|
@@ -2150,9 +2224,11 @@ export function createServer(apiKey) {
|
|
|
2150
2224
|
description: "Get available location options (countries and regions) for discovery filters. " +
|
|
2151
2225
|
"ALWAYS call this to get valid country/region values before filtering by location fields " +
|
|
2152
2226
|
"(company_hq_country, company_hq_region, person_country, person_region, company_investor_country, etc.). " +
|
|
2153
|
-
"Returns {countries: [{name, value, flag}], regions: [{name, value, flag}]}. " +
|
|
2227
|
+
"Returns {countries: [{name, value, flag}], regions: [{name, value, flag}], registration_countries: [{name, value, flag}]}. " +
|
|
2154
2228
|
"Use 'value' (alpha-2 codes for countries e.g. 'DE' for Germany, IDs for regions) " +
|
|
2155
|
-
"in filter conditions with contains_any/not_contains_any operators."
|
|
2229
|
+
"in filter conditions with contains_any/not_contains_any operators. " +
|
|
2230
|
+
"registration_countries are the countries whose company registers we read: the only values that can match person_registration_country " +
|
|
2231
|
+
"and the registration_countries of the company_registration person signal, so any other country returns no people.",
|
|
2156
2232
|
inputSchema: {},
|
|
2157
2233
|
}, async () => {
|
|
2158
2234
|
try {
|
|
@@ -2868,7 +2944,7 @@ export function createServer(apiKey) {
|
|
|
2868
2944
|
"body (the full HTML, null when there is none), reply_to_message_id (the message this one answers, when the mailbox states it), " +
|
|
2869
2945
|
"is_read, is_starred, attachments [{ id, message_id, type (image | video | audio | file | other), filename, content_type, size }], " +
|
|
2870
2946
|
"inline_images (the files embedded in the body: the same keys plus cid), account { id, name, email, is_owner } }. Every key is always present. " +
|
|
2871
|
-
"Read text when you need what was said and body only when the markup matters. " +
|
|
2947
|
+
"Read text when you need what was said and body only when the markup matters. To read what an attached file SAYS, pass its id, its message_id and the message's account.id to lighthouse_inbox_email_attachment. " +
|
|
2872
2948
|
"When the user can see only the metadata of that account, text and body are null, both file lists are empty and data.has_limited_access is true (false otherwise). Thread ids are " +
|
|
2873
2949
|
"account-specific: BOTH thread_id AND account_id (the thread row's account.id from " +
|
|
2874
2950
|
"lighthouse_inbox_email_threads) are required. " +
|
|
@@ -2899,6 +2975,38 @@ export function createServer(apiKey) {
|
|
|
2899
2975
|
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
2900
2976
|
}
|
|
2901
2977
|
});
|
|
2978
|
+
server.registerTool("lighthouse_inbox_email_attachment", {
|
|
2979
|
+
title: "Read Email Attachment",
|
|
2980
|
+
description: "What one email attachment SAYS, as text: PDFs, Word, Excel and PowerPoint files (and their OpenDocument versions), text, CSV and HTML. " +
|
|
2981
|
+
"WHEN TO USE: the user asks what an attached file contains (\"summarise the report Jane sent\", \"what are the numbers in the attached model\", \"check the attachment on that email\"). " +
|
|
2982
|
+
"Find the message first: lighthouse_inbox_email_threads (a subject, a sender, a date), then lighthouse_inbox_email_thread, whose messages carry attachments [{ id, message_id, filename, ... }] and account { id }. Pass that attachment's id as attachment_id, its message_id, and the message's account.id as account_id. " +
|
|
2983
|
+
"Returns { attachment { id, message_id, type, filename, content_type, size }, message { id, thread_id, subject }, account { id, name, email, is_owner }, kind, pages_total, text, reason, note }. " +
|
|
2984
|
+
FILE_CONTENT_RULES +
|
|
2985
|
+
" An attachment in an account the user may see only the metadata of answers not found.",
|
|
2986
|
+
inputSchema: {
|
|
2987
|
+
message_id: z.string().describe("The message_id on the attachment (the message that carries it)"),
|
|
2988
|
+
attachment_id: z.string().describe("The attachment's id, from a message's attachments (or inline_images) in lighthouse_inbox_email_thread"),
|
|
2989
|
+
account_id: z.string().uuid().describe("Required: the account.id of that message (attachment ids belong to one account)"),
|
|
2990
|
+
offset_chars: z.number().min(0).default(0).describe("Character offset to start from (meta.next_offset_chars of the previous window)"),
|
|
2991
|
+
max_chars: z.number().min(1).default(FILE_CONTENT_WINDOW_CHARS).describe("Characters per window"),
|
|
2992
|
+
},
|
|
2993
|
+
}, async ({ message_id, attachment_id, account_id, offset_chars, max_chars }) => {
|
|
2994
|
+
try {
|
|
2995
|
+
const path = `/inbox/email/messages/${encodeURIComponent(message_id)}/attachments/${encodeURIComponent(attachment_id)}`;
|
|
2996
|
+
const res = await api.get(`${path}/content`, { account_id, offset_chars, max_chars });
|
|
2997
|
+
const imageType = viewableImageType(res, (data) => {
|
|
2998
|
+
const attachment = (data.attachment ?? {});
|
|
2999
|
+
return { contentType: attachment.content_type, size: attachment.size };
|
|
3000
|
+
});
|
|
3001
|
+
if (!imageType)
|
|
3002
|
+
return text(res);
|
|
3003
|
+
const image = await api.download(path, { account_id }, IMAGE_BYTES_MAX);
|
|
3004
|
+
return textWithImage(res, image, imageType);
|
|
3005
|
+
}
|
|
3006
|
+
catch (e) {
|
|
3007
|
+
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3008
|
+
}
|
|
3009
|
+
});
|
|
2902
3010
|
server.registerTool("lighthouse_inbox_email_create_draft", {
|
|
2903
3011
|
title: "Create Email Draft",
|
|
2904
3012
|
description: "Save an email draft in one of the user's mailboxes. The draft is NOT sent: it lands in the mailbox for the " +
|
|
@@ -3022,8 +3130,8 @@ export function createServer(apiKey) {
|
|
|
3022
3130
|
description: "Calendars the API key's user can read: their own connected calendar (is_owner true) and colleagues' calendars shared with them. " +
|
|
3023
3131
|
"The same account object the email and LinkedIn channels return: { id, name, email, is_owner, provider (google | microsoft | other), " +
|
|
3024
3132
|
"status (connected | pending | disconnected), status_reason (credentials | error | stopped | other; null unless disconnected), " +
|
|
3025
|
-
"owner, permissions { calendar_view }, created_at }. " +
|
|
3026
|
-
"A calendar is the same connected account as a mailbox, behind its own sharing switch, so a mailbox shared for mail only is NOT listed here. " +
|
|
3133
|
+
"owner, permissions { calendar_view, meeting_notes_view }, created_at }: calendar_view reads the calendar's meetings, meeting_notes_view reads the notes and transcripts of the meetings its owner recorded, including meetings the user was not invited to. " +
|
|
3134
|
+
"A calendar is the same connected account as a mailbox, behind its own sharing switch, so a mailbox shared for mail only is NOT listed here, and neither is a calendar shared for meeting notes only: those notes come through the note tools, and each one's meeting.meeting_key opens its transcript. " +
|
|
3027
3135
|
"Use scope 'me' to get only the user's own calendar: the SAME object, or null. " +
|
|
3028
3136
|
"Use it to know whose meetings lighthouse_inbox_calendar_meetings can see, or to get an account_id to narrow that tool to one calendar. Returns [] when no calendar is connected.",
|
|
3029
3137
|
inputSchema: {
|
|
@@ -3044,7 +3152,7 @@ export function createServer(apiKey) {
|
|
|
3044
3152
|
description: "Calendar meetings across every calendar the API key's user can read (their own, plus colleagues' calendars shared with them), read live from the calendar. PAST meetings newest first by default (the last 6 months; pass months up to 36, or after / before, to move the window), or the SCHEDULED ones soonest first with upcoming=true (90 days ahead by default; an explicit before reaches further). " +
|
|
3045
3153
|
"Narrow by CRM record (record_id + record_type, company or person: the record's addresses and domains are matched), by emails / domains lists (comma separated), by words in the meeting TITLE (query; never the notes), or by one calendar (account_id from lighthouse_inbox_calendar_accounts). No filter lists the user's whole calendar over the window. " +
|
|
3046
3154
|
"WHEN TO USE: \"when did we last meet X\", \"what meetings do I have next week\", \"have we talked to anyone at acme.com\", \"which calls with this company were recorded\". To find what was DISCUSSED about a topic, list the meetings and read the notes of the likely ones. " +
|
|
3047
|
-
"Each row: { id, meeting_key (only when the meeting was recorded), ical_uid, title, description, location, start, end, is_all_day, is_recurring (a recurring meeting appears once per occurrence), status (confirmed | tentative | cancelled | null), meeting_url (the video link), organizer { name, email }, attendees [{ name, email, response_status }] (everyone invited, the calendar's own account included; response_status is accepted | declined | tentative | needs_action, where needs_action means that attendee has not answered yet; a missing title, description, location, name or email is null, never an empty string), account { id, name, email, is_owner } (the calendar it was read from), recording: null when not recorded, else { has_recording, has_transcript, has_notes, duration_seconds, language, recorded_by }, the same object lighthouse_inbox_calendar_meeting returns (recorded_by is the recorder as a workspace member { id, first_name, last_name, full_name, avatar_url, email }, or null; has_notes is true only when notes are ready and the user may
|
|
3155
|
+
"Each row: { id, meeting_key (only when the meeting was recorded), ical_uid, title, description, location, start, end, is_all_day, is_recurring (a recurring meeting appears once per occurrence), status (confirmed | tentative | cancelled | null), meeting_url (the video link), organizer { name, email }, attendees [{ name, email, response_status }] (everyone invited, the calendar's own account included; response_status is accepted | declined | tentative | needs_action, where needs_action means that attendee has not answered yet; a missing title, description, location, name or email is null, never an empty string), account { id, name, email, is_owner } (the calendar it was read from), recording: null when not recorded, else { has_recording, has_transcript, has_notes, duration_seconds, language, recorded_by }, the same object lighthouse_inbox_calendar_meeting returns (recorded_by is the recorder as a workspace member { id, first_name, last_name, full_name, avatar_url, email }, or null; has_notes is true only when notes are ready and the user may read them) }. " +
|
|
3048
3156
|
"A row with meeting_key is a recorded meeting: pass it to lighthouse_inbox_calendar_meeting_notes for what was discussed and decided, or lighthouse_inbox_calendar_meeting_transcript for what was said. " +
|
|
3049
3157
|
"Paged with a CURSOR: pass meta.next_cursor as cursor (same filters) while meta.has_more is true; the window is walked from its near end in 30-day steps, so the first page always holds the newest (or soonest) meetings. A date-only before includes that whole day. meta.window { after, before } is the window that was read. meta.accounts lists every calendar the user can see, one entry of { id, name, email, is_owner, ok, error, count } (a calendar that could not be read is ok false with its error code while the others are still read). meta.complete false means a step could not be fully scanned in time or a calendar failed: filter by record, emails or domains, or narrow the window. meta.note explains an empty or partial result (no calendar connected, a record with no addresses, a calendar that could not be read).",
|
|
3050
3158
|
inputSchema: {
|
|
@@ -3074,10 +3182,10 @@ export function createServer(apiKey) {
|
|
|
3074
3182
|
});
|
|
3075
3183
|
server.registerTool("lighthouse_inbox_calendar_meeting", {
|
|
3076
3184
|
title: "Get Recorded Meeting",
|
|
3077
|
-
description: "One recorded meeting's metadata by meeting_key (
|
|
3078
|
-
"WHEN TO USE: to check whether a transcript or notes exist before reading them, or to answer how long a call lasted. It never returns the transcript or the notes themselves. Not found when the meeting was not recorded or the API key's user
|
|
3185
|
+
description: "One recorded meeting's metadata by meeting_key (" + MEETING_KEY_SOURCES + "): { meeting_key, ical_uid, title, occurred_at, meeting_url (the video link), recording { has_recording, has_transcript, has_notes, duration_seconds, language, recorded_by } (the same object the meetings list carries; recorded_by is the recorder as a workspace member, or null), notes { id, status, created_at }: the note lighthouse_inbox_calendar_meeting_notes returns, or null when there is none the user may read }. The title and the time are the same here as on the meetings list and inside the notes. " +
|
|
3186
|
+
"WHEN TO USE: to check whether a transcript or notes exist before reading them, or to answer how long a call lasted. It never returns the transcript or the notes themselves, and the API never returns the recording itself. Not found when the meeting was not recorded or the API key's user may not read it. A recurring meeting keeps the recording of its most recently recorded occurrence. " + MEETING_ACCESS_RULE,
|
|
3079
3187
|
inputSchema: {
|
|
3080
|
-
meeting_key: z.string().describe("The meeting_key of a recorded meeting"),
|
|
3188
|
+
meeting_key: z.string().describe("The meeting_key of a recorded meeting, " + MEETING_KEY_SOURCES),
|
|
3081
3189
|
},
|
|
3082
3190
|
}, async (params) => {
|
|
3083
3191
|
try {
|
|
@@ -3090,11 +3198,11 @@ export function createServer(apiKey) {
|
|
|
3090
3198
|
});
|
|
3091
3199
|
server.registerTool("lighthouse_inbox_calendar_meeting_notes", {
|
|
3092
3200
|
title: "Get Meeting Notes",
|
|
3093
|
-
description: "The meeting notes written from one recorded meeting, by meeting_key: the note in the same shape as lighthouse_crm_get_note (type 'meeting', with the meeting object and the records it is linked to), plus content_markdown (what was generated: Meeting Context first, then the sections) and is_edited (true when a member changed the note since, in which case content is the version to trust). " +
|
|
3201
|
+
description: "The meeting notes written from one recorded meeting, by meeting_key (" + MEETING_KEY_SOURCES + "): the note in the same shape as lighthouse_crm_get_note (type 'meeting', with the meeting object and the records it is linked to), plus content_markdown (what was generated: Meeting Context first, then the sections) and is_edited (true when a member changed the note since, in which case content is the version to trust). " +
|
|
3094
3202
|
"WHEN TO USE: the user asks what was discussed, decided or agreed in a specific meeting, the next steps from a call, or who attended. Lighter than the transcript: prefer it unless the answer depends on exactly what was said. " +
|
|
3095
|
-
"Not found when the meeting has no notes yet (see has_notes on the meeting) or the API key's user
|
|
3203
|
+
"Not found when the meeting has no notes yet (see has_notes on the meeting) or the API key's user may not read them; for a recurring meeting the newest occurrence the user may read is returned. " + MEETING_ACCESS_RULE,
|
|
3096
3204
|
inputSchema: {
|
|
3097
|
-
meeting_key: z.string().describe("The meeting_key of a recorded meeting"),
|
|
3205
|
+
meeting_key: z.string().describe("The meeting_key of a recorded meeting, " + MEETING_KEY_SOURCES),
|
|
3098
3206
|
},
|
|
3099
3207
|
}, async (params) => {
|
|
3100
3208
|
try {
|
|
@@ -3107,12 +3215,12 @@ export function createServer(apiKey) {
|
|
|
3107
3215
|
});
|
|
3108
3216
|
server.registerTool("lighthouse_inbox_calendar_meeting_transcript", {
|
|
3109
3217
|
title: "Get Meeting Transcript",
|
|
3110
|
-
description: "The transcript of one recorded meeting by meeting_key, as text: one \"Speaker: text\" line per utterance, in order. " +
|
|
3218
|
+
description: "The transcript of one recorded meeting by meeting_key (" + MEETING_KEY_SOURCES + "), as text: one \"Speaker: text\" line per utterance, in order. A note's meeting_key opens this transcript when that note's meeting.has_transcript is true. " +
|
|
3111
3219
|
"WHEN TO USE: only when the answer depends on what was actually SAID (an exact quote, a number, who said what, a nuance the notes would flatten). For what was discussed or decided, lighthouse_inbox_calendar_meeting_notes is enough and far smaller. " +
|
|
3112
3220
|
`A long meeting comes back in windows of max_chars (default ${TRANSCRIPT_WINDOW_CHARS}): meta.has_more true means the meeting continues past this window, so pass meta.next_offset_chars as offset_chars to read on, and never treat a window with has_more as the end of the meeting. ` +
|
|
3113
|
-
"Not found when the meeting has no transcript or the API key's user
|
|
3221
|
+
"Not found when the meeting has no transcript or the API key's user may not read it. " + MEETING_ACCESS_RULE,
|
|
3114
3222
|
inputSchema: {
|
|
3115
|
-
meeting_key: z.string().describe("The meeting_key of a recorded meeting"),
|
|
3223
|
+
meeting_key: z.string().describe("The meeting_key of a recorded meeting, " + MEETING_KEY_SOURCES),
|
|
3116
3224
|
offset_chars: z.number().min(0).default(0).describe("Character offset to start from (meta.next_offset_chars of the previous window)"),
|
|
3117
3225
|
max_chars: z.number().min(1).default(TRANSCRIPT_WINDOW_CHARS).describe("Characters per window"),
|
|
3118
3226
|
},
|
|
@@ -3125,5 +3233,68 @@ export function createServer(apiKey) {
|
|
|
3125
3233
|
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3126
3234
|
}
|
|
3127
3235
|
});
|
|
3236
|
+
if (options.privateEquity !== true)
|
|
3237
|
+
return server;
|
|
3238
|
+
server.registerTool("lighthouse_private_equity_list_saved_searches", {
|
|
3239
|
+
title: "List Private Equity Saved Searches",
|
|
3240
|
+
description: "List the Private Equity saved searches the user can see in this workspace (their own, the ones shared with the workspace, and the ones shared with one of their teams), ordered by name. " +
|
|
3241
|
+
"Each search carries id, name, sharing (private | workspace), shared_with_teams, is_owner, owner, created_at, updated_at, last_run_at and last_run: the stored run its results come from ({ id, created_at, last_fetched_at, result_count, match_count, created_by }, where result_count is how many companies are stored and match_count how many the search matched in the company database, or null), or null when it never ran. The search's filters are not returned. " +
|
|
3242
|
+
"READ ONLY: this reads what the workspace already stored. It never runs a search, never fetches new companies and never spends credits; a Private Equity search is run, and paid for, only in the Lighthouse app. " +
|
|
3243
|
+
"WHEN TO USE: the user asks which Private Equity searches exist, or names one whose results they want; pass its id to lighthouse_private_equity_list_saved_search_results. " +
|
|
3244
|
+
"These are separate from the Discovery saved searches (lighthouse_discovery_list_saved_searches), which search a different database.",
|
|
3245
|
+
inputSchema: {
|
|
3246
|
+
offset: z.number().int().min(0).optional().describe("Number of saved searches to skip (default 0)"),
|
|
3247
|
+
limit: z.number().int().min(1).max(100).optional().describe("Saved searches per page, 1 to 100 (default 25)"),
|
|
3248
|
+
},
|
|
3249
|
+
}, async ({ offset, limit }) => {
|
|
3250
|
+
try {
|
|
3251
|
+
const res = await api.get("/private-equity/saved-searches", { offset, limit });
|
|
3252
|
+
return text(res);
|
|
3253
|
+
}
|
|
3254
|
+
catch (e) {
|
|
3255
|
+
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3256
|
+
}
|
|
3257
|
+
});
|
|
3258
|
+
server.registerTool("lighthouse_private_equity_list_saved_search_results", {
|
|
3259
|
+
title: "List Private Equity Saved Search Results",
|
|
3260
|
+
description: "Read one page of the companies a Private Equity saved search's LAST run stored, in the order that run returned them. " +
|
|
3261
|
+
"Each row is a stored company: id, name, domain, website_url, linkedin_url, short_description, avatar_url, hq ({ country, country_code, region, city }) and hq_label, industry, category, sector, naics ({ code, label }), business_models, employees and revenue_usd ({ value, estimated }), founded_year, company_type, num_locations, crm_id (the id of this workspace's CRM company for it, or null), lighthouse_company (the matching company in Lighthouse's own data, or null), from_lighthouse (the keys whose value was filled from Lighthouse's own data), fetched_at, profile_updated_at, and match ({ snippets, headline }: why a keyword or product search matched it, or null). " +
|
|
3262
|
+
"meta carries total, limit, offset, has_more, has_results and run (the stored run the page is read from). A search that never ran, or whose last run is no longer stored, answers an empty page with meta.has_results false. " +
|
|
3263
|
+
"READ ONLY: the rows are exactly what the search stored when someone last ran it in the app, even if its filters were edited since. This never runs the search again, never fetches more companies and never spends credits; for newer results, ask the user to run the search in the Lighthouse app. " +
|
|
3264
|
+
"WHEN TO USE: the user asks what a Private Equity search found. Page with offset while meta.has_more is true. For one company's full stored profile use lighthouse_private_equity_get_company; when crm_id is set, lighthouse_crm_get_record (record_type 'company') opens the CRM company. " +
|
|
3265
|
+
"Not found when the saved search does not exist or the user cannot see it.",
|
|
3266
|
+
inputSchema: {
|
|
3267
|
+
search_id: z.string().uuid().describe("Private Equity saved search ID (from lighthouse_private_equity_list_saved_searches)"),
|
|
3268
|
+
offset: z.number().int().min(0).optional().describe("Number of companies to skip (default 0)"),
|
|
3269
|
+
limit: z.number().int().min(1).max(100).optional().describe("Companies per page, 1 to 100 (default 25)"),
|
|
3270
|
+
},
|
|
3271
|
+
}, async ({ search_id, offset, limit }) => {
|
|
3272
|
+
try {
|
|
3273
|
+
const res = await api.get(`/private-equity/saved-searches/${encodeURIComponent(search_id)}/results`, { offset, limit });
|
|
3274
|
+
return text(res);
|
|
3275
|
+
}
|
|
3276
|
+
catch (e) {
|
|
3277
|
+
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3278
|
+
}
|
|
3279
|
+
});
|
|
3280
|
+
server.registerTool("lighthouse_private_equity_get_company", {
|
|
3281
|
+
title: "Get Private Equity Company",
|
|
3282
|
+
description: "Read one company's stored Private Equity profile by company_id: the id of a row returned by lighthouse_private_equity_list_saved_search_results (a text id, not a UUID, and not a Discovery or CRM id). " +
|
|
3283
|
+
"Returns the keys of a results row (without match) plus the full profile: legal_names, commercial_names, long_description, long_description_extracted, locations, classification ({ naics_secondary, nace, sic, isic }), target_markets, core_offerings, technology_focus, certification_focus, supply_chain_focus, business_tags, technologies and technologies_total, contact ({ primary_phone, phone_numbers, primary_email, emails }), socials, website_language, profile_created_at, is_fresh and run_count. " +
|
|
3284
|
+
"READ ONLY: the profile as a Private Equity search of this workspace stored it, never refreshed. It never fetches the company again and never spends credits. " +
|
|
3285
|
+
"WHEN TO USE: the user asks for details on one company from a Private Equity search's results. When crm_id is set, lighthouse_crm_get_record (record_type 'company') opens the CRM company. " +
|
|
3286
|
+
"Not found when none of this workspace's Private Equity searches returned that company.",
|
|
3287
|
+
inputSchema: {
|
|
3288
|
+
company_id: z.string().min(1).describe("The company's id, as a row of lighthouse_private_equity_list_saved_search_results gives it"),
|
|
3289
|
+
},
|
|
3290
|
+
}, async ({ company_id }) => {
|
|
3291
|
+
try {
|
|
3292
|
+
const res = await api.get(`/private-equity/companies/${encodeURIComponent(company_id)}`);
|
|
3293
|
+
return text(res);
|
|
3294
|
+
}
|
|
3295
|
+
catch (e) {
|
|
3296
|
+
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3297
|
+
}
|
|
3298
|
+
});
|
|
3128
3299
|
return server;
|
|
3129
3300
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trylighthouse/mcp-server",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "MCP server for the Lighthouse CRM API",
|
|
5
5
|
"homepage": "https://trylighthouse.vc",
|
|
6
6
|
"type": "module",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"scripts": {
|
|
21
21
|
"build": "rm -rf build && tsc && chmod 755 build/index.js && node scripts/check-no-comments.mjs",
|
|
22
22
|
"dev": "tsc --watch",
|
|
23
|
+
"test": "node --test test/*.test.mjs",
|
|
23
24
|
"prepublishOnly": "npm run build"
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|