@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.
Files changed (60) hide show
  1. package/README.md +110 -42
  2. package/dist/annotations.js +45 -0
  3. package/dist/completions.js +90 -0
  4. package/dist/config.js +55 -29
  5. package/dist/errors.js +59 -14
  6. package/dist/generated/openapi-inputs.js +4324 -1429
  7. package/dist/generated/openapi-outputs.js +21992 -8516
  8. package/dist/generated/route-catalog.js +1480 -0
  9. package/dist/http.js +95 -103
  10. package/dist/index.js +16 -10
  11. package/dist/oauth-resolver.js +58 -36
  12. package/dist/observability.js +11 -3
  13. package/dist/openapi-zod.js +129 -65
  14. package/dist/operations/catalog.js +50 -0
  15. package/dist/operations/groups/analytics.js +122 -0
  16. package/dist/operations/groups/bookings.js +189 -0
  17. package/dist/operations/groups/broadcasts.js +106 -0
  18. package/dist/operations/groups/cadences.js +14 -0
  19. package/dist/operations/groups/callEvents.js +131 -0
  20. package/dist/operations/groups/campaigns.js +132 -0
  21. package/dist/operations/groups/capabilities.js +8 -0
  22. package/dist/operations/groups/coach.js +43 -0
  23. package/dist/operations/groups/conversations.js +48 -0
  24. package/dist/operations/groups/insights.js +33 -0
  25. package/dist/operations/groups/integrations.js +80 -0
  26. package/dist/operations/groups/knowledge.js +34 -0
  27. package/dist/operations/groups/leads.js +101 -0
  28. package/dist/operations/groups/learning.js +24 -0
  29. package/dist/operations/groups/memory.js +27 -0
  30. package/dist/operations/groups/reports.js +14 -0
  31. package/dist/operations/groups/reviewQueue.js +57 -0
  32. package/dist/operations/groups/safety.js +8 -0
  33. package/dist/operations/groups/usage.js +13 -0
  34. package/dist/operations/groups/voice.js +14 -0
  35. package/dist/operations/groups/webhooks.js +76 -0
  36. package/dist/operations/manifest.js +40 -0
  37. package/dist/operations/registry.js +331 -0
  38. package/dist/operations.js +11 -780
  39. package/dist/optional-field.js +12 -0
  40. package/dist/page-shape.js +47 -0
  41. package/dist/pagination.js +164 -0
  42. package/dist/prompts.js +63 -8
  43. package/dist/provenance.js +18 -2
  44. package/dist/remote.js +80 -49
  45. package/dist/resilience.js +117 -0
  46. package/dist/serialization.js +36 -0
  47. package/dist/server.js +256 -281
  48. package/dist/task-tools/analytics.js +168 -0
  49. package/dist/task-tools/bookings.js +154 -0
  50. package/dist/task-tools/cadences.js +65 -0
  51. package/dist/task-tools/calls.js +202 -0
  52. package/dist/task-tools/inbox.js +74 -0
  53. package/dist/task-tools/leads.js +123 -0
  54. package/dist/task-tools/runtime.js +92 -0
  55. package/dist/task-tools.js +21 -0
  56. package/dist/tool-cache.js +39 -0
  57. package/dist/tool.js +228 -0
  58. package/dist/tsconfig.tsbuildinfo +1 -1
  59. package/dist/version.js +14 -0
  60. package/package.json +36 -33
package/dist/server.js CHANGED
@@ -1,281 +1,190 @@
1
- import { McpServer, ResourceTemplate, } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { Effect, Layer } from "effect";
1
+ import { completable, McpServer, ResourceTemplate, } from "@modelcontextprotocol/server";
2
+ import { Effect, Layer, Option } from "effect";
3
3
  import * as z from "zod/v4";
4
+ import { completionCallbacks, isCompletionVariable, templateCompletions, } from "./completions.js";
4
5
  import { LucaConfigLive } from "./config.js";
5
6
  import { formatError } from "./errors.js";
6
7
  import { LucaApi, LucaApiLive } from "./http.js";
