@trylighthouse/mcp-server 0.2.16 → 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/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. ` +
@@ -81,11 +84,15 @@ const ADDRESS_INPUT = z.union([
81
84
  }),
82
85
  ]);
83
86
  const RECIPIENTS = z.union([ADDRESS_INPUT, z.array(ADDRESS_INPUT)]);
84
- export function createServer(apiKey, options = {}) {
87
+ const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
88
+ const CREATES = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
89
+ const ADDS_IF_MISSING = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
90
+ const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false };
91
+ export function createServer(apiKey) {
85
92
  const api = new LighthouseAPI(apiKey);
86
93
  const server = new McpServer({
87
94
  name: "lighthouse",
88
- version: "0.2.16",
95
+ version: "0.2.20",
89
96
  });
90
97
  const originalRegister = server.registerTool.bind(server);
91
98
  server.registerTool = (name, schemaOrConfig, handler) => {
@@ -259,6 +266,7 @@ export function createServer(apiKey, options = {}) {
259
266
  "(an outer AND of two OR groups; country codes from list_locations).";
260
267
  server.registerTool("lighthouse_crm_list_attributes", {
261
268
  title: "List CRM Attributes",
269
+ annotations: READ_ONLY,
262
270
  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. " +
263
271
  "`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. " +
264
272
  "ALWAYS call this before building filters for search_records / list_records or creating/updating views. Use the exact permalinks it returns. " +
@@ -292,6 +300,7 @@ export function createServer(apiKey, options = {}) {
292
300
  });
