@trylighthouse/mcp-server 0.1.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2903 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { LighthouseAPI } from "./api.js";
4
+ import { leanDiscovery } from "./leanDiscovery.js";
5
+ function text(data) {
6
+ const failed = data != null && typeof data === "object" && data.error != null;
7
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }], ...(failed ? { isError: true } : {}) };
8
+ }
9
+ function err(message) {
10
+ return { content: [{ type: "text", text: message }], isError: true };
11
+ }
12
+ function profileSection(res, keys) {
13
+ if (res == null || typeof res !== "object")
14
+ return res;
15
+ const envelope = res;
16
+ if (envelope.error != null || envelope.data == null || typeof envelope.data !== "object" || Array.isArray(envelope.data)) {
17
+ return res;
18
+ }
19
+ const data = envelope.data;
20
+ const section = { id: data.id ?? null, name: data.name ?? null };
21
+ for (const key of keys)
22
+ section[key] = data[key] ?? null;
23
+ return { ...envelope, data: section };
24
+ }
25
+ const TRANSCRIPT_WINDOW_CHARS = 40_000;
26
+ export function createServer(apiKey) {
27
+ const api = new LighthouseAPI(apiKey);
28
+ const server = new McpServer({
29
+ name: "lighthouse",
30
+ version: "0.1.101",
31
+ });
32
+ const originalRegister = server.registerTool.bind(server);
33
+ server.registerTool = (name, schemaOrConfig, handler) => {
34
+ const wrappedHandler = async (...args) => {
35
+ api.setCurrentTool(name);
36
+ try {
37
+ return await handler(...args);
38
+ }
39
+ finally {
40
+ api.setCurrentTool(null);
41
+ }
42
+ };
43
+ return originalRegister(name, schemaOrConfig, wrappedHandler);
44
+ };
45
+ const CRM_FILTER_DESCRIPTION = "CRM filter object. Format: " +
46
+ '{"operator":"AND","conditions":[{"field":"<key>","operator":"<op>","value":<val>}],"groups":[]}. ' +
47
+ "IMPORTANT: First call lighthouse_crm_get_attributes to discover available field keys and types. " +
48
+ "Operators by field type, " +
49
+ "text/url: contains, not_contains, starts_with, ends_with, eq, neq, empty, not_empty; " +
50
+ "number/currency/rating/percentage: gt, gte, lt, lte, eq, neq, empty, not_empty; " +
51
+ "growth (headcount_growth on companies: the linked global profile's headcount change over 1m / 3m / 6m / 1y): gt, gte, lt, lte, eq, neq over ONE window with the value {\"timeframe\":\"1m\"|\"3m\"|\"6m\"|\"1y\",\"value\":<percent number>} (timeframe defaults to 3m; a bare number means the default window; an unknown window is refused), and empty / not_empty with no value (no window has data / at least one does); " +
52
+ "duration (time_in_stage on deals, time_in_status on tasks, and every time_in_<status field> twin that lighthouse_crm_get_attributes lists with a source_field: how long the record has held its current status): gt, gte, lt, lte, eq, neq over a whole number of DAYS (the value is a bare number such as 30, never an object; anything else is refused), and empty / not_empty with no value (the status is empty / set); " +
53
+ "date/datetimez: before, after, on, empty, not_empty; " +
54
+ "note (the notes field of companies, people, deals and custom object records: the notes linked to the record, never a body): eq (has a note matching), neq (has no note matching) with an object value whose keys are all optional and combined on ONE note: {\"title\":\"text it contains\",\"created_by\":[\"<teammate id from lighthouse_workspace_list_users>\"],\"since\":\"YYYY-MM-DD or a rolling period such as 30 days\",\"until\":\"...\",\"tags\":[\"<note tag permalink>\"],\"type\":\"standard\"|\"meeting\"} (an unknown key or a bad value is refused; with no refinement use not_empty), and empty / not_empty with no value (no note at all / at least one); " +
55
+ "select/status/user: contains_any, not_contains_any, empty, not_empty; " +
56
+ "multi_select/category/multi_user/multi_record/signal_select: contains_any, not_contains_any, contains_all, empty, not_empty; " +
57
+ "record: contains_any, not_contains_any, empty, not_empty; " +
58
+ "investors (a company's investors, a read-only multi_record of the GLOBAL DATABASE's companies and people): contains_any, not_contains_any, contains_all, empty, not_empty; the value is a list of investor ids from lighthouse_discovery_search_entities with record_type 'investor', never CRM record ids; " +
59
+ "experience / education (a person's work history and schools, read-only multi_records of the GLOBAL DATABASE's companies and schools): contains_any, not_contains_any, contains_all, empty, not_empty; the value is a list of ids from lighthouse_discovery_search_entities with record_type 'company' (record_type 'school' for education), never CRM record ids; " +
60
+ "domain/email/phone: contains_any, not_contains_any, contains_all, empty, not_empty; " +
61
+ "list_entries (the record's list memberships; the value is an array of list ids from lighthouse_crm_list_lists, and the list's record_type must be this record type): contains_any, not_contains_any, contains_all, empty, not_empty (empty = in no list, not_empty = in at least one list; both take no value). A question about the records IN a list with further conditions or an order (\"companies in list X created this month, newest first\") is ONE call: either lighthouse_crm_get_list_records with the same filters and sort, or this search with a list_entries contains_any condition on that list's id beside the other conditions; never read the whole list and narrow it by hand; " +
62
+ "checkbox: eq (value is true or false); " +
63
+ "interaction (first/last email & linkedin interaction, overall interaction-with): eq, neq, empty, not_empty; " +
64
+ "interaction-type (first/last interaction type): contains_any, not_contains_any, empty, not_empty. " +
65
+ "Value formats: text ops → string; number ops → number (a rating field's value is an integer 1-5, or null when unrated); " +
66
+ "contains_any/not_contains_any/contains_all → array of values; " +
67
+ "empty/not_empty → omit value; date ops → ISO date string \"YYYY-MM-DD\" or a rolling token resolved against the current time on every run: \"7 days\" or \"3 months ago\" = that period back (bare token means ago), \"2 weeks from now\" = that period forward (e.g. next_meeting_date before \"60 days from now\" = within the next 60 days). " +
68
+ "interaction eq/neq → {\"members\":[teammateUserId,...],\"directions\":[\"outbound\"|\"inbound\"|\"none\"]} " +
69
+ "(members OR-set of teammate user ids, directions OR-set, the two ANDed; meeting-based interactions have no member/direction so a direction/member filter excludes them; for companies a match means at least one associated person has such a touchpoint); " +
70
+ "interaction-type contains_any → array subset of [\"email\",\"linkedin\",\"meeting\"]; " +
71
+ "interaction date fields before/after/on → ISO date or a rolling token (\"60 days\", \"60 days from now\"), empty/not_empty → omit value. " +
72
+ "Linked-record fields filter via dotted paths <relation>.<field> (e.g. associated_people.emails on a company, associated_companies.name on a person; custom record-link fields and two-level paths like associated_deals.associated_people.emails work too) using the leaf field type's operators. Dotted paths work on company, person, deal and custom object records (a custom object's own record-link fields are its relations); on tasks and notes a dotted condition is ignored, so never use dotted fields there. " +
73
+ "On a dotted path every operator quantifies across ALL linked records: positive operators (contains, eq, contains_any, contains_all, the date and number comparisons, not_empty) match when at least one linked record matches; negative operators (neq, not_contains, not_contains_any, empty) match when NO linked record matches, INCLUDING records with no linked records at all, so not_contains_any with every option listed returns the same records as empty. " +
74
+ "semantic_search (people and companies ONLY; a FILTER, not an attribute: lighthouse_crm_get_attributes never lists it and no record carries it): operator matches, value {\"vector\":\"[...]\",\"min_similarity\"?:0 to 0.99}. It matches each record's linked global profile against a READY embedding. This API does NOT build embeddings from text: a {\"text\":...} value is refused with a 400 that says so, and you must never write a vector yourself. The vector comes from the app: a saved view's filters (lighthouse_crm_get_view returns them with the vector) or a condition the user copied with Copy for API in the filter builder. Reuse such a condition VERBATIM, at any depth of the tree and on a linked path (associated_companies.semantic_search on a person = people whose companies match the description, associated_people.semantic_search on a company). With no sort the page is ranked by similarity; only records linked to the global database can match. When the user wants a description-based search of the CRM and no saved view or copied condition carries a vector, say so and offer the attribute filters, or lighthouse_discovery_semantic_search_companies / _people for the global database (NOT the CRM). " +
75
+ "Dotted linked interaction filters, e.g. {\"field\":\"associated_companies.last_email_interaction\",\"operator\":\"eq\",\"value\":{\"members\":[\"<id>\"],\"directions\":[\"outbound\"]}}; dotted interaction keys support eq, neq, empty and not_empty. " +
76
+ "The `operator` of a block (the top level OR any group) is \"AND\" or \"OR\" and joins that block's conditions AND its nested groups: \"AND\" = every one must match, \"OR\" = at least one matches. Set the top-level operator to \"OR\" to OR the top-level conditions, or nest a group to mix logic like (A OR B) AND C. " +
77
+ 'Nested-group example for "(domain contains acme OR name contains acme) AND created after 2025-01-01": ' +
78
+ '{"operator":"AND","conditions":[{"field":"created_at","operator":"after","value":"2025-01-01"}],"groups":[{"operator":"OR","conditions":[{"field":"domain","operator":"contains_any","value":["acme"]},{"field":"name","operator":"contains","value":"acme"}],"groups":[]}]} ' +
79
+ "(field keys above are illustrative, use the real keys from lighthouse_crm_get_attributes). " +
80
+ "Dates have NO range operator, express a window as two conditions (after <start> AND before <end>) in an AND group, computing the bounds from today's date. " +
81
+ "Use the EXACT field keys and option values returned by lighthouse_crm_get_attributes / lighthouse_crm_list_options, never abbreviate or invent them, the chosen values for a select/multi_select condition MUST be a subset of that field's options. If a requested concept has no matching field or option (e.g. a 'competitor'/'portfolio' status that no field actually holds), TELL THE USER rather than dropping the clause or mapping it onto an unrelated field. " +
82
+ "Do NOT use discovery field keys here, CRM fields come from lighthouse_crm_get_attributes. " +
83
+ "RESPONSE VALIDATION META: this search NEVER fails on a bad filter; instead the response carries, alongside `records`/`total`, three arrays you MUST inspect (collected recursively across nested groups): " +
84
+ "`ignored_filter_fields` (field keys not recognized; that condition was dropped), " +
85
+ "`invalid_operators` ([{field, operator}] where the operator is not valid for that field's type; that condition was dropped), and " +
86
+ "`invalid_logical_operators` ([{operator}] where a group's join was not AND/OR; treated as AND). " +
87
+ "If ANY are non-empty, part of your filter did NOT apply (results are broader/narrower than intended): fix the offending field/operator and retry, or tell the user which clauses could not be applied. A `warnings` array summarizes the same in plain text.";
88
+ const CRM_SORT_DESCRIPTION = 'CRM sort array. Format: [{"field":"<key>","direction":"asc"|"desc"}]. ' +
89
+ "Multiple entries sort by the first, then tie-break by the next. " +
90
+ "NOT every field is sortable. A field is sortable ONLY when its type (the `data_type` returned by lighthouse_crm_get_attributes) is one of: " +
91
+ "text, url, number, currency, percentage, rating, date, datetime, datetimez, select, status, user, checkbox, " +
92
+ "record, record_company_name, record_person_name, record_deal_name, growth, note, duration. " +
93
+ "growth (headcount_growth) sorts by ONE window: add \"timeframe\":\"1m\"|\"3m\"|\"6m\"|\"1y\" to the sort item ({\"field\":\"headcount_growth\",\"direction\":\"desc\",\"timeframe\":\"6m\"}; 3m when omitted; an unknown window comes back in invalid_sort_fields). " +
94
+ "note (the notes field) orders by the newest note's date, records without notes last. " +
95
+ "duration (time_in_stage, time_in_status, every time_in_<status field> twin) orders by the time in the current status: asc = fewest days first, records whose status is empty last; the sort item is the plain {\"field\",\"direction\"} with no extra key. " +
96
+ "NOT sortable (never put these in sort): multi-value fields (multi_select, signal_select, category, multi_user, multi_record), domain, email, phone, list_entries, " +
97
+ "linked/lookup fields, and interaction object & interaction-type fields (the first/last email & linkedin interaction objects and the interaction-type selects); sort by the interaction's date sub-field instead. " +
98
+ "Use the EXACT field key and type from lighthouse_crm_get_attributes; if the user asks to sort by a field whose type is not in the sortable list above, tell them that field can't be sorted rather than guessing a key. " +
99
+ "RESPONSE VALIDATION META: an unsortable field does NOT fail the search; it comes back in the response's `invalid_sort_fields` array ([{field, reason}]) and the search runs in the default order. If `invalid_sort_fields` is non-empty your sort did not apply: pick a sortable field or tell the user.";
100
+ const DISCOVERY_FILTER_DESCRIPTION = "Discovery filter object. Format: " +
101
+ '{"operator":"AND","conditions":[{"field":"<permalink>","operator":"<op>","value":<val>}],"groups":[]}. ' +
102
+ "IMPORTANT: `field` MUST be one of the prefixed discovery filter keys listed for this entity at the end of this description (company_* or person_*). " +
103
+ "Those keys are NOT the data fields returned by lighthouse_discovery_get_attributes: a condition whose `field` is a raw data column (e.g. 'categories', 'headquarter_country', 'founded_on') is dropped (it filters nothing) and reported back in the response's `ignored_filter_fields` so you can catch it. " +
104
+ "For category/industry values, call lighthouse_discovery_get_industries. " +
105
+ "For location values, call lighthouse_discovery_get_locations and use its `value` field: countries are ISO 3166-1 alpha-2 codes in UPPERCASE (e.g. \"US\", \"GB\", \"DE\"), regions are the region codes it returns. Never pass a lowercase code (\"us\") or a country name (\"United States\"). This applies to EVERY country/region filter key (company_hq_country, company_investor_country, person_country, person_work_country, and the rest). " +
106
+ "For tag/highlight values, call lighthouse_discovery_get_tags. " +
107
+ "Fields use prefixed keys (company_name, company_headcount, person_country, etc.). " +
108
+ "Operators by field type, " +
109
+ "short_text: contains, is; " +
110
+ "long_text: contains, not_contains, empty, not_empty; " +
111
+ "text/url: contains, not_contains, starts_with, ends_with, eq, neq, empty, not_empty; " +
112
+ "number/currency: between, gt, gte, lt, lte, eq, neq, empty, not_empty; " +
113
+ "range: between, gt, lt, empty, not_empty; " +
114
+ "date: after, before, empty, not_empty (empty on a work-experience or stealth date means no entry has that date set, so records with no entries at all also match; created_at fields are never empty); " +
115
+ "select: contains_any, not_contains_any, empty, not_empty; " +
116
+ "multi_select: contains_any, not_contains_any, contains_all, empty, not_empty; " +
117
+ "investor_multi_select: contains_any, not_contains_any, contains_all, empty, not_empty " +
118
+ "(value is array of investor UUIDs or objects [{\"id\":\"uuid\",\"label\":\"Name\"}]. Call lighthouse_discovery_search_entities with record_type 'investor' to find investor IDs by name); " +
119
+ "work_experience_multi_select: contains_any, not_contains_any, contains_all, empty, not_empty " +
120
+ "(value is array of organization UUIDs or objects [{\"id\":\"uuid\",\"label\":\"Name\"}]. Call lighthouse_discovery_search_entities with record_type 'company' to find organization IDs by name); " +
121
+ "education_multi_select: contains_any, not_contains_any, contains_all, empty, not_empty " +
122
+ "(value is array of institution UUIDs or objects [{\"id\":\"uuid\",\"label\":\"Name\"}]. Call lighthouse_discovery_search_entities with record_type 'school' to find school IDs by name); " +
123
+ "funding_type_multi_select: contains_any, not_contains_any, empty, not_empty; " +
124
+ "multi_user: contains_any, not_contains_any, contains_all, empty, not_empty (value is array of user UUIDs); " +
125
+ "boolean/checkbox: eq; " +
126
+ "json: contains, not_contains, empty, not_empty. " +
127
+ "SPECIAL COMPOUND TYPES, " +
128
+ "signal_select (company_signals): operators contains_any/not_contains_any/contains_all, value is array of signal strings e.g. [\"new_funding_round\",\"growth_headcount\"]; " +
129
+ "signal_select (person_signals): operators contains_any/not_contains_any/contains_all, value is {\"signals\":[\"company_registration\",...],\"registration_countries\":[\"US\",\"DE\",...],\"signal_dates\":{\"stealth\":\"2024-03-01\"},\"stealth_predicted_areas\":[\"fintech\",...]} " +
130
+ "(every key besides signals is optional: registration_countries only filters the company_registration signal by country; " +
131
+ "signal_dates maps a signal to a 'started on/after' ISO date and applies to stealth / out_of_stealth / founded_new_company; " +
132
+ "stealth_predicted_areas refines the stealth signal by predicted category group, same values as person_stealth_predicted_area); " +
133
+ "language_select: operators contains_any/not_contains_any/contains_all, value is {\"languages\":[<ISO 639-1 two-letter codes, e.g. \"en\",\"de\",\"fr\">],\"proficiency\":[<one or more of exactly \"NATIVE_BILINGUAL\",\"EXPERT\",\"ADVANCED\",\"INTERMEDIATE\",\"BASIC\">]} " +
134
+ "(languages are ISO 639-1 codes; proficiency is optional, omit to match any level, and use those exact uppercase tokens, no other values are accepted). " +
135
+ "Value formats: text ops → string; number ops → number; between → {\"min\": <num>, \"max\": <num>}; " +
136
+ "contains_any/not_contains_any/contains_all → array of values; empty/not_empty → omit value; boolean eq → true/false; " +
137
+ "date ops → ISO date string \"YYYY-MM-DD\". " +
138
+ "The `operator` of a block (the top level OR any group) is \"AND\" or \"OR\" and joins that block's conditions AND its nested groups: \"AND\" = every one must match, \"OR\" = at least one matches. Set the top-level operator to \"OR\" to OR the top-level conditions, or nest a group to mix logic, e.g. (A OR B) AND C = {\"operator\":\"AND\",\"conditions\":[C],\"groups\":[{\"operator\":\"OR\",\"conditions\":[A,B],\"groups\":[]}]}. A worked example with real keys is in the field-key list below. " +
139
+ "company_signals value is a plain array of strings; person_signals value is an object {\"signals\":[...],\"registration_countries\"?:[...],\"signal_dates\"?:{...},\"stealth_predicted_areas\"?:[...]}, these are the only contains_any fields whose value is not a bare array. " +
140
+ "Use the company_*/person_* keys listed below VERBATIM, do not invent variants (no _usd suffix, no abbreviation like hq for headquarter) and do not use the bare permalinks returned by lighthouse_discovery_get_attributes; any unrecognized key is dropped (the search runs as if that condition were absent) and echoed in the response's `ignored_filter_fields`, so ALWAYS check that array, otherwise a mistyped key returns a result that looks fine but is unfiltered. " +
141
+ "NOT EXPRESSIBLE on this surface (it searches the global database, NOT your CRM): CRM list membership, CRM record owner / unassigned, CRM custom fields, deal stage, role-change dates, and geography finer than country (no US state). If a request needs one of these, apply what you CAN and TELL THE USER which clauses you could not express, do NOT silently drop them or map them onto an unrelated field. " +
142
+ "Do NOT use CRM attribute keys here. The complete set of valid discovery filter keys for this entity is listed at the end of this description. " +
143
+ "RESPONSE VALIDATION META: this search NEVER fails on a bad filter; instead the response carries, alongside `items`/`total`, three arrays you MUST inspect (collected recursively across nested groups): " +
144
+ "`ignored_filter_fields` (keys not recognized; that condition was dropped), " +
145
+ "`invalid_operators` ([{field, operator}] where the operator is not valid for that field's type; that condition was dropped), and " +
146
+ "`invalid_logical_operators` ([{operator}] where a group's join was not AND/OR; treated as AND). " +
147
+ "If ANY are non-empty, part of your filter did NOT apply (results are broader than intended): fix the offending field/operator and retry, or tell the user which clauses could not be applied. A `warnings` array summarizes the same in plain text.";
148
+ const COMPANY_DISCOVERY_SORT_DESCRIPTION = 'Discovery company sort. Format: {"by":"<field>","dir":"asc"|"desc"}. ' +
149
+ "ONLY two fields are sortable: founded_on (founding date) and total_funding_amount_usd (total funding raised, in USD). " +
150
+ "Omit sort to use the default: relevance score, descending. " +
151
+ "No other field is sortable here (NOT headcount, name, last-funding, etc.); an unsupported `by` is ignored and results fall back to relevance order, with a note in meta.warnings. " +
152
+ "If the user asks to sort companies by anything else, tell them only founding date and total funding are supported. " +
153
+ "RESPONSE VALIDATION META: an unsupported sort does NOT fail the search; it comes back in the response's `invalid_sort_fields` array (and `warnings`) and results fall back to relevance order.";
154
+ const COMPANY_DISCOVERY_FIELDS = " COMPANY FILTER KEYS (use EXACTLY these as the condition `field`; any other key is ignored): " +
155
+ "Basics: company_name (short_text), company_domain (url), company_description (long_text), company_headcount (number), company_founded_on (date, year), company_created_at (date). " +
156
+ "Industry: company_categories (multi_select; values from lighthouse_discovery_get_industries → categories), company_sdg_impact (multi_select; values from get_industries → sdg_categories). " +
157
+ "Signals: company_signals (multi_select; values: new_funding_round, growth_website, growth_headcount, growth_job_posts, growth_lnk_followers, growth_tw_followers, growth_npm, growth_app, growth_patents, bl_launch, hn_launch, gh_launch, prhu_launch, prhu_award). " +
158
+ "Tags: company_tags (multi_select; values: 2nd_time_founder, serial_founder, has_technical_founder, has_non_technical_founder, raised_previous_funding, has_previous_exit, sales_exp, co_exp, eng_exp, ai_and_data_exp, product_exp, hr_exp, impact_people_tag, a_founder_left, all_founders_left). " +
159
+ "Funding: company_total_funding (number, USD), company_last_funding_amount (number, USD), company_last_funding_date (date), company_last_funding_type (funding_type_multi_select; values from lighthouse_discovery_get_funding_types, e.g. seed, series_a, plus no_funding to match companies with no recorded funding round). " +
160
+ "Location: company_hq_country (multi_select; UPPERCASE ISO alpha-2 codes e.g. \"US\", \"GB\" from lighthouse_discovery_get_locations → countries), company_hq_region (multi_select; get_locations → regions), company_hq_city (text), company_german_state (multi_select; German federal states). " +
161
+ "Socials: company_linkedin, company_twitter, company_facebook, company_github, company_producthunt (url; matches the profile handle/permalink with text operators). " +
162
+ "Investors (who invested IN this company): company_investor (investor_multi_select; ids from lighthouse_discovery_search_entities record_type 'investor'), company_investor_country (multi_select; get_locations countries), company_investor_region (multi_select; get_locations regions). " +
163
+ "Investments (the company AS an investor; the investments_top_* keys match the investor's TOP-RANKED preference only, e.g. their #1 country, not every investment they ever made): company_is_investor (checkbox; eq true = the company is itself an investor such as a fund or CVC, eq false = exclude investors), company_num_investments (number; total recorded investments), company_investments_top_category (multi_select; get_industries categories), company_investments_top_sdg (multi_select; get_industries sdg_categories), company_investments_top_country (multi_select; get_locations countries), company_investments_top_region (multi_select; get_locations regions), company_investments_top_stage (funding_type_multi_select; get_funding_types values, no_funding not applicable). " +
164
+ "Team: company_team_past_companies (work_experience_multi_select; ids from lighthouse_discovery_search_entities record_type 'company'), company_team_education (education_multi_select; ids from lighthouse_discovery_search_entities record_type 'school'), company_team_experience (multi_select; same values as company_tags), company_team_language (language_select), company_team_language_proficiency (multi_select; use with company_team_language to require a proficiency level), company_team_name_origin (multi_select; get_locations countries), company_team_location_country, company_team_location_region, company_team_work_country, company_team_work_region, company_team_edu_country, company_team_edu_region (multi_select; get_locations). " +
165
+ "CRM (filter the global database by YOUR workspace CRM): company_internal_crm_status (checkbox; eq true = already in your CRM, false = not), company_crm_list_entries (multi_select; companies that are in specific CRM company lists, list ids from lighthouse_crm_list_lists), company_crm_deals_list_entries (multi_select; companies whose deals are in specific deal lists), company_crm_team_network (multi_user; companies whose team members are connected to specific workspace users, ids from lighthouse_workspace_list_users), company_crm_team_list_entries (multi_select; companies whose team members are in specific people lists), company_external_crm_status (checkbox; needs a connected external CRM), company_external_crm_lists (multi_select; needs a connected external CRM). " +
166
+ 'NESTED-GROUP EXAMPLE for "headcount 10-50 AND (HQ in US or UK) AND raised Series A": ' +
167
+ '{"operator":"AND","conditions":[{"field":"company_headcount","operator":"between","value":{"min":10,"max":50}},{"field":"company_last_funding_type","operator":"contains_any","value":["series_a"]}],"groups":[{"operator":"OR","conditions":[{"field":"company_hq_country","operator":"contains_any","value":["US","GB"]}],"groups":[]}]} ' +
168
+ "(country codes from get_locations, funding-type values from get_funding_types).";
169
+ const PERSON_DISCOVERY_FIELDS = " PERSON FILTER KEYS (use EXACTLY these as the condition `field`; any other key is ignored): " +
170
+ "Basics: person_name (short_text), person_created_at (date). " +
171
+ "Investments (the person AS an investor, e.g. an angel; the investments_top_* keys match the investor's TOP-RANKED preference only, e.g. their #1 country, not every investment they ever made): person_is_investor (checkbox; eq true = only investors, eq false = exclude investors), person_num_investments (number; total recorded investments), person_investments_top_category (multi_select; get_industries categories), person_investments_top_sdg (multi_select; get_industries sdg_categories), person_investments_top_country (multi_select; get_locations countries), person_investments_top_region (multi_select; get_locations regions), person_investments_top_stage (funding_type_multi_select; get_funding_types values, no_funding not applicable). " +
172
+ "Industry: person_current_categories, person_past_categories (multi_select; values from lighthouse_discovery_get_industries → categories), person_current_sdg_impact, person_past_sdg_impact (multi_select; get_industries → sdg_categories). " +
173
+ "Signals: person_signals (signal_select; value {\"signals\":[...],\"registration_countries\"?:[...],\"signal_dates\"?:{<signal>:\"YYYY-MM-DD\"},\"stealth_predicted_areas\"?:[...]}), person_registration_country (multi_select; filters company_registration signals by country, get_locations countries). " +
174
+ "Tags: person_tags (multi_select; values: phd_doctorate, 2nd_time_founder, serial_founder, has_exit, raised_funding, sales_exp, co_exp, eng_exp, ai_and_data_exp, product_exp, hr_exp). " +
175
+ "Language & origin: person_language (language_select), person_language_proficiency (multi_select; use with person_language to require a proficiency level), person_name_origin (multi_select; get_locations countries). " +
176
+ "Current location: person_country (multi_select; UPPERCASE ISO alpha-2 codes e.g. \"US\", \"GB\" from lighthouse_discovery_get_locations → countries), person_region (multi_select; get_locations → regions), person_german_state (multi_select; German federal states). " +
177
+ "Experience: person_past_companies (work_experience_multi_select; ids from lighthouse_discovery_search_entities record_type 'company'), person_education (education_multi_select; ids from lighthouse_discovery_search_entities record_type 'school'), person_experience_start_date, person_experience_end_date (date; matches people with at least one work experience whose start/end date passes the bound, independent of the other experience conditions), person_work_country, person_work_region, person_edu_country, person_edu_region (multi_select; get_locations). " +
178
+ "Stealth: person_stealth_start_date, person_stealth_end_date (date), person_stealth_predicted_area (multi_select; the predicted category-GROUP a stealth founder is building in. Values: agriculture_farming, ai_ml, apparel_fashion, ar_vr, cloud_devops, commerce, consumer_goods_electronics, content_publishing, crypto_blockchain, customer_service, data_analytics, deeptech, design, edtech, energy, engineering_applied_sciences, entertainment_recreation, events, financial_services_fintech, food_beverage, gaming, geospatial_technologies, hardware, healthtech_biotech, hospitality_travel, hr, impact, industrial_manufacturing, infrastructure, it_software, legal, miscellaneous, natural_resources, public_sector_defense, real_estate, sales_marketing, security_privacy_risk, sharing_economy, social_communities, sports, telecommunications, transportation_mobility_logistics). " +
179
+ "Primary company: person_company_hq_country, person_company_hq_region (multi_select; get_locations), person_company_headcount (number), person_company_founded_on (date), person_company_total_funding, person_company_last_funding_amount (number, USD), person_company_last_funding_date (date), person_company_last_funding_type (funding_type_multi_select; values from lighthouse_discovery_get_funding_types, including no_funding). " +
180
+ "CRM: person_internal_crm_status (checkbox; eq true/false), person_crm_network (multi_user; ids from lighthouse_workspace_list_users), person_external_crm_status (checkbox; needs a connected external CRM), person_external_crm_lists (multi_select; needs a connected external CRM). " +
181
+ 'NESTED-GROUP EXAMPLE for "(based in Germany or France) AND a technical or AI/data background": ' +
182
+ '{"operator":"AND","conditions":[],"groups":[{"operator":"OR","conditions":[{"field":"person_country","operator":"contains_any","value":["DE","FR"]}],"groups":[]},{"operator":"OR","conditions":[{"field":"person_tags","operator":"contains_any","value":["eng_exp","ai_and_data_exp"]}],"groups":[]}]} ' +
183
+ "(an outer AND of two OR groups; country codes from get_locations).";
184
+ server.registerTool("lighthouse_crm_get_attributes", {
185
+ title: "Get CRM Attributes",
186
+ description: "Get a lightweight catalog of the fields for a CRM record type: each field's permalink, label, data_type, links_to, read_only, is_custom, has_options, and source_field. " +
187
+ "`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. " +
188
+ "ALWAYS call this before building filters for search_records / list_records or creating/updating views. Use the exact permalinks it returns. " +
189
+ "To keep the response small it does NOT include select/multi_select option values: when a field has has_options=true, call lighthouse_crm_list_options with that permalink to get its allowed values before filtering or setting it. " +
190
+ "Supports built-in types (company, person, deal, task) and custom objects (use the object's permalink, e.g. 'lp', 'project'). " +
191
+ "When called without record_type, returns all types including custom objects as top-level keys.",
192
+ inputSchema: {
193
+ record_type: z
194
+ .string()
195
+ .optional()
196
+ .describe("Record type: 'company', 'person', 'deal', 'task', or a custom object permalink. Omit to get all types."),
197
+ list: z
198
+ .string()
199
+ .optional()
200
+ .describe("List ID (UUID) to scope attributes. Returns global fields plus list-scoped custom fields. If provided without record_type, auto-resolves the list's entity type."),
201
+ },
202
+ }, async (params) => {
203
+ try {
204
+ const res = await api.post("/mcp/records/attributes", params);
205
+ return text(res);
206
+ }
207
+ catch (e) {
208
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
209
+ }
210
+ });
211
+ server.registerTool("lighthouse_crm_get_attribute", {
212
+ title: "Get CRM Attribute",
213
+ description: "Get a single custom field definition. Response returns 'permalink', 'is_custom', 'data_type', 'links_to'. " +
214
+ "For custom objects, use the object's permalink as record_type (e.g. 'lp').",
215
+ inputSchema: {
216
+ permalink: z.string().describe("Field permalink (unique key) of the attribute"),
217
+ record_type: z.string().describe("Record type: 'company', 'person', 'deal', 'task', or a custom object permalink"),
218
+ },
219
+ }, async (params) => {
220
+ try {
221
+ const res = await api.get(`/attributes/${params.record_type}/${params.permalink}`);
222
+ return text(res);
223
+ }
224
+ catch (e) {
225
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
226
+ }
227
+ });
228
+ server.registerTool("lighthouse_crm_create_attribute", {
229
+ title: "Create CRM Attribute",
230
+ description: "Create a new custom field in your CRM workspace. " +
231
+ "Types: text, number, currency, percentage, date, datetimez, select, multi_select, status, checkbox, url, email, phone, rating, record, multi_record, user, multi_user. " +
232
+ "Supports custom objects, use the object's permalink as record_type (e.g. 'lp'). " +
233
+ "For record/multi_record fields, links_to can be a built-in type or a custom object permalink.",
234
+ inputSchema: {
235
+ label: z.string().describe("Display label for the field"),
236
+ record_type: z.string().describe("Record type: 'company', 'person', 'deal', or a custom object permalink (e.g. 'lp')"),
237
+ data_type: z
238
+ .enum([
239
+ "text",
240
+ "number",
241
+ "currency",
242
+ "percentage",
243
+ "date",
244
+ "datetimez",
245
+ "select",
246
+ "multi_select",
247
+ "status",
248
+ "checkbox",
249
+ "url",
250
+ "email",
251
+ "phone",
252
+ "rating",
253
+ "record",
254
+ "multi_record",
255
+ "user",
256
+ "multi_user",
257
+ ])
258
+ .describe("Field type. One of: text, number, currency, percentage, date, datetimez, select, multi_select, status, checkbox, url, email, phone, rating, record, multi_record, user, multi_user."),
259
+ permalink: z.string().optional().describe("Unique identifier for the field. Auto-generated from label if omitted."),
260
+ links_to: z.string().optional().describe("For record/multi_record fields: which record type to link to (e.g. 'company', 'deal', or a custom object permalink like 'lp')"),
261
+ options: z
262
+ .array(z.record(z.unknown()))
263
+ .optional()
264
+ .describe("For select/multi_select/status fields: array of {value, label} option objects"),
265
+ list: z
266
+ .string()
267
+ .optional()
268
+ .describe("List ID (UUID) to scope this attribute to a specific list. Omit for global (workspace-wide) attributes."),
269
+ },
270
+ }, async (params) => {
271
+ try {
272
+ const res = await api.post("/attributes", params);
273
+ return text(res);
274
+ }
275
+ catch (e) {
276
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
277
+ }
278
+ });
279
+ server.registerTool("lighthouse_crm_update_attribute", {
280
+ title: "Update CRM Attribute",
281
+ description: "Update a custom field's display label (the field type, linked record type, and options are fixed after creation). " +
282
+ "For custom objects, use the object's permalink as record_type.",
283
+ inputSchema: {
284
+ permalink: z.string().describe("Field permalink of the attribute to update"),
285
+ record_type: z.string().describe("Record type: 'company', 'person', 'deal', or a custom object permalink"),
286
+ label: z.string().describe("New display label"),
287
+ },
288
+ }, async (params) => {
289
+ try {
290
+ const { permalink, record_type, ...body } = params;
291
+ const res = await api.patch(`/attributes/${record_type}/${permalink}`, body);
292
+ return text(res);
293
+ }
294
+ catch (e) {
295
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
296
+ }
297
+ });
298
+ server.registerTool("lighthouse_crm_delete_attribute", {
299
+ title: "Delete CRM Attribute",
300
+ description: "Delete a custom field and all its data. Only custom fields can be deleted, not system fields. " +
301
+ "For custom objects, use the object's permalink as record_type.",
302
+ inputSchema: {
303
+ permalink: z.string().describe("Field permalink of the attribute to delete"),
304
+ record_type: z.string().describe("Record type: 'company', 'person', 'deal', or a custom object permalink"),
305
+ },
306
+ }, async (params) => {
307
+ try {
308
+ const res = await api.delete(`/attributes/${params.record_type}/${params.permalink}`);
309
+ return text(res);
310
+ }
311
+ catch (e) {
312
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
313
+ }
314
+ });
315
+ server.registerTool("lighthouse_crm_get_color_palette", {
316
+ title: "Get Color Palette",
317
+ description: "Get the available color palette for select/multi_select/status field options. " +
318
+ "Returns an array of {hex_code, label} objects. " +
319
+ "Use this to discover valid hex color codes when creating or updating select options.",
320
+ inputSchema: {},
321
+ }, async () => {
322
+ try {
323
+ const res = await api.get("/attributes/colors");
324
+ return text(res);
325
+ }
326
+ catch (e) {
327
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
328
+ }
329
+ });
330
+ server.registerTool("lighthouse_crm_list_options", {
331
+ title: "List CRM Field Options",
332
+ description: "Get the allowed values (value, label, color) for a CRM select / multi_select field. " +
333
+ "Call this when lighthouse_crm_get_attributes shows has_options=true for a field and you need its values before filtering or setting it. " +
334
+ "Results are paginated (max 100 per page): use `search` to narrow by label, and `limit` / `offset` to page through a field with many options. " +
335
+ "For custom objects, use the object's permalink as record_type.",
336
+ inputSchema: {
337
+ record_type: z.string().describe("Record type: 'company', 'person', 'deal', 'task', or a custom object permalink"),
338
+ permalink: z.string().describe("Field permalink of the select/multi_select field"),
339
+ search: z.string().optional().describe("Filter options whose label contains this text (case-insensitive)"),
340
+ limit: z.number().optional().describe("Max options to return (default 100, max 100)"),
341
+ offset: z.number().optional().describe("Number of options to skip for pagination (default 0)"),
342
+ },
343
+ }, async (params) => {
344
+ try {
345
+ const res = await api.post("/mcp/records/options", params);
346
+ return text(res);
347
+ }
348
+ catch (e) {
349
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
350
+ }
351
+ });
352
+ server.registerTool("lighthouse_crm_create_option", {
353
+ title: "Create CRM Field Option",
354
+ description: "Add a new option to a select/multi_select field. The option value is auto-generated from the label. " +
355
+ "Re-creating a system default option that was previously removed restores it (its original color and label are kept). " +
356
+ "Returns the updated list of all options for the field (each with value, label, color). " +
357
+ "For custom objects, use the object's permalink as record_type. " +
358
+ "Options can only be created on company, person, deal, and custom objects.",
359
+ inputSchema: {
360
+ record_type: z.string().describe("Record type: 'company', 'person', 'deal', or a custom object permalink"),
361
+ permalink: z.string().describe("Field permalink of the select/multi_select field"),
362
+ label: z.string().describe("Option display label"),
363
+ color: z.string().optional().describe("Option color (hex code from palette, auto-assigned if omitted)"),
364
+ },
365
+ }, async (params) => {
366
+ try {
367
+ const { record_type, permalink, ...body } = params;
368
+ const res = await api.post(`/options/${record_type}/${permalink}`, body);
369
+ return text(res);
370
+ }
371
+ catch (e) {
372
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
373
+ }
374
+ });
375
+ server.registerTool("lighthouse_crm_delete_option", {
376
+ title: "Delete CRM Field Option",
377
+ 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). " +
378
+ "For custom objects, use the object's permalink as record_type.",
379
+ inputSchema: {
380
+ record_type: z.string().describe("Record type: 'company', 'person', 'deal', 'task', or a custom object permalink"),
381
+ permalink: z.string().describe("Field permalink of the select/multi_select field"),
382
+ value: z.string().describe("Option value to delete"),
383
+ },
384
+ }, async (params) => {
385
+ try {
386
+ const res = await api.delete(`/options/${encodeURIComponent(params.record_type)}/${encodeURIComponent(params.permalink)}/${encodeURIComponent(params.value)}`);
387
+ return text(res);
388
+ }
389
+ catch (e) {
390
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
391
+ }
392
+ });
393
+ server.registerTool("lighthouse_crm_search_records", {
394
+ title: "Search CRM Records",
395
+ description: "Search and filter records in YOUR CRM workspace (companies, people, or deals you've added). " +
396
+ "Supports filters, sorting, pagination, and saved views. " +
397
+ "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). " +
398
+ "NEVER guess field keys, ALWAYS call lighthouse_crm_get_attributes first to get the exact field keys " +
399
+ "for the record_type, then use those keys in your filter conditions. " +
400
+ "The response's `meta` includes `total` (an integer match count) and `ignored_filter_fields` (any filter key that is not a valid attribute for this record_type, each with a did_you_mean suggestion when a close match can be computed). Check `ignored_filter_fields` to confirm every filter was actually applied. " +
401
+ "NOT for discovering new companies/people, use lighthouse_discovery_* tools for that.",
402
+ inputSchema: {
403
+ record_type: z.string().describe("Record type (company, person, deal, or custom object permalink)"),
404
+ filters: z
405
+ .record(z.unknown())
406
+ .optional()
407
+ .describe(CRM_FILTER_DESCRIPTION),
408
+ sort: z
409
+ .array(z.record(z.unknown()))
410
+ .optional()
411
+ .describe(CRM_SORT_DESCRIPTION),
412
+ limit: z.number().min(1).max(100).default(25).describe("Records per page (max 100)"),
413
+ offset: z.number().min(0).default(0).describe("Number of records to skip"),
414
+ view_id: z
415
+ .string()
416
+ .uuid()
417
+ .optional()
418
+ .describe("Load saved filters/sort from a view. User-provided filters override view filters."),
419
+ },
420
+ }, async (params) => {
421
+ try {
422
+ const res = await api.post("/mcp/records/search", {
423
+ record_type: params.record_type,
424
+ filters: params.filters,
425
+ sort: params.sort,
426
+ limit: params.limit,
427
+ offset: params.offset,
428
+ view_id: params.view_id,
429
+ });
430
+ return text(res);
431
+ }
432
+ catch (e) {
433
+ return err(`Search failed: ${e instanceof Error ? e.message : String(e)}`);
434
+ }
435
+ });
436
+ server.registerTool("lighthouse_crm_get_record", {
437
+ title: "Get CRM Record",
438
+ 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. " +
439
+ "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: " +
440
+ "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; " +
441
+ "for a person started_on, ended_on, registered_on, company ({id, name, domain, linkedin, logo_url}), entity_name, role, entity_description, industries, domain, founded_on, country, country_name, city, county, state, register_url, registrations (every registration: [{entity_name, registered_on, city, country, country_name, register_url, role, picked_up_on}]), predicted_areas ([{permalink, label, origin}]), out_of_stealth_reason, github, bio, explanation }. " +
442
+ "Filter these fields by the signal VALUES (strings). Search results carry the same shape. " +
443
+ "Two more read-only keys on a company: investors ({ company: [{ database_id, crm_id, name, avatar_url, is_lead }], person: [{ database_id, crm_id, first_name, last_name, avatar_url, is_lead }] }: the global database's investors of the company, both keys always present; database_id = the investor's id in the database tools and the value the investors filter takes, crm_id = the workspace's CRM record linked to that investor or null when it is not in the CRM, is_lead = led one of its rounds) and headcount_growth ({ \"1m\", \"3m\", \"6m\", \"1y\" }: the headcount's percent change per window, null when unknown, plus value = the headcount on measured_on; null when the record has no linked profile or no data). " +
444
+ "Two read-only keys on a person: experience and education ([{ database_id, crm_id, name, avatar_url }]: the companies the linked global profile worked at and the schools it studied at, always present, [] without a linked profile; database_id = the id in the database tools and the value the experience / education filters take, crm_id = the workspace's CRM company linked to that company or school, null when it is not in the CRM). " +
445
+ "A third read-only key on every company, person, deal and custom object record: notes ({ count, last_note_at, notes: [ { id, title, type, created_at, created_by, tags, records, meeting } ] }: the notes linked to the record, the newest five with the author, the kind, the tags, the other records they link to and the meeting details of a meeting note; never the body, which lighthouse_crm_get_note reads; count is every note the caller may see). " +
446
+ "A read-only duration twin sits beside every status field (time_in_stage on a deal, time_in_status on a task, time_in_<status field> beside a custom status field; lighthouse_crm_get_attributes lists each with its source_field): { days, since } (the whole days the record has held its current status and the ISO timestamp since when), null while the status is empty. " +
447
+ "(Discovery profiles keep their own signals list: one row per feed window with type, created_at, timeframe, percentage_change.)",
448
+ inputSchema: {
449
+ record_type: z.string().describe("Record type (company, person, deal, or custom object permalink)"),
450
+ id: z.string().uuid().describe("Record ID"),
451
+ },
452
+ }, async (params) => {
453
+ try {
454
+ const res = await api.get(`/records/${params.record_type}/${params.id}`);
455
+ return text(res);
456
+ }
457
+ catch (e) {
458
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
459
+ }
460
+ });
461
+ server.registerTool("lighthouse_crm_get_record_activity", {
462
+ title: "Get Record Activity",
463
+ 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. " +
464
+ "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. " +
465
+ "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 / 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). " +
466
+ "changes[] holds the field edits: { field, label, data_type, is_custom, list, previous, current }, where previous and current are each { value, display }: value is what is stored (an option value, a uuid, a number) and display is the same thing resolved for humans (an option's label, a linked record or user object). Consecutive edits to the same field by the same person on the same day are collapsed into one entry, so previous is the value before the burst and current the value after it. " +
467
+ "Every entry has id, activity_id, action, occurred_at, summary, actor, source, target and changes. One save that changed several fields is reported as one entry per field: id stays unique per entry, activity_id is shared by the entries from that same save. The five detail keys are on every entry and null on the entries they do not describe: note and task carry that note's / task's body inline (no follow-up call needed), merge carries { merged_record, kept_record }, document carries { type, id, name } for an attached file or folder, and related_records (an empty array when there are none) lists the OTHER records when one activity spanned several (never the entry's own target, which is already under target, and never the record whose feed this is). " +
468
+ "record_type is company, person, deal, or a custom object permalink; pass task or note to read a task's or note's own history. " +
469
+ "SCOPE: the feed covers records, notes and tasks being created, updated, deleted and merged, plus files and folders being attached. It does NOT record list membership changes or detaching a document, so do not present it to the user as a complete audit trail.",
470
+ inputSchema: {
471
+ record_type: z.string().describe("Record type (company, person, deal, a custom object permalink, or task / note)"),
472
+ id: z.string().uuid().describe("Record ID"),
473
+ limit: z.number().min(1).max(100).default(25).describe("Entries per page (max 100)"),
474
+ offset: z.number().min(0).default(0).describe("Entries to skip. meta.total and meta.has_more say whether more remain"),
475
+ },
476
+ }, async ({ record_type, id, limit, offset }) => {
477
+ try {
478
+ const res = await api.get(`/records/${record_type}/${id}/activity`, { limit, offset });
479
+ return text(res);
480
+ }
481
+ catch (e) {
482
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
483
+ }
484
+ });
485
+ server.registerTool("lighthouse_crm_list_ai_reports", {
486
+ title: "List AI Reports on a Record",
487
+ 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. " +
488
+ "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. " +
489
+ "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, name }, created_at, completed_at. Only a success report has a document: read it with lighthouse_crm_get_ai_report. " +
490
+ "A report quoting a mailbox, calendar, LinkedIn account or file the API key's user cannot read is left out of the list (its author always sees it).",
491
+ inputSchema: {
492
+ record_type: z.string().describe("Record type: company, person, deal, or a custom object permalink"),
493
+ id: z.string().uuid().describe("Record ID"),
494
+ limit: z.number().min(1).max(50).default(25).describe("Reports per page (max 50)"),
495
+ offset: z.number().min(0).default(0).describe("Reports to skip; meta.total and meta.has_more say whether more remain"),
496
+ },
497
+ }, async ({ record_type, id, limit, offset }) => {
498
+ try {
499
+ const res = await api.get(`/records/${encodeURIComponent(record_type)}/${id}/ai-reports`, { limit, offset });
500
+ return text(res);
501
+ }
502
+ catch (e) {
503
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
504
+ }
505
+ });
506
+ server.registerTool("lighthouse_crm_get_ai_report", {
507
+ title: "Get AI Report",
508
+ 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). " +
509
+ "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. " +
510
+ "content is null while the report is still processing or failed. Answers 403 naming the channel when the report draws on a shared mailbox, calendar, LinkedIn account or document the API key's user cannot read, and 404 for an unknown id.",
511
+ inputSchema: {
512
+ id: z.string().uuid().describe("Report ID from lighthouse_crm_list_ai_reports"),
513
+ },
514
+ }, async (params) => {
515
+ try {
516
+ const res = await api.get(`/ai-reports/${params.id}`);
517
+ return text(res);
518
+ }
519
+ catch (e) {
520
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
521
+ }
522
+ });
523
+ server.registerTool("lighthouse_crm_create_record", {
524
+ title: "Create CRM Record",
525
+ description: "Create a new CRM record. Pass all fields (standard + custom) in a single flat data object, " +
526
+ "the API splits standard vs custom fields automatically. " +
527
+ "BEFORE calling this tool, call lighthouse_crm_get_attributes to discover available field keys and types for the record_type. " +
528
+ "A name is strongly recommended for every record: company → name (string). person → fn or first_name (string). deal → name (string). " +
529
+ "custom objects → name (string) is the only standard field; all other fields are custom. " +
530
+ "If you omit the name, the record is still created but stored with the name \"Unnamed\", so always provide one when you can. " +
531
+ "IMPORTANT data format rules (company/person/deal ONLY, custom objects do NOT have relation fields): " +
532
+ "domain (company) → ALWAYS pass as array of strings, even for one domain: [\"acme.com\"]. " +
533
+ "associated_people (company) → array of person UUIDs (or {id} objects). " +
534
+ "associated_deals (company) → array of deal UUIDs. " +
535
+ "emails (person) → array of email strings: [\"john@acme.com\"]. " +
536
+ "phones (person) → array of objects: [{\"prefix\":\"+1\",\"number\":\"5551234\"}]. " +
537
+ "associated_companies (person/deal) → array of company UUIDs. " +
538
+ "associated_people (deal) → array of person UUIDs. " +
539
+ "owner (deal) → UUID string of the owner user. " +
540
+ "Custom fields use their field permalink as the key. A custom rating field's value is an integer 1-5 (or null to clear). " +
541
+ "The response returns all fields as top-level keys (custom fields are NOT nested under an attributes object).",
542
+ inputSchema: {
543
+ record_type: z.string().describe("Record type (company, person, deal, or custom object permalink)"),
544
+ data: z.record(z.unknown()).describe("All record fields, standard, custom, and relations in a single flat object"),
545
+ },
546
+ }, async (params) => {
547
+ try {
548
+ const res = await api.post(`/records/${params.record_type}`, {
549
+ data: params.data,
550
+ });
551
+ return text(res);
552
+ }
553
+ catch (e) {
554
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
555
+ }
556
+ });
557
+ server.registerTool("lighthouse_crm_update_record", {
558
+ title: "Update CRM Record",
559
+ description: "Update an existing CRM record. Only include the fields you want to change. " +
560
+ "Standard and custom fields can be mixed in a single flat data object, the API splits them automatically. " +
561
+ "BEFORE calling this tool, call lighthouse_crm_get_attributes to discover available field keys and types. " +
562
+ "Relation and multi-value fields (domain, associated_people, associated_deals, emails, phones, associated_companies, and custom multi_select/multi_record/multi_user) " +
563
+ "support two modes (company/person/deal ONLY; custom objects have only 'name' and custom fields): " +
564
+ "(1) pass a bare ARRAY to REPLACE the whole field, e.g. domain: [\"acme.com\"], or an empty array [] to clear it (remove all); " +
565
+ "(2) pass { \"add\": [...] } and/or { \"remove\": [...] } to append or remove items WITHOUT fetching first, " +
566
+ "e.g. domain: { \"add\": [\"new.com\"] } appends one domain, associated_people: { \"remove\": [\"<uuid>\"] } removes one member. " +
567
+ "Use add/remove to avoid a get-then-merge round-trip; it is also the only safe way to change one relation member without disturbing the others. " +
568
+ "Field formats: " +
569
+ "domain (company) → array of strings: [\"acme.com\"]. " +
570
+ "associated_people (company) → array of person UUIDs. " +
571
+ "emails (person) → array of email strings. " +
572
+ "phones (person) → array of objects: [{\"prefix\":\"+1\",\"number\":\"5551234\"}]. " +
573
+ "associated_companies (person/deal) → array of company UUIDs. " +
574
+ "associated_people (deal) → array of person UUIDs. " +
575
+ "associated_deals (company/person) → array of deal UUIDs. " +
576
+ "A custom rating field's value → an integer 1-5 (or null to clear). " +
577
+ "The response returns all fields as top-level keys (custom fields are NOT nested under an attributes object).",
578
+ inputSchema: {
579
+ record_type: z.string().describe("Record type (company, person, deal, or custom object permalink)"),
580
+ id: z.string().uuid().describe("Record ID"),
581
+ data: z.record(z.unknown()).describe("Fields to update, standard, custom, and relations in a single flat object"),
582
+ },
583
+ }, async (params) => {
584
+ try {
585
+ const res = await api.patch(`/records/${params.record_type}/${params.id}`, {
586
+ data: params.data,
587
+ });
588
+ return text(res);
589
+ }
590
+ catch (e) {
591
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
592
+ }
593
+ });
594
+ server.registerTool("lighthouse_crm_upsert_record", {
595
+ title: "Upsert CRM Record",
596
+ 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. " +
597
+ "Only person and company support upsert, person matches on email or linkedin, company on domain or linkedin (set matching_attribute accordingly). " +
598
+ "The value to match on MUST be present in data (e.g. with matching_attribute 'email', include emails: [\"jane@acme.com\"]; with 'domain', include domain: [\"acme.com\"]; with 'linkedin', include linkedin: \"...\"). " +
599
+ "All other field rules (including the { add, remove } append/remove syntax) are the same as lighthouse_crm_update_record. " +
600
+ "Use this instead of create when you might be re-importing the same person/company and want to avoid duplicates.",
601
+ inputSchema: {
602
+ record_type: z.enum(["person", "company"]).describe("Only person or company can be upserted"),
603
+ matching_attribute: z
604
+ .enum(["email", "domain", "linkedin"])
605
+ .describe("Unique attribute to match on: person → email or linkedin; company → domain or linkedin"),
606
+ data: z.record(z.unknown()).describe("Record fields, MUST include the matching attribute's value so a created record carries it"),
607
+ },
608
+ }, async (params) => {
609
+ try {
610
+ const res = await api.put(`/records/${params.record_type}?matching_attribute=${params.matching_attribute}`, { data: params.data });
611
+ return text(res);
612
+ }
613
+ catch (e) {
614
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
615
+ }
616
+ });
617
+ server.registerTool("lighthouse_crm_delete_record", {
618
+ title: "Delete CRM Record",
619
+ description: "Permanently delete a CRM record. For custom objects, use the object's permalink as record_type.",
620
+ inputSchema: {
621
+ record_type: z.string().describe("Record type: company, person, deal, or a custom object permalink"),
622
+ id: z.string().uuid().describe("Record ID"),
623
+ },
624
+ }, async (params) => {
625
+ try {
626
+ const res = await api.delete(`/records/${params.record_type}/${params.id}`);
627
+ return text(res);
628
+ }
629
+ catch (e) {
630
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
631
+ }
632
+ });
633
+ server.registerTool("lighthouse_crm_bulk_create_records", {
634
+ title: "Bulk Create CRM Records",
635
+ description: "Create up to 100 CRM records of the same record_type in one call. " +
636
+ "Each item in `records` is a flat data object, the SAME shape you'd pass as `data` to lighthouse_crm_create_record. " +
637
+ "BEFORE calling, call lighthouse_crm_get_attributes to discover field keys and types. " +
638
+ "Partial success: the response returns a `results` array (one entry per item, in order, with success/data/error) and a `summary` " +
639
+ "({total, succeeded, failed}). A failed item does NOT roll back the others. " +
640
+ "Same per-record field rules as lighthouse_crm_create_record (domain as array, emails as array, owner as UUID, etc.).",
641
+ inputSchema: {
642
+ record_type: z.string().describe("Record type for ALL items (company, person, deal, or a custom object permalink)"),
643
+ records: z
644
+ .array(z.record(z.unknown()))
645
+ .min(1)
646
+ .max(100)
647
+ .describe("Array of flat data objects (max 100), one per record to create"),
648
+ },
649
+ }, async (params) => {
650
+ try {
651
+ const res = await api.post(`/records/${params.record_type}/bulk`, { records: params.records });
652
+ return text(res);
653
+ }
654
+ catch (e) {
655
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
656
+ }
657
+ });
658
+ server.registerTool("lighthouse_crm_bulk_update_records", {
659
+ title: "Bulk Update CRM Records",
660
+ description: "Update up to 100 CRM records of the same record_type in one call. " +
661
+ "Each item in `records` is { id, data }, `id` is the record UUID and `data` is a flat object of the fields to change " +
662
+ "(same shape as lighthouse_crm_update_record). " +
663
+ "Relation and multi-value fields are REPLACED per item when you pass a bare array (send [] to clear); to append or remove without fetching first, pass { add: [...] } / { remove: [...] } per field (same as lighthouse_crm_update_record). " +
664
+ "Partial success: the response returns a `results` array (one entry per item, in order, with success/data/error) and a `summary` ({total, succeeded, failed}).",
665
+ inputSchema: {
666
+ record_type: z.string().describe("Record type for ALL items (company, person, deal, or a custom object permalink)"),
667
+ records: z
668
+ .array(z.object({
669
+ id: z.string().uuid().describe("Record UUID to update"),
670
+ data: z.record(z.unknown()).describe("Flat object of fields to change"),
671
+ }))
672
+ .min(1)
673
+ .max(100)
674
+ .describe("Array of { id, data } items (max 100)"),
675
+ },
676
+ }, async (params) => {
677
+ try {
678
+ const res = await api.patch(`/records/${params.record_type}/bulk`, { records: params.records });
679
+ return text(res);
680
+ }
681
+ catch (e) {
682
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
683
+ }
684
+ });
685
+ server.registerTool("lighthouse_crm_bulk_upsert_records", {
686
+ title: "Bulk Upsert CRM Records",
687
+ description: "Find-or-create up to 100 records of the same record_type in one call, each matched on a unique attribute. " +
688
+ "For each item, if a record already matches the value it is updated, otherwise a new one is created. " +
689
+ "Person matches on email or linkedin, company on domain or linkedin (set matching_attribute for the whole batch). " +
690
+ "Each item in `records` is a flat data object that MUST include the matching attribute's value (e.g. matching_attribute 'email' means each item includes emails: [\"...\"]). " +
691
+ "Only person and company support upsert; deals and custom objects do not. " +
692
+ "Partial success: the response returns a `results` array (one entry per item, in order, with success/created/data/error) and a `summary` ({total, succeeded, failed}). " +
693
+ "Ideal for re-importing a batch of people or companies without creating duplicates.",
694
+ inputSchema: {
695
+ record_type: z.enum(["person", "company"]).describe("Only person or company can be upserted"),
696
+ matching_attribute: z
697
+ .enum(["email", "domain", "linkedin"])
698
+ .describe("Unique attribute to match on for ALL items: person uses email or linkedin; company uses domain or linkedin"),
699
+ records: z
700
+ .array(z.record(z.unknown()))
701
+ .min(1)
702
+ .max(100)
703
+ .describe("Array of flat data objects (max 100); each MUST include the matching attribute's value"),
704
+ },
705
+ }, async (params) => {
706
+ try {
707
+ const res = await api.put(`/records/${params.record_type}/bulk?matching_attribute=${params.matching_attribute}`, { records: params.records });
708
+ return text(res);
709
+ }
710
+ catch (e) {
711
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
712
+ }
713
+ });
714
+ server.registerTool("lighthouse_crm_bulk_delete_records", {
715
+ title: "Bulk Delete CRM Records",
716
+ description: "Permanently delete up to 100 CRM records of the same record_type in one call. " +
717
+ "Pass `ids` as an array of record UUIDs. " +
718
+ "Partial success: the response returns a `results` array (one entry per id, in order, with success/error) and a `summary` ({total, succeeded, failed}).",
719
+ inputSchema: {
720
+ record_type: z.string().describe("Record type for ALL ids (company, person, deal, or a custom object permalink)"),
721
+ ids: z.array(z.string().uuid()).min(1).max(100).describe("Array of record UUIDs to delete (max 100)"),
722
+ },
723
+ }, async (params) => {
724
+ try {
725
+ const res = await api.delete(`/records/${params.record_type}/bulk`, { ids: params.ids });
726
+ return text(res);
727
+ }
728
+ catch (e) {
729
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
730
+ }
731
+ });
732
+ server.registerTool("lighthouse_crm_create_list", {
733
+ title: "Create CRM List",
734
+ description: "Create a new CRM list to organize records. Supports companies, people, deals, and custom objects. " +
735
+ "Defaults to 'private' visibility. " +
736
+ "To share with specific teams after creation, use lighthouse_crm_share_list_with_teams. " +
737
+ "Requires lists.edit permission.",
738
+ inputSchema: {
739
+ name: z.string().describe("List name"),
740
+ record_type: z.string().describe("Record type for this list: 'company', 'person', 'deal', or a custom object permalink (e.g. 'lp', 'project')"),
741
+ sharing: z.enum(["private", "workspace"]).default("private").optional().describe("Visibility: 'private' (default) or 'workspace'"),
742
+ color: z.string().optional().describe("Hex color code for the list"),
743
+ },
744
+ }, async (params) => {
745
+ try {
746
+ const res = await api.post("/lists", params);
747
+ return text(res);
748
+ }
749
+ catch (e) {
750
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
751
+ }
752
+ });
753
+ server.registerTool("lighthouse_crm_list_lists", {
754
+ title: "List CRM Lists",
755
+ 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.",
756
+ inputSchema: {
757
+ limit: z.number().min(1).max(100).default(25),
758
+ offset: z.number().min(0).default(0),
759
+ },
760
+ }, async (params) => {
761
+ try {
762
+ const res = await api.get("/lists", params);
763
+ return text(res);
764
+ }
765
+ catch (e) {
766
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
767
+ }
768
+ });
769
+ server.registerTool("lighthouse_crm_get_list", {
770
+ title: "Get CRM List",
771
+ 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').",
772
+ inputSchema: {
773
+ id: z.string().uuid().describe("List ID"),
774
+ },
775
+ }, async (params) => {
776
+ try {
777
+ const res = await api.get(`/lists/${params.id}`);
778
+ return text(res);
779
+ }
780
+ catch (e) {
781
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
782
+ }
783
+ });
784
+ server.registerTool("lighthouse_crm_update_list", {
785
+ title: "Update CRM List",
786
+ description: "Update a CRM list's name or configuration.",
787
+ inputSchema: {
788
+ id: z.string().uuid().describe("List ID"),
789
+ name: z.string().optional().describe("New list name"),
790
+ sharing: z.enum(["private", "workspace"]).optional().describe("New visibility setting"),
791
+ color: z.string().optional().describe("New hex color code"),
792
+ },
793
+ }, async (params) => {
794
+ try {
795
+ const { id, ...body } = params;
796
+ const res = await api.patch(`/lists/${id}`, body);
797
+ return text(res);
798
+ }
799
+ catch (e) {
800
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
801
+ }
802
+ });
803
+ server.registerTool("lighthouse_crm_delete_list", {
804
+ title: "Delete CRM List",
805
+ description: "Permanently delete a CRM list.",
806
+ inputSchema: {
807
+ id: z.string().uuid().describe("List ID"),
808
+ },
809
+ }, async (params) => {
810
+ try {
811
+ const res = await api.delete(`/lists/${params.id}`);
812
+ return text(res);
813
+ }
814
+ catch (e) {
815
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
816
+ }
817
+ });
818
+ server.registerTool("lighthouse_crm_get_list_records", {
819
+ title: "Get List Records",
820
+ description: "Get records in a CRM list with optional filtering, sorting, and view support. " +
821
+ "Same filter format as search_records. To get valid filter keys, call lighthouse_crm_get_attributes with this list's record_type, which is 'company', 'person', 'deal', or a custom object permalink. " +
822
+ "The response's `meta` includes `total` (an integer match count) and `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). Check `ignored_filter_fields` to confirm every filter was actually applied.",
823
+ inputSchema: {
824
+ id: z.string().uuid().describe("List ID"),
825
+ filters: z.record(z.unknown()).optional().describe(CRM_FILTER_DESCRIPTION),
826
+ sort: z.array(z.record(z.unknown())).optional().describe(CRM_SORT_DESCRIPTION),
827
+ limit: z.number().min(1).max(100).default(25),
828
+ offset: z.number().min(0).default(0),
829
+ view_id: z.string().uuid().optional().describe("Load saved filters/sort from a view. User-provided filters override view filters."),
830
+ },
831
+ }, async (params) => {
832
+ try {
833
+ const { id, ...body } = params;
834
+ const res = await api.post(`/lists/${id}/records/search`, body);
835
+ return text(res);
836
+ }
837
+ catch (e) {
838
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
839
+ }
840
+ });
841
+ server.registerTool("lighthouse_crm_add_to_list", {
842
+ title: "Add Record to List",
843
+ 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.",
844
+ inputSchema: {
845
+ list_id: z.string().uuid().describe("List ID"),
846
+ record_id: z.string().uuid().describe("Record ID to add"),
847
+ },
848
+ }, async (params) => {
849
+ try {
850
+ const res = await api.post(`/lists/${params.list_id}/records`, {
851
+ record_id: params.record_id,
852
+ });
853
+ return text(res);
854
+ }
855
+ catch (e) {
856
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
857
+ }
858
+ });
859
+ server.registerTool("lighthouse_crm_remove_from_list", {
860
+ title: "Remove Record from List",
861
+ 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.",
862
+ inputSchema: {
863
+ list_id: z.string().uuid().describe("List ID"),
864
+ record_id: z.string().uuid().describe("Record ID to remove"),
865
+ },
866
+ }, async (params) => {
867
+ try {
868
+ const res = await api.delete(`/lists/${params.list_id}/records/${params.record_id}`);
869
+ return text(res);
870
+ }
871
+ catch (e) {
872
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
873
+ }
874
+ });
875
+ server.registerTool("lighthouse_crm_bulk_add_to_list", {
876
+ title: "Bulk Add Records to List",
877
+ 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. " +
878
+ "Partial success: the response returns a `results` array (one entry per id, in order, with success/error) and a `summary` ({total, succeeded, failed}). " +
879
+ "A failed id does NOT prevent the others from being added.",
880
+ inputSchema: {
881
+ list_id: z.string().uuid().describe("List ID"),
882
+ record_ids: z.array(z.string().uuid()).min(1).max(100).describe("Array of record UUIDs to add (max 100)"),
883
+ },
884
+ }, async (params) => {
885
+ try {
886
+ const res = await api.post(`/lists/${params.list_id}/records/bulk`, { record_ids: params.record_ids });
887
+ return text(res);
888
+ }
889
+ catch (e) {
890
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
891
+ }
892
+ });
893
+ server.registerTool("lighthouse_crm_bulk_remove_from_list", {
894
+ title: "Bulk Remove Records from List",
895
+ 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. " +
896
+ "Partial success: the response returns a `results` array (one entry per id, in order, with success/error) and a `summary` ({total, succeeded, failed}).",
897
+ inputSchema: {
898
+ list_id: z.string().uuid().describe("List ID"),
899
+ record_ids: z.array(z.string().uuid()).min(1).max(100).describe("Array of record UUIDs to remove (max 100)"),
900
+ },
901
+ }, async (params) => {
902
+ try {
903
+ const res = await api.delete(`/lists/${params.list_id}/records/bulk`, { record_ids: params.record_ids });
904
+ return text(res);
905
+ }
906
+ catch (e) {
907
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
908
+ }
909
+ });
910
+ server.registerTool("lighthouse_crm_share_list_with_teams", {
911
+ title: "Share List with Teams",
912
+ description: "Grant team-level access to a CRM list. Only the list owner can share. " +
913
+ "Use lighthouse_workspace_list_teams to find team IDs first. " +
914
+ "Requires lists.share permission. " +
915
+ "Returns formatted list: {id, name, record_type, sharing, color, count, owner, shared_with_teams, created_at, updated_at}, where record_type is 'company', 'person', 'deal', or a custom object permalink.",
916
+ inputSchema: {
917
+ id: z.string().uuid().describe("List ID"),
918
+ team_ids: z.array(z.string().uuid()).describe("Array of team UUIDs to grant access to"),
919
+ },
920
+ }, async (params) => {
921
+ try {
922
+ const { id, ...body } = params;
923
+ const res = await api.post(`/lists/${id}/teams`, body);
924
+ return text(res);
925
+ }
926
+ catch (e) {
927
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
928
+ }
929
+ });
930
+ server.registerTool("lighthouse_crm_revoke_list_from_teams", {
931
+ title: "Revoke List Team Access",
932
+ description: "Revoke team-level access from a CRM list. Only the list owner can revoke. " +
933
+ "Requires lists.share permission. " +
934
+ "Returns formatted list: {id, name, record_type, sharing, color, count, owner, shared_with_teams, created_at, updated_at}, where record_type is 'company', 'person', 'deal', or a custom object permalink.",
935
+ inputSchema: {
936
+ id: z.string().uuid().describe("List ID"),
937
+ team_ids: z.array(z.string().uuid()).describe("Array of team UUIDs to revoke access from"),
938
+ },
939
+ }, async (params) => {
940
+ try {
941
+ const { id, ...body } = params;
942
+ const res = await api.delete(`/lists/${id}/teams`, body);
943
+ return text(res);
944
+ }
945
+ catch (e) {
946
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
947
+ }
948
+ });
949
+ server.registerTool("lighthouse_crm_update_list_sharing", {
950
+ title: "Update List Sharing Status",
951
+ description: "Change a CRM list's sharing visibility. Setting to 'private' also removes all team permissions. " +
952
+ "Only the list owner can change sharing. Requires lists.share permission. " +
953
+ "Returns formatted list: {id, name, record_type, sharing, color, count, owner, shared_with_teams, created_at, updated_at}, where record_type is 'company', 'person', 'deal', or a custom object permalink.",
954
+ inputSchema: {
955
+ id: z.string().uuid().describe("List ID"),
956
+ sharing: z.enum(["private", "workspace"]).describe("New sharing visibility"),
957
+ },
958
+ }, async (params) => {
959
+ try {
960
+ const { id, ...body } = params;
961
+ const res = await api.patch(`/lists/${id}/sharing`, body);
962
+ return text(res);
963
+ }
964
+ catch (e) {
965
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
966
+ }
967
+ });
968
+ server.registerTool("lighthouse_crm_list_notes", {
969
+ title: "List Notes",
970
+ description: "Get notes for a CRM record. Each note in the response includes a `records` map, every record (company/person/deal/custom object) the note is currently linked to, plus a `tags` array of any workspace tags attached to the note (each with id, label, color). " +
971
+ "Every note also carries `type` ('standard' for a note a member wrote, 'meeting' for meeting notes written from a recorded meeting) and `meeting` (null on a standard note; on a meeting note { title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording }, where attendees is [{ email, name }] with name possibly null, call_type is a short category from a fixed list such as 'First founder call' or 'Internal discussion', and duration_seconds / ical_uid / attendees are omitted when unknown). Meeting notes are included only when the API key's user can open the meeting's recording.",
972
+ inputSchema: {
973
+ record_id: z.string().uuid().describe("Record ID to list notes for"),
974
+ limit: z.number().min(1).max(100).default(25),
975
+ offset: z.number().min(0).default(0),
976
+ },
977
+ }, async (params) => {
978
+ try {
979
+ const res = await api.get("/notes", params);
980
+ return text(res);
981
+ }
982
+ catch (e) {
983
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
984
+ }
985
+ });
986
+ server.registerTool("lighthouse_crm_search_notes", {
987
+ title: "Search Notes",
988
+ 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. " +
989
+ 'filters: {"operator":"AND","conditions":[{"field":"tags","operator":"contains_any","value":["<tag_permalink>"]}]}: nested groups supported. ' +
990
+ "Filterable fields and their operators: title (text: contains/not_contains/starts_with/ends_with/eq/neq/empty/not_empty), " +
991
+ "created_at (date: before/after/on/empty/not_empty: values accept YYYY-MM-DD or relative periods like \"7 days\", \"3 months ago\", \"2 weeks from now\"), " +
992
+ "created_by (user id: contains_any/not_contains_any/empty/not_empty), " +
993
+ "tags (tag permalinks from lighthouse_crm_list_note_tags: contains_any/not_contains_any/contains_all/empty/not_empty), " +
994
+ "records (linked record ids: contains_any/not_contains_any/contains_all/empty/not_empty; pass a plain array of record ids of ANY type, custom object records included, or an object keyed by record type or custom object permalink {company:[ids],person:[ids],deal:[ids],\"<permalink>\":[ids]}), " +
995
+ "type (select: contains_any / not_contains_any ONLY, with an array of \"standard\" and / or \"meeting\"; [\"meeting\"] selects meeting notes written from a recorded meeting, [\"standard\"] the notes members wrote; any other value is rejected). " +
996
+ "An unknown field or invalid operator is rejected with a clear error naming it. " +
997
+ "Every returned note carries `type` ('standard' | 'meeting') and `meeting` (null on a standard note; on a meeting note { title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording }; attendees is [{ email, name }] with name possibly null; duration_seconds, ical_uid and attendees are omitted when unknown). Meeting notes are included only when the API key's user can open the meeting's recording.",
998
+ inputSchema: {
999
+ search: z.string().optional().describe("Free-text search on the note title"),
1000
+ filters: z.record(z.any()).optional().describe("CRM-style filter object for notes (see tool description for fields + operators)"),
1001
+ sort: z.array(z.record(z.any())).optional().describe('Sort array: [{"field":"<key>","direction":"asc"|"desc"}] (default: created_at desc). Sortable fields: title, created_at, created_by. tags, records and type are not sortable.'),
1002
+ limit: z.number().min(1).max(100).default(25),
1003
+ offset: z.number().min(0).default(0),
1004
+ },
1005
+ }, async (params) => {
1006
+ try {
1007
+ const res = await api.post("/notes/search", params);
1008
+ return text(res);
1009
+ }
1010
+ catch (e) {
1011
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1012
+ }
1013
+ });
1014
+ server.registerTool("lighthouse_crm_create_note", {
1015
+ title: "Create Note",
1016
+ 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. " +
1017
+ "Link records via the `records` param, keyed by type. " +
1018
+ "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. " +
1019
+ "Content is HTML; plain text works too. " +
1020
+ "To @mention/tag workspace users: " +
1021
+ "1) Call lighthouse_workspace_list_users first to find the user's ID, " +
1022
+ "2) Include their user IDs in tagged_users, " +
1023
+ "3) Include a mention span in the HTML content: " +
1024
+ '<span data-type="mention" data-id="USER_UUID" data-label="First Last">@First Last</span>. ' +
1025
+ "Tagged users receive an in-app notification. " +
1026
+ "Optionally attach workspace tags by permalink via `tags` (list the available tags with lighthouse_crm_list_note_tags); these are labeled, colored tags shared across the workspace and are separate from tagged_users. " +
1027
+ "This always creates a standard note (type 'standard', meeting null): there is no type input (one sent in the request is ignored), and meeting notes are written by the Notetaker from a recording, never through this tool.",
1028
+ inputSchema: {
1029
+ records: z
1030
+ .record(z.array(z.string().uuid()))
1031
+ .optional()
1032
+ .describe("Records to link, keyed by record type. Optional: omit for a standalone note. Any record ids you pass must exist or the call returns a not-found error."),
1033
+ title: z.string().describe("Note title"),
1034
+ content: z.string().describe("Note content (plain text or HTML). Use <p> tags for paragraphs. For @mentions, include mention spans (see description)."),
1035
+ tagged_users: z
1036
+ .array(z.string().uuid())
1037
+ .optional()
1038
+ .describe("Array of workspace user UUIDs to tag/mention in the note. Must match mention spans in content."),
1039
+ tags: z
1040
+ .array(z.string())
1041
+ .optional()
1042
+ .describe("Array of workspace tag permalinks to attach to the note (from lighthouse_crm_list_note_tags). Distinct from tagged_users."),
1043
+ },
1044
+ }, async (params) => {
1045
+ try {
1046
+ const res = await api.post("/notes", params);
1047
+ return text(res);
1048
+ }
1049
+ catch (e) {
1050
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1051
+ }
1052
+ });
1053
+ server.registerTool("lighthouse_crm_get_note", {
1054
+ title: "Get Note",
1055
+ description: "Get a single note by ID. Response includes a `records` object keyed by record type (or custom object permalink) with every linked record, plus a `tags` array of any workspace tags attached to the note (each with permalink, label, color). " +
1056
+ "`type` is 'standard' for a note a member wrote and 'meeting' for meeting notes written from a recorded meeting; `meeting` is null on a standard note and { title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording } on a meeting note: title and occurred_at come from the meeting itself, attendees is [{ email, name }] with name possibly null, call_type is one of 'First founder call', 'Follow-up founder call', 'Diligence call', 'Re-engagement', 'Portfolio check-in', 'Board meeting', 'Investment committee', 'LP meeting', 'Fundraising call', 'Co-investor or syndication call', 'Internal discussion', 'Hiring or reference call', 'Partnership or vendor call' or 'Other', and duration_seconds / ical_uid / attendees are omitted when unknown. A meeting note is returned only when the API key's user can open the meeting's recording; otherwise it is reported as not found.",
1057
+ inputSchema: {
1058
+ id: z.string().uuid().describe("Note ID"),
1059
+ },
1060
+ }, async (params) => {
1061
+ try {
1062
+ const res = await api.get(`/notes/${params.id}`);
1063
+ return text(res);
1064
+ }
1065
+ catch (e) {
1066
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1067
+ }
1068
+ });
1069
+ server.registerTool("lighthouse_crm_update_note", {
1070
+ title: "Update Note",
1071
+ 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. " +
1072
+ "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). " +
1073
+ "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. " +
1074
+ "To @mention/tag workspace users: 1) call lighthouse_workspace_list_users to find the user's ID, 2) include their user IDs in tagged_users, 3) include a mention span in the HTML content: " +
1075
+ '<span data-type="mention" data-id="USER_UUID" data-label="First Last">@First Last</span>. ' +
1076
+ "A user newly named in tagged_users receives one in-app notification; keep the spans of people already mentioned when you resend the content. " +
1077
+ "tags are the workspace's labeled, colored note tags (permalinks from lighthouse_crm_list_note_tags), separate from tagged_users: the array REPLACES the tag set (an empty array clears it); to add or remove single tags without touching the rest use lighthouse_crm_manage_note_tags. " +
1078
+ "Works on meeting notes too: the edit is kept on the current version and the note reports it as edited. " +
1079
+ "Returns the updated note in the same shape as lighthouse_crm_get_note. To change which records a note is attached to use lighthouse_crm_link_note_record / lighthouse_crm_unlink_note_record; to remove it use lighthouse_crm_delete_note.",
1080
+ inputSchema: {
1081
+ id: z.string().uuid().describe("Note ID"),
1082
+ title: z.string().optional().describe("New title"),
1083
+ content: z.string().optional().describe("New content, replacing the whole body. HTML (<p> paragraphs, <ul><li> bullets) or plain text. For @mentions include mention spans (see description)."),
1084
+ tags: z.array(z.string()).optional().describe("Workspace tag permalinks to set on the note (from lighthouse_crm_list_note_tags). Replaces the current set; [] clears it. Distinct from tagged_users."),
1085
+ tagged_users: z.array(z.string().uuid()).optional().describe("Workspace user UUIDs to @mention in the note. Must match mention spans in content."),
1086
+ },
1087
+ }, async ({ id, ...changes }) => {
1088
+ try {
1089
+ const res = await api.patch(`/notes/${id}`, changes);
1090
+ return text(res);
1091
+ }
1092
+ catch (e) {
1093
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1094
+ }
1095
+ });
1096
+ server.registerTool("lighthouse_crm_delete_note", {
1097
+ title: "Delete Note",
1098
+ 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.",
1099
+ inputSchema: {
1100
+ id: z.string().uuid().describe("Note ID"),
1101
+ },
1102
+ }, async (params) => {
1103
+ try {
1104
+ const res = await api.delete(`/notes/${params.id}`);
1105
+ return text(res);
1106
+ }
1107
+ catch (e) {
1108
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1109
+ }
1110
+ });
1111
+ server.registerTool("lighthouse_crm_link_note_record", {
1112
+ title: "Link Record to Note",
1113
+ description: "Attach a record to an existing note. Call once per record. " +
1114
+ "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. " +
1115
+ "Use lighthouse_crm_get_note to see the current set of linked records.",
1116
+ inputSchema: {
1117
+ note_id: z.string().uuid().describe("Note ID"),
1118
+ record_type: z.string().describe("Record type: company, person, deal, or a custom object permalink"),
1119
+ record_id: z.string().uuid().describe("Existing CRM record id to link (not a discovery id)"),
1120
+ },
1121
+ }, async ({ note_id, record_type, record_id }) => {
1122
+ try {
1123
+ const res = await api.post(`/notes/${note_id}/records`, { record_type, record_id });
1124
+ return text(res);
1125
+ }
1126
+ catch (e) {
1127
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1128
+ }
1129
+ });
1130
+ server.registerTool("lighthouse_crm_unlink_note_record", {
1131
+ title: "Unlink Record from Note",
1132
+ description: "Remove a record link from a note. Call once per record. " +
1133
+ "Removing the last linked record is allowed and leaves a standalone note (it stays in the workspace Notes section).",
1134
+ inputSchema: {
1135
+ note_id: z.string().uuid().describe("Note ID"),
1136
+ record_type: z.string().describe("Record type: company, person, deal, or a custom object permalink"),
1137
+ record_id: z.string().uuid().describe("Record ID to unlink"),
1138
+ },
1139
+ }, async ({ note_id, record_type, record_id }) => {
1140
+ try {
1141
+ const res = await api.delete(`/notes/${note_id}/records`, { record_type, record_id });
1142
+ return text(res);
1143
+ }
1144
+ catch (e) {
1145
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1146
+ }
1147
+ });
1148
+ server.registerTool("lighthouse_crm_list_note_tags", {
1149
+ title: "List Note Tags",
1150
+ description: "List the workspace's note tags: labeled, colored tags that can be attached to notes as a multi-select. " +
1151
+ "Returns an array of { permalink, label, color }. " +
1152
+ "Use this to find tag permalinks before attaching them to a note (via lighthouse_crm_create_note `tags` or lighthouse_crm_manage_note_tags). " +
1153
+ "These are distinct from tagged_users (user @mentions on a note).",
1154
+ inputSchema: {},
1155
+ }, async () => {
1156
+ try {
1157
+ const res = await api.get("/note-tags");
1158
+ return text(res);
1159
+ }
1160
+ catch (e) {
1161
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1162
+ }
1163
+ });
1164
+ server.registerTool("lighthouse_crm_create_note_tag", {
1165
+ title: "Create Note Tag",
1166
+ description: "Create a new note tag in the workspace vocabulary. The tag can then be attached to notes. " +
1167
+ "Returns the created tag as { permalink, label, color }. " +
1168
+ "Color is an optional palette code (get available colors from lighthouse_crm_get_color_palette); omit it to auto-assign one.",
1169
+ inputSchema: {
1170
+ label: z.string().describe("Tag display label"),
1171
+ color: z.string().optional().describe("Tag color (palette code, auto-assigned if omitted)"),
1172
+ },
1173
+ }, async (params) => {
1174
+ try {
1175
+ const res = await api.post("/note-tags", params);
1176
+ return text(res);
1177
+ }
1178
+ catch (e) {
1179
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1180
+ }
1181
+ });
1182
+ server.registerTool("lighthouse_crm_update_note_tag", {
1183
+ title: "Update Note Tag",
1184
+ description: "Update a note tag's label and/or color. Only the fields you pass are changed. " +
1185
+ "Returns the updated tag as { permalink, label, color }.",
1186
+ inputSchema: {
1187
+ permalink: z.string().describe("Tag permalink"),
1188
+ label: z.string().optional().describe("New tag display label"),
1189
+ color: z.string().optional().describe("New tag color (palette code)"),
1190
+ },
1191
+ }, async ({ permalink, ...rest }) => {
1192
+ try {
1193
+ const res = await api.patch(`/note-tags/${permalink}`, rest);
1194
+ return text(res);
1195
+ }
1196
+ catch (e) {
1197
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1198
+ }
1199
+ });
1200
+ server.registerTool("lighthouse_crm_delete_note_tag", {
1201
+ title: "Delete Note Tag",
1202
+ description: "Delete a note tag from the workspace vocabulary. The tag is also detached from any notes it was attached to.",
1203
+ inputSchema: {
1204
+ permalink: z.string().describe("Tag permalink"),
1205
+ },
1206
+ }, async ({ permalink }) => {
1207
+ try {
1208
+ const res = await api.delete(`/note-tags/${permalink}`);
1209
+ return text(res);
1210
+ }
1211
+ catch (e) {
1212
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1213
+ }
1214
+ });
1215
+ server.registerTool("lighthouse_crm_manage_note_tags", {
1216
+ title: "Attach or Detach Note Tags",
1217
+ description: "Attach or detach workspace tags on an existing note. Pass operation ADD to attach the tag permalinks, or REMOVE to detach them. " +
1218
+ "Find tag permalinks with lighthouse_crm_list_note_tags. Returns the updated note (including its current `tags`).",
1219
+ inputSchema: {
1220
+ note_id: z.string().uuid().describe("Note ID"),
1221
+ operation: z.enum(["ADD", "REMOVE"]).describe("ADD to attach the tags, REMOVE to detach them"),
1222
+ tags: z.array(z.string()).describe("Tag permalinks to attach or detach"),
1223
+ },
1224
+ }, async ({ note_id, operation, tags }) => {
1225
+ try {
1226
+ const res = operation === "ADD"
1227
+ ? await api.post(`/notes/${note_id}/tags`, { tags })
1228
+ : await api.delete(`/notes/${note_id}/tags`, { tags });
1229
+ return text(res);
1230
+ }
1231
+ catch (e) {
1232
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1233
+ }
1234
+ });
1235
+ server.registerTool("lighthouse_crm_list_tasks", {
1236
+ title: "List Tasks",
1237
+ description: "List CRM tasks with optional filtering and sorting. " +
1238
+ '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); ' +
1239
+ 'sort: [{"field":"due_date","direction":"asc"}]; ' +
1240
+ "Filterable fields: title, status, priority, due_date, description, assigned_to, records (person/company/deal/custom object permalink), created_by (the teammate who created the task, filtered like any user field), created_at, time_in_status (read-only, a whole number of days in the current status). Tasks have no custom fields: any other field name is refused with a 400.",
1241
+ inputSchema: {
1242
+ filters: z.record(z.any()).optional().describe("CRM filter object for tasks. Filterable fields: title, status, priority, due_date, description, assigned_to, records (person/company/deal/custom object permalink), created_by (the teammate who created the task, filtered like any user field), created_at, time_in_status (read-only, a whole number of days in the current status). Tasks have no custom fields: any other field name is refused with a 400. " + CRM_FILTER_DESCRIPTION),
1243
+ sort: z.array(z.record(z.any())).optional().describe('Task sort array: [{"field":"<key>","direction":"asc"|"desc"}], applied in order (default: due_date asc). ' +
1244
+ "Sortable task fields are title, status, priority, due_date, created_at, created_by, time_in_status (asc = fewest days in the current status first). Tasks have no custom fields. " +
1245
+ "Multi-value fields are NOT sortable (assigned_to is multi_user; records and multi_select fields can't be sorted). " +
1246
+ 'An unknown or unsortable sort field is rejected with a clear error (field "x" is not sortable), so pick a sortable field. ' +
1247
+ "status and priority sort by their position in the field's option list, not alphabetically. Priority positions are its magnitude, so ascending on priority is least urgent first and descending puts the urgent work on top."),
1248
+ limit: z.number().min(1).max(100).default(25),
1249
+ offset: z.number().min(0).default(0),
1250
+ },
1251
+ }, async (params) => {
1252
+ try {
1253
+ const res = await api.post("/tasks/search", params);
1254
+ return text(res);
1255
+ }
1256
+ catch (e) {
1257
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1258
+ }
1259
+ });
1260
+ server.registerTool("lighthouse_crm_get_task", {
1261
+ title: "Get Task",
1262
+ description: "Get a single CRM task by ID.",
1263
+ inputSchema: {
1264
+ id: z.string().uuid().describe("Task ID"),
1265
+ },
1266
+ }, async (params) => {
1267
+ try {
1268
+ const res = await api.get(`/tasks/${params.id}`);
1269
+ return text(res);
1270
+ }
1271
+ catch (e) {
1272
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1273
+ }
1274
+ });
1275
+ server.registerTool("lighthouse_crm_create_task", {
1276
+ title: "Create Task",
1277
+ description: "Create a CRM task, optionally linked to records and assigned to users. " +
1278
+ "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`.",
1279
+ inputSchema: {
1280
+ title: z.string().describe("Task title"),
1281
+ description: z.string().optional().describe("Task description"),
1282
+ status: z.string().optional().describe("Task status. Use the option value (not the label) from lighthouse_crm_list_options (record_type 'task', permalink 'status'); valid values are workspace-specific, do not assume any fixed set."),
1283
+ priority: z.string().nullable().optional().describe("Priority option value from lighthouse_crm_list_options (record_type 'task', permalink 'priority'): urgent, high, medium, low or very_low unless this workspace configured its own. Speak to the user in the label, never the value. Send null to clear it."),
1284
+ due_date: z.string().optional().describe("Due date (ISO 8601)"),
1285
+ assigned_to: z
1286
+ .array(z.string().uuid())
1287
+ .optional()
1288
+ .describe("Array of user UUIDs to assign the task to"),
1289
+ records: z
1290
+ .record(z.array(z.string().uuid()))
1291
+ .optional()
1292
+ .describe("Records to link, keyed by type: {company: [ids], person: [ids], deal: [ids], <custom_object_permalink>: [ids]}"),
1293
+ },
1294
+ }, async (params) => {
1295
+ try {
1296
+ const res = await api.post("/tasks", { data: params });
1297
+ return text(res);
1298
+ }
1299
+ catch (e) {
1300
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1301
+ }
1302
+ });
1303
+ server.registerTool("lighthouse_crm_update_task", {
1304
+ title: "Update Task",
1305
+ description: "Update a CRM task. Provide only fields you want to change. " +
1306
+ "assigned_to replaces all current assignees. records replaces all linked records. " +
1307
+ "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`.",
1308
+ inputSchema: {
1309
+ id: z.string().uuid().describe("Task ID"),
1310
+ title: z.string().optional(),
1311
+ description: z.string().optional(),
1312
+ status: z.string().optional().describe("Task status. Use the option value (not the label) from lighthouse_crm_list_options (record_type 'task', permalink 'status'); valid values are workspace-specific, do not assume any fixed set."),
1313
+ priority: z.string().nullable().optional().describe("Priority option value from lighthouse_crm_list_options (record_type 'task', permalink 'priority'): urgent, high, medium, low or very_low unless this workspace configured its own. Speak to the user in the label, never the value. Send null to clear it."),
1314
+ due_date: z.string().optional(),
1315
+ assigned_to: z
1316
+ .array(z.string().uuid())
1317
+ .optional()
1318
+ .describe("Array of user UUIDs to assign (replaces current assignees)"),
1319
+ records: z
1320
+ .record(z.array(z.string().uuid()))
1321
+ .optional()
1322
+ .describe("Records to link (replaces current), keyed by type: {company: [ids], person: [ids], deal: [ids], <custom_object_permalink>: [ids]}"),
1323
+ },
1324
+ }, async (params) => {
1325
+ try {
1326
+ const { id, ...rest } = params;
1327
+ const res = await api.patch(`/tasks/${id}`, { data: rest });
1328
+ return text(res);
1329
+ }
1330
+ catch (e) {
1331
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1332
+ }
1333
+ });
1334
+ server.registerTool("lighthouse_crm_delete_task", {
1335
+ title: "Delete Task",
1336
+ description: "Permanently delete a CRM task.",
1337
+ inputSchema: {
1338
+ id: z.string().uuid().describe("Task ID"),
1339
+ },
1340
+ }, async (params) => {
1341
+ try {
1342
+ const res = await api.delete(`/tasks/${params.id}`);
1343
+ return text(res);
1344
+ }
1345
+ catch (e) {
1346
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1347
+ }
1348
+ });
1349
+ server.registerTool("lighthouse_list_documents", {
1350
+ title: "List Documents",
1351
+ description: "List folders and files at the workspace root, or inside a specific folder. " +
1352
+ "Returns { folders, files, breadcrumb } plus meta.folders_next_cursor / " +
1353
+ "meta.files_next_cursor for cursor-based pagination. Pass those cursors " +
1354
+ "back on the next call to get the next page; null means exhausted. " +
1355
+ "Each row includes my_role ('owner' | 'editor' | 'viewer'), is_owner, " +
1356
+ "owner_name, and shared_with_teams[] (each entry has { team_id, role }).",
1357
+ inputSchema: {
1358
+ folder_id: z.string().uuid().optional().describe("Folder UUID to list contents of (omit for workspace root)"),
1359
+ limit: z.number().int().min(1).max(500).optional().describe("Rows per sub-list (folders, files). Default 200, max 500"),
1360
+ folders_cursor: z.string().optional().describe("Opaque cursor (stringified JSON) from a previous response's meta.folders_next_cursor"),
1361
+ files_cursor: z.string().optional().describe("Opaque cursor (stringified JSON) from a previous response's meta.files_next_cursor"),
1362
+ },
1363
+ }, async (params) => {
1364
+ try {
1365
+ const res = await api.get(`/v1/documents/folders`, params);
1366
+ return text(res);
1367
+ }
1368
+ catch (e) {
1369
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1370
+ }
1371
+ });
1372
+ server.registerTool("lighthouse_create_folder", {
1373
+ title: "Create Folder",
1374
+ description: "Create a folder. At the workspace root, sharing_status is required. " +
1375
+ "Inside a parent folder, sharing is automatically inherited from the parent.",
1376
+ inputSchema: {
1377
+ name: z.string().describe("Folder name"),
1378
+ parent_folder_id: z.string().uuid().optional().describe("Parent folder UUID, omit for root"),
1379
+ sharing_status: z.enum(["PRIVATE", "WORKSPACE"]).optional().describe("Required at root, ignored inside a parent"),
1380
+ team_ids: z.array(z.string().uuid()).optional().describe("Team UUIDs to grant access when sharing_status=PRIVATE"),
1381
+ role: z.enum(["viewer", "editor"]).optional().describe("Role applied to team_ids grants. Defaults to 'viewer'. Viewer grants on WORKSPACE folders are rejected as redundant."),
1382
+ },
1383
+ }, async (params) => {
1384
+ try {
1385
+ const res = await api.post(`/v1/documents/folders`, params);
1386
+ return text(res);
1387
+ }
1388
+ catch (e) {
1389
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1390
+ }
1391
+ });
1392
+ server.registerTool("lighthouse_update_folder", {
1393
+ title: "Update Folder",
1394
+ 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. " +
1395
+ "Pass team_ids as the full desired list, anything not in the list is revoked.",
1396
+ inputSchema: {
1397
+ folder_id: z.string().uuid().describe("Folder UUID"),
1398
+ name: z.string().optional().describe("New name"),
1399
+ parent_folder_id: z.string().uuid().optional().describe("New parent folder UUID (owner-only)"),
1400
+ clear_parent: z.boolean().optional().describe("Move to workspace root (owner-only)"),
1401
+ sharing_status: z.enum(["PRIVATE", "WORKSPACE"]).optional().describe("Explicit override (null clears to inherit)"),
1402
+ team_ids: z.array(z.string().uuid()).optional().describe("Desired team grants (replaces existing)"),
1403
+ role: z.enum(["viewer", "editor"]).optional().describe("Role applied to NEW team grants (default viewer). Viewer grants are rejected on WORKSPACE folders as redundant."),
1404
+ },
1405
+ }, async (params) => {
1406
+ try {
1407
+ const { folder_id, ...body } = params;
1408
+ const res = await api.patch(`/v1/documents/folders/${folder_id}`, body);
1409
+ return text(res);
1410
+ }
1411
+ catch (e) {
1412
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1413
+ }
1414
+ });
1415
+ server.registerTool("lighthouse_delete_folder", {
1416
+ title: "Delete Folder",
1417
+ 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.",
1418
+ inputSchema: {
1419
+ folder_id: z.string().uuid().describe("Folder UUID"),
1420
+ },
1421
+ }, async (params) => {
1422
+ try {
1423
+ const res = await api.delete(`/v1/documents/folders/${params.folder_id}`);
1424
+ return text(res);
1425
+ }
1426
+ catch (e) {
1427
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1428
+ }
1429
+ });
1430
+ server.registerTool("lighthouse_link_folder_to_record", {
1431
+ title: "Link Folder to Record",
1432
+ description: "Link a folder to a CRM record, associating the whole folder with the record. The folder then appears on the record (in list_record_documents' `folders`, with its file_count); its individual files are not flattened into the record's direct `files` list.",
1433
+ inputSchema: {
1434
+ folder_id: z.string().uuid().describe("Folder UUID"),
1435
+ record_type: z.string().describe("company, person, deal, or a custom object permalink"),
1436
+ record_id: z.string().uuid().describe("Record UUID"),
1437
+ },
1438
+ }, async (params) => {
1439
+ try {
1440
+ const { folder_id, ...body } = params;
1441
+ const res = await api.post(`/v1/documents/folders/${folder_id}/records`, body);
1442
+ return text(res);
1443
+ }
1444
+ catch (e) {
1445
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1446
+ }
1447
+ });
1448
+ server.registerTool("lighthouse_unlink_folder_from_record", {
1449
+ title: "Unlink Folder from Record",
1450
+ description: "Remove a folder→record link. Files inside stop appearing on the record's Files tab.",
1451
+ inputSchema: {
1452
+ folder_id: z.string().uuid().describe("Folder UUID"),
1453
+ record_type: z.string().describe("company, person, deal, or a custom object permalink"),
1454
+ record_id: z.string().uuid().describe("Record UUID"),
1455
+ },
1456
+ }, async (params) => {
1457
+ try {
1458
+ const { folder_id, ...body } = params;
1459
+ const res = await api.delete(`/v1/documents/folders/${folder_id}/records`, body);
1460
+ return text(res);
1461
+ }
1462
+ catch (e) {
1463
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1464
+ }
1465
+ });
1466
+ server.registerTool("lighthouse_upload_file", {
1467
+ title: "Upload File",
1468
+ description: "Reserve a file row and get a signed upload URL: " +
1469
+ "1) call this; 2) POST the file to upload_url as multipart/form-data, first append every key/value " +
1470
+ "from the returned upload_fields, then append the file last under the field name 'file'. " +
1471
+ "Do not set an Authorization header. Max 200MB, enforced by storage (oversized uploads are rejected). " +
1472
+ "The file is confirmed and sized automatically the first time you read it " +
1473
+ "(lighthouse_get_file or a folder listing), no separate finalize step. " +
1474
+ "The URL expires in 30 minutes.",
1475
+ inputSchema: {
1476
+ name: z.string().describe("File name (e.g. 'pitch-deck.pdf')"),
1477
+ content_type: z.string().optional().describe("MIME type (e.g. 'application/pdf')"),
1478
+ folder_id: z.string().uuid().optional().describe("Target folder UUID, omit to upload at workspace root"),
1479
+ sharing_status: z.enum(["PRIVATE", "WORKSPACE"]).optional().describe("Required at root; defaults to inherit inside a folder"),
1480
+ team_ids: z.array(z.string().uuid()).optional().describe("Team UUIDs to grant access when sharing_status=PRIVATE"),
1481
+ role: z.enum(["viewer", "editor"]).optional().describe("Role applied to team_ids grants. Defaults to 'viewer'. Viewer grants on WORKSPACE files are rejected as redundant."),
1482
+ },
1483
+ }, async (params) => {
1484
+ try {
1485
+ const res = await api.post(`/v1/documents/files/upload-url`, params);
1486
+ return text(res);
1487
+ }
1488
+ catch (e) {
1489
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1490
+ }
1491
+ });
1492
+ server.registerTool("lighthouse_get_file", {
1493
+ title: "Get File",
1494
+ description: "Get file metadata. Response includes my_role ('owner' | 'editor' | 'viewer'), is_owner, owner_name, sharing_status, and shared_with_teams[] (each entry has { team_id, role }). Pass download_url=true to also receive a short-lived signed read URL (expires in 15 minutes).",
1495
+ inputSchema: {
1496
+ file_id: z.string().uuid().describe("File UUID"),
1497
+ download_url: z.boolean().optional().describe("Include a short-lived signed download URL"),
1498
+ },
1499
+ }, async (params) => {
1500
+ try {
1501
+ const { file_id, download_url } = params;
1502
+ const res = await api.get(`/v1/documents/files/${file_id}`, download_url ? { download_url: "1" } : undefined);
1503
+ return text(res);
1504
+ }
1505
+ catch (e) {
1506
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1507
+ }
1508
+ });
1509
+ server.registerTool("lighthouse_update_file", {
1510
+ title: "Update File",
1511
+ description: "Rename, move, or change sharing on a file. Editors can rename/move-between-folders and delete; only the owner can move to root, change sharing, or grant team access. " +
1512
+ "team_ids replaces the current set when supplied.",
1513
+ inputSchema: {
1514
+ file_id: z.string().uuid().describe("File UUID"),
1515
+ name: z.string().optional().describe("New name"),
1516
+ folder_id: z.string().uuid().optional().describe("New parent folder UUID"),
1517
+ clear_folder: z.boolean().optional().describe("Move to workspace root (owner-only)"),
1518
+ sharing_status: z.enum(["PRIVATE", "WORKSPACE"]).optional().describe("Explicit override (null clears to inherit)"),
1519
+ team_ids: z.array(z.string().uuid()).optional().describe("Desired team grants (replaces existing)"),
1520
+ role: z.enum(["viewer", "editor"]).optional().describe("Role applied to NEW team grants (default viewer). Viewer grants are rejected on WORKSPACE files as redundant."),
1521
+ },
1522
+ }, async (params) => {
1523
+ try {
1524
+ const { file_id, ...body } = params;
1525
+ const res = await api.patch(`/v1/documents/files/${file_id}`, body);
1526
+ return text(res);
1527
+ }
1528
+ catch (e) {
1529
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1530
+ }
1531
+ });
1532
+ server.registerTool("lighthouse_delete_file", {
1533
+ title: "Delete File",
1534
+ 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.",
1535
+ inputSchema: {
1536
+ file_id: z.string().uuid().describe("File UUID"),
1537
+ },
1538
+ }, async (params) => {
1539
+ try {
1540
+ const res = await api.delete(`/v1/documents/files/${params.file_id}`);
1541
+ return text(res);
1542
+ }
1543
+ catch (e) {
1544
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1545
+ }
1546
+ });
1547
+ server.registerTool("lighthouse_link_file_to_record", {
1548
+ title: "Link File to Record",
1549
+ description: "Make a file appear on a CRM record's Files tab.",
1550
+ inputSchema: {
1551
+ file_id: z.string().uuid().describe("File UUID"),
1552
+ record_type: z.string().describe("company, person, deal, or a custom object permalink"),
1553
+ record_id: z.string().uuid().describe("Record UUID"),
1554
+ },
1555
+ }, async (params) => {
1556
+ try {
1557
+ const { file_id, ...body } = params;
1558
+ const res = await api.post(`/v1/documents/files/${file_id}/records`, body);
1559
+ return text(res);
1560
+ }
1561
+ catch (e) {
1562
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1563
+ }
1564
+ });
1565
+ server.registerTool("lighthouse_unlink_file_from_record", {
1566
+ title: "Unlink File from Record",
1567
+ description: "Remove a file→record link.",
1568
+ inputSchema: {
1569
+ file_id: z.string().uuid().describe("File UUID"),
1570
+ record_type: z.string().describe("company, person, deal, or a custom object permalink"),
1571
+ record_id: z.string().uuid().describe("Record UUID"),
1572
+ },
1573
+ }, async (params) => {
1574
+ try {
1575
+ const { file_id, ...body } = params;
1576
+ const res = await api.delete(`/v1/documents/files/${file_id}/records`, body);
1577
+ return text(res);
1578
+ }
1579
+ catch (e) {
1580
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1581
+ }
1582
+ });
1583
+ server.registerTool("lighthouse_list_record_documents", {
1584
+ title: "List Record Documents",
1585
+ 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`. A linked folder appears in `folders` with its own file_count, so to read a folder's contents list that folder by its id. " +
1586
+ "Each row includes my_role ('owner' | 'editor' | 'viewer'), is_owner, owner_name, and shared_with_teams[] (each entry has { team_id, role }). " +
1587
+ "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.",
1588
+ inputSchema: {
1589
+ record_type: z.string().describe("company, person, deal, or a custom object permalink"),
1590
+ record_id: z.string().uuid().describe("Record UUID"),
1591
+ limit: z.number().int().min(1).max(500).optional().describe("Rows per sub-list. Default 200, max 500"),
1592
+ folders_cursor: z.string().optional().describe("Opaque cursor from a previous meta.folders_next_cursor"),
1593
+ files_cursor: z.string().optional().describe("Opaque cursor from a previous meta.files_next_cursor"),
1594
+ },
1595
+ }, async (params) => {
1596
+ try {
1597
+ const { record_type, record_id, limit, folders_cursor, files_cursor } = params;
1598
+ const query = {};
1599
+ if (limit !== undefined)
1600
+ query.limit = limit;
1601
+ if (folders_cursor)
1602
+ query.folders_cursor = folders_cursor;
1603
+ if (files_cursor)
1604
+ query.files_cursor = files_cursor;
1605
+ const res = await api.get(`/v1/documents/records/${record_type}/${record_id}`, query);
1606
+ return text(res);
1607
+ }
1608
+ catch (e) {
1609
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1610
+ }
1611
+ });
1612
+ server.registerTool("lighthouse_crm_list_views", {
1613
+ title: "List CRM Views",
1614
+ description: "Get all saved CRM views, or list views scoped to a specific list. " +
1615
+ "Supports built-in types and custom objects. " +
1616
+ "Views store filter/sort configurations that can be loaded in search_records.",
1617
+ inputSchema: {
1618
+ record_type: z.string().optional().describe("Filter by record type: 'company', 'person', 'deal', or a custom object permalink (e.g. 'lp', 'project')"),
1619
+ list_id: z.string().uuid().optional().describe("Filter views by list ID. When provided, returns only views scoped to that list."),
1620
+ },
1621
+ }, async (params) => {
1622
+ try {
1623
+ const res = await api.get("/views", params);
1624
+ return text(res);
1625
+ }
1626
+ catch (e) {
1627
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1628
+ }
1629
+ });
1630
+ server.registerTool("lighthouse_crm_update_view", {
1631
+ title: "Update CRM View",
1632
+ description: "Update a saved CRM view or list view's name, filters, sort, or layout. " +
1633
+ "Automatically detects whether it's a CRM view or list view. " +
1634
+ "To change sharing settings, use lighthouse_crm_update_view_sharing instead. " +
1635
+ "Uses CRM filter format, call lighthouse_crm_get_attributes first if modifying filters. " +
1636
+ "A semantic_search condition in the new filters must carry its vector (keep the one the view already has, from lighthouse_crm_get_view, or one copied from the app): this API never builds an embedding from text.",
1637
+ inputSchema: {
1638
+ id: z.string().uuid().describe("View ID"),
1639
+ name: z.string().optional(),
1640
+ filters: z.record(z.unknown()).optional().describe(CRM_FILTER_DESCRIPTION),
1641
+ sort: z.array(z.record(z.unknown())).optional().describe(CRM_SORT_DESCRIPTION),
1642
+ view_type: z.enum(["table", "kanban"]).optional(),
1643
+ grouped_by: z.string().optional().describe("Field key to group by (required for kanban views)"),
1644
+ },
1645
+ }, async (params) => {
1646
+ try {
1647
+ const { id, ...body } = params;
1648
+ const res = await api.patch(`/views/${id}`, body);
1649
+ return text(res);
1650
+ }
1651
+ catch (e) {
1652
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1653
+ }
1654
+ });
1655
+ server.registerTool("lighthouse_crm_get_view", {
1656
+ title: "Get CRM View",
1657
+ description: "Get a single saved CRM view or list view by ID. Automatically detects the view type. The filters come back exactly as saved, a semantic_search condition with its vector included: reuse such a condition verbatim in lighthouse_crm_search_records or lighthouse_crm_get_list_records.",
1658
+ inputSchema: {
1659
+ id: z.string().uuid().describe("View ID"),
1660
+ },
1661
+ }, async (params) => {
1662
+ try {
1663
+ const res = await api.get(`/views/${params.id}`);
1664
+ return text(res);
1665
+ }
1666
+ catch (e) {
1667
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1668
+ }
1669
+ });
1670
+ server.registerTool("lighthouse_crm_delete_view", {
1671
+ title: "Delete CRM View",
1672
+ description: "Permanently delete a saved CRM view or list view. Automatically detects the view type.",
1673
+ inputSchema: {
1674
+ id: z.string().uuid().describe("View ID"),
1675
+ },
1676
+ }, async (params) => {
1677
+ try {
1678
+ const res = await api.delete(`/views/${params.id}`);
1679
+ return text(res);
1680
+ }
1681
+ catch (e) {
1682
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1683
+ }
1684
+ });
1685
+ server.registerTool("lighthouse_workspace_me", {
1686
+ title: "Get Current User",
1687
+ description: "Get the current authenticated user's profile (name, email, role, etc.).",
1688
+ inputSchema: {},
1689
+ }, async () => {
1690
+ try {
1691
+ const res = await api.get("/users/me");
1692
+ return text(res);
1693
+ }
1694
+ catch (e) {
1695
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1696
+ }
1697
+ });
1698
+ server.registerTool("lighthouse_workspace_list_users", {
1699
+ title: "List Workspace Users",
1700
+ description: "List users in the workspace. Use this to find user IDs for task assignment, note tagging, or filtering by user. " +
1701
+ "Supports search by name/email.",
1702
+ inputSchema: {
1703
+ search: z.string().optional().describe("Search by name or email"),
1704
+ team_id: z.string().uuid().optional().describe("Filter by team ID"),
1705
+ role_id: z.string().uuid().optional().describe("Filter by role ID"),
1706
+ limit: z.number().min(1).max(100).default(25),
1707
+ offset: z.number().min(0).default(0),
1708
+ },
1709
+ }, async (params) => {
1710
+ try {
1711
+ const res = await api.get("/users", params);
1712
+ return text(res);
1713
+ }
1714
+ catch (e) {
1715
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1716
+ }
1717
+ });
1718
+ server.registerTool("lighthouse_workspace_get_user", {
1719
+ title: "Get Workspace User",
1720
+ description: "Get a specific workspace user's profile by ID.",
1721
+ inputSchema: {
1722
+ id: z.string().uuid().describe("User ID"),
1723
+ },
1724
+ }, async (params) => {
1725
+ try {
1726
+ const res = await api.get(`/users/${params.id}`);
1727
+ return text(res);
1728
+ }
1729
+ catch (e) {
1730
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1731
+ }
1732
+ });
1733
+ server.registerTool("lighthouse_workspace_list_teams", {
1734
+ title: "List Workspace Teams",
1735
+ description: "List all teams in the workspace.",
1736
+ inputSchema: {
1737
+ limit: z.number().min(1).max(100).default(25),
1738
+ offset: z.number().min(0).default(0),
1739
+ },
1740
+ }, async (params) => {
1741
+ try {
1742
+ const res = await api.get("/teams", params);
1743
+ return text(res);
1744
+ }
1745
+ catch (e) {
1746
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1747
+ }
1748
+ });
1749
+ server.registerTool("lighthouse_workspace_get_team", {
1750
+ title: "Get Workspace Team",
1751
+ description: "Get a specific team by ID with its members.",
1752
+ inputSchema: {
1753
+ id: z.string().uuid().describe("Team ID"),
1754
+ },
1755
+ }, async (params) => {
1756
+ try {
1757
+ const res = await api.get(`/teams/${params.id}`);
1758
+ return text(res);
1759
+ }
1760
+ catch (e) {
1761
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1762
+ }
1763
+ });
1764
+ server.registerTool("lighthouse_discovery_get_attributes", {
1765
+ title: "Get Discovery Data Fields",
1766
+ 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. " +
1767
+ "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. " +
1768
+ "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_get_industries / lighthouse_discovery_get_locations / lighthouse_discovery_get_funding_types / lighthouse_discovery_get_tags tools). " +
1769
+ "Calling this is optional and NOT required before searching. These are Lighthouse's global database fields, not your CRM fields.",
1770
+ inputSchema: {
1771
+ type: z
1772
+ .enum(["company", "person"])
1773
+ .optional()
1774
+ .describe("Entity type: 'company' or 'person'. If omitted, returns both as { company: [...], person: [...] }."),
1775
+ },
1776
+ }, async (params) => {
1777
+ try {
1778
+ const res = await api.post("/mcp/database/attributes", params);
1779
+ return text(res);
1780
+ }
1781
+ catch (e) {
1782
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1783
+ }
1784
+ });
1785
+ server.registerTool("lighthouse_discovery_list_options", {
1786
+ title: "List Discovery Field Options",
1787
+ description: "Get the allowed values (value, label, color) for a single discovery select / multi_select field. " +
1788
+ "Call this when lighthouse_discovery_get_attributes shows has_options=true for a field and you need its valid values. " +
1789
+ "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). " +
1790
+ "For the large shared taxonomies you can also use the dedicated tools: get_industries, get_locations, get_funding_types, get_tags.",
1791
+ inputSchema: {
1792
+ record_type: z.enum(["company", "person"]).describe("Entity type the field belongs to"),
1793
+ field_permalink: z.string().describe("Field permalink (from lighthouse_discovery_get_attributes)"),
1794
+ search: z.string().optional().describe("Filter options whose label contains this text (case-insensitive)"),
1795
+ limit: z.number().optional().describe("Max options to return (default 100, max 100)"),
1796
+ offset: z.number().optional().describe("Number of options to skip for pagination (default 0)"),
1797
+ },
1798
+ }, async (params) => {
1799
+ try {
1800
+ const res = await api.post("/mcp/database/options", params);
1801
+ return text(res);
1802
+ }
1803
+ catch (e) {
1804
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1805
+ }
1806
+ });
1807
+ server.registerTool("lighthouse_discovery_search_companies", {
1808
+ title: "Search Discovery Companies",
1809
+ description: "Search Lighthouse's global company database to DISCOVER new companies. " +
1810
+ "This searches Lighthouse's enriched database (millions of companies), NOT your CRM. " +
1811
+ "WORKFLOW: 1) Build filters using ONLY the company_* filter keys listed in the `filters` parameter below. " +
1812
+ "Do NOT call lighthouse_discovery_get_attributes for filter keys. It returns the data fields each result carries (e.g. categories, headquarter_country), which are NOT valid filter keys; if used to filter they are dropped and reported in the response's `ignored_filter_fields`. " +
1813
+ "2) For category filters (company_categories, company_sdg_impact), call lighthouse_discovery_get_industries to get valid values. " +
1814
+ "3) For location filters (company_hq_country, company_hq_region, and any other *_country/*_region key), call lighthouse_discovery_get_locations to get country/region codes. " +
1815
+ "4) For investor/past-company/education filters, call lighthouse_discovery_search_entities (record_type 'investor', 'company', or 'school') to resolve names to ids. " +
1816
+ "The COMPLETE, authoritative list of company_* filter keys (every field with its data type and where its values come from) is at the END of the `filters` parameter description below. Read it and use those keys exactly; do not anchor on a subset, invent variants (no _usd suffix), or abbreviate (hq vs headquarter). " +
1817
+ "The response's `meta` includes `total` (the match count, e.g. \"247\", or \"1k+\" when there are 1000+) and `ignored_filter_fields` (any filter key that was NOT recognized, each with a did_you_mean suggestion when a close match can be computed). Check `ignored_filter_fields` to confirm every filter was actually applied.",
1818
+ inputSchema: {
1819
+ filters: z
1820
+ .record(z.unknown())
1821
+ .optional()
1822
+ .describe(DISCOVERY_FILTER_DESCRIPTION + COMPANY_DISCOVERY_FIELDS),
1823
+ sort: z.record(z.unknown()).optional().describe(COMPANY_DISCOVERY_SORT_DESCRIPTION),
1824
+ skip: z.number().min(0).default(0).describe("Number of results to skip"),
1825
+ limit: z.number().min(1).max(100).default(25).describe("Results per page"),
1826
+ },
1827
+ }, async (params) => {
1828
+ try {
1829
+ const res = await api.post("/mcp/database/search", { record_type: "company", ...params });
1830
+ return text(leanDiscovery(res, "company"));
1831
+ }
1832
+ catch (e) {
1833
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1834
+ }
1835
+ });
1836
+ server.registerTool("lighthouse_discovery_search_people", {
1837
+ title: "Search Discovery People",
1838
+ description: "Search Lighthouse's global people database to DISCOVER new people. " +
1839
+ "This searches Lighthouse's enriched database, NOT your CRM. " +
1840
+ "WORKFLOW: 1) Build filters using ONLY the person_* filter keys listed in the `filters` parameter below. " +
1841
+ "Do NOT call lighthouse_discovery_get_attributes for filter keys. It returns the data fields each result carries (e.g. country, categories), which are NOT valid filter keys; if used to filter they are dropped and reported in the response's `ignored_filter_fields`. " +
1842
+ "2) For category filters (person_current_categories, person_past_categories), call lighthouse_discovery_get_industries to get valid values. " +
1843
+ "3) For location filters (person_country, person_region, and any other *_country/*_region key), call lighthouse_discovery_get_locations to get country/region codes. " +
1844
+ "4) For past-company/education filters, call lighthouse_discovery_search_entities (record_type 'company' or 'school') to resolve names to ids. " +
1845
+ "The COMPLETE, authoritative list of person_* filter keys (every field with its data type and where its values come from) is at the END of the `filters` parameter description below. Read it and use those keys exactly; do not anchor on a subset, invent variants, or abbreviate. " +
1846
+ "The response's `meta` includes `total` (the match count, e.g. \"247\", or \"1k+\" when there are 1000+) and `ignored_filter_fields` (any filter key that was NOT recognized, each with a did_you_mean suggestion when a close match can be computed). Check `ignored_filter_fields` to confirm every filter was actually applied. " +
1847
+ "People results are returned by founder relevance; custom sorting is NOT supported for people, so there is no sort parameter.",
1848
+ inputSchema: {
1849
+ filters: z
1850
+ .record(z.unknown())
1851
+ .optional()
1852
+ .describe(DISCOVERY_FILTER_DESCRIPTION + PERSON_DISCOVERY_FIELDS),
1853
+ skip: z.number().min(0).default(0).describe("Number of results to skip"),
1854
+ limit: z.number().min(1).max(100).default(25).describe("Results per page"),
1855
+ },
1856
+ }, async (params) => {
1857
+ try {
1858
+ const res = await api.post("/mcp/database/search", { record_type: "person", ...params });
1859
+ return text(leanDiscovery(res, "person"));
1860
+ }
1861
+ catch (e) {
1862
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1863
+ }
1864
+ });
1865
+ server.registerTool("lighthouse_discovery_semantic_search_companies", {
1866
+ title: "Semantic Search Companies",
1867
+ 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. " +
1868
+ "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'. " +
1869
+ "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. " +
1870
+ "PREFER lighthouse_discovery_search_companies (no `query`) when the user only needs exact attribute filters (a specific country, funding stage, headcount range) with no conceptual component. " +
1871
+ "If the user asks for something this cannot satisfy (an exact field the database does not expose, a guarantee of completeness, or ordering by a specific metric), tell them that plainly and fall back to lighthouse_discovery_search_companies with filters rather than guessing. " +
1872
+ "The response's `meta` includes `total` (the match count, e.g. \"247\", or \"1k+\" when there are 1000+) and `ignored_filter_fields` (any filter key that was NOT recognized, each with a did_you_mean suggestion when a close match can be computed). Check `ignored_filter_fields` to confirm every filter was actually applied. " +
1873
+ "`query` is required; everything else is optional.",
1874
+ inputSchema: {
1875
+ query: z.string().min(1).describe("Plain-language description of the ideal company to match by meaning. Required."),
1876
+ filters: z
1877
+ .record(z.unknown())
1878
+ .optional()
1879
+ .describe("Optional structured filters applied together with the semantic match (AND). " + DISCOVERY_FILTER_DESCRIPTION + COMPANY_DISCOVERY_FIELDS),
1880
+ min_similarity: z
1881
+ .number()
1882
+ .min(0)
1883
+ .max(0.99)
1884
+ .optional()
1885
+ .describe("Optional strictness cutoff (0-1, higher = stricter / fewer results). Defaults to a balanced value."),
1886
+ skip: z.number().min(0).default(0).describe("Number of results to skip"),
1887
+ limit: z.number().min(1).max(100).default(25).describe("Results per page"),
1888
+ },
1889
+ }, async (params) => {
1890
+ try {
1891
+ const res = await api.post("/database/semantic-search-companies", params);
1892
+ return text(leanDiscovery(res, "company"));
1893
+ }
1894
+ catch (e) {
1895
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1896
+ }
1897
+ });
1898
+ server.registerTool("lighthouse_discovery_semantic_search_people", {
1899
+ title: "Semantic Search People",
1900
+ 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. " +
1901
+ "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'. " +
1902
+ "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. " +
1903
+ "PREFER lighthouse_discovery_search_people (no `query`) when the user only needs exact attribute filters with no conceptual component. " +
1904
+ "If the user asks for something this cannot satisfy (an exact field the database does not expose, a guarantee of completeness, or ordering by a specific metric), tell them that plainly and fall back to lighthouse_discovery_search_people with filters rather than guessing. " +
1905
+ "The response's `meta` includes `total` (the match count, e.g. \"247\", or \"1k+\" when there are 1000+) and `ignored_filter_fields` (any filter key that was NOT recognized, each with a did_you_mean suggestion when a close match can be computed). Check `ignored_filter_fields` to confirm every filter was actually applied. " +
1906
+ "`query` is required; everything else is optional.",
1907
+ inputSchema: {
1908
+ query: z.string().min(1).describe("Plain-language description of the ideal person to match by meaning. Required."),
1909
+ filters: z
1910
+ .record(z.unknown())
1911
+ .optional()
1912
+ .describe("Optional structured filters applied together with the semantic match (AND). " + DISCOVERY_FILTER_DESCRIPTION + PERSON_DISCOVERY_FIELDS),
1913
+ min_similarity: z
1914
+ .number()
1915
+ .min(0)
1916
+ .max(0.99)
1917
+ .optional()
1918
+ .describe("Optional strictness cutoff (0-1, higher = stricter / fewer results). Defaults to a balanced value."),
1919
+ skip: z.number().min(0).default(0).describe("Number of results to skip"),
1920
+ limit: z.number().min(1).max(100).default(25).describe("Results per page"),
1921
+ },
1922
+ }, async (params) => {
1923
+ try {
1924
+ const res = await api.post("/database/semantic-search-people", params);
1925
+ return text(leanDiscovery(res, "person"));
1926
+ }
1927
+ catch (e) {
1928
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1929
+ }
1930
+ });
1931
+ server.registerTool("lighthouse_discovery_lookup_by_domain", {
1932
+ title: "Lookup Companies by Domain",
1933
+ description: "Look up companies in Lighthouse's global database by their website domain(s). " +
1934
+ "Returns enriched company profiles for matching domains. Max 1000 domains per request. " +
1935
+ "Each returned profile is condensed (the long growth and workforce history and the activity-signal " +
1936
+ "feed are omitted, and the associated-people list is capped) to keep the response compact; for a " +
1937
+ "single company's full profile call lighthouse_discovery_get_company. " +
1938
+ "Consumes API entity credits for each new company returned.",
1939
+ inputSchema: {
1940
+ domains: z
1941
+ .array(z.string())
1942
+ .describe("Array of domains to look up (e.g. ['google.com', 'stripe.com']). Max 1000."),
1943
+ },
1944
+ }, async (params) => {
1945
+ try {
1946
+ const res = await api.post("/database/get-companies-by-domain", params);
1947
+ return text(leanDiscovery(res, "company"));
1948
+ }
1949
+ catch (e) {
1950
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1951
+ }
1952
+ });
1953
+ server.registerTool("lighthouse_discovery_lookup_by_linkedin", {
1954
+ title: "Lookup Companies by LinkedIn",
1955
+ description: "Look up companies in Lighthouse's global database by LinkedIn URL(s). " +
1956
+ "Returns enriched company profiles for matching LinkedIn company pages. Max 1000 URLs per request. " +
1957
+ "Each returned profile is condensed (the long growth and workforce history and the activity-signal " +
1958
+ "feed are omitted, and the associated-people list is capped) to keep the response compact; for a " +
1959
+ "single company's full profile call lighthouse_discovery_get_company. " +
1960
+ "Consumes API entity credits for each new company returned.",
1961
+ inputSchema: {
1962
+ linkedin_urls: z
1963
+ .array(z.string())
1964
+ .describe("Array of LinkedIn company URLs (e.g. ['https://linkedin.com/company/stripe']). Max 1000."),
1965
+ },
1966
+ }, async (params) => {
1967
+ try {
1968
+ const res = await api.post("/database/get-companies-by-linkedin", params);
1969
+ return text(leanDiscovery(res, "company"));
1970
+ }
1971
+ catch (e) {
1972
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1973
+ }
1974
+ });
1975
+ server.registerTool("lighthouse_discovery_lookup_people_by_linkedin", {
1976
+ title: "Lookup People by LinkedIn",
1977
+ description: "Look up people in Lighthouse's global database by LinkedIn profile URL(s). " +
1978
+ "Returns enriched person profiles for matching LinkedIn pages. Max 1000 URLs per request. " +
1979
+ "Each returned profile is condensed (the activity-signal feed is omitted and the associated-companies " +
1980
+ "and education lists are capped) to keep the response compact; for a single person's full profile call " +
1981
+ "lighthouse_discovery_get_person. " +
1982
+ "Consumes API entity credits for each new person returned.",
1983
+ inputSchema: {
1984
+ linkedin_urls: z
1985
+ .array(z.string())
1986
+ .describe("Array of LinkedIn profile URLs (e.g. ['https://linkedin.com/in/john-doe']). Max 1000."),
1987
+ },
1988
+ }, async (params) => {
1989
+ try {
1990
+ const res = await api.post("/database/get-people-by-linkedin", params);
1991
+ return text(leanDiscovery(res, "person"));
1992
+ }
1993
+ catch (e) {
1994
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1995
+ }
1996
+ });
1997
+ server.registerTool("lighthouse_discovery_get_saved_searches", {
1998
+ title: "List Saved Searches",
1999
+ description: "List all saved discovery searches in the workspace. " +
2000
+ "Returns saved searches you own and workspace-shared searches. " +
2001
+ "Each search includes its filters and type (company or person). " +
2002
+ "Use the search ID with lighthouse_discovery_get_search_results to run a saved search.",
2003
+ inputSchema: {},
2004
+ }, async () => {
2005
+ try {
2006
+ const res = await api.get("/database/get-saved-searches");
2007
+ return text(res);
2008
+ }
2009
+ catch (e) {
2010
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2011
+ }
2012
+ });
2013
+ server.registerTool("lighthouse_discovery_get_search_results", {
2014
+ title: "Get Saved Search Results",
2015
+ description: "Run a saved discovery search and get its results. " +
2016
+ "The search's saved filters are applied automatically. " +
2017
+ "Optionally filter by signal creation date range. " +
2018
+ "Consumes API entity credits for new entities returned.",
2019
+ inputSchema: {
2020
+ search_id: z.string().uuid().describe("Saved search ID (from lighthouse_discovery_get_saved_searches)"),
2021
+ signal_created_after: z.string().optional().describe("Only include results with signals created after this ISO timestamp"),
2022
+ signal_created_before: z.string().optional().describe("Only include results with signals created before this ISO timestamp"),
2023
+ skip: z.number().min(0).default(0).describe("Number of results to skip"),
2024
+ limit: z.number().min(1).max(100).default(25).describe("Results per page"),
2025
+ },
2026
+ }, async (params) => {
2027
+ try {
2028
+ const res = await api.post("/database/get-search-results", params);
2029
+ return text(leanDiscovery(res, "auto"));
2030
+ }
2031
+ catch (e) {
2032
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2033
+ }
2034
+ });
2035
+ server.registerTool("lighthouse_discovery_get_locations", {
2036
+ title: "Get Location Options",
2037
+ description: "Get available location options (countries and regions) for discovery filters. " +
2038
+ "ALWAYS call this to get valid country/region values before filtering by location fields " +
2039
+ "(company_hq_country, company_hq_region, person_country, person_region, company_investor_country, etc.). " +
2040
+ "Returns {countries: [{name, value, flag}], regions: [{name, value, flag}]}. " +
2041
+ "Use 'value' (alpha-2 codes for countries e.g. 'DE' for Germany, IDs for regions) " +
2042
+ "in filter conditions with contains_any/not_contains_any operators.",
2043
+ inputSchema: {},
2044
+ }, async () => {
2045
+ try {
2046
+ const res = await api.get("/database/get-locations");
2047
+ return text(res);
2048
+ }
2049
+ catch (e) {
2050
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2051
+ }
2052
+ });
2053
+ server.registerTool("lighthouse_discovery_get_industries", {
2054
+ title: "Get Industry & Category Options",
2055
+ description: "Get available industry and category options for discovery filters. " +
2056
+ "ALWAYS call this to get valid category values before filtering by company_categories, company_sdg_impact, " +
2057
+ "person_current_categories, person_past_categories, person_current_sdg_impact, or person_past_sdg_impact. " +
2058
+ "Returns {categories: [{name, value}], sdg_categories: [{name, value}]}. " +
2059
+ "Categories include sectors like SaaS, B2B, FinTech, HealthTech, etc. " +
2060
+ "Use 'value' (permalink string) in filter conditions with contains_any/not_contains_any/contains_all operators.",
2061
+ inputSchema: {},
2062
+ }, async () => {
2063
+ try {
2064
+ const res = await api.get("/database/get-industries");
2065
+ return text(res);
2066
+ }
2067
+ catch (e) {
2068
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2069
+ }
2070
+ });
2071
+ server.registerTool("lighthouse_discovery_get_tags", {
2072
+ title: "Get Tag Options",
2073
+ description: "Get the tag options for discovery filters. " +
2074
+ "ALWAYS call this to get valid tag values before filtering by company_tags or person_tags. " +
2075
+ "Returns {company_tags: [{value, name}], person_tags: [{value, name}]}. " +
2076
+ "Use 'value' in filter conditions with contains_any/not_contains_any/contains_all operators.",
2077
+ inputSchema: {},
2078
+ }, async () => {
2079
+ try {
2080
+ const res = await api.get("/database/get-tags");
2081
+ return text(res);
2082
+ }
2083
+ catch (e) {
2084
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2085
+ }
2086
+ });
2087
+ server.registerTool("lighthouse_discovery_get_funding_types", {
2088
+ title: "Get Funding Type Options",
2089
+ description: "Get the funding round types available for the funding discovery filters " +
2090
+ "(company_last_funding_type, person_company_last_funding_type). " +
2091
+ "Returns an array of {name, label} ordered by stage. " +
2092
+ "Use each option's 'name' (e.g. 'seed', 'series_a') as the filter value with " +
2093
+ "contains_any / not_contains_any operators. " +
2094
+ "The filters also accept the special value 'no_funding' to match companies with no recorded funding round.",
2095
+ inputSchema: {},
2096
+ }, async () => {
2097
+ try {
2098
+ const res = await api.get("/database/get-funding-types");
2099
+ return text(res);
2100
+ }
2101
+ catch (e) {
2102
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2103
+ }
2104
+ });
2105
+ server.registerTool("lighthouse_discovery_search_investors", {
2106
+ title: "Search Investors by Name",
2107
+ description: "Search for investors by name to get their UUIDs for use in discovery filters. " +
2108
+ "Use the returned 'id' values in company_investor filter conditions (investor_multi_select type). " +
2109
+ "Returns [{id, name, avatar_url}]. Pass the id (or [{id, label}] objects) as the filter value. " +
2110
+ "DEPRECATED: use lighthouse_discovery_search_entities with record_type 'investor' instead (better, typo-tolerant ranking). Kept for backward compatibility.",
2111
+ inputSchema: {
2112
+ query: z.string().describe("Investor name to search for (e.g. 'Sequoia', 'a16z')"),
2113
+ },
2114
+ }, async (params) => {
2115
+ try {
2116
+ const res = await api.get("/database/search-entities", { type: "investor", query: params.query });
2117
+ return text(leanDiscovery(res, "auto"));
2118
+ }
2119
+ catch (e) {
2120
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2121
+ }
2122
+ });
2123
+ server.registerTool("lighthouse_discovery_search_organizations", {
2124
+ title: "Search Organizations by Name",
2125
+ description: "Search for organizations (companies) by name to get their UUIDs for use in discovery filters. " +
2126
+ "Use the returned 'id' values in work_experience_multi_select filter conditions " +
2127
+ "(company_team_past_companies, person_past_companies). " +
2128
+ "Returns a THIN identity row [{id, name, avatar_url, domain, short_description, headcount, location, total_funding_amount_usd}]. This is NOT a full company profile. " +
2129
+ "For the complete profile, pass the id to lighthouse_discovery_get_company (or use lighthouse_discovery_get_entity_by_name); do not report a company's details as missing based on this row. " +
2130
+ "DEPRECATED: use lighthouse_discovery_search_entities with record_type 'company' instead (better, typo-tolerant ranking). Kept for backward compatibility.",
2131
+ inputSchema: {
2132
+ query: z.string().describe("Organization name to search for (e.g. 'Google', 'McKinsey')"),
2133
+ },
2134
+ }, async (params) => {
2135
+ try {
2136
+ const res = await api.get("/database/search-entities", { type: "organization", query: params.query });
2137
+ return text(leanDiscovery(res, "auto"));
2138
+ }
2139
+ catch (e) {
2140
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2141
+ }
2142
+ });
2143
+ server.registerTool("lighthouse_discovery_search_schools", {
2144
+ title: "Search Schools by Name",
2145
+ description: "Search for educational institutions by name to get their UUIDs for use in discovery filters. " +
2146
+ "Use the returned 'id' values in education_multi_select filter conditions " +
2147
+ "(company_team_education, person_education). " +
2148
+ "Returns [{id, name, avatar_url}]. " +
2149
+ "DEPRECATED: use lighthouse_discovery_search_entities with record_type 'school' instead (better, typo-tolerant ranking). Kept for backward compatibility.",
2150
+ inputSchema: {
2151
+ query: z.string().describe("Institution name to search for (e.g. 'MIT', 'Stanford', 'Harvard')"),
2152
+ },
2153
+ }, async (params) => {
2154
+ try {
2155
+ const res = await api.get("/database/search-entities", { type: "school", query: params.query });
2156
+ return text(leanDiscovery(res, "auto"));
2157
+ }
2158
+ catch (e) {
2159
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2160
+ }
2161
+ });
2162
+ server.registerTool("lighthouse_discovery_search_entities", {
2163
+ title: "Search Entities (companies, people, investors, schools)",
2164
+ description: "Similarity-ranked, typo-tolerant search across companies, people, investors, and schools. " +
2165
+ "Returns lightweight matches (id, record_type, name, avatar_url, is_discoverable, plus a few " +
2166
+ "disambiguators like domain/employees/location) ranked by relevance then popularity (e.g. " +
2167
+ "searching 'Apple' returns the largest Apple first). " +
2168
+ "Use this to: (a) get an id to drop into a discovery filter (past companies, education, investor " +
2169
+ "filters), or (b) locate an entity before reading its full profile. " +
2170
+ "It searches ALL entities, including ones that do not appear in discovery result lists, so you will " +
2171
+ "always find a match if one exists. If you only need an id for a filter, stop after this call. " +
2172
+ "To read full details, pass the returned id to lighthouse_discovery_get_company (companies) or " +
2173
+ "lighthouse_discovery_get_person (people). " +
2174
+ "is_discoverable means the entity also appears in discovery result lists; when false you can still " +
2175
+ "read its full profile by id, it just will not show up in list/search-results endpoints.",
2176
+ inputSchema: {
2177
+ record_type: z
2178
+ .enum(["company", "person", "investor", "school"])
2179
+ .describe("Which kind of entity to search for"),
2180
+ query: z.string().describe("Name to search for (typo-tolerant, e.g. 'Relay Robotics', 'a16z', 'MIT')"),
2181
+ limit: z.number().int().min(1).max(100).optional().describe("Max results (1-100, default 25)"),
2182
+ offset: z.number().int().min(0).optional().describe("Results to skip for pagination (default 0)"),
2183
+ },
2184
+ }, async (params) => {
2185
+ try {
2186
+ const query = { record_type: params.record_type, query: params.query };
2187
+ if (params.limit !== undefined)
2188
+ query.limit = params.limit;
2189
+ if (params.offset !== undefined)
2190
+ query.offset = params.offset;
2191
+ const res = await api.get("/database/search-entities-v2", query);
2192
+ return text(leanDiscovery(res, "auto"));
2193
+ }
2194
+ catch (e) {
2195
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2196
+ }
2197
+ });
2198
+ server.registerTool("lighthouse_discovery_get_company", {
2199
+ title: "Get Company Profile by ID",
2200
+ description: "Returns the profile for a single company by id: firmographics, funding, categories, tags, links, " +
2201
+ "and the first several associated people. " +
2202
+ "To stay within response limits, this returns a condensed view: the long growth and workforce " +
2203
+ "history and the activity-signal feed are omitted, and the associated-people list is capped. " +
2204
+ "Always returns data for an entity that exists, including companies not shown in discovery result " +
2205
+ "lists, so there is no dead-end. " +
2206
+ "Obtain the id from lighthouse_discovery_search_entities (record_type 'company') first. " +
2207
+ "This accepts only a company id (not a person, investor, or school id), and does not search by name.",
2208
+ inputSchema: {
2209
+ id: z.string().describe("The company id (UUID) returned by lighthouse_discovery_search_entities"),
2210
+ },
2211
+ }, async (params) => {
2212
+ try {
2213
+ const res = await api.get(`/database/company/${encodeURIComponent(params.id)}`);
2214
+ return text(leanDiscovery(res, "company"));
2215
+ }
2216
+ catch (e) {
2217
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2218
+ }
2219
+ });
2220
+ server.registerTool("lighthouse_discovery_get_person", {
2221
+ title: "Get Person Profile by ID",
2222
+ description: "Returns the profile for a single person by id: bio, links, founder score, person tags, " +
2223
+ "the first several associated companies / work history, and education. " +
2224
+ "To stay within response limits, this returns a condensed view: the activity-signal feed is omitted, " +
2225
+ "and the associated-companies and education lists are capped. " +
2226
+ "Always returns data for an entity that exists, including people not shown in discovery result lists, " +
2227
+ "so there is no dead-end. " +
2228
+ "Obtain the id from lighthouse_discovery_search_entities (record_type 'person') first. " +
2229
+ "This accepts only a person id, and does not search by name.",
2230
+ inputSchema: {
2231
+ id: z.string().describe("The person id (UUID) returned by lighthouse_discovery_search_entities"),
2232
+ },
2233
+ }, async (params) => {
2234
+ try {
2235
+ const res = await api.get(`/database/person/${encodeURIComponent(params.id)}`);
2236
+ return text(leanDiscovery(res, "person"));
2237
+ }
2238
+ catch (e) {
2239
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2240
+ }
2241
+ });
2242
+ server.registerTool("lighthouse_discovery_get_company_traction", {
2243
+ title: "Get Company Traction History",
2244
+ 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. " +
2245
+ "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 }. " +
2246
+ "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. " +
2247
+ "A null block means nothing is recorded for that company. Same id as lighthouse_discovery_get_company (from lighthouse_discovery_search_entities with record_type 'company').",
2248
+ inputSchema: {
2249
+ id: z.string().describe("The company id (UUID) returned by lighthouse_discovery_search_entities"),
2250
+ },
2251
+ }, async (params) => {
2252
+ try {
2253
+ const res = await api.get(`/database/company/${encodeURIComponent(params.id)}`);
2254
+ return text(profileSection(res, ["workforce_data", "growth_metrics"]));
2255
+ }
2256
+ catch (e) {
2257
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2258
+ }
2259
+ });
2260
+ server.registerTool("lighthouse_discovery_get_company_signals", {
2261
+ title: "Get Company Signals",
2262
+ description: "The detected events for one company in the global database, by company id: the activity-signal feed lighthouse_discovery_get_company omits. " +
2263
+ "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. " +
2264
+ "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. " +
2265
+ "A null or empty list means no events are recorded. Same id as lighthouse_discovery_get_company.",
2266
+ inputSchema: {
2267
+ id: z.string().describe("The company id (UUID) returned by lighthouse_discovery_search_entities"),
2268
+ },
2269
+ }, async (params) => {
2270
+ try {
2271
+ const res = await api.get(`/database/company/${encodeURIComponent(params.id)}`);
2272
+ return text(profileSection(res, ["signals"]));
2273
+ }
2274
+ catch (e) {
2275
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2276
+ }
2277
+ });
2278
+ server.registerTool("lighthouse_discovery_get_person_signals", {
2279
+ title: "Get Person Signals",
2280
+ description: "The detected events for one person in the global database, by person id: the signal feed lighthouse_discovery_get_person omits. " +
2281
+ "signals is a list of { type, created_at, new_company, domain, org_id }: 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. " +
2282
+ "WHEN TO USE: \"is this founder starting something new\", \"did they leave their company\", \"any recent moves by X\". For the person's work history use lighthouse_discovery_get_person. " +
2283
+ "A null or empty list means no events are recorded. Same id as lighthouse_discovery_get_person.",
2284
+ inputSchema: {
2285
+ id: z.string().describe("The person id (UUID) returned by lighthouse_discovery_search_entities"),
2286
+ },
2287
+ }, async (params) => {
2288
+ try {
2289
+ const res = await api.get(`/database/person/${encodeURIComponent(params.id)}`);
2290
+ return text(profileSection(res, ["signals"]));
2291
+ }
2292
+ catch (e) {
2293
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2294
+ }
2295
+ });
2296
+ server.registerTool("lighthouse_discovery_get_entity_by_name", {
2297
+ title: "Get Entity Profile by Name",
2298
+ description: "DEFAULT way to get the COMPLETE profile of ONE company or person when you have its name. " +
2299
+ "Resolves the single best-ranked match and returns its full profile in one call (equivalent to " +
2300
+ "lighthouse_discovery_search_entities followed by lighthouse_discovery_get_company / get_person, combined). " +
2301
+ "For investors and schools it returns the lightweight identity row (there is no deep profile for those). " +
2302
+ "It picks the single top match, so when the name is ambiguous and you want to choose among candidates, " +
2303
+ "use lighthouse_discovery_search_entities instead and pass the chosen id to get_company / get_person. " +
2304
+ "Do NOT build a profile from a search/lookup result row (those are thin identity rows): use this tool, " +
2305
+ "or get_company / get_person with the id.",
2306
+ inputSchema: {
2307
+ record_type: z
2308
+ .enum(["company", "person", "investor", "school"])
2309
+ .describe("Which kind of entity to resolve"),
2310
+ name: z.string().describe("Name to resolve to a single best match"),
2311
+ },
2312
+ }, async (params) => {
2313
+ try {
2314
+ const res = await api.get("/database/entity-by-name", { record_type: params.record_type, name: params.name });
2315
+ return text(leanDiscovery(res, "auto"));
2316
+ }
2317
+ catch (e) {
2318
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2319
+ }
2320
+ });
2321
+ const WIDGET_CONFIG_DESCRIPTION = "Widget config object. Structure depends on report_type: " +
2322
+ "STANDARD (bar/line/pie/table/kpi): {data_source:'companies'|'deals'|'people' (ALWAYS plural: the in-app report editor only recognizes the plural form) or a custom object permalink (e.g. 'lp'; then ALSO set custom_object_permalink to the same permalink; it is required), report_type:'standard' (always set it explicitly), " +
2323
+ "dimension:{field:'<field_key>', is_custom_field:boolean, time_grouping?:'day'|'week'|'month'|'quarter'|'year'}. REQUIRED for EVERY standard widget INCLUDING kpi (a kpi groups its measure by this dimension); omitting it fails with DIMENSION_REQUIRED. For a line chart the dimension field must be a date/datetime type. " +
2324
+ "measure:{aggregation:'COUNT'|'SUM'|'AVG'|'MIN'|'MAX'|'COUNT_DISTINCT', field:'*'|'<field_key>', is_custom_field:boolean}. Aggregations are UPPERCASE. SUM/AVG/MIN/MAX require a numeric field (number/currency/rating/percentage); COUNT always counts records, so pair it with field:'*' only. Do NOT pair SUM/AVG with a multi-value (multi_select/multi_user/multi_record) dimension or segment (the value would be over-counted); use COUNT there instead. " +
2325
+ "segment_by?:{field:'<field_key>', is_custom_field:boolean} splits each bar into stacked series. It ONLY applies to a bar widget; it is ignored for line/pie/table/kpi. " +
2326
+ "filter?:{operator:'AND'|'OR', conditions:[...], groups:[...]}, " +
2327
+ "date_range?:{type:'relative', relative_period:'all_time'|'last_7_days'|'last_30_days'|'last_90_days'|'this_month'|'last_month'|'this_quarter'|'last_quarter'|'this_year'|'last_year'} " +
2328
+ "OR {type:'absolute', start_date:'YYYY-MM-DD', end_date:'YYYY-MM-DD'}. NOTE: date_range scopes the date FIELD you group by (the x-axis) when the dimension is a date/datetime/datetimez field, so it only makes sense for a date-dimension report; for a non-date dimension it falls back to the record's creation date, so prefer a filter condition instead. " +
2329
+ "included_stages?:['value1','value2'], show_axis_labels?:boolean, show_zeros?:boolean, sort_by_stage_order?:boolean}. " +
2330
+ "INTERACTIONS (person/company data_source only): you can group or segment by an interaction date " +
2331
+ "(first_email_date, last_email_date, first_linkedin_message_date, last_linkedin_message_date, first_meeting_date, last_meeting_date, first_interaction_date, last_interaction_date) " +
2332
+ "or by first_interaction_type/last_interaction_type, all with is_custom_field:false; and you can measure lifetime interaction totals with " +
2333
+ "measure.field:'__ix_count_all__'|'__ix_count_email__'|'__ix_count_linkedin__'|'__ix_count_meeting__' (is_custom_field:false; the aggregation is ignored). " +
2334
+ "FUNNEL: {report_type:'funnel', entity_type:'company'|'deal'|'person' (custom objects are NOT supported for funnel), data_source:'companies'|'deals'|'people' (plural form of entity_type; ALWAYS include it or the widget opens with the wrong data source in the in-app editor), stage_field:'<status field_key>', stage_field_is_custom:boolean (true when stage_field is a custom status field), " +
2335
+ "included_stages?:['stage1','stage2'], date_range?:{...}, filter?:{...} (same format and field support as the standard filter above, including relation fields like list_entries and custom fields; it restricts which records the funnel counts)}. " +
2336
+ "HISTORICAL: {report_type:'stage_changes', entity_type:'company'|'deal'|'person' (custom objects are NOT supported for historical), data_source:'companies'|'deals'|'people' (plural form of entity_type; ALWAYS include it), " +
2337
+ "field_permalink:'<status field_key>', time_grouping?:'day'|'week'|'month'|'quarter'|'year', included_stages?:['stage1','stage2'], date_range?:{...}}. " +
2338
+ "included_stages values must be the option VALUES from lighthouse_crm_get_attributes (not display labels). " +
2339
+ "ALWAYS call lighthouse_crm_get_attributes first to discover valid field keys for dimension, measure, and stage_field.";
2340
+ server.registerTool("lighthouse_reports_list_dashboards", {
2341
+ title: "List Dashboards",
2342
+ description: "List all report dashboards accessible to the current user. " +
2343
+ "Returns dashboards you own, workspace-shared dashboards, and team-shared dashboards. " +
2344
+ "Each dashboard includes: id, name, description, sharing (private/workspace), is_owner, " +
2345
+ "owner {id, first_name, last_name, full_name, avatar_url, email}, widget_count, shared_with_teams, created_at, updated_at.",
2346
+ inputSchema: {},
2347
+ }, async () => {
2348
+ try {
2349
+ const res = await api.get("/dashboards");
2350
+ return text(res);
2351
+ }
2352
+ catch (e) {
2353
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2354
+ }
2355
+ });
2356
+ server.registerTool("lighthouse_reports_create_dashboard", {
2357
+ title: "Create Dashboard",
2358
+ description: "Create a new report dashboard. Dashboards contain report widgets (charts, tables, KPIs). " +
2359
+ "Requires reports.create permission. Returns the full dashboard object (same format as get dashboard).",
2360
+ inputSchema: {
2361
+ name: z.string().describe("Dashboard name"),
2362
+ description: z.string().optional().describe("Dashboard description"),
2363
+ sharing: z
2364
+ .enum(["private", "workspace"])
2365
+ .default("private")
2366
+ .describe("Visibility: 'private' (only you) or 'workspace' (all members)"),
2367
+ team_ids: z
2368
+ .array(z.string().uuid())
2369
+ .optional()
2370
+ .describe("Team IDs to share this dashboard with"),
2371
+ },
2372
+ }, async (params) => {
2373
+ try {
2374
+ const res = await api.post("/dashboards", params);
2375
+ return text(res);
2376
+ }
2377
+ catch (e) {
2378
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2379
+ }
2380
+ });
2381
+ server.registerTool("lighthouse_reports_get_dashboard", {
2382
+ title: "Get Dashboard",
2383
+ description: "Get a single dashboard by ID with all its widgets. " +
2384
+ "Returns: id, name, description, sharing, is_owner, owner {id, first_name, last_name, full_name, avatar_url, email}, " +
2385
+ "widget_count, shared_with_teams, widgets [{id, name, widget_type, config, created_at, updated_at}], created_at, updated_at.",
2386
+ inputSchema: {
2387
+ id: z.string().uuid().describe("Dashboard ID"),
2388
+ },
2389
+ }, async (params) => {
2390
+ try {
2391
+ const res = await api.get(`/dashboards/${params.id}`);
2392
+ return text(res);
2393
+ }
2394
+ catch (e) {
2395
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2396
+ }
2397
+ });
2398
+ server.registerTool("lighthouse_reports_update_dashboard", {
2399
+ title: "Update Dashboard",
2400
+ description: "Update a dashboard's name, description, or sharing. Only the dashboard owner can update. " +
2401
+ "Returns the full dashboard object (same format as get dashboard).",
2402
+ inputSchema: {
2403
+ id: z.string().uuid().describe("Dashboard ID"),
2404
+ name: z.string().optional().describe("New dashboard name"),
2405
+ description: z.string().optional().describe("New description"),
2406
+ sharing: z.enum(["private", "workspace"]).optional().describe("New visibility setting"),
2407
+ },
2408
+ }, async (params) => {
2409
+ try {
2410
+ const { id, ...body } = params;
2411
+ const res = await api.patch(`/dashboards/${id}`, body);
2412
+ return text(res);
2413
+ }
2414
+ catch (e) {
2415
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2416
+ }
2417
+ });
2418
+ server.registerTool("lighthouse_reports_delete_dashboard", {
2419
+ title: "Delete Dashboard",
2420
+ description: "Permanently delete a dashboard and all its widgets. Only the dashboard owner can delete.",
2421
+ inputSchema: {
2422
+ id: z.string().uuid().describe("Dashboard ID"),
2423
+ },
2424
+ }, async (params) => {
2425
+ try {
2426
+ const res = await api.delete(`/dashboards/${params.id}`);
2427
+ return text(res);
2428
+ }
2429
+ catch (e) {
2430
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2431
+ }
2432
+ });
2433
+ server.registerTool("lighthouse_reports_list_widgets", {
2434
+ title: "List Dashboard Widgets",
2435
+ description: "Get all widgets for a dashboard. " +
2436
+ "Each widget includes: id, name, widget_type, config, created_at, updated_at.",
2437
+ inputSchema: {
2438
+ id: z.string().uuid().describe("Dashboard ID"),
2439
+ },
2440
+ }, async (params) => {
2441
+ try {
2442
+ const res = await api.get(`/dashboards/${params.id}/widgets`);
2443
+ return text(res);
2444
+ }
2445
+ catch (e) {
2446
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2447
+ }
2448
+ });
2449
+ server.registerTool("lighthouse_reports_create_widget", {
2450
+ title: "Create Report Widget",
2451
+ description: "Create a report widget (chart/table/KPI) on a dashboard. " +
2452
+ "WORKFLOW: 1) Call lighthouse_crm_get_attributes to discover field keys for the entity type, " +
2453
+ "2) Build a config object using those keys, 3) Create the widget. " +
2454
+ "Widget types: bar, line, pie, table, kpi, funnel, area. " +
2455
+ "Report types: standard (aggregate by field), funnel (pipeline conversion), stage_changes (historical transitions). " +
2456
+ "Returns: {id, name, widget_type, config, created_at, updated_at}.",
2457
+ inputSchema: {
2458
+ dashboard_id: z.string().uuid().describe("Dashboard ID to add widget to"),
2459
+ name: z.string().describe("Widget/report name"),
2460
+ widget_type: z
2461
+ .enum(["bar", "line", "pie", "table", "kpi", "funnel", "area"])
2462
+ .describe("Visualization type. For bar/line/pie/table/kpi set config.report_type:'standard' (requires a dimension); for funnel set config.report_type:'funnel' (requires stage_field); historical stage-change reports use config.report_type:'stage_changes' with widget_type bar/line/table. Avoid 'area' (it renders as a line chart; use 'line' instead). config.segment_by only takes effect on a 'bar' widget (it is ignored by line/pie/table/kpi)."),
2463
+ config: z.record(z.unknown()).describe(WIDGET_CONFIG_DESCRIPTION),
2464
+ },
2465
+ }, async (params) => {
2466
+ try {
2467
+ const { dashboard_id, ...body } = params;
2468
+ const res = await api.post(`/dashboards/${dashboard_id}/widgets`, body);
2469
+ return text(res);
2470
+ }
2471
+ catch (e) {
2472
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2473
+ }
2474
+ });
2475
+ server.registerTool("lighthouse_reports_update_widget", {
2476
+ title: "Rename Report Widget",
2477
+ description: "Rename a report widget. Only the name can be changed, a widget's type and " +
2478
+ "configuration are fixed once created. To change what a widget reports, delete " +
2479
+ "it and create a new one. " +
2480
+ "Returns: {id, name, widget_type, config, created_at, updated_at}.",
2481
+ inputSchema: {
2482
+ dashboard_id: z.string().uuid().describe("Dashboard ID"),
2483
+ widget_id: z.string().uuid().describe("Widget ID"),
2484
+ name: z.string().describe("New widget name"),
2485
+ },
2486
+ }, async (params) => {
2487
+ try {
2488
+ const { dashboard_id, widget_id, ...body } = params;
2489
+ const res = await api.patch(`/dashboards/${dashboard_id}/widgets/${widget_id}`, body);
2490
+ return text(res);
2491
+ }
2492
+ catch (e) {
2493
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2494
+ }
2495
+ });
2496
+ server.registerTool("lighthouse_reports_delete_widget", {
2497
+ title: "Delete Report Widget",
2498
+ description: "Permanently delete a report widget from a dashboard.",
2499
+ inputSchema: {
2500
+ dashboard_id: z.string().uuid().describe("Dashboard ID"),
2501
+ widget_id: z.string().uuid().describe("Widget ID"),
2502
+ },
2503
+ }, async (params) => {
2504
+ try {
2505
+ const res = await api.delete(`/dashboards/${params.dashboard_id}/widgets/${params.widget_id}`);
2506
+ return text(res);
2507
+ }
2508
+ catch (e) {
2509
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2510
+ }
2511
+ });
2512
+ server.registerTool("lighthouse_reports_get_widget_data", {
2513
+ title: "Get Widget Report Data",
2514
+ description: "Fetch the aggregated report data for a specific widget. " +
2515
+ "Returns the computed data based on the widget's config (dimensions, measures, segments, filters). " +
2516
+ "For standard reports: array of {label, value, segment?} rows (label = the grouped dimension value, value = the aggregated measure, segment = present only when segment_by is set). " +
2517
+ "For funnel reports: array of {stage, count} rows. " +
2518
+ "For historical reports: array of {period, from_stage, to_stage, transition_count} rows.",
2519
+ inputSchema: {
2520
+ dashboard_id: z.string().uuid().describe("Dashboard ID"),
2521
+ widget_id: z.string().uuid().describe("Widget ID"),
2522
+ },
2523
+ }, async (params) => {
2524
+ try {
2525
+ const res = await api.get(`/dashboards/${params.dashboard_id}/widgets/${params.widget_id}/data`);
2526
+ return text(res);
2527
+ }
2528
+ catch (e) {
2529
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2530
+ }
2531
+ });
2532
+ server.registerTool("lighthouse_reports_share_dashboard_with_teams", {
2533
+ title: "Share Dashboard with Teams",
2534
+ description: "Grant team-level access to a dashboard. Only the dashboard owner can share. " +
2535
+ "Use lighthouse_workspace_list_teams to find team IDs first. " +
2536
+ "Requires reports.edit permission.",
2537
+ inputSchema: {
2538
+ id: z.string().uuid().describe("Dashboard ID"),
2539
+ team_ids: z.array(z.string().uuid()).describe("Array of team UUIDs to grant access to"),
2540
+ },
2541
+ }, async (params) => {
2542
+ try {
2543
+ const { id, ...body } = params;
2544
+ const res = await api.post(`/dashboards/${id}/teams`, body);
2545
+ return text(res);
2546
+ }
2547
+ catch (e) {
2548
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2549
+ }
2550
+ });
2551
+ server.registerTool("lighthouse_reports_revoke_dashboard_from_teams", {
2552
+ title: "Revoke Dashboard Team Access",
2553
+ description: "Revoke team-level access from a dashboard. Only the dashboard owner can revoke. " +
2554
+ "Requires reports.edit permission.",
2555
+ inputSchema: {
2556
+ id: z.string().uuid().describe("Dashboard ID"),
2557
+ team_ids: z.array(z.string().uuid()).describe("Array of team UUIDs to revoke access from"),
2558
+ },
2559
+ }, async (params) => {
2560
+ try {
2561
+ const { id, ...body } = params;
2562
+ const res = await api.delete(`/dashboards/${id}/teams`, body);
2563
+ return text(res);
2564
+ }
2565
+ catch (e) {
2566
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2567
+ }
2568
+ });
2569
+ server.registerTool("lighthouse_crm_share_view_with_teams", {
2570
+ title: "Share View with Teams",
2571
+ description: "Grant team-level access to a CRM view or list view. Only the view owner can share. " +
2572
+ "Use lighthouse_workspace_list_teams to find team IDs first. " +
2573
+ "Requires views.share permission. " +
2574
+ "Returns formatted view: {id, name, entity_type, view_type, filters, sort, grouped_by, sharing, owner, shared_with_teams, created_at}.",
2575
+ inputSchema: {
2576
+ id: z.string().uuid().describe("View ID"),
2577
+ team_ids: z.array(z.string().uuid()).describe("Array of team UUIDs to grant access to"),
2578
+ },
2579
+ }, async (params) => {
2580
+ try {
2581
+ const { id, ...body } = params;
2582
+ const res = await api.post(`/views/${id}/teams`, body);
2583
+ return text(res);
2584
+ }
2585
+ catch (e) {
2586
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2587
+ }
2588
+ });
2589
+ server.registerTool("lighthouse_crm_revoke_view_from_teams", {
2590
+ title: "Revoke View Team Access",
2591
+ description: "Revoke team-level access from a CRM view or list view. Only the view owner can revoke. " +
2592
+ "Requires views.share permission. " +
2593
+ "Returns formatted view: {id, name, entity_type, view_type, filters, sort, grouped_by, sharing, owner, shared_with_teams, created_at}.",
2594
+ inputSchema: {
2595
+ id: z.string().uuid().describe("View ID"),
2596
+ team_ids: z.array(z.string().uuid()).describe("Array of team UUIDs to revoke access from"),
2597
+ },
2598
+ }, async (params) => {
2599
+ try {
2600
+ const { id, ...body } = params;
2601
+ const res = await api.delete(`/views/${id}/teams`, body);
2602
+ return text(res);
2603
+ }
2604
+ catch (e) {
2605
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2606
+ }
2607
+ });
2608
+ server.registerTool("lighthouse_crm_update_view_sharing", {
2609
+ title: "Update View Sharing Status",
2610
+ description: "Change a CRM view or list view's sharing visibility. Setting to 'private' also removes all team permissions. " +
2611
+ "Automatically detects view type. Only the view owner can change sharing. Requires views.share permission. " +
2612
+ "Returns formatted view: {id, name, entity_type, view_type, filters, sort, grouped_by, sharing, owner, shared_with_teams, created_at}.",
2613
+ inputSchema: {
2614
+ id: z.string().uuid().describe("View ID"),
2615
+ sharing: z.enum(["private", "workspace"]).describe("New sharing visibility"),
2616
+ },
2617
+ }, async (params) => {
2618
+ try {
2619
+ const { id, ...body } = params;
2620
+ const res = await api.patch(`/views/${id}/sharing`, body);
2621
+ return text(res);
2622
+ }
2623
+ catch (e) {
2624
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2625
+ }
2626
+ });
2627
+ server.registerTool("lighthouse_inbox_email_accounts", {
2628
+ title: "List Email Accounts",
2629
+ description: "List the email accounts the user can access: their own connected mailbox plus mailboxes other workspace " +
2630
+ "members shared with them. Each account carries `status` (connected | disconnected | pending, where disconnected " +
2631
+ "means the account needs to be reconnected) and `permissions` flags: email_metadata (see subject/snippet), " +
2632
+ "email_view (read full emails), email_send, email_create_draft, calendar_view. " +
2633
+ "Use scope 'me' to get only the user's own account (single object or null). " +
2634
+ "Account ids can be passed as account_id to the message tools to scope them to one mailbox.",
2635
+ inputSchema: {
2636
+ scope: z.enum(["me", "all"]).default("all").describe("'me' = only the user's own account; 'all' = own + shared"),
2637
+ },
2638
+ }, async (params) => {
2639
+ try {
2640
+ const path = params.scope === "me" ? "/inbox/email/accounts/me" : "/inbox/email/accounts";
2641
+ const res = await api.get(path);
2642
+ return text(res);
2643
+ }
2644
+ catch (e) {
2645
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2646
+ }
2647
+ });
2648
+ server.registerTool("lighthouse_inbox_linkedin_accounts", {
2649
+ title: "List LinkedIn Accounts",
2650
+ description: "List the LinkedIn messaging accounts the user can access: their own connected account plus accounts other " +
2651
+ "workspace members shared with them. Each account carries `status` (connected | disconnected | pending) and " +
2652
+ "`permissions` flags: message_view (read messages), message_send. " +
2653
+ "Use scope 'me' to get only the user's own account (single object or null).",
2654
+ inputSchema: {
2655
+ scope: z.enum(["me", "all"]).default("all").describe("'me' = only the user's own account; 'all' = own + shared"),
2656
+ },
2657
+ }, async (params) => {
2658
+ try {
2659
+ const path = params.scope === "me" ? "/inbox/linkedin/accounts/me" : "/inbox/linkedin/accounts";
2660
+ const res = await api.get(path);
2661
+ return text(res);
2662
+ }
2663
+ catch (e) {
2664
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2665
+ }
2666
+ });
2667
+ server.registerTool("lighthouse_inbox_email_conversations", {
2668
+ title: "List Email Conversations",
2669
+ description: "List email conversations across every email account the user can read, newest activity first. Filters are " +
2670
+ "all optional and combinable: record_id + record_type ('company' or 'person' ONLY) for correspondence with " +
2671
+ "a CRM record (person = their email addresses, company = its domains); email / domain / free-text query; " +
2672
+ "account_id; after / before dates; unread. No filters = most recent conversations. Great for due diligence: " +
2673
+ "pull all correspondence with a company via record_id or domain, then read each conversation. " +
2674
+ "Live provider data, so no offset: after/before pick the window and a page continues with cursor = meta.next_cursor (same filters) while meta.has_more is true. Conversation ids " +
2675
+ "are account-specific: read one with lighthouse_inbox_email_conversation, passing the row's account.id. " +
2676
+ "Check meta.accounts for per-account failures.",
2677
+ inputSchema: {
2678
+ record_id: z.string().uuid().optional().describe("CRM record ID (requires record_type)"),
2679
+ record_type: z.enum(["company", "person"]).optional().describe("Type of the CRM record (company or person only)"),
2680
+ email: z.string().optional().describe("Email address to search correspondence with (comma-separate for several)"),
2681
+ domain: z.string().optional().describe("Domain to search correspondence with, e.g. example.com (comma-separate for several)"),
2682
+ query: z.string().optional().describe("Free-text search; combined with record/email/domain it narrows those results"),
2683
+ account_id: z.string().uuid().optional().describe("Only this email account (id from lighthouse_inbox_email_accounts)"),
2684
+ after: z.string().optional().describe("Only conversations active on/after this date (e.g. 2024-01-31)"),
2685
+ before: z.string().optional().describe("Only conversations active on/before this date"),
2686
+ unread: z.boolean().optional().describe("Only conversations with (true) / without (false) unread messages"),
2687
+ limit: z.number().min(1).max(100).default(25).describe("Max conversations returned after merging accounts"),
2688
+ cursor: z.string().optional().describe("meta.next_cursor of the previous page, with the same filters"),
2689
+ },
2690
+ }, async (params) => {
2691
+ try {
2692
+ const res = await api.get("/inbox/email/conversations", params);
2693
+ return text(res);
2694
+ }
2695
+ catch (e) {
2696
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2697
+ }
2698
+ });
2699
+ server.registerTool("lighthouse_inbox_email_conversation", {
2700
+ title: "Get Email Conversation",
2701
+ description: "Read one email conversation: the conversation object plus its full messages, newest first: structured " +
2702
+ "from/to/cc/bcc, attachment metadata, and the HTML body when the user can read full emails on that account " +
2703
+ "(metadata-only access returns subject/snippet and data.limited_access: true). Conversation ids are " +
2704
+ "account-specific: BOTH conversation_id AND account_id (the conversation row's account.id from " +
2705
+ "lighthouse_inbox_email_conversations) are required. Long conversations page with a CURSOR like the other inbox lists: pass meta.next_cursor as cursor while meta.has_more is true. A message with no body answers body: null.",
2706
+ inputSchema: {
2707
+ conversation_id: z.string().describe("Conversation ID from lighthouse_inbox_email_conversations"),
2708
+ account_id: z.string().uuid().describe("Required: the email account holding the conversation (row's account.id)"),
2709
+ limit: z.number().min(1).max(100).default(15).describe("Max messages per page. Bodies are full HTML and can be large, so raise this deliberately"),
2710
+ cursor: z.string().optional().describe("meta.next_cursor of the previous page, to fetch the next page of a long conversation"),
2711
+ },
2712
+ }, async (params) => {
2713
+ try {
2714
+ const { conversation_id, ...rest } = params;
2715
+ const res = await api.get(`/inbox/email/conversations/${encodeURIComponent(conversation_id)}`, rest);
2716
+ return text(res);
2717
+ }
2718
+ catch (e) {
2719
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2720
+ }
2721
+ });
2722
+ server.registerTool("lighthouse_inbox_email_create_draft", {
2723
+ title: "Create Email Draft",
2724
+ description: "Save an email draft in one of the user's mailboxes. The draft is NOT sent: it lands in the mailbox for the " +
2725
+ "user to review, edit and send themselves, and there is no tool that can send it. Use this to prepare a reply " +
2726
+ "or a new email on the user's behalf. account_id is REQUIRED (from lighthouse_inbox_email_accounts) and must " +
2727
+ "be an account whose permissions include email_create_draft. At least one recipient is required across to, cc " +
2728
+ "and bcc. To draft a reply, pass reply_to_message_id (a MESSAGE id from lighthouse_inbox_email_conversation, " +
2729
+ "not a conversation id) plus conversation_id, and set the recipients explicitly: a reply does not inherit them. " +
2730
+ "body is HTML. Attachments are not supported here; use the REST API for those. " +
2731
+ "Returns the saved draft: {id, conversation_id, reply_to_message_id, date, subject, body, from, to, cc, bcc, " +
2732
+ "attachments, account}.",
2733
+ inputSchema: {
2734
+ account_id: z.string().uuid().describe("Required: the mailbox to save the draft in (id from lighthouse_inbox_email_accounts)"),
2735
+ to: z.array(z.string()).optional().describe("Recipient email addresses"),
2736
+ cc: z.array(z.string()).optional().describe("Cc email addresses"),
2737
+ bcc: z.array(z.string()).optional().describe("Bcc email addresses"),
2738
+ subject: z.string().optional().describe("Subject line"),
2739
+ body: z.string().optional().describe("Message body as HTML"),
2740
+ conversation_id: z.string().optional().describe("Save the draft inside this existing conversation (id from lighthouse_inbox_email_conversations)"),
2741
+ reply_to_message_id: z.string().optional().describe("Message id this draft replies to, from lighthouse_inbox_email_conversation. Required for the reply to thread correctly"),
2742
+ },
2743
+ }, async (params) => {
2744
+ try {
2745
+ const res = await api.post("/inbox/email/drafts", params);
2746
+ return text(res);
2747
+ }
2748
+ catch (e) {
2749
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2750
+ }
2751
+ });
2752
+ server.registerTool("lighthouse_inbox_linkedin_chats", {
2753
+ title: "List LinkedIn Chats",
2754
+ description: "List LinkedIn conversations across every LinkedIn account the user can view, newest activity first, " +
2755
+ "paginated. Filters are all optional and combinable: record_id + record_type ('company' or 'person' ONLY, where a " +
2756
+ "company matches its team members and company page), person_linkedin / company_linkedin (LinkedIn URL or " +
2757
+ "slug), account_id, unread, and a free-text query on conversation/participant names. Each chat carries " +
2758
+ "attendees, last_message, unread_count, and the account it lives in. Use " +
2759
+ "lighthouse_inbox_linkedin_chat_messages with a chat id to read a conversation. Great for due diligence: " +
2760
+ "filter chats by record_id or company_linkedin, then read each conversation.",
2761
+ inputSchema: {
2762
+ record_id: z.string().uuid().optional().describe("CRM record ID (requires record_type)"),
2763
+ record_type: z.enum(["company", "person"]).optional().describe("Type of the CRM record (company or person only)"),
2764
+ person_linkedin: z.string().optional().describe("Person LinkedIn URL or slug, e.g. https://linkedin.com/in/username"),
2765
+ company_linkedin: z.string().optional().describe("Company LinkedIn URL or slug, e.g. https://linkedin.com/company/acme"),
2766
+ account_id: z.string().uuid().optional().describe("Only this LinkedIn account (id from lighthouse_inbox_linkedin_accounts)"),
2767
+ unread: z.boolean().optional().describe("Only chats with (true) / without (false) unread messages"),
2768
+ query: z.string().optional().describe("Text search on conversation and participant names"),
2769
+ limit: z.number().min(1).max(100).default(25).describe("Conversations per page"),
2770
+ offset: z.number().min(0).default(0).describe("Conversations to skip for pagination"),
2771
+ },
2772
+ }, async (params) => {
2773
+ try {
2774
+ const res = await api.get("/inbox/linkedin/chats", params);
2775
+ return text(res);
2776
+ }
2777
+ catch (e) {
2778
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2779
+ }
2780
+ });
2781
+ server.registerTool("lighthouse_inbox_linkedin_chat_messages", {
2782
+ title: "Get LinkedIn Chat Messages",
2783
+ description: "Read one LinkedIn conversation: returns the chat object (attendees, account) plus its messages (full text, " +
2784
+ "InMail subject, attachment metadata), newest first, paginated, with optional after/before date bounds. " +
2785
+ "Get chat ids from lighthouse_inbox_linkedin_chats.",
2786
+ inputSchema: {
2787
+ chat_id: z.string().describe("Chat ID from lighthouse_inbox_linkedin_chats"),
2788
+ after: z.string().optional().describe("Only messages on/after this date (e.g. 2024-01-31)"),
2789
+ before: z.string().optional().describe("Only messages on/before this date"),
2790
+ limit: z.number().min(1).max(100).default(25).describe("Messages per page"),
2791
+ offset: z.number().min(0).default(0).describe("Messages to skip for pagination"),
2792
+ },
2793
+ }, async (params) => {
2794
+ try {
2795
+ const { chat_id, ...rest } = params;
2796
+ const res = await api.get(`/inbox/linkedin/chats/${encodeURIComponent(chat_id)}/messages`, rest);
2797
+ return text(res);
2798
+ }
2799
+ catch (e) {
2800
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2801
+ }
2802
+ });
2803
+ server.registerTool("lighthouse_inbox_calendar_accounts", {
2804
+ title: "List Calendar Accounts",
2805
+ description: "Calendars the API key's user can read: their own connected calendar (is_own true) and colleagues' calendars shared with them, each with id, email, provider, status, owner and permissions { calendar_view }. " +
2806
+ "A calendar is the same connected account as a mailbox, behind its own sharing switch, so a mailbox shared for mail only is NOT listed here. " +
2807
+ "Use it to know whose meetings lighthouse_inbox_calendar_meetings can see, or to get an account_id to narrow that tool to one calendar. Returns [] when no calendar is connected.",
2808
+ inputSchema: {},
2809
+ }, async () => {
2810
+ try {
2811
+ const res = await api.get("/inbox/calendar/accounts");
2812
+ return text(res);
2813
+ }
2814
+ catch (e) {
2815
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2816
+ }
2817
+ });
2818
+ server.registerTool("lighthouse_inbox_calendar_meetings", {
2819
+ title: "List Meetings",
2820
+ 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). " +
2821
+ "Narrow by CRM record (record_id + record_type, company or person: the record's addresses and domains are matched), by email / domain 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. " +
2822
+ "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. " +
2823
+ "Each row: id, meeting_key (only when the meeting was recorded), title, description, location, start, end, is_all_day, is_recurring (a recurring meeting appears once per occurrence), status, meeting_link, organizer { name, email }, attendees [{ name, email, response_status }] (response_status is accepted | declined | tentative | needs_action; a missing title, description, location, name or email is null, never an empty string), account { id, email, is_own } (the calendar it was read from) and recording: null when not recorded, else { has_recording, has_transcript, has_notes, duration_seconds, language, recorded_by }. " +
2824
+ "A row with meeting_key is a recorded meeting: pass it to lighthouse_inbox_calendar_meeting_notes for what was discussed and decided, or lighthouse_inbox_calendar_meeting_transcript for what was said. " +
2825
+ "Paged with a CURSOR: pass meta.next_cursor as cursor (same filters) while meta.has_more is true; the window is walked from its near end in 30-day steps, so the first page always holds the newest (or soonest) meetings. meta.complete false means a step could not be fully scanned in time: filter by record, email or domain, or narrow the window. meta.note explains an empty result (no calendar connected, a record with no addresses).",
2826
+ inputSchema: {
2827
+ record_id: z.string().uuid().optional().describe("Meetings with this CRM record's people (requires record_type)"),
2828
+ record_type: z.enum(["company", "person"]).optional().describe("The record's type (requires record_id)"),
2829
+ email: z.string().optional().describe("Comma-separated attendee email addresses to match"),
2830
+ domain: z.string().optional().describe("Comma-separated attendee domains to match (e.g. acme.com)"),
2831
+ query: z.string().optional().describe("Words to match in the meeting title"),
2832
+ account_id: z.string().uuid().optional().describe("Only this calendar (id from lighthouse_inbox_calendar_accounts)"),
2833
+ after: z.string().optional().describe("Only meetings on/after this date (YYYY-MM-DD)"),
2834
+ before: z.string().optional().describe("Only meetings on/before this date (YYYY-MM-DD); with upcoming=true it extends the reach past the 90-day default"),
2835
+ months: z.number().min(1).max(36).optional().describe("How many months back to look when after is not given (default 6, max 36)"),
2836
+ upcoming: z.boolean().optional().describe("true: scheduled meetings from now on, soonest first, instead of past ones"),
2837
+ limit: z.number().min(1).max(100).default(25).describe("Meetings per page"),
2838
+ cursor: z.string().optional().describe("meta.next_cursor of the previous page, with the same filters"),
2839
+ },
2840
+ }, async (params) => {
2841
+ try {
2842
+ const res = await api.get("/inbox/calendar/meetings", params);
2843
+ return text(res);
2844
+ }
2845
+ catch (e) {
2846
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2847
+ }
2848
+ });
2849
+ server.registerTool("lighthouse_inbox_calendar_meeting", {
2850
+ title: "Get Recorded Meeting",
2851
+ description: "One recorded meeting's metadata by meeting_key (from lighthouse_inbox_calendar_meetings, or the meeting object of a meeting note): title, occurred_at, duration_seconds, language, recorded_by, has_recording, has_transcript, has_notes, and notes { note_id, status, created_at } when meeting notes exist (status ready means readable; processing means they are still being written). " +
2852
+ "WHEN TO USE: to check whether a transcript or notes exist before reading them, or to answer how long a call lasted. It never returns the transcript or the notes themselves. Not found when the meeting was not recorded or the API key's user cannot open its recording.",
2853
+ inputSchema: {
2854
+ meeting_key: z.string().describe("The meeting_key of a recorded meeting"),
2855
+ },
2856
+ }, async (params) => {
2857
+ try {
2858
+ const res = await api.get(`/inbox/calendar/meetings/${encodeURIComponent(params.meeting_key)}`);
2859
+ return text(res);
2860
+ }
2861
+ catch (e) {
2862
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2863
+ }
2864
+ });
2865
+ server.registerTool("lighthouse_inbox_calendar_meeting_notes", {
2866
+ title: "Get Meeting Notes",
2867
+ description: "The meeting notes written from one recorded meeting, by meeting_key: the note in the same shape as lighthouse_crm_get_note (type 'meeting', with the meeting object and the records it is linked to), plus content_markdown (what was generated: Meeting Context first, then the sections) and edited (true when a member changed the note since, in which case content is the version to trust). " +
2868
+ "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. " +
2869
+ "Not found when the meeting has no notes yet (see has_notes on the meeting) or the API key's user cannot open its recording.",
2870
+ inputSchema: {
2871
+ meeting_key: z.string().describe("The meeting_key of a recorded meeting"),
2872
+ },
2873
+ }, async (params) => {
2874
+ try {
2875
+ const res = await api.get(`/inbox/calendar/meetings/${encodeURIComponent(params.meeting_key)}/notes`);
2876
+ return text(res);
2877
+ }
2878
+ catch (e) {
2879
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2880
+ }
2881
+ });
2882
+ server.registerTool("lighthouse_inbox_calendar_meeting_transcript", {
2883
+ title: "Get Meeting Transcript",
2884
+ description: "The transcript of one recorded meeting by meeting_key, as text: one \"Speaker: text\" line per utterance, in order. " +
2885
+ "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. " +
2886
+ `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. ` +
2887
+ "Not found when the meeting has no transcript or the API key's user cannot open its recording.",
2888
+ inputSchema: {
2889
+ meeting_key: z.string().describe("The meeting_key of a recorded meeting"),
2890
+ offset_chars: z.number().min(0).default(0).describe("Character offset to start from (meta.next_offset_chars of the previous window)"),
2891
+ max_chars: z.number().min(1).default(TRANSCRIPT_WINDOW_CHARS).describe("Characters per window"),
2892
+ },
2893
+ }, async ({ meeting_key, offset_chars, max_chars }) => {
2894
+ try {
2895
+ const res = await api.get(`/inbox/calendar/meetings/${encodeURIComponent(meeting_key)}/transcript`, { offset_chars, max_chars });
2896
+ return text(res);
2897
+ }
2898
+ catch (e) {
2899
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2900
+ }
2901
+ });
2902
+ return server;
2903
+ }