7
- import { DEFAULT_MAX_PAGES, LUCA_OPERATIONS, operationGroups, operationManifest, toolAnnotations, } from "./operations.js";
8
8
  import { logAgentSurfaceEvent } from "./observability.js";
9
- import { LUCA_PROMPTS } from "./prompts.js";
10
- import { UNTRUSTED_CONTENT_NOTE, untrustedProvenance } from "./provenance.js";
9
+ import { LUCA_OPERATIONS, operationGroups, operationManifest, } from "./operations.js";
10
+ import { optionalField } from "./optional-field.js";
11
+ import { LUCA_PROMPTS, } from "./prompts.js";
12
+ import { provenanceFields } from "./provenance.js";
11
13
  import { LUCA_RESOURCES } from "./resources.js";
14
+ import { toJsonText } from "./serialization.js";
15
+ import { LUCA_TASK_TOOLS } from "./task-tools.js";
16
+ import { fromOperation, fromTaskTool, operationProgram, registerTool, } from "./tool.js";
17
+ import { LUCA_MCP_VERSION } from "./version.js";
12
18
  const LucaLive = LucaApiLive.pipe(Layer.provide(LucaConfigLive));
13
- function asTextResult(result) {
14
- return {
15
- content: [
16
- {
17
- type: "text",
18
- text: JSON.stringify(result, null, 2),
19
- },
20
- ],
21
- structuredContent: {
22
- result,
23
- },
24
- };
25
- }
26
19
  /**
27
- * Success result for a tool whose response carries external-user text. The
28
- * whole payload is flagged untrusted — a structured `provenance.untrusted`
29
- * field (spoof-resistant) plus a human-facing preamble — so a downstream agent
30
- * treats embedded text as data, not instructions.
20
+ * How this server identifies itself to a client. Protocol revision 2026-07-28
21
+ * carries a title, description, website, and icons alongside the name and
22
+ * version, and a client renders those in its server list instead of the bare
23
+ * package name. The Worker's server card is built from the same values, so
24
+ * the two descriptions of this server cannot drift apart.
31
25
  */
