@setluca/mcp 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -42
- package/dist/annotations.js +45 -0
- package/dist/completions.js +90 -0
- package/dist/config.js +55 -29
- package/dist/errors.js +59 -14
- package/dist/generated/openapi-inputs.js +4324 -1429
- package/dist/generated/openapi-outputs.js +21992 -8516
- package/dist/generated/route-catalog.js +1480 -0
- package/dist/http.js +95 -103
- package/dist/index.js +16 -10
- package/dist/oauth-resolver.js +58 -36
- package/dist/observability.js +11 -3
- package/dist/openapi-zod.js +129 -65
- package/dist/operations/catalog.js +50 -0
- package/dist/operations/groups/analytics.js +122 -0
- package/dist/operations/groups/bookings.js +189 -0
- package/dist/operations/groups/broadcasts.js +106 -0
- package/dist/operations/groups/cadences.js +14 -0
- package/dist/operations/groups/callEvents.js +131 -0
- package/dist/operations/groups/campaigns.js +132 -0
- package/dist/operations/groups/capabilities.js +8 -0
- package/dist/operations/groups/coach.js +43 -0
- package/dist/operations/groups/conversations.js +48 -0
- package/dist/operations/groups/insights.js +33 -0
- package/dist/operations/groups/integrations.js +80 -0
- package/dist/operations/groups/knowledge.js +34 -0
- package/dist/operations/groups/leads.js +101 -0
- package/dist/operations/groups/learning.js +24 -0
- package/dist/operations/groups/memory.js +27 -0
- package/dist/operations/groups/reports.js +14 -0
- package/dist/operations/groups/reviewQueue.js +57 -0
- package/dist/operations/groups/safety.js +8 -0
- package/dist/operations/groups/usage.js +13 -0
- package/dist/operations/groups/voice.js +14 -0
- package/dist/operations/groups/webhooks.js +76 -0
- package/dist/operations/manifest.js +40 -0
- package/dist/operations/registry.js +331 -0
- package/dist/operations.js +11 -780
- package/dist/optional-field.js +12 -0
- package/dist/page-shape.js +47 -0
- package/dist/pagination.js +164 -0
- package/dist/prompts.js +63 -8
- package/dist/provenance.js +18 -2
- package/dist/remote.js +80 -49
- package/dist/resilience.js +117 -0
- package/dist/serialization.js +36 -0
- package/dist/server.js +256 -281
- package/dist/task-tools/analytics.js +168 -0
- package/dist/task-tools/bookings.js +154 -0
- package/dist/task-tools/cadences.js +65 -0
- package/dist/task-tools/calls.js +202 -0
- package/dist/task-tools/inbox.js +74 -0
- package/dist/task-tools/leads.js +123 -0
- package/dist/task-tools/runtime.js +92 -0
- package/dist/task-tools.js +21 -0
- package/dist/tool-cache.js +39 -0
- package/dist/tool.js +228 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/version.js +14 -0
- package/package.json +36 -33
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ requires an explicit confirmation.
|
|
|
21
21
|
Copy the block below and paste it to your assistant — it will walk you through
|
|
22
22
|
the rest:
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
```text
|
|
25
25
|
Set up the Luca MCP server for me.
|
|
26
26
|
|
|
27
27
|
Luca (setluca.com) is an AI setter for coaches; its MCP server exposes the
|
|
@@ -40,6 +40,9 @@ fits this client, or ask me which I prefer:
|
|
|
40
40
|
Luca -> Settings -> Developer API keys). Never print the key back to me.
|
|
41
41
|
Optional env var: LUCA_WORKSPACE_SLUG — only if my key can reach more
|
|
42
42
|
than one workspace.
|
|
43
|
+
Optional env var: LUCA_TOOLSET=tasks — a smaller task-level surface
|
|
44
|
+
(triage, find, flag, book, analytics); recommended for coach-facing
|
|
45
|
+
assistants that don't need raw API coverage.
|
|
43
46
|
|
|
44
47
|
Claude Code CLI:
|
|
45
48
|
claude mcp add luca --env LUCA_API_KEY=<my-key> -- npx -y @setluca/mcp
|
|
@@ -58,7 +61,7 @@ After configuring, verify the connection: call the luca_capabilities_get tool
|
|
|
58
61
|
and summarize what my key can do. If a call returns 401 the key is wrong or
|
|
59
62
|
revoked; 403 scope_required means the key is missing a scope; 400
|
|
60
63
|
workspace_required means you should set LUCA_WORKSPACE_SLUG.
|
|
61
|
-
|
|
64
|
+
```
|
|
62
65
|
|
|
63
66
|
Prefer to do it yourself? The two manual paths are below.
|
|
64
67
|
|
|
@@ -95,35 +98,37 @@ screen (read-only up to full access). Manage or revoke connections anytime in
|
|
|
95
98
|
}
|
|
96
99
|
```
|
|
97
100
|
|
|
98
|
-
3. Ask your assistant
|
|
101
|
+
3. Ask your assistant _"What's in my Luca review queue?"_ to confirm it works.
|
|
99
102
|
|
|
100
|
-
Requires Node.js
|
|
103
|
+
Requires Node.js 20+ (for `npx`). Without a key the server still starts and
|
|
101
104
|
lists tools; the first tool call returns a clear
|
|
102
105
|
`Set LUCA_API_KEY to a Luca developer API key.` error instead of failing
|
|
103
106
|
silently.
|
|
104
107
|
|
|
105
108
|
## First things to try
|
|
106
109
|
|
|
107
|
-
-
|
|
108
|
-
-
|
|
109
|
-
-
|
|
110
|
-
-
|
|
111
|
-
-
|
|
112
|
-
-
|
|
110
|
+
- _"What needs my attention in Luca this morning?"_
|
|
111
|
+
- _"Show me the review queue and recommend what to approve."_
|
|
112
|
+
- _"Pull up everything about this lead before my call."_
|
|
113
|
+
- _"Why did Luca mark this lead as hot?"_
|
|
114
|
+
- _"How is my broadcast performing?"_
|
|
115
|
+
- _"Draft a reply to this lead — keep it short and warm."_
|
|
113
116
|
|
|
114
117
|
Your client's prompt picker also gets one-click workflows:
|
|
115
118
|
`luca-morning-report`, `luca-triage-queue`, `luca-draft-reply` (accepts
|
|
116
|
-
optional tone/content guidance),
|
|
119
|
+
optional tone/content guidance), `luca-close-the-call-loop`, and
|
|
120
|
+
`luca-api-planner`.
|
|
117
121
|
|
|
118
122
|
## Configuration
|
|
119
123
|
|
|
120
|
-
| Variable
|
|
121
|
-
|
|
122
|
-
| `LUCA_API_KEY`
|
|
123
|
-
| `LUCA_API_BASE_URL`
|
|
124
|
-
| `LUCA_AUTH_HEADER`
|
|
125
|
-
| `LUCA_WORKSPACE_ID`
|
|
126
|
-
| `LUCA_WORKSPACE_SLUG` | no
|
|
124
|
+
| Variable | Required | What it does |
|
|
125
|
+
| --------------------- | ----------- | ---------------------------------------------------------------------------------------------------- |
|
|
126
|
+
| `LUCA_API_KEY` | yes (local) | Your developer API key from Luca settings. |
|
|
127
|
+
| `LUCA_API_BASE_URL` | no | Luca API origin. Defaults to `https://api.setluca.com`. |
|
|
128
|
+
| `LUCA_AUTH_HEADER` | no | `x-api-key` (default) or `authorization`. |
|
|
129
|
+
| `LUCA_WORKSPACE_ID` | no | Default workspace (uuid) when your key can access several. |
|
|
130
|
+
| `LUCA_WORKSPACE_SLUG` | no | Same, by slug — handy for agencies. |
|
|
131
|
+
| `LUCA_TOOLSET` | no | `full` (default) or `tasks` — trim the surface to the fourteen task tools plus capability discovery. |
|
|
127
132
|
|
|
128
133
|
Every tool also accepts `workspaceId` / `workspaceSlug` arguments to override
|
|
129
134
|
the default for a single call. If your key can reach multiple workspaces and
|
|
@@ -131,20 +136,45 @@ none is selected, calls return `workspace_required` — set one of the above.
|
|
|
131
136
|
|
|
132
137
|
## What's inside
|
|
133
138
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
|
139
|
-
|
|
|
140
|
-
|
|
|
141
|
-
|
|
|
142
|
-
|
|
|
143
|
-
|
|
|
144
|
-
|
|
|
145
|
-
|
|
|
146
|
-
|
|
|
147
|
-
|
|
|
139
|
+
Tools cover the Luca surface an API key can safely reach. Most are generated
|
|
140
|
+
1:1 from the public API; the rest are composed task tools. `docs/tools.md` is
|
|
141
|
+
generated from the code and carries the current count.
|
|
142
|
+
|
|
143
|
+
| Group | What it covers |
|
|
144
|
+
| ------------- | ----------------------------------------------------------------- |
|
|
145
|
+
| Leads | Profiles, timelines, notes, consent, imports, "explain this lead" |
|
|
146
|
+
| Conversations | Message history and context (send requires confirmation) |
|
|
147
|
+
| Review queue | Pending drafts, approve, reject, restore, media retry |
|
|
148
|
+
| Bookings | Create, reschedule, cancel |
|
|
149
|
+
| Campaigns | Drafts, enrollment, analytics, comment automation |
|
|
150
|
+
| Broadcasts | Draft → approve → launch flow, with confirmation gates |
|
|
151
|
+
| Webhooks | Subscriptions, deliveries, events, replays, signature guide |
|
|
152
|
+
| Integrations | CRM connections, mappings, sync runs |
|
|
153
|
+
| Call events | Post-call feedback, summaries, CRM push, outcome corrections |
|
|
154
|
+
| Analytics | Funnel, revenue, forecast, call intelligence, ghosted leads |
|
|
155
|
+
| Voice | Read-only voice-fingerprint summary |
|
|
156
|
+
| Capabilities | Discover what this key can do |
|
|
157
|
+
| Task tools | Intent-level workflows over the operation tools — see below |
|
|
158
|
+
|
|
159
|
+
**Task tools** — start here; each composes several operation tools behind one
|
|
160
|
+
intent-level call:
|
|
161
|
+
|
|
162
|
+
| Tool | What it does |
|
|
163
|
+
| ----------------------------- | ------------------------------------------------------------------- |
|
|
164
|
+
| `luca_triage_inbox` | Prioritized review queue with per-item explanations |
|
|
165
|
+
| `luca_morning_report` | Structured last-24h queue digest (no model call) |
|
|
166
|
+
| `luca_find_leads` | Search leads by free text and channel |
|
|
167
|
+
| `luca_draft_reply` | Draft a voice-matched reply and queue it for review |
|
|
168
|
+
| `luca_approve_and_send` | Approve a draft and send it (needs `confirm: true`) |
|
|
169
|
+
| `luca_flag_for_human` | Flag a lead for a human to review |
|
|
170
|
+
| `luca_book_call` | Book a call with a lead |
|
|
171
|
+
| `luca_rescue_silent_leads` | Enroll silent leads into the rescue cadence (needs `confirm: true`) |
|
|
172
|
+
| `luca_pause_cadence` | Pause a lead's running rescue cadence |
|
|
173
|
+
| `luca_analytics_rollup` | Roll up campaign and broadcast performance |
|
|
174
|
+
| `luca_analytics_magic_monday` | The weekly report: funnel, revenue, speed, forecast, call intel |
|
|
175
|
+
| `luca_analytics_deep_dive` | All seven analytics reads, for a specific question |
|
|
176
|
+
| `luca_post_call_queue` | Calls still needing a report, and calls needing a revenue outcome |
|
|
177
|
+
| `luca_close_call_loop` | File a call's attendance and what it was worth, in one call |
|
|
148
178
|
|
|
149
179
|
**Resources** — attach context without a tool round-trip: a lead
|
|
150
180
|
(`luca://lead/{leadId}`), a thread (`luca://thread/{conversationId}`), today's
|
|
@@ -167,20 +197,56 @@ review queue (`luca://queue/today`), the voice profile
|
|
|
167
197
|
to continue.
|
|
168
198
|
- **Scopes are enforced.** A key only reaches the tool groups its scopes
|
|
169
199
|
allow; everything else returns a clear `403`.
|
|
200
|
+
- **Redacted keys see less, and say so.** A key's data-sensitivity tier
|
|
201
|
+
decides whether it gets verbatim lead content or summaries only. A
|
|
202
|
+
`redacted`-tier key still lists the queue, reads a conversation, or explains
|
|
203
|
+
a lead, but content-bearing fields come back blanked. Tools whose output can
|
|
204
|
+
be redacted mark those fields in their `outputSchema` with
|
|
205
|
+
`x-luca-redacted-fields`, so a client can tell "blanked for your tier" from
|
|
206
|
+
"no data" — treat a blanked field as unknown, not as an empty/negative
|
|
207
|
+
signal.
|
|
170
208
|
- **The boundary is the public API.** This server cannot touch Luca's
|
|
171
209
|
database, internal routes, webhook secrets, or browser sessions — it is
|
|
172
210
|
exactly as powerful as the API key you give it.
|
|
173
211
|
|
|
212
|
+
## Privacy Policy
|
|
213
|
+
|
|
214
|
+
Full policy: **https://setluca.com/privacy**. What it means for this server
|
|
215
|
+
specifically:
|
|
216
|
+
|
|
217
|
+
- **What it collects.** Nothing of its own. The server holds no database and
|
|
218
|
+
writes no store. It forwards a tool call to the Luca API under your key and
|
|
219
|
+
returns the response. The hosted server at `mcp.setluca.com` is stateless:
|
|
220
|
+
each request carries its own credential and nothing survives it.
|
|
221
|
+
- **How it is used and stored.** Your workspace data stays in Luca, governed by
|
|
222
|
+
the policy above. A tool call emits one structured log line carrying the tool
|
|
223
|
+
name, the toolset, the outcome, and how long it took. Tool arguments, lead
|
|
224
|
+
message text, and drafted replies never reach a log line.
|
|
225
|
+
- **What is shared.** This server sends your data to one place: the Luca API.
|
|
226
|
+
Luca's own sub-processors are named in the policy above. The other party in
|
|
227
|
+
the exchange is your MCP client, and what it does with a tool result is
|
|
228
|
+
governed by that client's policy — read it before pointing a hosted client at
|
|
229
|
+
a live workspace.
|
|
230
|
+
- **How long it is kept.** Nothing is retained here. Retention of the
|
|
231
|
+
underlying records is Luca's, and the policy above states it: conversations
|
|
232
|
+
and the voice profile live as long as the account does, and deletion on
|
|
233
|
+
request removes them.
|
|
234
|
+
- **Your key is the boundary.** This server is exactly as powerful as the key
|
|
235
|
+
you give it. Revoke a key in Luca settings and every client holding it stops
|
|
236
|
+
working immediately.
|
|
237
|
+
- **Contact.** hello@setluca.com.
|
|
238
|
+
|
|
174
239
|
## Troubleshooting
|
|
175
240
|
|
|
176
|
-
| Symptom
|
|
177
|
-
|
|
178
|
-
| `Set LUCA_API_KEY to a Luca developer API key.` | Add the env var to your client config (see Connect).
|
|
179
|
-
| `401 unauthorized`
|
|
180
|
-
| `403 scope_required`
|
|
181
|
-
| `
|
|
182
|
-
|
|
|
183
|
-
|
|
|
241
|
+
| Symptom | Fix |
|
|
242
|
+
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
243
|
+
| `Set LUCA_API_KEY to a Luca developer API key.` | Add the env var to your client config (see Connect). |
|
|
244
|
+
| `401 unauthorized` | The key is wrong or revoked — mint a new one in Luca settings. |
|
|
245
|
+
| `403 scope_required` | The key lacks a scope for that tool — re-mint with the scopes you need. |
|
|
246
|
+
| `403 needs_scope` | The key's granted capability tier is too low for that tool (for example a read-only tier calling a queue-operating tool like launch or send) — re-mint or reconnect with a higher tier. |
|
|
247
|
+
| `400 workspace_required` | The key reaches several workspaces — set `LUCA_WORKSPACE_SLUG` or pass `workspaceSlug`. |
|
|
248
|
+
| Confirmation error on send/launch | Expected: re-call the tool with `confirm: true` once a human approved. |
|
|
249
|
+
| Tools list but every call fails | Check the key first (`401`), then scopes (`403`) — the error body names the missing scope. |
|
|
184
250
|
|
|
185
251
|
## For contributors
|
|
186
252
|
|
|
@@ -191,7 +257,8 @@ The deep technical material lives in [docs/](./docs):
|
|
|
191
257
|
[Configuration](./docs/configuration.md) ·
|
|
192
258
|
[Security boundary](./docs/security.md) ·
|
|
193
259
|
[Tool reference](./docs/tools.md) ·
|
|
194
|
-
[Release](./docs/release.md)
|
|
260
|
+
[Release](./docs/release.md) ·
|
|
261
|
+
[Connector directory](./docs/connector-directory.md)
|
|
195
262
|
|
|
196
263
|
From the repo root: `bun --filter @setluca/mcp verify` runs the full local
|
|
197
264
|
gate (docs + schema drift, typecheck, tests, build, stdio smoke test).
|
|
@@ -199,6 +266,7 @@ gate (docs + schema drift, typecheck, tests, build, stdio smoke test).
|
|
|
199
266
|
## Links
|
|
200
267
|
|
|
201
268
|
- Luca: https://setluca.com
|
|
269
|
+
- Privacy policy: https://setluca.com/privacy
|
|
202
270
|
- API docs: https://api.setluca.com/docs
|
|
203
271
|
- OpenAPI: https://api.setluca.com/openapi.json
|
|
204
|
-
- MCP registry listing: `io.github.
|
|
272
|
+
- MCP registry listing: `io.github.setluca/luca-mcp`
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool annotations, so a client can reason about a tool before calling it
|
|
3
|
+
* (auto-approve reads, flag destructive writes). Hints only — never a security
|
|
4
|
+
* boundary; the Luca API still enforces scopes and safety server-side.
|
|
5
|
+
*/
|
|
6
|
+
export function toolAnnotations(source) {
|
|
7
|
+
return {
|
|
8
|
+
title: source.title,
|
|
9
|
+
readOnlyHint: source.readOnly,
|
|
10
|
+
// MCP defines the non-destructive half as additive-only, so anything that
|
|
11
|
+
// overwrites or removes existing state reads true here: a delete, a PATCH,
|
|
12
|
+
// a PUT replace, or a write whose real-world effect we gate on a confirm.
|
|
13
|
+
// A create is a write and still reads false, because nothing it touches
|
|
14
|
+
// existed before the call.
|
|
15
|
+
destructiveHint: !source.readOnly && (source.confirmRequired || source.mutatesExisting),
|
|
16
|
+
// Reads are naturally idempotent; flagged writes carry an idempotency-key
|
|
17
|
+
// contract that makes a repeat with the same key a no-op.
|
|
18
|
+
idempotentHint: source.readOnly || source.idempotencyRequired,
|
|
19
|
+
// Every tool reaches the external Luca API, outside this server's control.
|
|
20
|
+
openWorldHint: true,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The HTTP methods that change state a caller already had. POST is absent on
|
|
25
|
+
* purpose: it creates, and a create has nothing prior to overwrite.
|
|
26
|
+
*/
|
|
27
|
+
const MUTATING_METHODS = new Set([
|
|
28
|
+
"DELETE",
|
|
29
|
+
"PATCH",
|
|
30
|
+
"PUT",
|
|
31
|
+
]);
|
|
32
|
+
/** True when this route's method changes or removes an existing record. */
|
|
33
|
+
export function mutatesExisting(method) {
|
|
34
|
+
return MUTATING_METHODS.has(method);
|
|
35
|
+
}
|
|
36
|
+
/** The annotation facts an operation carries, read off its method and flags. */
|
|
37
|
+
export function operationAnnotations(operation) {
|
|
38
|
+
return toolAnnotations({
|
|
39
|
+
title: operation.title,
|
|
40
|
+
readOnly: operation.method === "GET",
|
|
41
|
+
mutatesExisting: mutatesExisting(operation.method),
|
|
42
|
+
confirmRequired: operation.confirmRequired,
|
|
43
|
+
idempotencyRequired: operation.idempotencyRequired,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Effect, Option, Schema } from "effect";
|
|
2
|
+
import { LucaApi } from "./http.js";
|
|
3
|
+
/**
|
|
4
|
+
* Argument completion for the two ids a coach types by hand. MCP completes
|
|
5
|
+
* prompt arguments and resource-template variables; there is no tool-argument
|
|
6
|
+
* completion in the protocol, so a tool's enum fields stay documented in their
|
|
7
|
+
* own JSON Schema and only these two names get a live source.
|
|
8
|
+
*
|
|
9
|
+
* Ids only. A completion list is rendered by the client, so offering a lead's
|
|
10
|
+
* name or a message would put lead-authored text somewhere the untrusted
|
|
11
|
+
* framing every tool result carries does not reach.
|
|
12
|
+
*
|
|
13
|
+
* Each name maps to the list operation its ids are read from. The field those
|
|
14
|
+
* ids arrive in is not named here — it is the operation's own page shape, so a
|
|
15
|
+
* route that renames its array cannot leave this file behind.
|
|
16
|
+
*/
|
|
17
|
+
const COMPLETION_SOURCES = {
|
|
18
|
+
leadId: "leads.list",
|
|
19
|
+
conversationId: "conversations.list",
|
|
20
|
+
};
|
|
21
|
+
/** One page is enough to complete from, and it is the API's own cap. */
|
|
22
|
+
const COMPLETION_PAGE_SIZE = 100;
|
|
23
|
+
/**
|
|
24
|
+
* Reads a list page down to the ids in one array field, ignoring every other
|
|
25
|
+
* field on the page and every other field on a record. A page that arrives in
|
|
26
|
+
* any other shape decodes to nothing, which the caller offers as no
|
|
27
|
+
* suggestions.
|
|
28
|
+
*/
|
|
29
|
+
function idsDecoder(itemsKey) {
|
|
30
|
+
const decode = Schema.decodeUnknownOption(Schema.Struct({
|
|
31
|
+
[itemsKey]: Schema.Array(Schema.Struct({ id: Schema.String })),
|
|
32
|
+
}));
|
|
33
|
+
return (page) => Option.match(decode(page), {
|
|
34
|
+
onNone: () => [],
|
|
35
|
+
onSome: (decoded) => decoded[itemsKey]?.map((record) => record.id) ?? [],
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The completion callbacks for one server, closed over the layer that serves
|
|
40
|
+
* the reads.
|
|
41
|
+
*
|
|
42
|
+
* A failed read answers with no suggestions rather than an error. A completion
|
|
43
|
+
* fires on a keystroke, so a transient 500 would otherwise surface as a failure
|
|
44
|
+
* for something the next character retries anyway.
|
|
45
|
+
*/
|
|
46
|
+
export function completionCallbacks(operationById, lucaLayer) {
|
|
47
|
+
function callbackFor(operationId) {
|
|
48
|
+
return (value) => Effect.runPromise(Effect.gen(function* () {
|
|
49
|
+
const operation = operationById(operationId);
|
|
50
|
+
// A source that does not paginate carries no array of records, so
|
|
51
|
+
// there is nothing to read ids out of.
|
|
52
|
+
const itemsKey = operation.pageShape?.itemsKey;
|
|
53
|
+
if (itemsKey === undefined) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const idsOn = idsDecoder(itemsKey);
|
|
57
|
+
const api = yield* LucaApi;
|
|
58
|
+
const page = yield* api.request({
|
|
59
|
+
operation,
|
|
60
|
+
...operation.buildRequest({
|
|
61
|
+
query: { limit: COMPLETION_PAGE_SIZE },
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
const typed = value ?? "";
|
|
65
|
+
return idsOn(page).filter((id) => id.startsWith(typed));
|
|
66
|
+
}).pipe(Effect.provide(lucaLayer), Effect.catchAllCause(() => Effect.succeed([]))));
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
leadId: callbackFor(COMPLETION_SOURCES.leadId),
|
|
70
|
+
conversationId: callbackFor(COMPLETION_SOURCES.conversationId),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Whether `name` is a template variable or prompt argument we can complete. */
|
|
74
|
+
export function isCompletionVariable(name) {
|
|
75
|
+
return Object.hasOwn(COMPLETION_SOURCES, name);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The `complete` map for one URI template: every `{variable}` in it that has a
|
|
79
|
+
* source. Returns undefined when none do, so a template with nothing to
|
|
80
|
+
* complete registers without the key at all.
|
|
81
|
+
*/
|
|
82
|
+
export function templateCompletions(template, callbacks) {
|
|
83
|
+
const complete = {};
|
|
84
|
+
for (const [, variable] of template.matchAll(/\{([^}]+)\}/g)) {
|
|
85
|
+
if (variable && isCompletionVariable(variable)) {
|
|
86
|
+
complete[variable] = callbacks[variable];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return Object.keys(complete).length > 0 ? complete : undefined;
|
|
90
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -1,47 +1,73 @@
|
|
|
1
|
-
import { Context, Effect, Layer } from "effect";
|
|
1
|
+
import { Context, Effect, Layer, Match, Option } from "effect";
|
|
2
|
+
import * as z from "zod/v4";
|
|
2
3
|
import { LucaConfigError } from "./errors.js";
|
|
4
|
+
import { optionalField } from "./optional-field.js";
|
|
3
5
|
const TRAILING_SLASHES = /\/+$/;
|
|
6
|
+
/** How long one HTTP attempt may run before it is cancelled and retried. */
|
|
7
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
4
8
|
export const LucaConfig = Context.GenericTag("@luca/mcp/LucaConfig");
|
|
5
9
|
function nonEmpty(value) {
|
|
6
|
-
|
|
7
|
-
|
|
10
|
+
// An all-whitespace variable is the same as an unset one, so it collapses to
|
|
11
|
+
// undefined and lets every caller's `??` fallback take over.
|
|
12
|
+
return value?.trim() || undefined;
|
|
8
13
|
}
|
|
9
14
|
function normalizeBaseUrl(value) {
|
|
10
15
|
const raw = nonEmpty(value) ?? "https://api.setluca.com";
|
|
11
16
|
return raw.replace(TRAILING_SLASHES, "");
|
|
12
17
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Every spelling of LUCA_AUTH_HEADER we accept, mapped to the header the client
|
|
20
|
+
* actually sends. "bearer" is here because it is what people write when they
|
|
21
|
+
* mean the Authorization header, and refusing it would be a papercut.
|
|
22
|
+
*/
|
|
23
|
+
const AUTH_HEADER_ALIASES = new Map([
|
|
24
|
+
["x-api-key", "x-api-key"],
|
|
25
|
+
["authorization", "authorization"],
|
|
26
|
+
["bearer", "authorization"],
|
|
27
|
+
]);
|
|
28
|
+
/**
|
|
29
|
+
* The per-attempt timeout a variable asks for, absent when it is unset or is
|
|
30
|
+
* not a positive whole number of milliseconds. Absent means the caller's
|
|
31
|
+
* default applies, so a typo degrades to the default rather than to no timeout
|
|
32
|
+
* at all.
|
|
33
|
+
*/
|
|
34
|
+
export function parseTimeoutMs(value) {
|
|
35
|
+
const parsed = z.coerce
|
|
36
|
+
.number()
|
|
37
|
+
.int()
|
|
38
|
+
.positive()
|
|
39
|
+
.safeParse(nonEmpty(value) ?? undefined);
|
|
40
|
+
return parsed.success ? parsed.data : undefined;
|
|
24
41
|
}
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
42
|
+
function parseAuthHeader(value) {
|
|
43
|
+
const alias = nonEmpty(value)?.toLowerCase() ?? "x-api-key";
|
|
44
|
+
return Match.value(AUTH_HEADER_ALIASES.get(alias)).pipe(Match.when(Match.undefined, () => {
|
|
28
45
|
throw new LucaConfigError({
|
|
29
|
-
message: "
|
|
46
|
+
message: "LUCA_AUTH_HEADER must be x-api-key or authorization",
|
|
30
47
|
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
48
|
+
}), Match.orElse((header) => header));
|
|
49
|
+
}
|
|
50
|
+
export function loadConfig(env = process.env) {
|
|
51
|
+
const apiKey = Option.fromNullable(nonEmpty(env.LUCA_API_KEY) ?? nonEmpty(env.LUCA_API_TOKEN)).pipe(Option.getOrThrowWith(() => new LucaConfigError({
|
|
52
|
+
message: "Set LUCA_API_KEY to a Luca developer API key.",
|
|
53
|
+
})));
|
|
34
54
|
return {
|
|
35
55
|
apiBaseUrl: normalizeBaseUrl(env.LUCA_API_BASE_URL),
|
|
36
56
|
apiKey,
|
|
37
57
|
authHeader: parseAuthHeader(env.LUCA_AUTH_HEADER),
|
|
38
|
-
...(workspaceId
|
|
39
|
-
...(workspaceSlug
|
|
58
|
+
...optionalField("workspaceId", nonEmpty(env.LUCA_WORKSPACE_ID)),
|
|
59
|
+
...optionalField("workspaceSlug", nonEmpty(env.LUCA_WORKSPACE_SLUG)),
|
|
60
|
+
...optionalField("requestTimeoutMs", parseTimeoutMs(env.LUCA_REQUEST_TIMEOUT_MS)),
|
|
40
61
|
};
|
|
41
62
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Every way `loadConfig` can fail has to reach the caller as a `LucaConfigError`
|
|
65
|
+
* so the layer's error channel stays one type. A config error passes through
|
|
66
|
+
* untouched; anything else — a getter on the environment that throws, a future
|
|
67
|
+
* caller of this helper that raises something unexpected — is wrapped rather
|
|
68
|
+
* than escaping as an untyped defect.
|
|
69
|
+
*/
|
|
70
|
+
export function toConfigError(cause) {
|
|
71
|
+
return Match.value(cause).pipe(Match.when(Match.instanceOf(LucaConfigError), (error) => error), Match.orElse((other) => new LucaConfigError({ message: String(other) })));
|
|
72
|
+
}
|
|
73
|
+
export const LucaConfigLive = Layer.effect(LucaConfig, Effect.try({ try: () => loadConfig(), catch: toConfigError }));
|
package/dist/errors.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { Data } from "effect";
|
|
1
|
+
import { Data, Match, Option } from "effect";
|
|
2
|
+
import * as z from "zod/v4";
|
|
3
|
+
import { toJsonPayload } from "./serialization.js";
|
|
2
4
|
export class LucaConfigError extends Data.TaggedError("LucaConfigError") {
|
|
3
5
|
}
|
|
4
6
|
export class LucaNetworkError extends Data.TaggedError("LucaNetworkError") {
|
|
@@ -7,19 +9,62 @@ export class LucaDecodeError extends Data.TaggedError("LucaDecodeError") {
|
|
|
7
9
|
}
|
|
8
10
|
export class LucaHttpError extends Data.TaggedError("LucaHttpError") {
|
|
9
11
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
/**
|
|
13
|
+
* One attempt ran past the request timeout and was cancelled. It is its own
|
|
14
|
+
* error rather than a network error because a caller can retry it safely under
|
|
15
|
+
* the same rules but cannot tell from a network failure whether the request
|
|
16
|
+
* reached the API.
|
|
17
|
+
*/
|
|
18
|
+
export class LucaTimeoutError extends Data.TaggedError("LucaTimeoutError") {
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A tool was called with arguments that are not a JSON object. The MCP SDK
|
|
22
|
+
* validates against the tool's own input schema before the handler runs, so a
|
|
23
|
+
* real client cannot reach this. It exists so no code ahead of `runPromise` can
|
|
24
|
+
* throw where the error channel cannot see it.
|
|
25
|
+
*/
|
|
26
|
+
export class LucaToolInputError extends Data.TaggedError("LucaToolInputError") {
|
|
27
|
+
}
|
|
28
|
+
const DECODE_BODY_EXCERPT = 500;
|
|
29
|
+
function formatDecodeError(error) {
|
|
30
|
+
return `${error.message}: ${error.body.slice(0, DECODE_BODY_EXCERPT)}`;
|
|
31
|
+
}
|
|
32
|
+
function formatHttpError(error) {
|
|
33
|
+
const request = Option.fromNullable(error.requestId).pipe(Option.match({
|
|
34
|
+
onNone: () => "",
|
|
35
|
+
onSome: (requestId) => ` requestId=${requestId}`,
|
|
36
|
+
}));
|
|
37
|
+
return `Luca API returned ${error.status} ${error.statusText}${request}: ${toJsonPayload(error.body)}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A value that carries no message of its own, rendered for a human. Anything
|
|
41
|
+
* that is not already a string comes back as JSON rather than "[object Object]".
|
|
42
|
+
*/
|
|
43
|
+
function formatJsonValue(error) {
|
|
44
|
+
const text = z.string().safeParse(error);
|
|
45
|
+
if (text.success) {
|
|
46
|
+
return text.data;
|
|
19
47
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return
|
|
48
|
+
const scalar = z.union([z.number(), z.boolean()]).safeParse(error);
|
|
49
|
+
if (scalar.success) {
|
|
50
|
+
return String(scalar.data);
|
|
23
51
|
}
|
|
24
|
-
return
|
|
52
|
+
return toJsonPayload(error);
|
|
53
|
+
}
|
|
54
|
+
/** Any failure this server can hand a client, as one line of text. */
|
|
55
|
+
export function formatError(error) {
|
|
56
|
+
return error instanceof Error ? formatThrown(error) : formatJsonValue(error);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A thrown value rendered for a human. The two structured errors get their own
|
|
60
|
+
* rendering; every other tagged error carries a `message`, and one branch
|
|
61
|
+
* covers all of them rather than a case per tag that would all say the same
|
|
62
|
+
* thing.
|
|
63
|
+
*
|
|
64
|
+
* The match is on the class, not on `_tag`. This takes any `Error`, including
|
|
65
|
+
* one thrown by a dependency that carries no tag at all, so there is no tagged
|
|
66
|
+
* union here for `Match.tag` to narrow.
|
|
67
|
+
*/
|
|
68
|
+
function formatThrown(error) {
|
|
69
|
+
return Match.value(error).pipe(Match.when(Match.instanceOf(LucaDecodeError), formatDecodeError), Match.when(Match.instanceOf(LucaHttpError), formatHttpError), Match.orElse((thrown) => thrown.message));
|
|
25
70
|
}
|