@trylighthouse/mcp-server 0.2.17 → 0.2.20

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 CHANGED
@@ -205,6 +205,20 @@ Webhook subscriptions are created and deleted with the REST API or the SDKs, wit
205
205
  | `lighthouse_reports_share_dashboard_with_teams` | Share a dashboard with teams |
206
206
  | `lighthouse_reports_revoke_dashboard_from_teams` | Revoke team access to a dashboard |
207
207
 
208
+ ### Sequences
209
+
210
+ People only: the add, update and remove tools take CRM person IDs. Everyone added lands in the sequence's Review, and nothing is sent until a member adds them from there in the app. Sequences are created and edited in the app.
211
+
212
+ | Tool | Description |
213
+ |------|-------------|
214
+ | `lighthouse_sequences_list` | List the sequences you can open, or find one by its name |
215
+ | `lighthouse_sequences_get` | Get a sequence with its steps, settings and sending accounts |
216
+ | `lighthouse_sequences_list_people` | List the people in a sequence and where each one stands |
217
+ | `lighthouse_sequences_add_people` | Add people to a sequence's Review |
218
+ | `lighthouse_sequences_update_people` | Stop, pause or resume people in a sequence (stopping ends that sequence only) |
219
+ | `lighthouse_sequences_remove_people` | Remove people from a sequence, deleting their history in it |
220
+ | `lighthouse_crm_list_record_sequences` | List the sequences a person, company or deal is in |
221
+
208
222
  ## Rate limits
209
223
 