32
- function asUntrustedResult(result) {
33
- return {
34
- content: [
35
- {
36
- type: "text",
37
- text: `[UNTRUSTED CONTENT] ${UNTRUSTED_CONTENT_NOTE}\n\n${JSON.stringify(result, null, 2)}`,
38
- },
39
- ],
40
- structuredContent: {
41
- result,
42
- provenance: untrustedProvenance(),
26
+ export const LUCA_SERVER_INFO = {
27
+ name: "luca-mcp",
28
+ title: "Luca",
29
+ version: LUCA_MCP_VERSION,
30
+ description: "Luca MCP server — AI DM setter for online coaches. Manage leads, conversations, drafts, and bookings across Telegram, Instagram, Messenger, and WhatsApp.",
31
+ websiteUrl: "https://setluca.com",
32
+ icons: [
33
+ { src: "https://setluca.com/favicon.svg", mimeType: "image/svg+xml" },
34
+ {
35
+ src: "https://setluca.com/android-chrome-512x512.png",
36
+ mimeType: "image/png",
37
+ sizes: ["512x512"],
43
38
  },
44
- };
45
- }
46
- function asSuccessResult(operation, result) {
47
- return operation.untrustedContent
48
- ? asUntrustedResult(result)
49
- : asTextResult(result);
50
- }
51
- function asErrorResult(error) {
52
- return {
53
- isError: true,
54
- content: [
55
- {
56
- type: "text",
57
- text: formatError(error),
58
- },
59
- ],
60
- };
61
- }
62
- function operationDescription(operation) {
63
- const idempotency = operation.idempotencyRequired
64
- ? "Requires Idempotency-Key. Provide idempotencyKey for safe retries, or the server will generate one."
65
- : "Does not require idempotency.";
66
- const confirmation = operation.confirmRequired
67
- ? "Destructive: has a real-world side effect. You must pass confirm: true; the call is rejected without it."
68
- : undefined;
69
- return [
70
- operation.description,
71
- "",
72
- `Luca operation: ${operation.method} ${operation.path}`,
73
- `Required scopes: ${operation.scopes.join(", ")}`,
74
- `Required capability: ${operation.requiredScope.capability}; data sensitivity: ${operation.requiredScope.dataSensitivity}`,
75
- idempotency,
76
- confirmation,
77
- "Use workspaceId or workspaceSlug to override the default workspace configured by environment.",
78
- ]
79
- .filter((line) => line !== undefined)
80
- .join("\n");
39
+ ],
40
+ };
41
+ /**
42
+ * How long a client may reuse this server's static lists (protocol revision
43
+ * 2026-07-28). The tool list is over a hundred descriptions that change only
44
+ * when this package is redeployed, so re-sending it on every connection is
45
+ * pure waste; an hour bounds how long a redeploy stays invisible, and a
46
+ * client calling a tool the list no longer names gets an ordinary error
47
+ * rather than a wrong answer.
48
+ *
49
+ * `private` throughout: the tool surface varies with the `?toolset=` query
50
+ * param, so a shared cache keyed on anything less than the whole request
51
+ * could answer one shape with another. `resources/read` is deliberately
52
+ * absent — it returns live coach data and keeps the conservative default of
53
+ * no caching at all.
54
+ *
55
+ * These hints reach a client on the remote Streamable HTTP transport only.
56
+ * The SDK installs the modern-revision handlers from its HTTP entry
57
+ * (`createMcpHandler`, which `createRemoteHandler` is built on); a server
58
+ * constructed here and connected straight to a stdio or in-memory transport
59
+ * negotiates the legacy `initialize` handshake instead, and the legacy wire
60
+ * codec stamps no cache fields. Passing the table is still correct on both
61
+ * paths — it is inert on stdio rather than wrong — but only the remote
62
+ * handler can be asserted against, which is where the coverage lives.
63
+ */
64
+ const STATIC_LIST_TTL_MS = 3_600_000;
65
+ const LUCA_CACHE_HINTS = {
66
+ "tools/list": { ttlMs: STATIC_LIST_TTL_MS, cacheScope: "private" },
67
+ "prompts/list": { ttlMs: STATIC_LIST_TTL_MS, cacheScope: "private" },
68
+ "resources/list": { ttlMs: STATIC_LIST_TTL_MS, cacheScope: "private" },
69
+ "resources/templates/list": {
70
+ ttlMs: STATIC_LIST_TTL_MS,
71
+ cacheScope: "private",
72
+ },
73
+ "server/discover": { ttlMs: STATIC_LIST_TTL_MS, cacheScope: "private" },
74
+ };
75
+ // Only "tasks" selects the trimmed surface; every other value, including an
76
+ // unset query param, takes the full-surface branch.
77
+ export function parseToolset(value) {
78
+ return value === "tasks" ? "tasks" : "full";
81
79
  }
82
- const CONFIRM_REQUIRED_MESSAGE = "This tool requires confirm: true because it has a real-world side effect (sends messages to leads or re-triggers downstream automations). Re-call the tool with confirm: true to proceed.";
83
- function confirmationError(operation) {
84
- logAgentSurfaceEvent("mcp.write_gate.rejected", {
85
- toolName: operation.toolName,
86
- });
87
- return {
88
- isError: true,
89
- content: [
90
- {
91
- type: "text",
92
- text: `${operation.toolName}: ${CONFIRM_REQUIRED_MESSAGE}`,
93
- },
94
- ],
95
- structuredContent: {
96
- error: {
97
- code: "confirmation_required",
98
- toolName: operation.toolName,
99
- requiredArgument: "confirm",
100
- },
101
- },
102
- };
80
+ const OPERATIONS_BY_ID = new Map(
81
+ // This map is built once at module load, so a mutant inside it never takes
82
+ // effect under a test runner that caches the module — it is unkillable by
83
+ // construction rather than untested; the lookups below are covered.
84
+ // Stryker disable next-line ArrowFunction: module-level initializer, never re-evaluated per test
85
+ LUCA_OPERATIONS.map((operation) => [operation.id, operation]));
86
+ function operationByIdOrThrow(id) {
87
+ // Defensive invariant: every task tool's `composes` list references ids from
88
+ // the same CI-validated manifest, so this throw is unreachable with real data
89
+ // and the message text is never observed.
90
+ return Option.fromNullable(OPERATIONS_BY_ID.get(id)).pipe(Option.getOrThrowWith(
91
+ // Stryker disable next-line all: unreachable defensive throw — every composed id exists in the manifest
92
+ () => new Error(`task tool composes unknown operation: ${id}`)));
103
93
  }
104
- function isPaginatablePage(value) {
105
- return typeof value === "object" && value !== null && "nextCursor" in value;
94
+ function registerLucaResources(server, lucaLayer, completions) {
95
+ for (const resource of LUCA_RESOURCES) {
96
+ const operation = resourceOperationOrThrow(resource.operationId);
97
+ const metadata = {
98
+ title: resource.title,
99
+ description: resource.description,
100
+ mimeType: "application/json",
101
+ };
102
+ // A resource is addressed either by a fixed uri or by a template; the two
103
+ // take different `registerResource` overloads, and a definition carrying
104
+ // neither is not addressable at all, so it registers nothing.
105
+ Option.fromNullable(resource.uri).pipe(Option.match({
106
+ onNone: () => registerTemplatedResource(server, resource, operation, lucaLayer, metadata, completions),
107
+ onSome: (uri) => server.registerResource(resource.name, uri, metadata, (readUri) => Effect.runPromise(readResource(resource, operation, lucaLayer, readUri, {}))),
108
+ }));
109
+ }
106
110
  }
107
- /**
108
- * The single array-valued field on a list response is the items array
109
- * (`{ leads: [...], nextCursor }`). Returns undefined for ambiguous shapes so
110
- * the paginator falls back to returning the raw page untouched.
111
- */
112
- function itemsKeyOf(page) {
113
- const arrayKeys = Object.entries(page)
114
- .filter(([key, value]) => key !== "nextCursor" && Array.isArray(value))
115
- .map(([key]) => key);
116
- return arrayKeys.length === 1 ? arrayKeys[0] : undefined;
111
+ function resourceOperationOrThrow(operationId) {
112
+ return Option.fromNullable(OPERATIONS_BY_ID.get(operationId)).pipe(
113
+ // Defensive invariant: every `LUCA_RESOURCES` entry points at an id from the
114
+ // CI-validated manifest, so this throw is unreachable with real data and the
115
+ // message text is never observed.
116
+ Option.getOrThrowWith(
117
+ // Stryker disable next-line all: unreachable defensive throw — every resource operation id exists in the manifest
118
+ () => new Error(`Unknown resource operation: ${operationId}`)));
117
119
  }
118
- function cursorOf(page) {
119
- return typeof page.nextCursor === "string" && page.nextCursor.length > 0
120
- ? page.nextCursor
121
- : undefined;
120
+ function registerTemplatedResource(server, resource, operation, lucaLayer, metadata, completions) {
121
+ return Option.fromNullable(resource.uriTemplate).pipe(Option.match({
122
+ onNone: () => undefined,
123
+ onSome: (template) => server.registerResource(resource.name,
124
+ // These templates are not enumerable — a lead or conversation uri is
125
+ // addressable but not listable — which an explicit `list: undefined`
126
+ // states at the call site. Omitting the key entirely reads the same
127
+ // to the SDK, so a mutant that drops it changes nothing observable.
128
+ // Stryker disable next-line ObjectLiteral: an absent `list` and an undefined one are the same to the SDK
129
+ new ResourceTemplate(template, {
130
+ list: undefined,
131
+ // A template whose variable has no live source registers without
132
+ // the key, rather than with an empty map a client would read as
133
+ // "completion offered, nothing to offer".
134
+ ...optionalField("complete", templateCompletions(template, completions)),
135
+ }), metadata, (uri, variables) => Effect.runPromise(readResource(resource, operation, lucaLayer, uri, variables))),
136
+ }));
122
137
  }
123
138
  /**
124
- * Bounded server-side auto-pagination for list tools. Follows nextCursor up to
125
- * maxPages, merging the items array, then returns a single aggregated result.
126
- * If more pages remain past the guard, the result is explicitly marked
127
- * `truncated` with a `nextCursor` to continue — never a silent partial result.
139
+ * One resource read. Mirrors the tool path: a failed read comes back as a typed
140
+ * error payload instead of a raw exception that would tear down the transport.
128
141
  */
129
- function paginatedProgram(operation, baseRequest, requestedMaxPages) {
130
- const maxPages = requestedMaxPages ?? DEFAULT_MAX_PAGES;
131
- return Effect.gen(function* () {
132
- const api = yield* LucaApi;
133
- const first = yield* api.request({ operation, ...baseRequest });
134
- if (!isPaginatablePage(first)) {
135
- return first;
136
- }
137
- const itemsKey = itemsKeyOf(first);
138
- if (!itemsKey) {
139
- return first;
140
- }
141
- const items = [...first[itemsKey]];
142
- let cursor = cursorOf(first);
143
- let pagesFetched = 1;
144
- while (cursor !== undefined && pagesFetched < maxPages) {
145
- const page = yield* api.request({
146
- operation,
147
- ...baseRequest,
148
- query: { ...(baseRequest.query ?? {}), cursor },
149
- });
150
- if (!isPaginatablePage(page)) {
151
- break;
152
- }
153
- const pageKey = itemsKeyOf(page) ?? itemsKey;
154
- items.push(...(page[pageKey] ?? []));
155
- cursor = cursorOf(page);
156
- pagesFetched += 1;
157
- }
158
- const truncated = cursor !== undefined;
159
- logPaginationTruncation(operation, pagesFetched, truncated);
160
- return {
161
- // Preserve every non-item, non-cursor top-level field from the first page
162
- // (e.g. analytics totals on comment-event lists). The first page's own
163
- // `nextCursor` is dropped in favor of the aggregated `pagination` below,
164
- // and the item array is replaced with the merged one.
165
- ...passthroughFields(first, itemsKey),
166
- [itemsKey]: items,
167
- pagination: {
168
- pagesFetched,
169
- truncated,
170
- nextCursor: cursor ?? null,
171
- },
172
- };
173
- });
142
+ function readResource(resource, operation, lucaLayer, uri, variables) {
143
+ return operationProgram(operation, resourceReadInput(resource, variables)).pipe(Effect.provide(lucaLayer), Effect.match({
144
+ onFailure: (error) => failedReadResult(uri, error),
145
+ onSuccess: (result) => readResult(operation, uri, result),
146
+ }));
174
147
  }
175
- /** Top-level first-page fields to carry through, minus items and nextCursor. */
176
- function passthroughFields(first, itemsKey) {
177
- const rest = {};
178
- for (const [key, value] of Object.entries(first)) {
179
- if (key !== itemsKey && key !== "nextCursor") {
180
- rest[key] = value;
181
- }
182
- }
183
- return rest;
148
+ /** A failed read, answered as a typed error payload the client can render. */
149
+ function failedReadResult(uri, error) {
150
+ return {
151
+ isError: true,
152
+ contents: [resourceContent(uri, { error: formatError(error) })],
153
+ };
184
154
  }
185
- function logPaginationTruncation(operation, pagesFetched, truncated) {
186
- if (truncated) {
187
- logAgentSurfaceEvent("mcp.pagination.truncated", {
188
- toolName: operation.toolName,
189
- pages: pagesFetched,
190
- });
191
- }
155
+ /** A successful read's contents. */
156
+ function readResult(operation, uri, result) {
157
+ return {
158
+ contents: [resourceContent(uri, resourcePayload(operation, result))],
159
+ };
192
160
  }
193
- function registerLucaOperation(server, operation, lucaLayer) {
194
- server.registerTool(operation.toolName, {
195
- title: operation.title,
196
- description: operationDescription(operation),
197
- inputSchema: operation.inputSchema,
198
- outputSchema: operation.outputSchema,
199
- annotations: toolAnnotations(operation),
200
- }, async (input) => {
201
- const toolInput = input;
202
- if (operation.confirmRequired && toolInput.confirm !== true) {
203
- return confirmationError(operation);
204
- }
205
- try {
206
- return await Effect.runPromise(operationProgram(operation, toolInput).pipe(Effect.provide(lucaLayer), Effect.match({
207
- onFailure: asErrorResult,
208
- onSuccess: (result) => asSuccessResult(operation, result),
209
- })));
210
- }
211
- catch (error) {
212
- return asErrorResult(error);
213
- }
161
+ /** The operation input a resource read runs, recorded as it is built. */
162
+ function resourceReadInput(resource, variables) {
163
+ const input = resource.buildInput(variables);
164
+ logAgentSurfaceEvent("mcp.resources.read", {
165
+ resourceType: resource.name,
166
+ hasCursor: input.cursor !== undefined,
214
167
  });
168
+ return input;
215
169
  }
216
- /** The Effect program that runs an operation, auto-paginating list tools. */
217
- function operationProgram(operation, toolInput) {
218
- const baseRequest = operation.buildRequest(toolInput);
219
- return operation.paginates
220
- ? paginatedProgram(operation, baseRequest, toolInput.maxPages)
221
- : Effect.gen(function* () {
222
- const api = yield* LucaApi;
223
- return yield* api.request({ operation, ...baseRequest });
224
- });
170
+ /**
171
+ * The payload of a successful read, provenance-tagged when it carries text an
172
+ * external user wrote. The rule itself lives in `provenance.ts`, shared with the
173
+ * tool results, so what "untrusted" attaches cannot drift between the two
174
+ * surfaces that answer with Luca data.
175
+ */
176
+ function resourcePayload(operation, result) {
177
+ return { result, ...provenanceFields(operation.untrustedContent) };
225
178
  }
226
- const OPERATIONS_BY_ID = new Map(LUCA_OPERATIONS.map((operation) => [operation.id, operation]));
227
- function registerLucaResources(server, lucaLayer) {
228
- for (const resource of LUCA_RESOURCES) {
229
- const operation = OPERATIONS_BY_ID.get(resource.operationId);
230
- if (!operation) {
231
- throw new Error(`Unknown resource operation: ${resource.operationId}`);
232
- }
233
- const read = async (uri, variables) => {
234
- const input = resource.buildInput(variables);
235
- logAgentSurfaceEvent("mcp.resources.read", {
236
- resourceType: resource.name,
237
- hasCursor: Boolean(input.cursor),
238
- });
239
- // Mirror the tool path: a failed read returns a typed error result
240
- // instead of a raw exception that would tear down the transport.
241
- return await Effect.runPromise(operationProgram(operation, input).pipe(Effect.provide(lucaLayer), Effect.match({
242
- onFailure: (error) => ({
243
- isError: true,
244
- contents: [
245
- {
246
- uri: uri.href,
247
- mimeType: "application/json",
248
- text: JSON.stringify({ error: formatError(error) }, null, 2),
249
- },
250
- ],
251
- }),
252
- onSuccess: (result) => ({
253
- contents: [
254
- {
255
- uri: uri.href,
256
- mimeType: "application/json",
257
- text: JSON.stringify(operation.untrustedContent
258
- ? { result, provenance: untrustedProvenance() }
259
- : { result }, null, 2),
260
- },
261
- ],
262
- }),
263
- })));
264
- };
265
- const metadata = {
266
- title: resource.title,
267
- description: resource.description,
268
- mimeType: "application/json",
269
- };
270
- if (resource.uri) {
271
- server.registerResource(resource.name, resource.uri, metadata, (uri) => read(uri, {}));
272
- }
273
- else if (resource.uriTemplate) {
274
- server.registerResource(resource.name, new ResourceTemplate(resource.uriTemplate, { list: undefined }), metadata, (uri, variables) => read(uri, variables));
275
- }
276
- }
179
+ /** A read's single JSON content block, in the shape MCP expects. */
180
+ function resourceContent(uri, payload) {
181
+ return {
182
+ uri: uri.href,
183
+ mimeType: "application/json",
184
+ text: toJsonText(payload),
185
+ };
277
186
  }
278
- function registerResources(server) {
187
+ function registerOperationManifestResource(server) {
279
188
  server.registerResource("luca-public-operations", "luca://operations", {
280
189
  title: "Luca Public API Operations",
281
190
  description: "MCP operation manifest derived from Luca's public API key policy.",
@@ -285,27 +194,70 @@ function registerResources(server) {
285
194
  {
286
195
  uri: uri.href,
287
196
  mimeType: "application/json",
288
- text: JSON.stringify({
197
+ text: toJsonText({
289
198
  apiDocs: "https://api.setluca.com/docs",
290
199
  openApi: "https://api.setluca.com/openapi.json",
291
200
  groups: operationGroups(),
292
201
  operations: operationManifest(),
293
- }, null, 2),
202
+ }),
294
203
  },
295
204
  ],
296
205
  }));
297
206
  }
298
- function registerPrompts(server) {
207
+ /**
208
+ * A prompt's argument schemas with a completion source attached to every
209
+ * argument that has one.
210
+ *
211
+ * Two details of the SDK shape this. It looks for the completion metadata on
212
+ * the schema *inside* the optional wrapper, not on the wrapper. And
213
+ * `completable` stamps that metadata with a non-configurable property, so a
214
+ * schema can be stamped once and never again — while `LUCA_PROMPTS` is a
215
+ * module singleton the Worker re-registers on every request. Cloning the inner
216
+ * schema first gives each build its own object to stamp.
217
+ */
218
+ function completableArgs(schema, completions) {
219
+ if (!completions) {
220
+ return schema;
221
+ }
222
+ return Object.fromEntries(Object.entries(schema).map(([name, field]) => [
223
+ name,
224
+ isCompletionVariable(name)
225
+ ? field.clone({
226
+ ...field.def,
227
+ innerType: completable(field.def.innerType.clone(), completions[name]),
228
+ })
229
+ : field,
230
+ ]));
231
+ }
232
+ /**
233
+ * A prompt's arguments as a Zod object, or nothing when it takes none. An
234
+ * argument-less prompt registers with no `argsSchema` at all, so a client can
235
+ * invoke it without sending an otherwise-required empty args object.
236
+ */
237
+ function promptArgsSchema(prompt, completions) {
238
+ if (Object.keys(prompt.argsSchema).length === 0) {
239
+ return;
240
+ }
241
+ return z.object(completableArgs(prompt.argsSchema, completions));
242
+ }
243
+ /**
244
+ * Registers the static planner prompt plus every prompt in `prompts`.
245
+ *
246
+ * The list is a parameter so a test can drive this with its own prompt instead
247
+ * of mutating the exported `LUCA_PROMPTS` array out from under other tests.
248
+ */
249
+ export function registerPrompts(server, prompts = LUCA_PROMPTS, completions) {
299
250
  server.registerPrompt("luca-api-planner", {
300
251
  title: "Luca API Planner",
301
252
  description: "Plan a Luca API workflow using the MCP tools, scopes, and idempotency rules.",
302
- argsSchema: {
253
+ argsSchema: z.object({
303
254
  task: z
304
255
  .string()
305
256
  .optional()
306
257
  .describe("The Luca API workflow the user wants to perform."),
307
- },
308
- }, ({ task }) => {
258
+ }),
259
+ }, (args) => {
260
+ const { task } = args;
309
261
  logAgentSurfaceEvent("mcp.prompts.get", {
310
262
  prompt: "luca-api-planner",
311
263
  hasGuidance: false,
@@ -321,7 +273,7 @@ function registerPrompts(server) {
321
273
  "Do not ask for database access, internal endpoints, webhook ingress secrets, or browser cookies.",
322
274
  "For writes, pass a stable idempotencyKey when retrying the same user intent.",
323
275
  "Check luca://operations when you need exact tool names, scopes, and paths.",
324
- task ? `User task: ${task}` : undefined,
276
+ Option.fromNullable(task).pipe(Option.filter((value) => value.length > 0), Option.map((value) => `User task: ${value}`), Option.getOrUndefined),
325
277
  ]
326
278
  .filter(Boolean)
327
279
  .join("\n"),
@@ -330,20 +282,23 @@ function registerPrompts(server) {
330
282
  ],
331
283
  };
332
284
  });
333
- for (const prompt of LUCA_PROMPTS) {
334
- // Omit argsSchema for argument-less prompts so a client can invoke them
335
- // without sending an (otherwise-required) empty args object.
336
- const config = Object.keys(prompt.argsSchema).length > 0
337
- ? {
338
- title: prompt.title,
339
- description: prompt.description,
340
- argsSchema: prompt.argsSchema,
341
- }
342
- : { title: prompt.title, description: prompt.description };
285
+ for (const prompt of prompts) {
286
+ const config = {
287
+ title: prompt.title,
288
+ description: prompt.description,
289
+ ...optionalField("argsSchema", promptArgsSchema(prompt, completions)),
290
+ };
343
291
  server.registerPrompt(prompt.name, config, (args) => {
292
+ // Parse with the prompt's own argsSchema, not a shared guess at one — a
293
+ // schema that does not match what the client was told to send silently
294
+ // drops any argument outside it before `prompt.build` ever sees it.
295
+ const parsedArgs = z.object(prompt.argsSchema).parse(args ?? {});
344
296
  logAgentSurfaceEvent("mcp.prompts.get", {
345
297
  prompt: prompt.name,
346
- hasGuidance: Boolean(args?.guidance),
298
+ // Booleans and the arg name only, never guidance text: `guidance` only
299
+ // exists on one prompt's schema, so this reads it without assuming
300
+ // every prompt shares that field.
301
+ hasGuidance: Boolean(parsedArgs.guidance),
347
302
  });
348
303
  return {
349
304
  messages: [
@@ -351,7 +306,7 @@ function registerPrompts(server) {
351
306
  role: "user",
352
307
  content: {
353
308
  type: "text",
354
- text: prompt.build((args ?? {})),
309
+ text: prompt.build(parsedArgs),
355
310
  },
356
311
  },
357
312
  ],
@@ -359,17 +314,37 @@ function registerPrompts(server) {
359
314
  });
360
315
  }
361
316
  }
317
+ /**
318
+ * Build the Luca MCP server: registers the operation tools, the task tools, the
319
+ * `luca://…` resources, and the planner prompt against a shared `LucaApi` layer.
320
+ * `options.lucaLayer` swaps the HTTP client (tests inject an in-memory handler);
321
+ * `options.toolset` trims the surface — `"tasks"` exposes only the task tools
322
+ * plus capability discovery, `"full"` (default) exposes everything.
323
+ */
362
324
  export function createLucaServer(options = {}) {
363
325
  const lucaLayer = options.lucaLayer ?? LucaLive;
364
- const server = new McpServer({
365
- name: "luca-mcp",
366
- version: "0.1.0",
326
+ // The default value is only ever compared against `"tasks"` below; any other
327
+ // string (including `""`) takes the full-surface branch, so the literal here
328
+ // cannot change behavior.
329
+ // Stryker disable next-line StringLiteral: only `"tasks"` is compared downstream, so any non-tasks default behaves as full
330
+ const toolset = options.toolset ?? "full";
331
+ const server = new McpServer(LUCA_SERVER_INFO, {
332
+ cacheHints: LUCA_CACHE_HINTS,
367
333
  });
368
- for (const operation of LUCA_OPERATIONS) {
369
- registerLucaOperation(server, operation, lucaLayer);
334
+ // The tasks surface carries one operation — capability discovery — so a client
335
+ // can still learn what the workspace supports without the full tool list.
336
+ const operations = toolset === "tasks"
337
+ ? [operationByIdOrThrow("capabilities.get")]
338
+ : LUCA_OPERATIONS;
339
+ for (const operation of operations) {
340
+ registerTool(server, fromOperation(operation), lucaLayer, toolset);
341
+ }
342
+ for (const tool of LUCA_TASK_TOOLS) {
343
+ registerTool(server, fromTaskTool(tool, operationByIdOrThrow), lucaLayer, toolset);
370
344
  }
371
- registerResources(server);
372
- registerLucaResources(server, lucaLayer);
373
- registerPrompts(server);
345
+ registerOperationManifestResource(server);
346
+ const completions = completionCallbacks(operationByIdOrThrow, lucaLayer);
347
+ registerLucaResources(server, lucaLayer, completions);
348
+ registerPrompts(server, LUCA_PROMPTS, completions);
374
349
  return server;
375
350
  }