293
301
  server.registerTool("lighthouse_crm_get_attribute", {
294
302
  title: "Get CRM Attribute",
303
+ annotations: READ_ONLY,
295
304
  description: "Get a single custom field definition. Response returns 'permalink', 'is_custom', 'data_type', 'links_to'. " +
296
305
  "For custom objects, use the object's permalink as record_type (e.g. 'lp').",
297
306
  inputSchema: {
@@ -309,6 +318,7 @@ export function createServer(apiKey, options = {}) {
309
318
  });
310
319
  server.registerTool("lighthouse_crm_create_attribute", {
311
320
  title: "Create CRM Attribute",
321
+ annotations: CREATES,
312
322
  description: "Create a new custom field in your CRM workspace. " +
313
323
  "Types: text, number, currency, percentage, date, datetimez, select, multi_select, status, checkbox, url, email, phone, rating, record, multi_record, user, multi_user. " +
314
324
  "Supports custom objects, use the object's permalink as record_type (e.g. 'lp'). " +
@@ -360,6 +370,7 @@ export function createServer(apiKey, options = {}) {
360
370
  });
361
371
  server.registerTool("lighthouse_crm_update_attribute", {
362
372
  title: "Update CRM Attribute",
373
+ annotations: DESTRUCTIVE,
363
374
  description: "Update a custom field's display label (the field type, linked record type, and options are fixed after creation). " +
364
375
  "For custom objects, use the object's permalink as record_type.",
365
376
  inputSchema: {
@@ -379,6 +390,7 @@ export function createServer(apiKey, options = {}) {
379
390
  });
380
391
  server.registerTool("lighthouse_crm_delete_attribute", {
381
392
  title: "Delete CRM Attribute",
393
+ annotations: DESTRUCTIVE,
382
394
  description: "Delete a custom field and all its data. Only custom fields can be deleted, not system fields. " +
383
395
  "For custom objects, use the object's permalink as record_type.",
384
396
  inputSchema: {
@@ -396,6 +408,7 @@ export function createServer(apiKey, options = {}) {
396
408
  });
397
409
  server.registerTool("lighthouse_crm_list_colors", {
398
410
  title: "List Colors",
411
+ annotations: READ_ONLY,
399
412
  description: "Get the available color palette for select/multi_select/status field options. " +
400
413
  "Returns an array of {hex_code, label} objects. " +
401
414
  "Use this to discover valid hex color codes when creating or updating select options.",
@@ -411,7 +424,8 @@ export function createServer(apiKey, options = {}) {
411
424
  });
412
425
  server.registerTool("lighthouse_crm_list_options", {
413
426
  title: "List CRM Field Options",
414
- 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. " +
427
+ annotations: READ_ONLY,
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. " +
415
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. " +
416
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. " +
417
431
  "For custom objects, use the object's permalink as record_type.",
@@ -434,9 +448,10 @@ export function createServer(apiKey, options = {}) {
434
448
  });
435
449
  server.registerTool("lighthouse_crm_create_option", {
436
450
  title: "Create CRM Field Option",
451
+ annotations: CREATES,
437
452
  description: "Add a new option to a select/multi_select field. The option value is auto-generated from the label. " +
438
453
  "Re-creating a system default option that was previously removed restores it (its original color and label are kept). " +
439
- "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. " +
440
455
  "For custom objects, use the object's permalink as record_type. " +
441
456
  "Options can only be created on company, person, deal, and custom objects.",
442
457
  inputSchema: {
@@ -457,6 +472,7 @@ export function createServer(apiKey, options = {}) {
457
472
  });
458
473
  server.registerTool("lighthouse_crm_delete_option", {
459
474
  title: "Delete CRM Field Option",
475
+ annotations: DESTRUCTIVE,
460
476
  description: "Delete an option value from a select/multi_select field. Custom options are removed permanently; a system default option is disabled for the workspace instead (re-create it to restore). " +
461
477
  "For custom objects, use the object's permalink as record_type.",
462
478
  inputSchema: {
@@ -475,6 +491,7 @@ export function createServer(apiKey, options = {}) {
475
491
  });
476
492
  server.registerTool("lighthouse_crm_search_records", {
477
493
  title: "Search CRM Records",
494
+ annotations: READ_ONLY,
478
495
  description: "Search and filter records in YOUR CRM workspace (companies, people, or deals you've added). " +
479
496
  "Supports filters, sorting, pagination, and saved views. " +
480
497
  "A search by MEANING over people or company records is a semantic_search condition in filters; it needs the ready vector of a saved view (lighthouse_crm_get_view) or of a condition copied from the app, never text and never a vector you wrote (the filters description has the rule). " +
@@ -520,6 +537,7 @@ export function createServer(apiKey, options = {}) {
520
537
  });
521
538
  server.registerTool("lighthouse_crm_get_record", {
522
539
  title: "Get CRM Record",
540
+ annotations: READ_ONLY,
523
541
  description: "Get a single CRM record by ID with all fields and relationships. Custom fields are returned as top-level keys, not nested under an attributes object. " +
524
542
  "Signal fields (company_signals on companies, person_signals on people) are arrays of signal objects, one per signal: { signal (the value), picked_up_on (ISO 8601 date, the day the feed picked it up), occurrences (the number of windows or registrations behind it, 1 otherwise), and what the feed knows: " +
525
543
  "for a company measured_on, timeframe, change_percent, value (the metric's value on measured_on, from the most recently measured window), windows (every window, each its own snapshot: [{timeframe, change_percent, value, measured_on, picked_up_on}]), money_raised_usd, investment_type, announced_on, investment_stage, num_investors, pre_money_valuation_usd; " +
@@ -546,6 +564,7 @@ export function createServer(apiKey, options = {}) {
546
564
  });
547
565
  server.registerTool("lighthouse_crm_list_record_activity", {
548
566
  title: "List Record Activity",
567
+ annotations: READ_ONLY,
549
568
  description: "Read the activity feed of one CRM record, newest first: who changed what, when, and from where. Use it to answer questions about history (\"what changed on this deal last week\", \"who moved it to won\", \"which fields has nobody touched\") instead of guessing from the record's current values. " +
550
569
  "Each entry has: action (created / updated / deleted / merged), occurred_at, summary (a ready-made sentence, ending \"via <the source>\" when a workflow or an API key made the change rather than a person, since both log a human as the actor), target ({ record_type, id, name }; the feed also carries the notes and tasks attached to the record, so the target is not always the record itself), actor (null for automated changes), and source. " +
551
570
  "source always has { type, label }: type is app / mobile / api / mcp / workflow / ai_assistant / notetaker / email_sync / calendar_sync / linkedin_sync / inbound_email / inbound_whatsapp / scout / lp_portal / sync, and label names the exact origin. The four trace keys are always present and null when the origin did not record them: workflow is { id, name, run_id, node_id } when a workflow made the change (tracing an entry back to the exact run and step), api_key_id / mcp_tool identify the key or AI tool behind an API change, and trigger is automatic or manual where the origin recorded whether a person asked for the change (meeting notes written on their own and notes a member asked for both log that member as the actor, so trigger is what tells them apart). " +
@@ -573,6 +592,7 @@ export function createServer(apiKey, options = {}) {
573
592
  });
574
593
  server.registerTool("lighthouse_crm_list_ai_reports", {
575
594
  title: "List AI Reports on a Record",
595
+ annotations: READ_ONLY,
576
596
  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. " +
577
597
  "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. " +
578
598
  "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. " +
@@ -596,6 +616,7 @@ export function createServer(apiKey, options = {}) {
596
616
  });
597
617
  server.registerTool("lighthouse_crm_get_ai_report", {
598
618
  title: "Get AI Report",
619
+ annotations: READ_ONLY,
599
620
  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). " +
600
621
  "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. " +
601
622
  "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, or on " + REPORT_ROLE_SOURCES + " (its author is never refused for its sources), and 404 for an unknown id.",
@@ -613,6 +634,7 @@ export function createServer(apiKey, options = {}) {
613
634
  });
614
635
  server.registerTool("lighthouse_crm_create_record", {
615
636
  title: "Create CRM Record",
637
+ annotations: CREATES,
616
638
  description: "Create a new CRM record. Pass all fields (standard + custom) in a single flat data object, " +
617
639
  "the API splits standard vs custom fields automatically. " +
618
640
  "BEFORE calling this tool, call lighthouse_crm_list_attributes to discover available field keys and types for the record_type. " +
@@ -647,6 +669,7 @@ export function createServer(apiKey, options = {}) {
647
669
  });
648
670
  server.registerTool("lighthouse_crm_update_record", {
649
671
  title: "Update CRM Record",
672
+ annotations: DESTRUCTIVE,
650
673
  description: "Update an existing CRM record. Only include the fields you want to change. " +
651
674
  "Standard and custom fields can be mixed in a single flat data object, the API splits them automatically. " +
652
675
  "BEFORE calling this tool, call lighthouse_crm_list_attributes to discover available field keys and types. " +
@@ -684,6 +707,7 @@ export function createServer(apiKey, options = {}) {
684
707
  });
685
708
  server.registerTool("lighthouse_crm_upsert_record", {
686
709
  title: "Upsert CRM Record",
710
+ annotations: DESTRUCTIVE,
687
711
  description: "Find-or-create a CRM record by a UNIQUE attribute: if a record already matches the value it is updated, otherwise a new one is created. " +
688
712
  "Only person and company support upsert, person matches on email or linkedin, company on domain or linkedin (set matching_attribute accordingly). " +
689
713
  "Pass the value to match on as `matching_value` (\"jane@acme.com\", \"acme.com\", a LinkedIn address). It may also sit inside data as the attribute itself (emails: [\"jane@acme.com\"], domain: [\"acme.com\"], linkedin: \"...\"), which is what this tool used to require; matching_value wins when both carry it, and a request with neither is refused. A record created this way still stores the value. " +
@@ -711,6 +735,7 @@ export function createServer(apiKey, options = {}) {
711
735
  });
712
736
  server.registerTool("lighthouse_crm_delete_record", {
713
737
  title: "Delete CRM Record",
738
+ annotations: DESTRUCTIVE,
714
739
  description: "Permanently delete a CRM record. For custom objects, use the object's permalink as record_type.",
715
740
  inputSchema: {
716
741
  record_type: z.string().describe("Record type: company, person, deal, or a custom object permalink"),
@@ -727,6 +752,7 @@ export function createServer(apiKey, options = {}) {
727
752
  });
728
753
  server.registerTool("lighthouse_crm_bulk_create_records", {
729
754
  title: "Bulk Create CRM Records",
755
+ annotations: CREATES,
730
756
  description: "Create up to 100 CRM records of the same record_type in one call. " +
731
757
  "Each item in `records` is a flat data object, the SAME shape you'd pass as `data` to lighthouse_crm_create_record. " +
732
758
  "BEFORE calling, call lighthouse_crm_list_attributes to discover field keys and types. " +
@@ -752,6 +778,7 @@ export function createServer(apiKey, options = {}) {
752
778
  });
753
779
  server.registerTool("lighthouse_crm_bulk_update_records", {
754
780
  title: "Bulk Update CRM Records",
781
+ annotations: DESTRUCTIVE,
755
782
  description: "Update up to 100 CRM records of the same record_type in one call. " +
756
783
  "Each item in `records` is { id, data }, `id` is the record UUID and `data` is a flat object of the fields to change " +
757
784
  "(same shape as lighthouse_crm_update_record). " +
@@ -779,6 +806,7 @@ export function createServer(apiKey, options = {}) {
779
806
  });
780
807
  server.registerTool("lighthouse_crm_bulk_upsert_records", {
781
808
  title: "Bulk Upsert CRM Records",
809
+ annotations: DESTRUCTIVE,
782
810
  description: "Find-or-create up to 100 records of the same record_type in one call, each matched on a unique attribute. " +
783
811
  "For each item, if a record already matches the value it is updated, otherwise a new one is created. " +
784
812
  "Person matches on email or linkedin, company on domain or linkedin (set matching_attribute for the whole batch). " +
@@ -808,6 +836,7 @@ export function createServer(apiKey, options = {}) {
808
836
  });
809
837
  server.registerTool("lighthouse_crm_bulk_delete_records", {
810
838
  title: "Bulk Delete CRM Records",
839
+ annotations: DESTRUCTIVE,
811
840
  description: "Permanently delete up to 100 CRM records of the same record_type in one call. " +
812
841
  "Pass `ids` as an array of record UUIDs. " +
813
842
  "Partial success: the response returns a `results` array (one entry per id, in order, with success/error) and a `summary` ({total, succeeded, failed}).",
@@ -826,6 +855,7 @@ export function createServer(apiKey, options = {}) {
826
855
  });
827
856
  server.registerTool("lighthouse_crm_create_list", {
828
857
  title: "Create CRM List",
858
+ annotations: CREATES,
829
859
  description: "Create a new CRM list to organize records. Supports companies, people, deals, and custom objects. " +
830
860
  "Defaults to 'private' visibility. " +
831
861
  "To share with specific teams after creation, use lighthouse_crm_share_list_with_teams. " +
@@ -847,6 +877,7 @@ export function createServer(apiKey, options = {}) {
847
877
  });
848
878
  server.registerTool("lighthouse_crm_list_lists", {
849
879
  title: "List CRM Lists",
880
+ annotations: READ_ONLY,
850
881
  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. " +
851
882
  "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. " +
852
883
  "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.",
@@ -867,6 +898,7 @@ export function createServer(apiKey, options = {}) {
867
898
  });
868
899
  server.registerTool("lighthouse_crm_get_list", {
869
900
  title: "Get CRM List",
901
+ annotations: READ_ONLY,
870
902
  description: "Get a single CRM list by ID with its configuration. Its record_type is 'company', 'person', 'deal', or a custom object permalink (e.g. 'lp', 'project'). " +
871
903
  "is_owner says whether the API key's user owns the list, so 'my lists' and 'lists shared with me' can be told apart without a second call. A list you cannot open answers 'List not found', the same as one that does not exist.",
872
904
  inputSchema: {
@@ -884,6 +916,7 @@ export function createServer(apiKey, options = {}) {
884
916
  });
885
917
  server.registerTool("lighthouse_crm_update_list", {
886
918
  title: "Update CRM List",
919
+ annotations: DESTRUCTIVE,
887
920
  description: "Update a CRM list's name or configuration. A list you cannot open answers 'List not found', the same as one that does not exist.",
888
921
  inputSchema: {
889
922
  list_id: z.string().uuid().optional().describe("List ID"),
@@ -904,6 +937,7 @@ export function createServer(apiKey, options = {}) {
904
937
  });
905
938
  server.registerTool("lighthouse_crm_delete_list", {
906
939
  title: "Delete CRM List",
940
+ annotations: DESTRUCTIVE,
907
941
  description: "Permanently delete a CRM list. A list you cannot open answers 'List not found', the same as one that does not exist.",
908
942
  inputSchema: {
909
943
  list_id: z.string().uuid().optional().describe("List ID"),
@@ -920,6 +954,7 @@ export function createServer(apiKey, options = {}) {
920
954
  });
921
955
  server.registerTool("lighthouse_crm_list_list_records", {
922
956
  title: "List Records in a List",
957
+ annotations: READ_ONLY,
923
958
  description: "Get records in a CRM list with optional filtering, sorting, and view support. " +
924
959
  "Same filter format as lighthouse_crm_search_records. To get valid filter keys, call lighthouse_crm_list_attributes with this list's record_type, which is 'company', 'person', 'deal', or a custom object permalink. " +
925
960
  "The response's `meta` includes `total` (an integer match count), `ignored_filter_fields` (any filter key that is not a valid attribute for this list's record type, each with a did_you_mean suggestion when a close match can be computed), `invalid_operators` (a condition whose operator its field does not take, left out of the results), `invalid_logical_operators` (a group joined by something other than AND/OR, read as AND) and `invalid_sort_fields` (a sort this list cannot apply, reported rather than refused, so the records come back in their default order). The saved filters of a view_id are reported on the same way. Check every array to confirm the whole filter and the sort were applied; a value in the wrong shape is refused instead, with a 400. " +
@@ -946,6 +981,7 @@ export function createServer(apiKey, options = {}) {
946
981
  });
947
982
  server.registerTool("lighthouse_crm_add_to_list", {
948
983
  title: "Add Record to List",
984
+ annotations: ADDS_IF_MISSING,
949
985
  description: "Add a CRM record to a list. The record type is inferred from the list, so the record must be of the list's record_type: 'company', 'person', 'deal', or a custom object permalink. Answers `reason` beside the flag: the sentence \"This record was already on the list.\" / \"This record was not on the list.\" when the call changed nothing, and null when it did. Both writes succeed either way, so this key is what tells a no-op from a real change.",
950
986
  inputSchema: {
951
987
  list_id: z.string().uuid().describe("List ID"),
@@ -964,6 +1000,7 @@ export function createServer(apiKey, options = {}) {
964
1000
  });
965
1001
  server.registerTool("lighthouse_crm_remove_from_list", {
966
1002
  title: "Remove Record from List",
1003
+ annotations: DESTRUCTIVE,
967
1004
  description: "Remove a record from a CRM list. The record type is inferred from the list, whose record_type is 'company', 'person', 'deal', or a custom object permalink. Answers `reason` beside the flag: the sentence \"This record was already on the list.\" / \"This record was not on the list.\" when the call changed nothing, and null when it did. Both writes succeed either way, so this key is what tells a no-op from a real change.",
968
1005
  inputSchema: {
969
1006
  list_id: z.string().uuid().describe("List ID"),
@@ -980,6 +1017,7 @@ export function createServer(apiKey, options = {}) {
980
1017
  });
981
1018
  server.registerTool("lighthouse_crm_bulk_add_to_list", {
982
1019
  title: "Bulk Add Records to List",
1020
+ annotations: ADDS_IF_MISSING,
983
1021
  description: "Add up to 100 CRM records to a list in one call. The record type is inferred from the list, so every id must be of the list's record_type: 'company', 'person', 'deal', or a custom object permalink. " +
984
1022
  "Partial success: the response returns a `results` array (one entry per id, in order, with success/error) and a `summary` ({total, succeeded, failed}). " +
985
1023
  "A failed id does NOT prevent the others from being added. Every result entry also carries `reason`, ONE key for \"why did this record not change\": the no-op sentence on a success that wrote nothing, the refusal in words on a failure, null when the record really changed. `error` stays beside it for a client that branches on a code.",
@@ -998,6 +1036,7 @@ export function createServer(apiKey, options = {}) {
998
1036
  });
999
1037
  server.registerTool("lighthouse_crm_bulk_remove_from_list", {
1000
1038
  title: "Bulk Remove Records from List",
1039
+ annotations: DESTRUCTIVE,
1001
1040
  description: "Remove up to 100 CRM records from a list in one call. Every id must be of the list's record_type: 'company', 'person', 'deal', or a custom object permalink. " +
1002
1041
  "Partial success: the response returns a `results` array (one entry per id, in order, with success/error) and a `summary` ({total, succeeded, failed}). Every result entry also carries `reason`, ONE key for \"why did this record not change\": the no-op sentence on a success that wrote nothing, the refusal in words on a failure, null when the record really changed. `error` stays beside it for a client that branches on a code.",
1003
1042
  inputSchema: {
@@ -1015,6 +1054,7 @@ export function createServer(apiKey, options = {}) {
1015
1054
  });
1016
1055
  server.registerTool("lighthouse_crm_share_list_with_teams", {
1017
1056
  title: "Share List with Teams",
1057
+ annotations: ADDS_IF_MISSING,
1018
1058
  description: "Grant team-level access to a CRM list. Only the list owner can share. " +
1019
1059
  "Use lighthouse_workspace_list_teams to find team IDs first. " +
1020
1060
  "Requires lists.share permission. " +
@@ -1036,6 +1076,7 @@ export function createServer(apiKey, options = {}) {
1036
1076
  });
1037
1077
  server.registerTool("lighthouse_crm_revoke_list_from_teams", {
1038
1078
  title: "Revoke List Team Access",
1079
+ annotations: DESTRUCTIVE,
1039
1080
  description: "Revoke team-level access from a CRM list. Only the list owner can revoke. " +
1040
1081
  "Requires lists.share permission. " +
1041
1082
  "Returns formatted list: {id, name, record_type, sharing, color, count, owner, is_owner, shared_with_teams, created_at, updated_at}, where record_type is 'company', 'person', 'deal', or a custom object permalink and is_owner says whether the API key's user owns the list.",
@@ -1056,6 +1097,7 @@ export function createServer(apiKey, options = {}) {
1056
1097
  });
1057
1098
  server.registerTool("lighthouse_crm_update_list_sharing", {
1058
1099
  title: "Update List Sharing Status",
1100
+ annotations: DESTRUCTIVE,
1059
1101
  description: "Change a CRM list's sharing visibility. Setting to 'private' also removes all team permissions. " +
1060
1102
  "Only the list owner can change sharing. Requires lists.share permission. " +
1061
1103
  "Returns formatted list: {id, name, record_type, sharing, color, count, owner, is_owner, shared_with_teams, created_at, updated_at}, where record_type is 'company', 'person', 'deal', or a custom object permalink and is_owner says whether the API key's user owns the list.",
@@ -1076,6 +1118,7 @@ export function createServer(apiKey, options = {}) {
1076
1118
  });
1077
1119
  server.registerTool("lighthouse_crm_list_notes", {
1078
1120
  title: "List Notes",
1121
+ annotations: READ_ONLY,
1079
1122
  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 that you can view, plus a `tags` array of any workspace tags attached to the note (each with id, label, color). " +
1080
1123
  LINKED_RECORDS_LEFT_OUT + " " +
1081
1124
  "`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. " +
@@ -1096,6 +1139,7 @@ export function createServer(apiKey, options = {}) {
1096
1139
  });
1097
1140
  server.registerTool("lighthouse_crm_search_notes", {
1098
1141
  title: "Search Notes",
1142
+ annotations: READ_ONLY,
1099
1143
  description: "Search ALL notes in the workspace (unlike List Notes, which needs one record). Combine a free-text title search with a CRM-style filter tree and sort. " +
1100
1144
  'filters: {"operator":"AND","conditions":[{"field":"tags","operator":"contains_any","value":["<tag_permalink>"]}]}: nested groups supported. ' +
1101
1145
  "Filterable fields and their operators: title (text: contains/not_contains/starts_with/ends_with/eq/neq/empty/not_empty), " +
@@ -1125,6 +1169,7 @@ export function createServer(apiKey, options = {}) {
1125
1169
  });
1126
1170
  server.registerTool("lighthouse_crm_create_note", {
1127
1171
  title: "Create Note",
1172
+ annotations: CREATES,
1128
1173
  description: "Create a note, optionally linked to one or more CRM records. Records are optional: omit them to create a standalone note that still appears in the workspace Notes section. " +
1129
1174
  "Link records via the `records` param, keyed by type. " +
1130
1175
  "The company/person/deal ids you pass MUST be EXISTING CRM record ids (the ids returned by lighthouse_crm_* tools), NOT discovery ids from lighthouse_discovery_* search results. To note on a discovery result, first add it to your CRM and use the resulting CRM record id. " +
@@ -1164,6 +1209,7 @@ export function createServer(apiKey, options = {}) {
1164
1209
  });
1165
1210
  server.registerTool("lighthouse_crm_get_note", {
1166
1211
  title: "Get Note",
1212
+ annotations: READ_ONLY,
1167
1213
  description: "Get a single note by ID. Response includes a `records` object keyed by record type (or custom object permalink) with every linked record you can view, plus a `tags` array of any workspace tags attached to the note (each with permalink, label, color). " +
1168
1214
  LINKED_RECORDS_LEFT_OUT + " " +
1169
1215
  "`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. " +
@@ -1183,6 +1229,7 @@ export function createServer(apiKey, options = {}) {
1183
1229
  });
1184
1230
  server.registerTool("lighthouse_crm_update_note", {
1185
1231
  title: "Update Note",
1232
+ annotations: DESTRUCTIVE,
1186
1233
  description: "Change an existing note's title, content, tags or mentions by note ID. Pass only what changes: a key that is absent is left untouched. " +
1187
1234
  "content REPLACES the whole body: to ADD to a note, read it with lighthouse_crm_get_note first and pass the full new content with the addition, never the addition alone (anything you leave out, an existing @mention span included, is gone). " +
1188
1235
  "Content is HTML, the same format lighthouse_crm_create_note takes: <p> for paragraphs, <ul><li> for bullets, <h2> for a heading, <strong> for emphasis; plain text works too and is stored as one paragraph. " +
@@ -1211,6 +1258,7 @@ export function createServer(apiKey, options = {}) {
1211
1258
  });
1212
1259
  server.registerTool("lighthouse_crm_delete_note", {
1213
1260
  title: "Delete Note",
1261
+ annotations: DESTRUCTIVE,
1214
1262
  description: "Permanently delete a note and all of its record links. Meeting notes are deleted the same way, together with every version of them; the meeting's recording and transcript are not affected.",
1215
1263
  inputSchema: {
1216
1264
  note_id: z.string().uuid().optional().describe("Note ID"),
@@ -1227,6 +1275,7 @@ export function createServer(apiKey, options = {}) {
1227
1275
  });
1228
1276
  server.registerTool("lighthouse_crm_link_note_record", {
1229
1277
  title: "Link Record to Note",
1278
+ annotations: ADDS_IF_MISSING,
1230
1279
  description: "Attach a record to an existing note. Call once per record. " +
1231
1280
  "The company/person/deal record_id MUST be an EXISTING CRM record id (an id returned by lighthouse_crm_* tools), NOT a discovery id from lighthouse_discovery_* search results. To link a discovery result, first add it to your CRM and use the resulting CRM record id. " +
1232
1281
  LINK_NOT_VIEWABLE_RULE + " " +
@@ -1247,6 +1296,7 @@ export function createServer(apiKey, options = {}) {
1247
1296
  });
1248
1297
  server.registerTool("lighthouse_crm_unlink_note_record", {
1249
1298
  title: "Unlink Record from Note",
1299
+ annotations: DESTRUCTIVE,
1250
1300
  description: "Remove a record link from a note. Call once per record. " +
1251
1301
  "Removing the last linked record is allowed and leaves a standalone note (it stays in the workspace Notes section). " +
1252
1302
  UNLINK_NOT_VIEWABLE_KEPT,
@@ -1266,6 +1316,7 @@ export function createServer(apiKey, options = {}) {
1266
1316
  });
1267
1317
  server.registerTool("lighthouse_crm_list_note_tags", {
1268
1318
  title: "List Note Tags",
1319
+ annotations: READ_ONLY,
1269
1320
  description: "List the workspace's note tags: labeled, colored tags that can be attached to notes as a multi-select. " +
1270
1321
  "Returns an array of { permalink, label, color }. " +
1271
1322
  "Use this to find tag permalinks before attaching them to a note (via lighthouse_crm_create_note `tags` or lighthouse_crm_manage_note_tags). " +
@@ -1282,6 +1333,7 @@ export function createServer(apiKey, options = {}) {
1282
1333
  });
1283
1334
  server.registerTool("lighthouse_crm_create_note_tag", {
1284
1335
  title: "Create Note Tag",
1336
+ annotations: CREATES,
1285
1337
  description: "Create a new note tag in the workspace vocabulary. The tag can then be attached to notes. " +
1286
1338
  "Returns the created tag as { permalink, label, color }. " +
1287
1339
  "Color is an optional palette code (get available colors from lighthouse_crm_list_colors); omit it to auto-assign one.",
@@ -1300,6 +1352,7 @@ export function createServer(apiKey, options = {}) {
1300
1352
  });
1301
1353
  server.registerTool("lighthouse_crm_update_note_tag", {
1302
1354
  title: "Update Note Tag",
1355
+ annotations: DESTRUCTIVE,
1303
1356
  description: "Update a note tag's label and/or color. Only the fields you pass are changed. " +
1304
1357
  "Returns the updated tag as { permalink, label, color }.",
1305
1358
  inputSchema: {
@@ -1318,6 +1371,7 @@ export function createServer(apiKey, options = {}) {
1318
1371
  });
1319
1372
  server.registerTool("lighthouse_crm_delete_note_tag", {
1320
1373
  title: "Delete Note Tag",
1374
+ annotations: DESTRUCTIVE,
1321
1375
  description: "Delete a note tag from the workspace vocabulary. The tag is also detached from any notes it was attached to.",
1322
1376
  inputSchema: {
1323
1377
  permalink: z.string().describe("Tag permalink"),
@@ -1333,6 +1387,7 @@ export function createServer(apiKey, options = {}) {
1333
1387
  });
1334
1388
  server.registerTool("lighthouse_crm_manage_note_tags", {
1335
1389
  title: "Attach or Detach Note Tags",
1390
+ annotations: DESTRUCTIVE,
1336
1391
  description: "Attach or detach workspace tags on an existing note. Pass operation ADD to attach the tag permalinks, or REMOVE to detach them. " +
1337
1392
  "Find tag permalinks with lighthouse_crm_list_note_tags. Returns the updated note (including its current `tags`).",
1338
1393
  inputSchema: {
@@ -1353,6 +1408,7 @@ export function createServer(apiKey, options = {}) {
1353
1408
  });
1354
1409
  server.registerTool("lighthouse_crm_list_tasks", {
1355
1410
  title: "List Tasks",
1411
+ annotations: READ_ONLY,
1356
1412
  description: "List CRM tasks with optional filtering and sorting. " +
1357
1413
  'filters (CRM-style): {"operator":"AND","conditions":[{"field":"status","operator":"contains_any","value":["<status_value>"]}]} (status AND priority are option fields whose values are workspace-specific; fetch them with lighthouse_crm_list_options(record_type:\'task\', permalink:\'status\' | \'priority\'), and filter priority with contains_any like status, not with a numeric comparison); ' +
1358
1414
  'sort: [{"field":"due_date","direction":"asc"}]; ' +
@@ -1379,6 +1435,7 @@ export function createServer(apiKey, options = {}) {
1379
1435
  });
1380
1436
  server.registerTool("lighthouse_crm_get_task", {
1381
1437
  title: "Get Task",
1438
+ annotations: READ_ONLY,
1382
1439
  description: "Get a single CRM task by ID. " +
1383
1440
  "status and priority answer the stored VALUE and nothing else. The words your workspace puts on those values come from lighthouse_crm_list_options(record_type:'task', permalink:'status' | 'priority'), one call that covers every task you will ever read.",
1384
1441
  inputSchema: {
@@ -1395,6 +1452,7 @@ export function createServer(apiKey, options = {}) {
1395
1452
  });
1396
1453
  server.registerTool("lighthouse_crm_create_task", {
1397
1454
  title: "Create Task",
1455
+ annotations: CREATES,
1398
1456
  description: "Create a CRM task, optionally linked to records and assigned to users. " +
1399
1457
  "Status values are configured per workspace and are NOT fixed: call lighthouse_crm_list_options with record_type 'task' and permalink 'status' to get the allowed values, then pass the option's value (not its label) in `status`. " +
1400
1458
  "status and priority answer the stored VALUE and nothing else. The words your workspace puts on those values come from lighthouse_crm_list_options(record_type:'task', permalink:'status' | 'priority'), one call that covers every task you will ever read.",
@@ -1424,6 +1482,7 @@ export function createServer(apiKey, options = {}) {
1424
1482
  });
1425
1483
  server.registerTool("lighthouse_crm_update_task", {
1426
1484
  title: "Update Task",
1485
+ annotations: DESTRUCTIVE,
1427
1486
  description: "Update a CRM task. Provide only fields you want to change. " +
1428
1487
  "assigned_to replaces all current assignees. In records, " + TASK_LINKS_PER_TYPE.charAt(0).toLowerCase() + TASK_LINKS_PER_TYPE.slice(1) + " " +
1429
1488
  "Status values are configured per workspace and are NOT fixed: call lighthouse_crm_list_options with record_type 'task' and permalink 'status' to get the allowed values, then pass the option's value (not its label) in `status`. " +
@@ -1456,6 +1515,7 @@ export function createServer(apiKey, options = {}) {
1456
1515
  });
1457
1516
  server.registerTool("lighthouse_crm_delete_task", {
1458
1517
  title: "Delete Task",
1518
+ annotations: DESTRUCTIVE,
1459
1519
  description: "Permanently delete a CRM task.",
1460
1520
  inputSchema: {
1461
1521
  id: z.string().uuid().describe("Task ID"),
@@ -1471,6 +1531,7 @@ export function createServer(apiKey, options = {}) {
1471
1531
  });
1472
1532
  server.registerTool("lighthouse_documents_list", {
1473
1533
  title: "List Documents",
1534
+ annotations: READ_ONLY,
1474
1535
  description: "List folders and files at the workspace root, or inside a specific folder. " +
1475
1536
  "Returns { folders, files, breadcrumb } plus meta.folders_next_cursor / " +
1476
1537
  "meta.files_next_cursor for cursor-based pagination. Pass those cursors " +
@@ -1478,7 +1539,8 @@ export function createServer(apiKey, options = {}) {
1478
1539
  "Each row includes my_role ('owner' | 'editor' | 'viewer'), is_owner, " +
1479
1540
  "owner (the member who owns it: { id, first_name, last_name, full_name, avatar_url, email }), " +
1480
1541
  "sharing ('private' or 'workspace'), linked_records_count, and " +
1481
- "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}`,
1482
1544
  inputSchema: {
1483
1545
  folder_id: z.string().uuid().optional().describe("Folder UUID to list contents of (omit for workspace root)"),
1484
1546
  limit: z.number().int().min(1).max(500).optional().describe("Rows per sub-list (folders, files). Default 200, max 500"),
@@ -1496,6 +1558,7 @@ export function createServer(apiKey, options = {}) {
1496
1558
  });
1497
1559
  server.registerTool("lighthouse_documents_create_folder", {
1498
1560
  title: "Create Folder",
1561
+ annotations: CREATES,
1499
1562
  description: "Create a folder. At the workspace root, sharing is required. " +
1500
1563
  "Inside a parent folder, sharing is automatically inherited from the parent. " +
1501
1564
  "The folder row comes back with owner (the member who created it, { id, first_name, last_name, full_name, avatar_url, email }), is_owner, my_role, sharing ('private' or 'workspace'), shared_with_teams[] (each { team_id, team_name, role, granted_at }), linked_records_count, created_at and updated_at.",
@@ -1518,6 +1581,7 @@ export function createServer(apiKey, options = {}) {
1518
1581
  });
1519
1582
  server.registerTool("lighthouse_documents_get_folder", {
1520
1583
  title: "Get Folder",
1584
+ annotations: READ_ONLY,
1521
1585
  description: "WHEN TO USE: the user asks about ONE folder they already know (\"who can see the Investor Updates folder\", \"what is that folder linked to\"), or you have a folder id from lighthouse_documents_list and need its full row. " +
1522
1586
  "Returns the folder itself, not its contents: name, parent_folder_id, sharing ('private' or 'workspace'), shared_with_teams[] (each { team_id, team_name, role, granted_at }), is_owner, my_role ('owner' | 'editor' | 'viewer'), owner ({ id, first_name, last_name, full_name, avatar_url, email }), linked_records_count, linked_records[] (every link you can view, each { record_type, record_id, record_name, avatar_url, created_at }), created_at and updated_at. " +
1523
1587
  "To list what is INSIDE it, call lighthouse_documents_list with this folder_id. A folder you cannot open answers the same not found as one that does not exist.",
@@ -1536,6 +1600,7 @@ export function createServer(apiKey, options = {}) {
1536
1600
  });
1537
1601
  server.registerTool("lighthouse_documents_update_folder", {
1538
1602
  title: "Update Folder",
1603
+ annotations: DESTRUCTIVE,
1539
1604
  description: "Rename, move, or change sharing on a folder. Editors can rename the folder; only owners can move it, change its sharing, or grant team access. A folder that holds files or folders other people own can only be moved down, into a folder below where it is now that you own, with every folder in between yours too; any other move of it answers 403 forbidden. " +
1540
1605
  "Pass team_ids as the full desired list, anything not in the list is revoked. " +
1541
1606
  "Note the two meanings of folder_id across the document tools: here it is the folder being changed, while on lighthouse_documents_update_file it is the folder the file is moved INTO. " +
@@ -1562,6 +1627,7 @@ export function createServer(apiKey, options = {}) {
1562
1627
  });
1563
1628
  server.registerTool("lighthouse_documents_delete_folder", {
1564
1629
  title: "Delete Folder",
1630
+ annotations: DESTRUCTIVE,
1565
1631
  description: "Restricted to the folder owner. Deletes the folder and its entire subtree only when you own every descendant folder and file. If any descendant is owned by another user, the request is rejected (CANNOT_DELETE_OTHERS_CONTENT_IN_FOLDER) and nothing is deleted. Editors on the folder cannot delete it. Irreversible: there is no recycle bin and the underlying stored files are permanently removed.",
1566
1632
  inputSchema: {
1567
1633
  folder_id: z.string().uuid().describe("Folder UUID"),
@@ -1577,6 +1643,7 @@ export function createServer(apiKey, options = {}) {
1577
1643
  });
1578
1644
  server.registerTool("lighthouse_documents_link_folder_to_record", {
1579
1645
  title: "Link Folder to Record",
1646
+ annotations: ADDS_IF_MISSING,
1580
1647
  description: "Link a folder to a CRM record, associating the whole folder with the record. The folder then appears on the record (in lighthouse_documents_list_for_record's `folders`); its individual files are not flattened into the record's direct `files` list. " +
1581
1648
  "Answers THE FOLDER itself, the same row lighthouse_documents_get_folder returns (owner, is_owner, my_role, sharing, shared_with_teams[] each { team_id, team_name, role, granted_at }, linked_records_count and linked_records[] as they are after the link), so there is no second call to make.",
1582
1649
  inputSchema: {
@@ -1596,6 +1663,7 @@ export function createServer(apiKey, options = {}) {
1596
1663
  });
1597
1664
  server.registerTool("lighthouse_documents_unlink_folder_from_record", {
1598
1665
  title: "Unlink Folder from Record",
1666
+ annotations: DESTRUCTIVE,
1599
1667
  description: "Remove a folder→record link. Files inside stop appearing on the record's Files tab. Answers THE FOLDER itself, the same row lighthouse_documents_get_folder returns, with its links as they are after the call.",
1600
1668
  inputSchema: {
1601
1669
  folder_id: z.string().uuid().describe("Folder UUID"),
@@ -1614,6 +1682,7 @@ export function createServer(apiKey, options = {}) {
1614
1682
  });
1615
1683
  server.registerTool("lighthouse_documents_upload_file", {
1616
1684
  title: "Upload File",
1685
+ annotations: CREATES,
1617
1686
  description: "Reserve a file row and get a signed upload URL: " +
1618
1687
  "1) call this; 2) POST the file to upload_url as multipart/form-data, first append every key/value " +
1619
1688
  "from the returned upload_fields, then append the file last under the field name 'file'. " +
@@ -1641,7 +1710,9 @@ export function createServer(apiKey, options = {}) {
1641
1710
  });
1642
1711
  server.registerTool("lighthouse_documents_get_file", {
1643
1712
  title: "Get File",
1644
- 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.",
1713
+ annotations: READ_ONLY,
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}`,
1645
1716
  inputSchema: {
1646
1717
  file_id: z.string().uuid().describe("File UUID"),
1647
1718
  download_url: z.boolean().optional().describe("Include a short-lived signed download URL"),
@@ -1658,12 +1729,14 @@ export function createServer(apiKey, options = {}) {
1658
1729
  });
1659
1730
  server.registerTool("lighthouse_documents_get_file_content", {
1660
1731
  title: "Read File",
1732
+ annotations: READ_ONLY,
1661
1733
  description: "What one stored file SAYS, as text: PDFs, Word, Excel and PowerPoint files (and their OpenDocument versions), text, CSV and HTML. " +
1662
1734
  "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\"). " +
1663
1735
  "Take the file id from lighthouse_documents_list or lighthouse_documents_list_for_record. " +
1664
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. " +
1665
1737
  FILE_CONTENT_RULES +
1666
- " 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}`,
1667
1740
  inputSchema: {
1668
1741
  file_id: z.string().uuid().optional().describe("File UUID. The name every other file tool takes."),
1669
1742
  id: z.string().uuid().optional().describe("Same as file_id. Send either one."),
@@ -1691,9 +1764,11 @@ export function createServer(apiKey, options = {}) {
1691
1764
  });
1692
1765
  server.registerTool("lighthouse_documents_update_file", {
1693
1766
  title: "Update File",
1767
+ annotations: DESTRUCTIVE,
1694
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. " +
1695
1769
  "team_ids replaces the current set when supplied. " +
1696
- "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,
1697
1772
  inputSchema: {
1698
1773
  file_id: z.string().uuid().describe("File UUID, the file being changed"),
1699
1774
  name: z.string().optional().describe("New name"),
@@ -1716,7 +1791,8 @@ export function createServer(apiKey, options = {}) {
1716
1791
  });
1717
1792
  server.registerTool("lighthouse_documents_delete_file", {
1718
1793
  title: "Delete File",
1719
- 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.",
1794
+ annotations: DESTRUCTIVE,
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,
1720
1796
  inputSchema: {
1721
1797
  file_id: z.string().uuid().describe("File UUID"),
1722
1798
  },
@@ -1731,7 +1807,8 @@ export function createServer(apiKey, options = {}) {
1731
1807
  });
1732
1808
  server.registerTool("lighthouse_documents_link_file_to_record", {
1733
1809
  title: "Link File to Record",
1734
- 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.",
1810
+ annotations: ADDS_IF_MISSING,
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,
1735
1812
  inputSchema: {
1736
1813
  file_id: z.string().uuid().describe("File UUID"),
1737
1814
  record_type: z.string().describe("company, person, deal, or a custom object permalink"),
@@ -1749,7 +1826,8 @@ export function createServer(apiKey, options = {}) {
1749
1826
  });
1750
1827
  server.registerTool("lighthouse_documents_unlink_file_from_record", {
1751
1828
  title: "Unlink File from Record",
1752
- 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.",
1829
+ annotations: DESTRUCTIVE,
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,
1753
1831
  inputSchema: {
1754
1832
  file_id: z.string().uuid().describe("File UUID"),
1755
1833
  record_type: z.string().describe("company, person, deal, or a custom object permalink"),
@@ -1767,9 +1845,11 @@ export function createServer(apiKey, options = {}) {
1767
1845
  });
1768
1846
  server.registerTool("lighthouse_documents_list_for_record", {
1769
1847
  title: "List a Record's Documents",
1848
+ annotations: READ_ONLY,
1770
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. " +
1771
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 }). " +
1772
- "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}`,
1773
1853
  inputSchema: {
1774
1854
  record_type: z.string().describe("company, person, deal, or a custom object permalink"),
1775
1855
  record_id: z.string().uuid().describe("Record UUID"),
@@ -1796,6 +1876,7 @@ export function createServer(apiKey, options = {}) {
1796
1876
  });
1797
1877
  server.registerTool("lighthouse_crm_list_views", {
1798
1878
  title: "List CRM Views",
1879
+ annotations: READ_ONLY,
1799
1880
  description: "Get all saved CRM views, or list views scoped to a specific list. " +
1800
1881
  "Supports built-in types and custom objects. " +
1801
1882
  "Views store filter/sort configurations that can be loaded in search_records. " +
@@ -1815,6 +1896,7 @@ export function createServer(apiKey, options = {}) {
1815
1896
  });
1816
1897
  server.registerTool("lighthouse_crm_update_view", {
1817
1898
  title: "Update CRM View",
1899
+ annotations: DESTRUCTIVE,
1818
1900
  description: "Update a saved CRM view or list view's name, filters, sort, or layout. " +
1819
1901
  "Automatically detects whether it's a CRM view or list view. " +
1820
1902
  "To change sharing settings, use lighthouse_crm_update_view_sharing instead. " +
@@ -1852,6 +1934,7 @@ export function createServer(apiKey, options = {}) {
1852
1934
  });
1853
1935
  server.registerTool("lighthouse_crm_get_view", {
1854
1936
  title: "Get CRM View",
1937
+ annotations: READ_ONLY,
1855
1938
  description: "Get a single saved CRM view or list view by ID. Automatically detects the view type. The filters come back as saved, a semantic_search condition with its vector included: reuse such a condition verbatim in lighthouse_crm_search_records or lighthouse_crm_list_list_records. Conditions on lists you cannot open are left out, and so are conditions on custom fields that belong to such a list. The row carries record_type, owner and is_owner (true when the API key's user owns the view). A kanban view's board settings come with it: hidden_kanban_groups (the values whose column is collapsed, always an array) and kanban_summary (the number at the top of each column, or null), both writable through lighthouse_crm_update_view exactly as they are read.",
1856
1939
  inputSchema: {
1857
1940
  id: z.string().uuid().describe("View ID"),
@@ -1867,6 +1950,7 @@ export function createServer(apiKey, options = {}) {
1867
1950
  });
1868
1951
  server.registerTool("lighthouse_crm_delete_view", {
1869
1952
  title: "Delete CRM View",
1953
+ annotations: DESTRUCTIVE,
1870
1954
  description: "Permanently delete a saved CRM view or list view. Automatically detects the view type.",
1871
1955
  inputSchema: {
1872
1956
  id: z.string().uuid().describe("View ID"),
@@ -1882,6 +1966,7 @@ export function createServer(apiKey, options = {}) {
1882
1966
  });
1883
1967
  server.registerTool("lighthouse_workspace_me", {
1884
1968
  title: "Get Current User",
1969
+ annotations: READ_ONLY,
1885
1970
  description: "Get the profile of the user this key acts as: id, name, email, avatar, phone_number, status, created_at, workspace_role, permissions and `teams` ([{ id, name, role, joined_at }], [] when they are on none). " +
1886
1971
  "It is the same object lighthouse_workspace_get_user answers for anyone else, so read it once to learn who you are acting as and what that role may do, rather than calling the user tool on yourself.",
1887
1972
  inputSchema: {},
@@ -1896,6 +1981,7 @@ export function createServer(apiKey, options = {}) {
1896
1981
  });
1897
1982
  server.registerTool("lighthouse_workspace_list_users", {
1898
1983
  title: "List Workspace Users",
1984
+ annotations: READ_ONLY,
1899
1985
  description: "List users in the workspace. Use this to find user IDs for task assignment, note tagging, or filtering by user. " +
1900
1986
  "Supports search by name/email.",
1901
1987
  inputSchema: {
@@ -1916,6 +2002,7 @@ export function createServer(apiKey, options = {}) {
1916
2002
  });
1917
2003
  server.registerTool("lighthouse_workspace_get_user", {
1918
2004
  title: "Get Workspace User",
2005
+ annotations: READ_ONLY,
1919
2006
  description: "Get a specific workspace user's profile by ID.",
1920
2007
  inputSchema: {
1921
2008
  id: z.string().uuid().describe("User ID"),
@@ -1931,6 +2018,7 @@ export function createServer(apiKey, options = {}) {
1931
2018
  });
1932
2019
  server.registerTool("lighthouse_workspace_list_teams", {
1933
2020
  title: "List Workspace Teams",
2021
+ annotations: READ_ONLY,
1934
2022
  description: "List all teams in the workspace.",
1935
2023
  inputSchema: {
1936
2024
  limit: z.number().min(1).max(100).default(25),
@@ -1947,6 +2035,7 @@ export function createServer(apiKey, options = {}) {
1947
2035
  });
1948
2036
  server.registerTool("lighthouse_workspace_get_team", {
1949
2037
  title: "Get Workspace Team",
2038
+ annotations: READ_ONLY,
1950
2039
  description: "Get a specific team by ID with its members.",
1951
2040
  inputSchema: {
1952
2041
  id: z.string().uuid().describe("Team ID"),
@@ -1966,6 +2055,7 @@ export function createServer(apiKey, options = {}) {
1966
2055
  const WEBHOOK_SUBSCRIPTION_WRITES = "This tool only reads: creating and deleting webhook subscriptions is done with the REST API (POST /v1/webhook-subscriptions and DELETE /v1/webhook-subscriptions/{id}) or the SDKs, with an API key, and a subscription can also be created or deleted in the app, under Settings > Developer > Webhooks. A turned-off subscription is not turned back on: delete it and create a new one.";
1967
2056
  server.registerTool("lighthouse_workspace_list_webhook_subscriptions", {
1968
2057
  title: "List Webhook Subscriptions",
2058
+ annotations: READ_ONLY,
1969
2059
  description: "List the webhook subscriptions of the user this key acts as (only their own), newest first, active and turned off alike: each one sends a signed POST to its url whenever a record of its record_type is created or updated. " +
1970
2060
  WEBHOOK_SUBSCRIPTION_SHAPE + " " +
1971
2061
  "WHEN TO USE: \"which webhooks do I have\", \"where are our company updates being sent\", \"why did our integration stop receiving changes\" (read status, disabled_reason and last_delivery). " +
@@ -1985,6 +2075,7 @@ export function createServer(apiKey, options = {}) {
1985
2075
  });
1986
2076
  server.registerTool("lighthouse_workspace_get_webhook_subscription", {
1987
2077
  title: "Get Webhook Subscription",
2078
+ annotations: READ_ONLY,
1988
2079
  description: "Get one of the webhook subscriptions of the user this key acts as, by id. " +
1989
2080
  WEBHOOK_SUBSCRIPTION_SHAPE + " " +
1990
2081
  "WHEN TO USE: \"is that webhook still on\", \"when did it last deliver\", for one subscription whose id you already have (from lighthouse_workspace_list_webhook_subscriptions). " +
@@ -2003,6 +2094,7 @@ export function createServer(apiKey, options = {}) {
2003
2094
  });
2004
2095
  server.registerTool("lighthouse_discovery_list_attributes", {
2005
2096
  title: "List Discovery Data Fields",
2097
+ annotations: READ_ONLY,
2006
2098
  description: "Get a lightweight catalog of the DATA FIELDS discovery results carry for companies or people: each field's permalink, label, data_type, links_to, read_only, and has_options. " +
2007
2099
  "These are OUTPUT/data fields, NOT filter keys. To FILTER a search you MUST use the prefixed company_* / person_* keys listed in the `filters` parameter of the discovery search tools. " +
2008
2100
  "To keep the response small it does NOT include option values: when a field has has_options=true and you need its allowed values, call lighthouse_discovery_list_options with that permalink (or the dedicated lighthouse_discovery_list_industries / lighthouse_discovery_list_locations / lighthouse_discovery_list_funding_types / lighthouse_discovery_list_tags tools). " +
@@ -2029,6 +2121,7 @@ export function createServer(apiKey, options = {}) {
2029
2121
  });
2030
2122
  server.registerTool("lighthouse_discovery_list_options", {
2031
2123
  title: "List Discovery Field Options",
2124
+ annotations: READ_ONLY,
2032
2125
  description: "Get the allowed values (value, label, color) for a single discovery select / multi_select field. " +
2033
2126
  "Call this when lighthouse_discovery_list_attributes shows has_options=true for a field and you need its valid values. " +
2034
2127
  "Results are paginated (max 100 per page): use `search` to narrow by label, and `limit` / `offset` to page through a field with many values (e.g. locations). " +
@@ -2052,6 +2145,7 @@ export function createServer(apiKey, options = {}) {
2052
2145
  });
2053
2146
  server.registerTool("lighthouse_discovery_search_companies", {
2054
2147
  title: "Search Discovery Companies",
2148
+ annotations: READ_ONLY,
2055
2149
  description: "Search Lighthouse's global company database to DISCOVER new companies. " +
2056
2150
  "This searches Lighthouse's enriched database (millions of companies), NOT your CRM. " +
2057
2151
  "WORKFLOW: 1) Build filters using ONLY the company_* filter keys listed in the `filters` parameter below. " +
@@ -2082,6 +2176,7 @@ export function createServer(apiKey, options = {}) {
2082
2176
  });
2083
2177
  server.registerTool("lighthouse_discovery_search_people", {
2084
2178
  title: "Search Discovery People",
2179
+ annotations: READ_ONLY,
2085
2180
  description: "Search Lighthouse's global people database to DISCOVER new people. " +
2086
2181
  "This searches Lighthouse's enriched database, NOT your CRM. " +
2087
2182
  "WORKFLOW: 1) Build filters using ONLY the person_* filter keys listed in the `filters` parameter below. " +
@@ -2112,6 +2207,7 @@ export function createServer(apiKey, options = {}) {
2112
2207
  });
2113
2208
  server.registerTool("lighthouse_discovery_semantic_search_companies", {
2114
2209
  title: "Semantic Search Companies",
2210
+ annotations: READ_ONLY,
2115
2211
  description: "Find companies by MEANING, not exact keywords. Describe the kind of company you are looking for in plain language (what it does, who it serves, its sector, its business model) and Lighthouse returns the most semantically similar companies from its global database (NOT your CRM), ranked by relevance. " +
2116
2212
  "USE THIS when the request is conceptual or hard to express as exact filters, e.g. 'companies like Stripe but for healthcare', 'startups building developer tools for AI agents', 'sustainable packaging for e-commerce'. " +
2117
2213
  "You CAN combine it with structured filters: pass `filters` using the same company_* keys as lighthouse_discovery_search_companies and they apply together (semantic match AND your filters), e.g. semantically-similar companies that are ALSO headquartered in Germany. " +
@@ -2146,6 +2242,7 @@ export function createServer(apiKey, options = {}) {
2146
2242
  });
2147
2243
  server.registerTool("lighthouse_discovery_semantic_search_people", {
2148
2244
  title: "Semantic Search People",
2245
+ annotations: READ_ONLY,
2149
2246
  description: "Find people by MEANING, not exact keywords. Describe the ideal person in plain language (their role, skills, the industry they work in, their background) and Lighthouse returns the most semantically similar people from its global database (NOT your CRM), ranked by relevance. " +
2150
2247
  "USE THIS when the request is conceptual or hard to express as exact filters, e.g. 'technical co-founders who worked on payments infrastructure', 'ex-FAANG ML engineers now in climate tech'. " +
2151
2248
  "You CAN combine it with structured filters: pass `filters` using the same person_* keys as lighthouse_discovery_search_people and they apply together (semantic match AND your filters), e.g. semantically-similar people who are ALSO based in the UK. " +
@@ -2180,6 +2277,7 @@ export function createServer(apiKey, options = {}) {
2180
2277
  });
2181
2278
  server.registerTool("lighthouse_discovery_lookup_by_domain", {
2182
2279
  title: "Lookup Companies by Domain",
2280
+ annotations: READ_ONLY,
2183
2281
  description: "Look up companies in Lighthouse's global database by their website domain(s). " +
2184
2282
  "Returns enriched company profiles for matching domains. Max 1000 domains per request. " +
2185
2283
  "Each returned profile is condensed (the long growth and workforce history and the activity-signal " +
@@ -2203,6 +2301,7 @@ export function createServer(apiKey, options = {}) {
2203
2301
  });
2204
2302
  server.registerTool("lighthouse_discovery_lookup_by_linkedin", {
2205
2303
  title: "Lookup Companies by LinkedIn",
2304
+ annotations: READ_ONLY,
2206
2305
  description: "Look up companies in Lighthouse's global database by LinkedIn URL(s). " +
2207
2306
  "Returns enriched company profiles for matching LinkedIn company pages. Max 1000 URLs per request. " +
2208
2307
  "Each returned profile is condensed (the long growth and workforce history and the activity-signal " +
@@ -2226,6 +2325,7 @@ export function createServer(apiKey, options = {}) {
2226
2325
  });
2227
2326
  server.registerTool("lighthouse_discovery_lookup_people_by_linkedin", {
2228
2327
  title: "Lookup People by LinkedIn",
2328
+ annotations: READ_ONLY,
2229
2329
  description: "Look up people in Lighthouse's global database by LinkedIn profile URL(s). " +
2230
2330
  "Returns enriched person profiles for matching LinkedIn pages. Max 1000 URLs per request. " +
2231
2331
  "Each returned profile is condensed (the activity-signal feed is omitted and the associated-companies " +
@@ -2249,6 +2349,7 @@ export function createServer(apiKey, options = {}) {
2249
2349
  });
2250
2350
  server.registerTool("lighthouse_discovery_list_saved_searches", {
2251
2351
  title: "List Saved Searches",
2352
+ annotations: READ_ONLY,
2252
2353
  description: "List all saved discovery searches in the workspace. " +
2253
2354
  "Returns saved searches you own and workspace-shared searches. " +
2254
2355
  "Each search includes its filters and type (company or person). " +
@@ -2265,6 +2366,7 @@ export function createServer(apiKey, options = {}) {
2265
2366
  });
2266
2367
  server.registerTool("lighthouse_discovery_list_search_results", {
2267
2368
  title: "List Saved Search Results",
2369
+ annotations: READ_ONLY,
2268
2370
  description: "Run a saved discovery search and get its results. " +
2269
2371
  "The search's saved filters are applied automatically. " +
2270
2372
  "Optionally filter by signal creation date range. " +
@@ -2288,6 +2390,7 @@ export function createServer(apiKey, options = {}) {
2288
2390
  });
2289
2391
  server.registerTool("lighthouse_discovery_list_locations", {
2290
2392
  title: "List Location Options",
2393
+ annotations: READ_ONLY,
2291
2394
  description: "Get available location options (countries and regions) for discovery filters. " +
2292
2395
  "ALWAYS call this to get valid country/region values before filtering by location fields " +
2293
2396
  "(company_hq_country, company_hq_region, person_country, person_region, company_investor_country, etc.). " +
@@ -2308,6 +2411,7 @@ export function createServer(apiKey, options = {}) {
2308
2411
  });
2309
2412
  server.registerTool("lighthouse_discovery_list_industries", {
2310
2413
  title: "List Industry & Category Options",
2414
+ annotations: READ_ONLY,
2311
2415
  description: "Get available industry and category options for discovery filters. " +
2312
2416
  "ALWAYS call this to get valid category values before filtering by company_categories, company_sdg_impact, " +
2313
2417
  "person_current_categories, person_past_categories, person_current_sdg_impact, or person_past_sdg_impact, " +
@@ -2329,6 +2433,7 @@ export function createServer(apiKey, options = {}) {
2329
2433
  });
2330
2434
  server.registerTool("lighthouse_discovery_list_tags", {
2331
2435
  title: "List Tag Options",
2436
+ annotations: READ_ONLY,
2332
2437
  description: "Get the tag options for discovery filters. " +
2333
2438
  "ALWAYS call this to get valid tag values before filtering by company_tags or person_tags. " +
2334
2439
  "Returns {company_tags: [{value, name}], person_tags: [{value, name}]}. " +
@@ -2345,6 +2450,7 @@ export function createServer(apiKey, options = {}) {
2345
2450
  });
2346
2451
  server.registerTool("lighthouse_discovery_list_funding_types", {
2347
2452
  title: "List Funding Type Options",
2453
+ annotations: READ_ONLY,
2348
2454
  description: "Get the funding round types available for the funding discovery filters " +
2349
2455
  "(company_last_funding_type, person_company_last_funding_type). " +
2350
2456
  "Returns an array of { value, label, name } ordered by stage. " +
@@ -2363,6 +2469,7 @@ export function createServer(apiKey, options = {}) {
2363
2469
  });
2364
2470
  server.registerTool("lighthouse_discovery_search_investors", {
2365
2471
  title: "Search Investors by Name",
2472
+ annotations: READ_ONLY,
2366
2473
  description: "Search for investors by name to get their UUIDs for use in discovery filters. " +
2367
2474
  "Use the returned 'id' values in company_investor filter conditions (investor_multi_select type). " +
2368
2475
  "Returns [{id, name, avatar_url}]. Pass the id (or [{id, label}] objects) as the filter value. " +
@@ -2381,6 +2488,7 @@ export function createServer(apiKey, options = {}) {
2381
2488
  });
2382
2489
  server.registerTool("lighthouse_discovery_search_organizations", {
2383
2490
  title: "Search Organizations by Name",
2491
+ annotations: READ_ONLY,
2384
2492
  description: "Search for organizations (companies) by name to get their UUIDs for use in discovery filters. " +
2385
2493
  "Use the returned 'id' values in work_experience_multi_select filter conditions " +
2386
2494
  "(company_team_past_companies, person_past_companies). " +
@@ -2401,6 +2509,7 @@ export function createServer(apiKey, options = {}) {
2401
2509
  });
2402
2510
  server.registerTool("lighthouse_discovery_search_schools", {
2403
2511
  title: "Search Schools by Name",
2512
+ annotations: READ_ONLY,
2404
2513
  description: "Search for educational institutions by name to get their UUIDs for use in discovery filters. " +
2405
2514
  "Use the returned 'id' values in education_multi_select filter conditions " +
2406
2515
  "(company_team_education, person_education). " +
@@ -2420,6 +2529,7 @@ export function createServer(apiKey, options = {}) {
2420
2529
  });
2421
2530
  server.registerTool("lighthouse_discovery_search_entities", {
2422
2531
  title: "Search Entities (companies, people, investors, schools)",
2532
+ annotations: READ_ONLY,
2423
2533
  description: "Similarity-ranked, typo-tolerant search across companies, people, investors, and schools. " +
2424
2534
  "Returns lightweight matches (id, record_type, name, avatar_url, is_discoverable, plus a few " +
2425
2535
  "disambiguators like domain/employees/location) ranked by relevance then popularity (e.g. " +
@@ -2456,6 +2566,7 @@ export function createServer(apiKey, options = {}) {
2456
2566
  });
2457
2567
  server.registerTool("lighthouse_discovery_get_company", {
2458
2568
  title: "Get Company Profile by ID",
2569
+ annotations: READ_ONLY,
2459
2570
  description: "Returns the profile for a single company by id: firmographics, funding, categories, tags, links, " +
2460
2571
  "and the first several associated people. " +
2461
2572
  "To stay within response limits, this returns a condensed view: the long growth and workforce " +
@@ -2482,6 +2593,7 @@ export function createServer(apiKey, options = {}) {
2482
2593
  });
2483
2594
  server.registerTool("lighthouse_discovery_get_person", {
2484
2595
  title: "Get Person Profile by ID",
2596
+ annotations: READ_ONLY,
2485
2597
  description: "Returns the profile for a single person by id: bio, links, founder score, person tags, " +
2486
2598
  "the first several associated companies / work history, and education. " +
2487
2599
  "To stay within response limits, this returns a condensed view: the activity-signal feed is omitted, " +
@@ -2506,6 +2618,7 @@ export function createServer(apiKey, options = {}) {
2506
2618
  });
2507
2619
  server.registerTool("lighthouse_discovery_get_company_traction", {
2508
2620
  title: "Get Company Traction History",
2621
+ annotations: READ_ONLY,
2509
2622
  description: "The traction history of one company in the global database, by company id: the two blocks lighthouse_discovery_get_company omits to stay small. " +
2510
2623
  "workforce_data is the month-by-month headcount and open-job-post history behind the traction charts (series of { date, value } points, oldest first where present); growth_metrics is a summary per tracked metric (headcount, job posts, web traffic, social following and similar, where recorded): { last_value, change_1m, change_3m, change_6m, change_1y, change_2y, change_3y, change_5y, date_last_value }. " +
2511
2624
  "WHEN TO USE: \"what was their headcount in March\", \"is this company growing\", \"when did hiring pick up\", \"how has traffic moved this year\": anything about a value over time or a rate of change. The profile tool only carries the current headcount. " +
@@ -2524,6 +2637,7 @@ export function createServer(apiKey, options = {}) {
2524
2637
  });
2525
2638
  server.registerTool("lighthouse_discovery_get_company_signals", {
2526
2639
  title: "Get Company Signals",
2640
+ annotations: READ_ONLY,
2527
2641
  description: "The detected events for one company in the global database, by company id: the activity-signal feed lighthouse_discovery_get_company omits. " +
2528
2642
  "signals is a list of { type, created_at, timeframe, percentage_change }: a funding round, a headcount or traffic jump, a hiring spike and similar, each with when it was detected and, for a change, over which timeframe and by how much. Sort by created_at yourself; the list is not ordered. " +
2529
2643
  "WHEN TO USE: \"what happened at X recently\", \"did they raise\", \"is headcount spiking\", \"any momentum signals\". For the numbers behind a signal read lighthouse_discovery_get_company_traction. " +
@@ -2542,6 +2656,7 @@ export function createServer(apiKey, options = {}) {
2542
2656
  });
2543
2657
  server.registerTool("lighthouse_discovery_get_person_signals", {
2544
2658
  title: "Get Person Signals",
2659
+ annotations: READ_ONLY,
2545
2660
  description: "The detected events for one person in the global database, by person id: the signal feed lighthouse_discovery_get_person omits. " +
2546
2661
  "signals is a list of { type, created_at, new_company, domain, org_id, predicted_areas }: a new company founded or registered, a stealth period, a departure and similar; org_id is a company id you can pass to lighthouse_discovery_get_company, domain its website. Sort by created_at yourself. " +
2547
2662
  "predicted_areas is what Lighthouse predicts the founder is building, on a stealth or out_of_stealth signal: [{ permalink, label, origin }], permalink being a value of lighthouse_discovery_list_industries → stealth_predicted_areas and origin what the prediction was read from; it is [] on every other signal and when nothing was predicted. " +
@@ -2561,6 +2676,7 @@ export function createServer(apiKey, options = {}) {
2561
2676
  });
2562
2677
  server.registerTool("lighthouse_discovery_get_entity_by_name", {
2563
2678
  title: "Get Entity Profile by Name",
2679
+ annotations: READ_ONLY,
2564
2680
  description: "DEFAULT way to get the COMPLETE profile of ONE company or person when you have its name. " +
2565
2681
  "Resolves the single best-ranked match and returns its full profile in one call (equivalent to " +
2566
2682
  "lighthouse_discovery_search_entities followed by lighthouse_discovery_get_company / get_person, combined). " +
@@ -2607,6 +2723,7 @@ export function createServer(apiKey, options = {}) {
2607
2723
  "ALWAYS call lighthouse_crm_list_attributes first to discover valid field keys for dimension, measure, and stage_field.";
2608
2724
  server.registerTool("lighthouse_reports_list_dashboards", {
2609
2725
  title: "List Dashboards",
2726
+ annotations: READ_ONLY,
2610
2727
  description: "List all report dashboards accessible to the current user. " +
2611
2728
  "Returns dashboards you own, workspace-shared dashboards, and team-shared dashboards. " +
2612
2729
  "Each dashboard includes: id, name, description, sharing (private/workspace), is_owner, " +
@@ -2623,6 +2740,7 @@ export function createServer(apiKey, options = {}) {
2623
2740
  });
2624
2741
  server.registerTool("lighthouse_reports_create_dashboard", {
2625
2742
  title: "Create Dashboard",
2743
+ annotations: CREATES,
2626
2744
  description: "Create a new report dashboard. Dashboards contain report widgets (charts, tables, KPIs). " +
2627
2745
  "Requires reports.create permission. Returns the full dashboard object (same format as get dashboard).",
2628
2746
  inputSchema: {
@@ -2648,6 +2766,7 @@ export function createServer(apiKey, options = {}) {
2648
2766
  });
2649
2767
  server.registerTool("lighthouse_reports_get_dashboard", {
2650
2768
  title: "Get Dashboard",
2769
+ annotations: READ_ONLY,
2651
2770
  description: "Get a single dashboard by ID with all its widgets. " +
2652
2771
  "Returns: id, name, description, sharing, is_owner, owner {id, first_name, last_name, full_name, avatar_url, email}, " +
2653
2772
  "widget_count, shared_with_teams, widgets [{id, name, widget_type, config, created_at, updated_at}], created_at, updated_at.",
@@ -2665,6 +2784,7 @@ export function createServer(apiKey, options = {}) {
2665
2784
  });
2666
2785
  server.registerTool("lighthouse_reports_update_dashboard", {
2667
2786
  title: "Update Dashboard",
2787
+ annotations: DESTRUCTIVE,
2668
2788
  description: "Update a dashboard's name, description, or sharing. Only the dashboard owner can update. " +
2669
2789
  "Returns the full dashboard object (same format as get dashboard), plus shared_along and audience_cannot_open. " +
2670
2790
  SHARE_ALONG_RULE,
@@ -2690,6 +2810,7 @@ export function createServer(apiKey, options = {}) {
2690
2810
  });
2691
2811
  server.registerTool("lighthouse_reports_delete_dashboard", {
2692
2812
  title: "Delete Dashboard",
2813
+ annotations: DESTRUCTIVE,
2693
2814
  description: "Permanently delete a dashboard and all its widgets. Only the dashboard owner can delete.",
2694
2815
  inputSchema: {
2695
2816
  id: z.string().uuid().describe("Dashboard ID"),
@@ -2705,6 +2826,7 @@ export function createServer(apiKey, options = {}) {
2705
2826
  });
2706
2827
  server.registerTool("lighthouse_reports_list_widgets", {
2707
2828
  title: "List Dashboard Widgets",
2829
+ annotations: READ_ONLY,
2708
2830
  description: "Get all widgets for a dashboard. " +
2709
2831
  "Each widget includes: id, name, widget_type, config, created_at, updated_at.",
2710
2832
  inputSchema: {
@@ -2722,6 +2844,7 @@ export function createServer(apiKey, options = {}) {
2722
2844
  });
2723
2845
  server.registerTool("lighthouse_reports_create_widget", {
2724
2846
  title: "Create Report Widget",
2847
+ annotations: CREATES,
2725
2848
  description: "Create a report widget (chart/table/KPI) on a dashboard. " +
2726
2849
  "WORKFLOW: 1) Call lighthouse_crm_list_attributes to discover field keys for the entity type, " +
2727
2850
  "2) Build a config object using those keys, 3) Create the widget. " +
@@ -2750,6 +2873,7 @@ export function createServer(apiKey, options = {}) {
2750
2873
  });
2751
2874
  server.registerTool("lighthouse_reports_update_widget", {
2752
2875
  title: "Rename Report Widget",
2876
+ annotations: DESTRUCTIVE,
2753
2877
  description: "Rename a report widget. Only the name can be changed, a widget's type and " +
2754
2878
  "configuration are fixed once created. To change what a widget reports, delete " +
2755
2879
  "it and create a new one. " +
@@ -2771,6 +2895,7 @@ export function createServer(apiKey, options = {}) {
2771
2895
  });
2772
2896
  server.registerTool("lighthouse_reports_delete_widget", {
2773
2897
  title: "Delete Report Widget",
2898
+ annotations: DESTRUCTIVE,
2774
2899
  description: "Permanently delete a report widget from a dashboard.",
2775
2900
  inputSchema: {
2776
2901
  dashboard_id: z.string().uuid().describe("Dashboard ID"),
@@ -2787,6 +2912,7 @@ export function createServer(apiKey, options = {}) {
2787
2912
  });
2788
2913
  server.registerTool("lighthouse_reports_get_widget_data", {
2789
2914
  title: "Get Widget Report Data",
2915
+ annotations: READ_ONLY,
2790
2916
  description: "Fetch the aggregated report data for a specific widget. " +
2791
2917
  "Returns the computed data based on the widget's config (dimensions, measures, segments, filters). " +
2792
2918
  "For standard reports: array of {label, label_is_empty, value, segment?, segment_is_empty?} rows (label = the grouped dimension value, value = the aggregated measure, segment = present only when segment_by is set). " +
@@ -2808,6 +2934,7 @@ export function createServer(apiKey, options = {}) {
2808
2934
  });
2809
2935
  server.registerTool("lighthouse_reports_share_dashboard_with_teams", {
2810
2936
  title: "Share Dashboard with Teams",
2937
+ annotations: ADDS_IF_MISSING,
2811
2938
  description: "Grant team-level access to a dashboard. Only the dashboard owner can share. " +
2812
2939
  "Use lighthouse_workspace_list_teams to find team IDs first. " +
2813
2940
  "Requires reports.edit permission. " +
@@ -2832,6 +2959,7 @@ export function createServer(apiKey, options = {}) {
2832
2959
  });
2833
2960
  server.registerTool("lighthouse_reports_revoke_dashboard_from_teams", {
2834
2961
  title: "Revoke Dashboard Team Access",
2962
+ annotations: DESTRUCTIVE,
2835
2963
  description: "Revoke team-level access from a dashboard. Only the dashboard owner can revoke. " +
2836
2964
  "Requires reports.edit permission.",
2837
2965
  inputSchema: {
@@ -2850,6 +2978,7 @@ export function createServer(apiKey, options = {}) {
2850
2978
  });
2851
2979
  server.registerTool("lighthouse_crm_share_view_with_teams", {
2852
2980
  title: "Share View with Teams",
2981
+ annotations: ADDS_IF_MISSING,
2853
2982
  description: "Grant team-level access to a CRM view or list view. Only the view owner can share. " +
2854
2983
  "Use lighthouse_workspace_list_teams to find team IDs first. " +
2855
2984
  "Requires views.share permission. " +
@@ -2875,6 +3004,7 @@ export function createServer(apiKey, options = {}) {
2875
3004
  });
2876
3005
  server.registerTool("lighthouse_crm_revoke_view_from_teams", {
2877
3006
  title: "Revoke View Team Access",
3007
+ annotations: DESTRUCTIVE,
2878
3008
  description: "Revoke team-level access from a CRM view or list view. Only the view owner can revoke. " +
2879
3009
  "Requires views.share permission. " +
2880
3010
  "Returns formatted view: {id, name, record_type, list_id, view_type, filters, sort, grouped_by, sharing, owner, is_owner, shared_with_teams, created_at}, where is_owner says whether the API key's user owns the view.",
@@ -2894,6 +3024,7 @@ export function createServer(apiKey, options = {}) {
2894
3024
  });
2895
3025
  server.registerTool("lighthouse_crm_update_view_sharing", {
2896
3026
  title: "Update View Sharing Status",
3027
+ annotations: DESTRUCTIVE,
2897
3028
  description: "Change a CRM view or list view's sharing visibility. Setting to 'private' also removes all team permissions. " +
2898
3029
  "Automatically detects view type. Only the view owner can change sharing. Requires views.share permission. " +
2899
3030
  "Returns formatted view: {id, name, record_type, list_id, view_type, filters, sort, grouped_by, sharing, owner, is_owner, shared_with_teams, created_at, shared_along, audience_cannot_open}, where is_owner says whether the API key's user owns the view. " +
@@ -2916,8 +3047,189 @@ export function createServer(apiKey, options = {}) {
2916
3047
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2917
3048
  }
2918
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
+ });
2919
3230
  server.registerTool("lighthouse_inbox_email_accounts", {
2920
3231
  title: "List Email Accounts",
3232
+ annotations: READ_ONLY,
2921
3233
  description: "List the email accounts the user can access: their own connected mailbox plus mailboxes other workspace " +
2922
3234
  "members shared with them. One account object on all three channels: { id, name (the owner's display name), " +
2923
3235
  "email (the mailbox address), is_owner (true when the account belongs to the API key's user, false for a colleague's), " +
@@ -2943,6 +3255,7 @@ export function createServer(apiKey, options = {}) {
2943
3255
  });
2944
3256
  server.registerTool("lighthouse_inbox_linkedin_accounts", {
2945
3257
  title: "List LinkedIn Accounts",
3258
+ annotations: READ_ONLY,
2946
3259
  description: "List the LinkedIn messaging accounts the user can access: their own connected account plus accounts other " +
2947
3260
  "workspace members shared with them. The same account object the email and calendar channels return: " +
2948
3261
  "{ id, name (the owner's display name), email (always null on LinkedIn), is_owner (true when the account belongs " +
@@ -2967,6 +3280,7 @@ export function createServer(apiKey, options = {}) {
2967
3280
  });
2968
3281
  server.registerTool("lighthouse_inbox_email_threads", {
2969
3282
  title: "List Email Threads",
3283
+ annotations: READ_ONLY,
2970
3284
  description: "List email threads (conversations) across every email account the user can read, newest activity first. Filters are " +
2971
3285
  "all optional and combinable: record_id + record_type ('company' or 'person' ONLY) for correspondence with " +
2972
3286
  "a CRM record (person = their email addresses, company = its domains); emails / domains (comma separated lists); " +
@@ -3007,6 +3321,7 @@ export function createServer(apiKey, options = {}) {
3007
3321
  });
3008
3322
  server.registerTool("lighthouse_inbox_email_thread", {
3009
3323
  title: "Get Email Thread",
3324
+ annotations: READ_ONLY,
3010
3325
  description: "Read one email thread: data.thread (the list's row) plus data.messages, newest first by default. Each message: " +
3011
3326
  "{ id, thread_id, date, direction (sent | received), from { name, email }, to / cc / bcc / reply_to [{ name, email }], " +
3012
3327
  "subject, snippet, text (the plain words of THIS message, with the quoted history of a reply removed and a forwarded block kept), " +
@@ -3046,6 +3361,7 @@ export function createServer(apiKey, options = {}) {
3046
3361
  });
3047
3362
  server.registerTool("lighthouse_inbox_email_attachment", {
3048
3363
  title: "Read Email Attachment",
3364
+ annotations: READ_ONLY,
3049
3365
  description: "What one email attachment SAYS, as text: PDFs, Word, Excel and PowerPoint files (and their OpenDocument versions), text, CSV and HTML. " +
3050
3366
  "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\"). " +
3051
3367
  "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. " +
@@ -3078,6 +3394,7 @@ export function createServer(apiKey, options = {}) {
3078
3394
  });
3079
3395
  server.registerTool("lighthouse_inbox_email_create_draft", {
3080
3396
  title: "Create Email Draft",
3397
+ annotations: CREATES,
3081
3398
  description: "Save an email draft in one of the user's mailboxes. The draft is NOT sent: it lands in the mailbox for the " +
3082
3399
  "user to review, edit and send themselves, and there is no tool that can send it. Use this to prepare a reply " +
3083
3400
  "or a new email on the user's behalf. account_id is REQUIRED (from lighthouse_inbox_email_accounts) and must " +
@@ -3113,6 +3430,7 @@ export function createServer(apiKey, options = {}) {
3113
3430
  });
3114
3431
  server.registerTool("lighthouse_inbox_linkedin_chats", {
3115
3432
  title: "List LinkedIn Chats",
3433
+ annotations: READ_ONLY,
3116
3434
  description: "List LinkedIn conversations across every LinkedIn account the user can view, newest activity first, " +
3117
3435
  "paginated. Filters are all optional and combinable: record_id + record_type ('company' or 'person' ONLY, where a " +
3118
3436
  "company matches its team members and company page), person_linkedin / company_linkedin (LinkedIn URL or " +
@@ -3154,6 +3472,7 @@ export function createServer(apiKey, options = {}) {
3154
3472
  });
3155
3473
  server.registerTool("lighthouse_inbox_linkedin_chat_messages", {
3156
3474
  title: "Get LinkedIn Chat Messages",
3475
+ annotations: READ_ONLY,
3157
3476
  description: "Read one LinkedIn conversation: returns the chat object (the same object lighthouse_inbox_linkedin_chats " +
3158
3477
  "returns) plus its messages, paginated, newest first by default (order 'asc' for oldest first), both sides " +
3159
3478
  "or one (direction 'sent' = what the account sent, 'received' = what the other people sent), with optional " +
@@ -3196,6 +3515,7 @@ export function createServer(apiKey, options = {}) {
3196
3515
  });
3197
3516
  server.registerTool("lighthouse_inbox_calendar_accounts", {
3198
3517
  title: "List Calendar Accounts",
3518
+ annotations: READ_ONLY,
3199
3519
  description: "Calendars the API key's user can read: their own connected calendar (is_owner true) and colleagues' calendars shared with them. " +
3200
3520
  "The same account object the email and LinkedIn channels return: { id, name, email, is_owner, provider (google | microsoft | other), " +
3201
3521
  "status (connected | pending | disconnected), status_reason (credentials | error | stopped | other; null unless disconnected), " +
@@ -3218,6 +3538,7 @@ export function createServer(apiKey, options = {}) {
3218
3538
  });
3219
3539
  server.registerTool("lighthouse_inbox_calendar_meetings", {
3220
3540
  title: "List Meetings",
3541
+ annotations: READ_ONLY,
3221
3542
  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). " +
3222
3543
  "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. " +
3223
3544
  "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. " +
@@ -3251,6 +3572,7 @@ export function createServer(apiKey, options = {}) {
3251
3572
  });
3252
3573
  server.registerTool("lighthouse_inbox_calendar_meeting", {
3253
3574
  title: "Get Recorded Meeting",
3575
+ annotations: READ_ONLY,
3254
3576
  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. " +
3255
3577
  "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,
3256
3578
  inputSchema: {
@@ -3267,7 +3589,8 @@ export function createServer(apiKey, options = {}) {
3267
3589
  });
3268
3590
  server.registerTool("lighthouse_inbox_calendar_meeting_notes", {
3269
3591
  title: "Get Meeting Notes",
3270
- 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). " +
3592
+ annotations: READ_ONLY,
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). " +
3271
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. " +
3272
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,
3273
3596
  inputSchema: {
@@ -3284,6 +3607,7 @@ export function createServer(apiKey, options = {}) {
3284
3607
  });
3285
3608
  server.registerTool("lighthouse_inbox_calendar_meeting_transcript", {
3286
3609
  title: "Get Meeting Transcript",
3610
+ annotations: READ_ONLY,
3287
3611
  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. " +
3288
3612
  "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. " +
3289
3613
  `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. ` +
@@ -3302,68 +3626,5 @@ export function createServer(apiKey, options = {}) {
3302
3626
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3303
3627
  }
3304
3628
  });
3305
- if (options.privateEquity !== true)
3306
- return server;
3307
- server.registerTool("lighthouse_private_equity_list_saved_searches", {
3308
- title: "List Private Equity Saved Searches",
3309
- 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. " +
3310
- "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. " +
3311
- "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. " +
3312
- "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. " +
3313
- "These are separate from the Discovery saved searches (lighthouse_discovery_list_saved_searches), which search a different database.",
3314
- inputSchema: {
3315
- offset: z.number().int().min(0).optional().describe("Number of saved searches to skip (default 0)"),
3316
- limit: z.number().int().min(1).max(100).optional().describe("Saved searches per page, 1 to 100 (default 25)"),
3317
- },
3318
- }, async ({ offset, limit }) => {
3319
- try {
3320
- const res = await api.get("/private-equity/saved-searches", { offset, limit });
3321
- return text(res);
3322
- }
3323
- catch (e) {
3324
- return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3325
- }
3326
- });
3327
- server.registerTool("lighthouse_private_equity_list_saved_search_results", {
3328
- title: "List Private Equity Saved Search Results",
3329
- description: "Read one page of the companies a Private Equity saved search's LAST run stored, in the order that run returned them. " +
3330
- "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). " +
3331
- "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. " +
3332
- "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. " +
3333
- "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. " +
3334
- "Not found when the saved search does not exist or the user cannot see it.",
3335
- inputSchema: {
3336
- search_id: z.string().uuid().describe("Private Equity saved search ID (from lighthouse_private_equity_list_saved_searches)"),
3337
- offset: z.number().int().min(0).optional().describe("Number of companies to skip (default 0)"),
3338
- limit: z.number().int().min(1).max(100).optional().describe("Companies per page, 1 to 100 (default 25)"),
3339
- },
3340
- }, async ({ search_id, offset, limit }) => {
3341
- try {
3342
- const res = await api.get(`/private-equity/saved-searches/${encodeURIComponent(search_id)}/results`, { offset, limit });
3343
- return text(res);
3344
- }
3345
- catch (e) {
3346
- return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3347
- }
3348
- });
3349
- server.registerTool("lighthouse_private_equity_get_company", {
3350
- title: "Get Private Equity Company",
3351
- 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). " +
3352
- "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. " +
3353
- "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. " +
3354
- "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. " +
3355
- "Not found when none of this workspace's Private Equity searches returned that company.",
3356
- inputSchema: {
3357
- company_id: z.string().min(1).describe("The company's id, as a row of lighthouse_private_equity_list_saved_search_results gives it"),
3358
- },
3359
- }, async ({ company_id }) => {
3360
- try {
3361
- const res = await api.get(`/private-equity/companies/${encodeURIComponent(company_id)}`);
3362
- return text(res);
3363
- }
3364
- catch (e) {
3365
- return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3366
- }
3367
- });
3368
3629
  return server;
3369
3630
  }