210
224
  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`).
package/build/server.d.ts CHANGED
@@ -1,5 +1,2 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- export interface CreateServerOptions {
3
- privateEquity?: boolean;
4
- }
5
- export declare function createServer(apiKey?: string, options?: CreateServerOptions): McpServer;
2
+ export declare function createServer(apiKey?: string): McpServer;
package/build/server.js CHANGED
@@ -57,6 +57,9 @@ function textWithImage(res, image, declaredType) {
57
57
  const mimeType = IMAGE_CONTENT_TYPES.has(image.contentType) ? image.contentType : declaredType;
58
58
  return { ...base, content: [...base.content, { type: "image", data: image.bytes.toString("base64"), mimeType }] };
59
59
  }
60
+ const FILE_BLOCKED_TOOL_RULE = "A file whose blocked_reason is threat_detected cannot be read or downloaded.";
61
+ const FILE_BLOCKED_KEYS = "A file a threat was found in also carries blocked_at and blocked_reason.";
62
+ const FILE_BLOCKED_ANSWER = "Reading or downloading such a file answers 409 file_blocked (\"This file could not be read: a threat was detected in it.\").";
60
63
  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
64
  "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
65
  `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. ` +
@@ -85,11 +88,11 @@ const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint:
85
88
  const CREATES = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
86
89
  const ADDS_IF_MISSING = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
87
90
  const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false };
88
- export function createServer(apiKey, options = {}) {
91
+ export function createServer(apiKey) {
89
92
  const api = new LighthouseAPI(apiKey);
90
93
  const server = new McpServer({
91
94
  name: "lighthouse",
92
- version: "0.2.17",
95
+ version: "0.2.20",
93
96
  });
94
97
  const originalRegister = server.registerTool.bind(server);
95
98
  server.registerTool = (name, schemaOrConfig, handler) => {
@@ -422,7 +425,7 @@ export function createServer(apiKey, options = {}) {
422
425
  server.registerTool("lighthouse_crm_list_options", {
423
426
  title: "List CRM Field Options",
424
427
  annotations: READ_ONLY,
425
- description: "Get the allowed values for a CRM select / multi_select field. Each row is { value, label, color, priority, is_default, enabled }, the same object the options[] of an attribute carries; a built-in value the workspace switched off is listed with enabled: false rather than left out, so do not offer it when setting a field. " +
428
+ description: "Get the allowed values for a CRM select / multi_select field. Each row is { value, label, color, priority, is_default, enabled }, the same object the options[] of an attribute carries; a built-in value the workspace switched off is not listed, and a write naming it is refused. " +
426
429
  "Call this when lighthouse_crm_list_attributes shows has_options=true for a field and you need its values before filtering or setting it. " +
427
430
  "Results are paginated (max 100 per page): use `search` to narrow by label, and `limit` / `offset` to page through a field with many options. " +
428
431
  "For custom objects, use the object's permalink as record_type.",
@@ -448,7 +451,7 @@ export function createServer(apiKey, options = {}) {
448
451
  annotations: CREATES,
449
452
  description: "Add a new option to a select/multi_select field. The option value is auto-generated from the label. " +
450
453
  "Re-creating a system default option that was previously removed restores it (its original color and label are kept). " +
451
- "Returns the updated list of ALL options for the field, each row { value, label, color, priority, is_default, enabled }, with a switched-off built-in value listed as enabled: false, plus meta.total. The list comes back in the order the field offers its options, so do not read the first row as the option you just created: find it by the label you sent. " +
454
+ "Returns the updated list of ALL options for the field, each row { value, label, color, priority, is_default, enabled }, without a built-in value the workspace switched off, plus meta.total. The list comes back in the order the field offers its options, so do not read the first row as the option you just created: find it by the label you sent. " +
452
455
  "For custom objects, use the object's permalink as record_type. " +
453
456
  "Options can only be created on company, person, deal, and custom objects.",
454
457
  inputSchema: {
@@ -1536,7 +1539,8 @@ export function createServer(apiKey, options = {}) {
1536
1539
  "Each row includes my_role ('owner' | 'editor' | 'viewer'), is_owner, " +
1537
1540
  "owner (the member who owns it: { id, first_name, last_name, full_name, avatar_url, email }), " +
1538
1541
  "sharing ('private' or 'workspace'), linked_records_count, and " +
1539
- "shared_with_teams[] (each entry has { team_id, team_name, role, granted_at }).",
1542
+ "shared_with_teams[] (each entry has { team_id, team_name, role, granted_at }). " +
1543
+ `${FILE_BLOCKED_KEYS} ${FILE_BLOCKED_TOOL_RULE}`,
1540
1544
  inputSchema: {
1541
1545
  folder_id: z.string().uuid().optional().describe("Folder UUID to list contents of (omit for workspace root)"),
1542
1546
  limit: z.number().int().min(1).max(500).optional().describe("Rows per sub-list (folders, files). Default 200, max 500"),
@@ -1707,7 +1711,8 @@ export function createServer(apiKey, options = {}) {
1707
1711
  server.registerTool("lighthouse_documents_get_file", {
1708
1712
  title: "Get File",
1709
1713
  annotations: READ_ONLY,
1710
- 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.",
1714
+ 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. " +
1715
+ `${FILE_BLOCKED_KEYS} ${FILE_BLOCKED_TOOL_RULE} ${FILE_BLOCKED_ANSWER}`,
1711
1716
  inputSchema: {
1712
1717
  file_id: z.string().uuid().describe("File UUID"),
1713
1718
  download_url: z.boolean().optional().describe("Include a short-lived signed download URL"),
@@ -1730,7 +1735,8 @@ export function createServer(apiKey, options = {}) {
1730
1735
  "Take the file id from lighthouse_documents_list or lighthouse_documents_list_for_record. " +
1731
1736
  "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. " +
1732
1737
  FILE_CONTENT_RULES +
1733
- " A file the user may not open answers forbidden, one that does not exist not found.",
1738
+ " A file the user may not open answers forbidden, one that does not exist not found. " +
1739
+ `${FILE_BLOCKED_TOOL_RULE} ${FILE_BLOCKED_ANSWER}`,
1734
1740
  inputSchema: {
1735
1741
  file_id: z.string().uuid().optional().describe("File UUID. The name every other file tool takes."),
1736
1742
  id: z.string().uuid().optional().describe("Same as file_id. Send either one."),
@@ -1761,7 +1767,8 @@ export function createServer(apiKey, options = {}) {
1761
1767
  annotations: DESTRUCTIVE,
1762
1768
  description: "Rename, move, or change sharing on a file. Editors can rename and delete it. Only the owner can move it (into another folder or to the root), change sharing, or grant team access, with one exception: the owner of the folder the file is in can also move it down into a sub-folder of that folder, when they own that sub-folder and every folder in between. " +
1763
1769
  "team_ids replaces the current set when supplied. " +
1764
- "Returns the file row: owner, is_owner, my_role, sharing, shared_with_teams[] (each { team_id, team_name, role, granted_at }), linked_records_count, size_bytes, content_type, created_at, updated_at.",
1770
+ "Returns the file row: owner, is_owner, my_role, sharing, shared_with_teams[] (each { team_id, team_name, role, granted_at }), linked_records_count, size_bytes, content_type, created_at, updated_at. " +
1771
+ FILE_BLOCKED_TOOL_RULE,
1765
1772
  inputSchema: {
1766
1773
  file_id: z.string().uuid().describe("File UUID, the file being changed"),
1767
1774
  name: z.string().optional().describe("New name"),
@@ -1785,7 +1792,7 @@ export function createServer(apiKey, options = {}) {
1785
1792
  server.registerTool("lighthouse_documents_delete_file", {
1786
1793
  title: "Delete File",
1787
1794
  annotations: DESTRUCTIVE,
1788
- description: "Owner or editor (file-level editor OR editor-on-parent-folder) can delete, provided they also have the files.delete workspace permission. Permanently deletes the file and purges the underlying object from storage. Irreversible.",
1795
+ description: "Owner or editor (file-level editor OR editor-on-parent-folder) can delete, provided they also have the files.delete workspace permission. Permanently deletes the file and purges the underlying object from storage. Irreversible. " + FILE_BLOCKED_TOOL_RULE,
1789
1796
  inputSchema: {
1790
1797
  file_id: z.string().uuid().describe("File UUID"),
1791
1798
  },
@@ -1801,7 +1808,7 @@ export function createServer(apiKey, options = {}) {
1801
1808
  server.registerTool("lighthouse_documents_link_file_to_record", {
1802
1809
  title: "Link File to Record",
1803
1810
  annotations: ADDS_IF_MISSING,
1804
- description: "Make a file appear on a CRM record's Files tab. Answers THE FILE itself, the same row lighthouse_documents_get_file returns, with its links as they are after the call.",
1811
+ description: "Make a file appear on a CRM record's Files tab. Answers THE FILE itself, the same row lighthouse_documents_get_file returns, with its links as they are after the call. " + FILE_BLOCKED_TOOL_RULE,
1805
1812
  inputSchema: {
1806
1813
  file_id: z.string().uuid().describe("File UUID"),
1807
1814
  record_type: z.string().describe("company, person, deal, or a custom object permalink"),
@@ -1820,7 +1827,7 @@ export function createServer(apiKey, options = {}) {
1820
1827
  server.registerTool("lighthouse_documents_unlink_file_from_record", {
1821
1828
  title: "Unlink File from Record",
1822
1829
  annotations: DESTRUCTIVE,
1823
- description: "Remove a file→record link. Answers THE FILE itself, the same row lighthouse_documents_get_file returns, with its links as they are after the call.",
1830
+ description: "Remove a file→record link. Answers THE FILE itself, the same row lighthouse_documents_get_file returns, with its links as they are after the call. " + FILE_BLOCKED_TOOL_RULE,
1824
1831
  inputSchema: {
1825
1832
  file_id: z.string().uuid().describe("File UUID"),
1826
1833
  record_type: z.string().describe("company, person, deal, or a custom object permalink"),
@@ -1841,7 +1848,8 @@ export function createServer(apiKey, options = {}) {
1841
1848
  annotations: READ_ONLY,
1842
1849
  description: "List folders and files linked to a CRM record. Returns { folders, files }. `files` are files linked DIRECTLY to the record; files that live inside a linked folder are NOT flattened into `files`. To read a linked folder's contents, list that folder by its id with lighthouse_documents_list. " +
1843
1850
  "Each row 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_at, and shared_with_teams[] (each entry has { team_id, team_name, role, granted_at }). " +
1844
- "Cursor-based pagination via meta.folders_next_cursor / meta.files_next_cursor; pass them back as folders_cursor / files_cursor to fetch the next page.",
1851
+ "Cursor-based pagination via meta.folders_next_cursor / meta.files_next_cursor; pass them back as folders_cursor / files_cursor to fetch the next page. " +
1852
+ `${FILE_BLOCKED_KEYS} ${FILE_BLOCKED_TOOL_RULE}`,
1845
1853
  inputSchema: {
1846
1854
  record_type: z.string().describe("company, person, deal, or a custom object permalink"),
1847
1855
  record_id: z.string().uuid().describe("Record UUID"),
@@ -3039,6 +3047,186 @@ export function createServer(apiKey, options = {}) {
3039
3047
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3040
3048
  }
3041
3049
  });
3050
+ const SEQUENCE_PEOPLE_PER_CALL_MAX = 100;
3051
+ const SEQUENCE_FINISHED_ELSEWHERE_DAYS = 30;
3052
+ const SEQUENCE_END_REASONS = "finished (went through every step), replied (their reply ended the run, or they replied after its last step), colleague_replied (someone else from their company replied), wrote_first (they wrote in before this sequence contacted them; not a reply), accepted_invitation, already_connected, not_accepted (did not accept the LinkedIn invitation), stopped_manually, unsubscribed, bounced, failed or stopped";
3053
+ const SEQUENCE_PERSON_STATUS = "status is the person's own place in the sequence, never the sequence's: in_review (waiting in the sequence's Review for a member to add them), ready (added from Review, waiting to start), active, paused and account_disconnected (waiting for its sending account, or for a check of any reply sent while it was disconnected) are still going; completed, stopped, failed, bounced and unsubscribed have ended. " +
3054
+ "end_reason only says why a run ENDED: a reply the sequence's rules let it carry on through shows only in outcome, so for everyone who replied read outcome too.";
3055
+ const SEQUENCE_STOPPABLE_RUNS = "ready, active, paused, or waiting for an account that can send or for a check of their replies";
3056
+ const SEQUENCE_RESUME_CHECK_RULE = "once any check of the replies they sent while an account was disconnected is done; a check that could not be made is ended, so they go on without it";
3057
+ const SEQUENCE_MEMBER = "{ id, first_name, last_name, full_name, avatar_url, email }";
3058
+ const SEQUENCE_PERSON_KEYS = "id (this person's run in the sequence), crm_person { id, first_name, last_name, avatar_url } (the CRM person: its id is what lighthouse_sequences_add_people, lighthouse_sequences_update_people and lighthouse_sequences_remove_people take, and what lighthouse_crm_get_record reads), " +
3059
+ "email (the address the run sends to), company { id, name, avatar_url, is_deleted } (the company its messages name, null when none), status, step_status (what the current step is doing, null when nothing), current_step { number (from 1), type } (null before the first step), " +
3060
+ "outcome (how their reply reads: positive, negative, neutral or automated; null with no reply), outcome_source (ai or manual), stop_reason, stopped_by { id, first_name, last_name } (the person from their company whose reply stopped this run; id is that colleague's own run in this sequence, not a CRM person id), " +
3061
+ "end_reason (why the run ended: " + SEQUENCE_END_REASONS + "; null while it is still going), ended_by (the member who ended it by hand, " + SEQUENCE_MEMBER + "; null when the sequence or the person ended it), last_error_code, " +
3062
+ "added_via (manual, csv, list, find_leads, crm_action, api, workflow or assistant), started_at, ended_at, created_at";
3063
+ const SEQUENCE_NOT_FOUND_RULE = "A sequence the API key's user cannot open answers 'Sequence not found', the same as one that does not exist.";
3064
+ server.registerTool("lighthouse_sequences_list", {
3065
+ title: "List Sequences",
3066
+ annotations: READ_ONLY,
3067
+ description: "List the outbound sequences the API key's user can open (their own, and those shared with the workspace or with one of their teams), newest first. A sequence sends each person in it a series of steps (emails, LinkedIn connection requests and LinkedIn messages) from connected accounts. " +
3068
+ "Each row: id, name, status (draft, active or paused), sharing (private or workspace), shared_with_teams [{ team_id, team_name, granted_at }], is_owner (true when the API key's user owns it), has_unpublished_changes (the app holds an edit not published yet; what is read here is the published sequence, what runs), owner (the member who created it, " + SEQUENCE_MEMBER + "), step_count, stats, created_at and updated_at. " +
3069
+ "stats counts its people: people (everyone, ended runs included), in_review (waiting in the sequence's Review), not_started (added from Review, waiting to start), active, ended, errors, contacted (sent at least one message or connection request, or wrote back), responded (wrote back; an out-of-office or a delivery notice never counts) and positive_responses. The response rate is responded / contacted. " +
3070
+ "WHEN TO USE: \"which sequences do we have\", \"how is our outreach doing\", or to find a sequence someone names: pass query with part of its name and read the id off the matching row instead of paging through all of them. meta.total counts the sequences that matched. " +
3071
+ "For one sequence's steps, settings and sending accounts use lighthouse_sequences_get; for who is in it, lighthouse_sequences_list_people. Whether a given person, company or deal is in a sequence is lighthouse_crm_list_record_sequences (a person's record also carries a sequences field), never a walk through every sequence. " +
3072
+ "Creating a sequence, editing its steps or settings, sharing it and turning it on or off are done in the app: no tool does them.",
3073
+ inputSchema: {
3074
+ query: z.string().optional().describe("Part of the sequence's name, in any case"),
3075
+ search: z.string().optional().describe("Same as query. Either name works; query wins when both are sent."),
3076
+ status: z.enum(["draft", "active", "paused"]).optional().describe("Only the sequences with this status. All of them when omitted"),
3077
+ limit: z.number().min(1).max(100).default(25).describe("Sequences per page (max 100)"),
3078
+ offset: z.number().min(0).default(0).describe("Sequences to skip; meta.total and meta.has_more say whether more remain"),
3079
+ },
3080
+ }, async (params) => {
3081
+ try {
3082
+ const res = await api.get("/sequences", params);
3083
+ return text(res);
3084
+ }
3085
+ catch (e) {
3086
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3087
+ }
3088
+ });
3089
+ server.registerTool("lighthouse_sequences_get", {
3090
+ title: "Get Sequence",
3091
+ annotations: READ_ONLY,
3092
+ description: "One sequence in full, by id: every key of a lighthouse_sequences_list row plus settings, people_not_started (people added from Review and still waiting to start), steps and senders. The steps and settings are the PUBLISHED sequence, what runs; has_unpublished_changes says the app holds an edit not published yet. " +
3093
+ "steps, in order, each { id, number (from 1), type (email, linkedin_connection_request or linkedin_message), channel (email or linkedin), delay { amount, unit (hours, days or weeks) } (the wait before the step, counted from when a person reaches it; null when it runs right away), message_mode (shared: one message for everyone, per_person: a draft written for each person, or ai: each person's message written by AI when they are added), subject (email steps only), message (the shared text, or a connection request's note; null when none, and on a per_person or ai step), include_signature (null on a connection request), sender_id (the sending account this step always uses, null when the sequence chooses), if_accepted and if_not_accepted (a connection request's rules, null on other steps), people_at_step, errors_at_step, drafts_written }. " +
3094
+ "senders, the accounts it sends from, each { id (what a step's sender_id points at), channel, account { id, name, email, is_owner }, owner (the member, " + SEQUENCE_MEMBER + "), is_connected, can_send (false: the people it would send to wait), daily_limit (this sequence's own, null when the account's applies), account_daily_limit, account_sent_today, sequence_sent_today }. " +
3095
+ "settings: schedule { days (ISO weekdays, 1 = Monday), window_start, window_end (HH:MM), timezone }, enrollment { daily_starts, prioritise_new_people }, senders { email, linkedin: { mode (rotate or fixed), fixed_sender_id } }, email_min_gap_minutes, linkedin_min_gap_minutes, stop_on_bounce, include_unsubscribe_link, unsubscribe_link_text and step_defaults (the rules every step follows); a value that is null was never changed, so the product default applies. " +
3096
+ "WHEN TO USE: what a sequence does (its steps in order, the wait before each, its messages, when it sends and from which accounts), or why its people are waiting (a sender that cannot send). For who is in it use lighthouse_sequences_list_people. " + SEQUENCE_NOT_FOUND_RULE,
3097
+ inputSchema: {
3098
+ id: z.string().uuid().describe("Sequence ID, from lighthouse_sequences_list or the sequence of a lighthouse_crm_list_record_sequences row"),
3099
+ },
3100
+ }, async (params) => {
3101
+ try {
3102
+ const res = await api.get(`/sequences/${params.id}`);
3103
+ return text(res);
3104
+ }
3105
+ catch (e) {
3106
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3107
+ }
3108
+ });
3109
+ server.registerTool("lighthouse_sequences_list_people", {
3110
+ title: "List Sequence People",
3111
+ annotations: READ_ONLY,
3112
+ description: "The people in ONE sequence, newest added first, one page at a time. Each row: " + SEQUENCE_PERSON_KEYS + ", linkedin (the LinkedIn handle the run uses) and sender (the member currently assigned to send to this person, null until one is picked and once that member has left the workspace). " +
3113
+ SEQUENCE_PERSON_STATUS + " " +
3114
+ "meta.counts { running, ended, by_reason { <end_reason>: n } } follows query but never state or end_reason, so one call says how many there are of each. " +
3115
+ "WHEN TO USE: \"who is in this sequence\", \"who is still going\" (state running), \"who replied\" (end_reason replied, then outcome on the others), \"who bounced\", or finding one person in it by name or address (query). " +
3116
+ "Whether one person, company or deal is in any sequence is lighthouse_crm_list_record_sequences. Needs a role that may view both sequences and people. " + SEQUENCE_NOT_FOUND_RULE,
3117
+ inputSchema: {
3118
+ sequence_id: z.string().uuid().describe("Sequence ID, from lighthouse_sequences_list"),
3119
+ query: z.string().optional().describe("Part of the person's name or of the address the sequence sends to, in any case"),
3120
+ search: z.string().optional().describe("Same as query. Either name works; query wins when both are sent."),
3121
+ state: z.enum(["running", "ended"]).optional().describe("running: their run is still going (Review included); ended: it is over. Both when omitted"),
3122
+ end_reason: z.string().optional().describe("Only the people whose run ended this way (implies ended, so state must be ended or left out): " + SEQUENCE_END_REASONS),
3123
+ limit: z.number().min(1).max(100).default(25).describe("People per page (max 100)"),
3124
+ offset: z.number().min(0).default(0).describe("People to skip; meta.total and meta.has_more say whether more remain"),
3125
+ },
3126
+ }, async (params) => {
3127
+ try {
3128
+ const { sequence_id, ...rest } = params;
3129
+ const res = await api.get(`/sequences/${sequence_id}/people`, rest);
3130
+ return text(res);
3131
+ }
3132
+ catch (e) {
3133
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3134
+ }
3135
+ });
3136
+ server.registerTool("lighthouse_sequences_add_people", {
3137
+ title: "Add People to Sequence",
3138
+ annotations: ADDS_IF_MISSING,
3139
+ description: `Add CRM PEOPLE to a sequence, up to ${SEQUENCE_PEOPLE_PER_CALL_MAX} per call: crm_person_ids are person record ids, never companies, deals or lists. ` +
3140
+ "Everyone added lands in the sequence's Review, exactly like an add made in the app, and NOTHING IS SENT until a member opens the sequence in the app and adds them from Review; no tool starts anyone right away, so tell the user the people are waiting in Review, not contacted. " +
3141
+ "When the sequence has a step written by AI (message_mode ai), each person added has their message on it written straight away, while they wait in Review. That uses the workspace's AI credits, shown as Sequence messages on the Credits page, unless the step writes on one of the workspace's own models; a step with no model chosen writes on the workspace's default model when it has one, which uses none. " +
3142
+ "Answers { added, counts, results, other_sequences, other_sequences_hidden }: results has one { crm_person_id, result } per id sent, in order (a repeated id is answered once), and counts carries every result word, 0 when none. " +
3143
+ "result is added (now in the sequence's Review), already_in_sequence (a person is in a sequence at most once, ever: an ended run is not restarted), in_other_sequence (still going or in Review in another sequence; send include_in_other_sequences true to add them anyway), duplicate_email or duplicate_linkedin (someone else in this sequence is reached at the same address or LinkedIn profile), unsubscribed (opted out, or their address bounced and the sequence has no LinkedIn step), finished_other_sequence (only with skip_finished_elsewhere) or crm_person_not_found (no person with that id in the workspace: a company or deal id, or a deleted person). " +
3144
+ "other_sequences names the sequences ({ id, name }) holding the people skipped as in_other_sequence that the API key's user can open; other_sequences_hidden counts the others. " +
3145
+ "WHEN TO USE: the user asks to put people into a sequence (\"add these founders to our outreach\"). Find the sequence with lighthouse_sequences_list and the people with lighthouse_crm_search_records first. Adding the same people again changes nothing (already_in_sequence). Needs a role that may edit sequences and view people. " + SEQUENCE_NOT_FOUND_RULE,
3146
+ inputSchema: {
3147
+ sequence_id: z.string().uuid().describe("Sequence ID, from lighthouse_sequences_list"),
3148
+ crm_person_ids: z.array(z.string().uuid()).min(1).max(SEQUENCE_PEOPLE_PER_CALL_MAX).describe(`CRM person record IDs to add (max ${SEQUENCE_PEOPLE_PER_CALL_MAX}). People only: a company or deal id comes back as crm_person_not_found`),
3149
+ include_in_other_sequences: z.boolean().optional().describe("true also adds the people still going or waiting in Review in another sequence. Default false: they are skipped as in_other_sequence"),
3150
+ skip_finished_elsewhere: z.boolean().optional().describe(`true leaves out the people who were contacted in another sequence and whose run there ended in the last ${SEQUENCE_FINISHED_ELSEWHERE_DAYS} days (result finished_other_sequence); with include_in_other_sequences also true, someone still live in another sequence is added all the same. Default false`),
3151
+ },
3152
+ }, async (params) => {
3153
+ try {
3154
+ const { sequence_id, ...body } = params;
3155
+ const res = await api.post(`/sequences/${sequence_id}/people`, body);
3156
+ return text(res);
3157
+ }
3158
+ catch (e) {
3159
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3160
+ }
3161
+ });
3162
+ server.registerTool("lighthouse_sequences_update_people", {
3163
+ title: "Update People in Sequence",
3164
+ annotations: DESTRUCTIVE,
3165
+ description: `Stop, pause or resume CRM people in a sequence, up to ${SEQUENCE_PEOPLE_PER_CALL_MAX} per call, by their CRM person ids (a person is in a sequence at most once, so the id names their run). ` +
3166
+ "stop: a run still going (" + SEQUENCE_STOPPABLE_RUNS + ") ends, stopped by the API key's user, and keeps its history. It ends THIS sequence only: the person's runs in other sequences go on. pause: a run that is ready or active holds where it is. resume: a paused run goes on " + SEQUENCE_RESUME_CHECK_RULE + ". " +
3167
+ "Answers { action, changed, results }: results has one { crm_person_id, result, status } per id sent, in order; result is stopped, paused or resumed when their run changed, not_in_sequence when they have no run in it, or not_applicable when their run does not take the action (a person still in Review stopped, an ended run paused, a run that is not paused resumed); status is their run's status now (null when they have none); changed counts the runs that changed. " +
3168
+ "WHEN TO USE: \"stop the sequence for Jane\", \"pause everyone at Acme\", \"resume the people I paused\". To take someone out entirely, history included, or out of Review before anyone is contacted, use lighthouse_sequences_remove_people. Needs a role that may edit sequences and view people. " + SEQUENCE_NOT_FOUND_RULE,
3169
+ inputSchema: {
3170
+ sequence_id: z.string().uuid().describe("Sequence ID, from lighthouse_sequences_list"),
3171
+ crm_person_ids: z.array(z.string().uuid()).min(1).max(SEQUENCE_PEOPLE_PER_CALL_MAX).describe(`CRM person record IDs (max ${SEQUENCE_PEOPLE_PER_CALL_MAX})`),
3172
+ action: z.enum(["stop", "pause", "resume"]).describe("stop ends their run in this sequence only (its history kept), pause holds it, resume continues a paused run " + SEQUENCE_RESUME_CHECK_RULE),
3173
+ },
3174
+ }, async (params) => {
3175
+ try {
3176
+ const { sequence_id, ...body } = params;
3177
+ const res = await api.patch(`/sequences/${sequence_id}/people`, body);
3178
+ return text(res);
3179
+ }
3180
+ catch (e) {
3181
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3182
+ }
3183
+ });
3184
+ server.registerTool("lighthouse_sequences_remove_people", {
3185
+ title: "Remove People from Sequence",
3186
+ annotations: DESTRUCTIVE,
3187
+ description: `Remove CRM people from a sequence, up to ${SEQUENCE_PEOPLE_PER_CALL_MAX} per call, by their CRM person ids: their run in it is DELETED together with its history, at any stage, Review included, and cannot be brought back. ` +
3188
+ "To end a run and keep its history, use lighthouse_sequences_update_people with action stop instead. " +
3189
+ "Answers { deleted: true, removed, results }: results has one { crm_person_id, result } per id sent, in order, result being removed or not_in_sequence; removed counts the runs deleted. " +
3190
+ "WHEN TO USE: the user asks to take someone out of a sequence altogether, to clear people out of its Review before anyone is contacted, or to undo an add. Needs a role that may edit sequences and view people. " + SEQUENCE_NOT_FOUND_RULE,
3191
+ inputSchema: {
3192
+ sequence_id: z.string().uuid().describe("Sequence ID, from lighthouse_sequences_list"),
3193
+ crm_person_ids: z.array(z.string().uuid()).min(1).max(SEQUENCE_PEOPLE_PER_CALL_MAX).describe(`CRM person record IDs to remove (max ${SEQUENCE_PEOPLE_PER_CALL_MAX})`),
3194
+ },
3195
+ }, async (params) => {
3196
+ try {
3197
+ const { sequence_id, ...body } = params;
3198
+ const res = await api.delete(`/sequences/${sequence_id}/people`, body);
3199
+ return text(res);
3200
+ }
3201
+ catch (e) {
3202
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3203
+ }
3204
+ });
3205
+ server.registerTool("lighthouse_crm_list_record_sequences", {
3206
+ title: "List Record Sequences",
3207
+ annotations: READ_ONLY,
3208
+ description: "The sequence runs ONE CRM person, company or deal reaches (the record's Sequences tab), runs still going first, then the latest activity: a person's own runs; for a company, the runs that name it as the person's company and the runs of the people on its current team; for a deal, its people's runs and the runs of its companies. A custom object reaches no sequence. " +
3209
+ "Each run: " + SEQUENCE_PERSON_KEYS + ", sequence { id, name, status }, step_count, reached_through (how the record reaches the run: any of person, company, team, deal and deal_company), at_company_now (on a company: whether the person is still on its current team; null on a person or a deal), last_sent_at (the last message or invitation that went out), last_reply_at (their last reply; an out-of-office or a delivery notice never counts) and added_by (the member who added them). Who and when only, never a message. " +
3210
+ SEQUENCE_PERSON_STATUS + " " +
3211
+ "meta.counts { live, ended } counts both groups whatever group asks. " +
3212
+ "WHEN TO USE: \"which sequences is Jane in\", \"is anyone at Acme being contacted\", \"when did we last write to them\", \"who added them\". For a quick yes or no on one person, the sequences field of lighthouse_crm_get_record already lists theirs; for a SET of records (everyone in a sequence, the companies whose team is in one) use one lighthouse_crm_search_records call with a sequences condition (associated_people.sequences on companies and deals) rather than reading records one by one. " +
3213
+ "Only the sequences the API key's user can open are read: a run in any other sequence is left out and not counted, so an empty answer means none they can see. A person can have more than one CRM record: check each. Needs a role that may view sequences, people and the record's type. A record that does not exist answers 'Record not found'.",
3214
+ inputSchema: {
3215
+ record_type: z.enum(["person", "company", "deal"]).describe("person, company or deal (a custom object reaches no sequence)"),
3216
+ record_id: z.string().uuid().describe("The record's ID"),
3217
+ group: z.enum(["live", "ended"]).optional().describe("live: still going (Review included); ended: over. Both when omitted"),
3218
+ limit: z.number().min(1).max(100).default(25).describe("Runs per page (max 100)"),
3219
+ offset: z.number().min(0).default(0).describe("Runs to skip; meta.total and meta.has_more say whether more remain"),
3220
+ },
3221
+ }, async ({ record_type, record_id, group, limit, offset }) => {
3222
+ try {
3223
+ const res = await api.get(`/records/${record_type}/${record_id}/sequences`, { group, limit, offset });
3224
+ return text(res);
3225
+ }
3226
+ catch (e) {
3227
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3228
+ }
3229
+ });
3042
3230
  server.registerTool("lighthouse_inbox_email_accounts", {
3043
3231
  title: "List Email Accounts",
3044
3232
  annotations: READ_ONLY,
@@ -3402,7 +3590,7 @@ export function createServer(apiKey, options = {}) {
3402
3590
  server.registerTool("lighthouse_inbox_calendar_meeting_notes", {
3403
3591
  title: "Get Meeting Notes",
3404
3592
  annotations: READ_ONLY,
3405
- 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 that you can view), 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). " +
3593
+ 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 that you can view), plus content_markdown (what was generated: Meeting Context first, then the sections, then Next steps last) and is_edited (true when a member changed the note since, in which case content is the version to trust). " +
3406
3594
  "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. " +
3407
3595
  "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,
3408
3596
  inputSchema: {
@@ -3438,71 +3626,5 @@ export function createServer(apiKey, options = {}) {
3438
3626
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3439
3627
  }
3440
3628
  });
3441
- if (options.privateEquity !== true)
3442
- return server;
3443
- server.registerTool("lighthouse_private_equity_list_saved_searches", {
3444
- title: "List Private Equity Saved Searches",
3445
- annotations: READ_ONLY,
3446
- 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. " +
3447
- "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. " +
3448
- "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. " +
3449
- "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. " +
3450
- "These are separate from the Discovery saved searches (lighthouse_discovery_list_saved_searches), which search a different database.",
3451
- inputSchema: {
3452
- offset: z.number().int().min(0).optional().describe("Number of saved searches to skip (default 0)"),
3453
- limit: z.number().int().min(1).max(100).optional().describe("Saved searches per page, 1 to 100 (default 25)"),
3454
- },
3455
- }, async ({ offset, limit }) => {
3456
- try {
3457
- const res = await api.get("/private-equity/saved-searches", { offset, limit });
3458
- return text(res);
3459
- }
3460
- catch (e) {
3461
- return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3462
- }
3463
- });
3464
- server.registerTool("lighthouse_private_equity_list_saved_search_results", {
3465
- title: "List Private Equity Saved Search Results",
3466
- annotations: READ_ONLY,
3467
- description: "Read one page of the companies a Private Equity saved search's LAST run stored, in the order that run returned them. " +
3468
- "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). " +
3469
- "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. " +
3470
- "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. " +
3471
- "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. " +
3472
- "Not found when the saved search does not exist or the user cannot see it.",
3473
- inputSchema: {
3474
- search_id: z.string().uuid().describe("Private Equity saved search ID (from lighthouse_private_equity_list_saved_searches)"),
3475
- offset: z.number().int().min(0).optional().describe("Number of companies to skip (default 0)"),
3476
- limit: z.number().int().min(1).max(100).optional().describe("Companies per page, 1 to 100 (default 25)"),
3477
- },
3478
- }, async ({ search_id, offset, limit }) => {
3479
- try {
3480
- const res = await api.get(`/private-equity/saved-searches/${encodeURIComponent(search_id)}/results`, { offset, limit });
3481
- return text(res);
3482
- }
3483
- catch (e) {
3484
- return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3485
- }
3486
- });
3487
- server.registerTool("lighthouse_private_equity_get_company", {
3488
- title: "Get Private Equity Company",
3489
- annotations: READ_ONLY,
3490
- 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). " +
3491
- "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. " +
3492
- "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. " +
3493
- "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. " +
3494
- "Not found when none of this workspace's Private Equity searches returned that company.",
3495
- inputSchema: {
3496
- company_id: z.string().min(1).describe("The company's id, as a row of lighthouse_private_equity_list_saved_search_results gives it"),
3497
- },
3498
- }, async ({ company_id }) => {
3499
- try {
3500
- const res = await api.get(`/private-equity/companies/${encodeURIComponent(company_id)}`);
3501
- return text(res);
3502
- }
3503
- catch (e) {
3504
- return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3505
- }
3506
- });
3507
3629
  return server;
3508
3630
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trylighthouse/mcp-server",
3
- "version": "0.2.17",
3
+ "version": "0.2.20",
4
4
  "description": "MCP server for the Lighthouse CRM API",
5
5
  "homepage": "https://trylighthouse.vc",
6
6
  "type": "module",