@setluca/mcp 0.1.1 → 0.2.1

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 (63) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +178 -80
  3. package/dist/annotations.js +45 -0
  4. package/dist/completions.js +110 -0
  5. package/dist/config.js +55 -29
  6. package/dist/errors.js +56 -15
  7. package/dist/generated/openapi-inputs.js +4324 -1429
  8. package/dist/generated/openapi-outputs.js +21992 -8516
  9. package/dist/generated/route-catalog.js +1480 -0
  10. package/dist/http.js +97 -103
  11. package/dist/index.js +16 -10
  12. package/dist/oauth-resolver.js +59 -37
  13. package/dist/observability.js +12 -4
  14. package/dist/openapi-zod.js +138 -72
  15. package/dist/operations/catalog.js +50 -0
  16. package/dist/operations/groups/analytics.js +122 -0
  17. package/dist/operations/groups/bookings.js +189 -0
  18. package/dist/operations/groups/broadcasts.js +106 -0
  19. package/dist/operations/groups/cadences.js +14 -0
  20. package/dist/operations/groups/callEvents.js +131 -0
  21. package/dist/operations/groups/campaigns.js +132 -0
  22. package/dist/operations/groups/capabilities.js +8 -0
  23. package/dist/operations/groups/coach.js +43 -0
  24. package/dist/operations/groups/conversations.js +48 -0
  25. package/dist/operations/groups/insights.js +33 -0
  26. package/dist/operations/groups/integrations.js +80 -0
  27. package/dist/operations/groups/knowledge.js +34 -0
  28. package/dist/operations/groups/leads.js +101 -0
  29. package/dist/operations/groups/learning.js +24 -0
  30. package/dist/operations/groups/memory.js +27 -0
  31. package/dist/operations/groups/reports.js +14 -0
  32. package/dist/operations/groups/reviewQueue.js +57 -0
  33. package/dist/operations/groups/safety.js +8 -0
  34. package/dist/operations/groups/usage.js +13 -0
  35. package/dist/operations/groups/voice.js +14 -0
  36. package/dist/operations/groups/webhooks.js +76 -0
  37. package/dist/operations/manifest.js +40 -0
  38. package/dist/operations/registry.js +331 -0
  39. package/dist/operations.js +11 -780
  40. package/dist/optional-field.js +12 -0
  41. package/dist/page-shape.js +62 -0
  42. package/dist/pagination.js +165 -0
  43. package/dist/prompts.js +89 -8
  44. package/dist/provenance.js +18 -2
  45. package/dist/remote.js +80 -49
  46. package/dist/resilience.js +127 -0
  47. package/dist/resources.js +17 -2
  48. package/dist/scopes.js +154 -0
  49. package/dist/serialization.js +36 -0
  50. package/dist/server.js +258 -316
  51. package/dist/task-tools/analytics.js +168 -0
  52. package/dist/task-tools/bookings.js +157 -0
  53. package/dist/task-tools/cadences.js +65 -0
  54. package/dist/task-tools/calls.js +202 -0
  55. package/dist/task-tools/inbox.js +120 -0
  56. package/dist/task-tools/leads.js +123 -0
  57. package/dist/task-tools/runtime.js +92 -0
  58. package/dist/task-tools.js +21 -0
  59. package/dist/tool-cache.js +39 -0
  60. package/dist/tool.js +228 -0
  61. package/dist/tsconfig.tsbuildinfo +1 -1
  62. package/dist/version.js +14 -0
  63. package/package.json +38 -34
package/dist/server.js CHANGED
@@ -1,314 +1,272 @@
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";
11
- import { LUCA_RESOURCES } from "./resources.js";
9
+ import { LUCA_OPERATIONS, operationGroups, operationManifest, } from "./operations.js";
10
+ import { optionalField } from "./optional-field.js";
11
+ import { LUCA_API_PLANNER_PROMPT, LUCA_PROMPTS, } from "./prompts.js";
12
+ import { provenanceFields } from "./provenance.js";
13
+ import { LUCA_OPERATION_MANIFEST_RESOURCE, 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
- };
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 left out on purpose.
52
+ * It returns live coach data and keeps the conservative default of no caching
53
+ * 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, inert on stdio rather than wrong, but only the remote handler can be
62
+ * 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";
45
79
  }
46
- function asSuccessResult(operation, result) {
47
- return operation.untrustedContent
48
- ? asUntrustedResult(result)
49
- : asTextResult(result);
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, and 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}`)));
50
93
  }
51
- function asErrorResult(error) {
52
- return {
53
- isError: true,
54
- content: [
55
- {
56
- type: "text",
57
- text: formatError(error),
58
- },
59
- ],
60
- };
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
+ }
61
110
  }
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");
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}`)));
81
119
  }
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
- });
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
+ }));
137
+ }
138
+ /**
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.
141
+ */
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
+ }));
147
+ }
148
+ /** A failed read, answered as a typed error payload the client can render. */
149
+ function failedReadResult(uri, error) {
87
150
  return {
88
151
  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
- },
152
+ contents: [resourceContent(uri, { error: formatError(error) })],
102
153
  };
103
154
  }
104
- function isPaginatablePage(value) {
105
- return typeof value === "object" && value !== null && "nextCursor" in value;
155
+ /** A successful read's contents. */
156
+ function readResult(operation, uri, result) {
157
+ return {
158
+ contents: [resourceContent(uri, resourcePayload(operation, result))],
159
+ };
106
160
  }
107
161
  /**
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.
162
+ * The operation input a resource read runs, recorded as it is built.
163
+ *
164
+ * Only the resource's name is logged. Every variable a template carries is an
165
+ * id the client already typed, and a resource read takes no cursor: each one
166
+ * answers with a whole record or a single page.
111
167
  */
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;
117
- }
118
- function cursorOf(page) {
119
- return typeof page.nextCursor === "string" && page.nextCursor.length > 0
120
- ? page.nextCursor
121
- : undefined;
168
+ function resourceReadInput(resource, variables) {
169
+ logAgentSurfaceEvent("mcp.resources.read", { resourceType: resource.name });
170
+ return resource.buildInput(variables);
122
171
  }
123
172
  /**
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.
173
+ * The payload of a successful read, provenance-tagged when it carries text an
174
+ * external user wrote. The rule itself lives in `provenance.ts`, shared with the
175
+ * tool results, so what "untrusted" attaches cannot drift between the two
176
+ * surfaces that answer with Luca data.
128
177
  */
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
- });
174
- }
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;
184
- }
185
- function logPaginationTruncation(operation, pagesFetched, truncated) {
186
- if (truncated) {
187
- logAgentSurfaceEvent("mcp.pagination.truncated", {
188
- toolName: operation.toolName,
189
- pages: pagesFetched,
190
- });
191
- }
192
- }
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
- }
214
- });
215
- }
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
- });
178
+ function resourcePayload(operation, result) {
179
+ return { result, ...provenanceFields(operation.untrustedContent) };
225
180
  }
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
- }
181
+ /** A read's single JSON content block, in the shape MCP expects. */
182
+ function resourceContent(uri, payload) {
183
+ return {
184
+ uri: uri.href,
185
+ mimeType: "application/json",
186
+ text: toJsonText(payload),
187
+ };
277
188
  }
278
- function registerResources(server) {
279
- server.registerResource("luca-public-operations", "luca://operations", {
280
- title: "Luca Public API Operations",
281
- description: "MCP operation manifest derived from Luca's public API key policy.",
189
+ function registerOperationManifestResource(server) {
190
+ server.registerResource(LUCA_OPERATION_MANIFEST_RESOURCE.name, LUCA_OPERATION_MANIFEST_RESOURCE.uri, {
191
+ title: LUCA_OPERATION_MANIFEST_RESOURCE.title,
192
+ description: LUCA_OPERATION_MANIFEST_RESOURCE.description,
282
193
  mimeType: "application/json",
283
194
  }, async (uri) => ({
284
195
  contents: [
285
196
  {
286
197
  uri: uri.href,
287
198
  mimeType: "application/json",
288
- text: JSON.stringify({
199
+ text: toJsonText({
289
200
  apiDocs: "https://api.setluca.com/docs",
290
201
  openApi: "https://api.setluca.com/openapi.json",
291
202
  groups: operationGroups(),
292
203
  operations: operationManifest(),
293
- }, null, 2),
204
+ }),
294
205
  },
295
206
  ],
296
207
  }));
297
208
  }
298
- function registerPrompts(server) {
299
- server.registerPrompt("luca-api-planner", {
300
- title: "Luca API Planner",
301
- description: "Plan a Luca API workflow using the MCP tools, scopes, and idempotency rules.",
302
- argsSchema: {
303
- task: z
304
- .string()
305
- .optional()
306
- .describe("The Luca API workflow the user wants to perform."),
307
- },
308
- }, ({ task }) => {
209
+ /**
210
+ * A prompt's argument schemas with a completion source attached to every
211
+ * argument that has one.
212
+ *
213
+ * Two details of the SDK shape this. It looks for the completion metadata on
214
+ * the schema *inside* the optional wrapper, not on the wrapper. And
215
+ * `completable` stamps that metadata with a non-configurable property, so a
216
+ * schema can be stamped once and never again, while `LUCA_PROMPTS` is a
217
+ * module singleton the Worker re-registers on every request. Cloning the inner
218
+ * schema first gives each build its own object to stamp.
219
+ */
220
+ function completableArgs(schema, completions) {
221
+ return Object.fromEntries(Object.entries(schema).map(([name, field]) => [
222
+ name,
223
+ isCompletionVariable(name)
224
+ ? field.clone({
225
+ ...field.def,
226
+ innerType: completable(field.def.innerType.clone(), completions[name]),
227
+ })
228
+ : field,
229
+ ]));
230
+ }
231
+ /**
232
+ * A prompt's arguments as a Zod object, or nothing when it takes none. An
233
+ * argument-less prompt registers with no `argsSchema` at all, so a client can
234
+ * invoke it without sending an otherwise-required empty args object.
235
+ */
236
+ function promptArgsSchema(prompt, completions) {
237
+ if (Object.keys(prompt.argsSchema).length === 0) {
238
+ return;
239
+ }
240
+ return z.object(completableArgs(prompt.argsSchema, completions));
241
+ }
242
+ /**
243
+ * Registers the planner prompt plus every prompt in `prompts`.
244
+ *
245
+ * The list is a parameter so a test can drive this with its own prompt instead
246
+ * of mutating the exported `LUCA_PROMPTS` array out from under other tests.
247
+ */
248
+ export function registerPrompts(server, prompts, completions) {
249
+ for (const prompt of [LUCA_API_PLANNER_PROMPT, ...prompts]) {
250
+ registerPrompt(server, prompt, completions);
251
+ }
252
+ }
253
+ function registerPrompt(server, prompt, completions) {
254
+ const config = {
255
+ title: prompt.title,
256
+ description: prompt.description,
257
+ ...optionalField("argsSchema", promptArgsSchema(prompt, completions)),
258
+ };
259
+ server.registerPrompt(prompt.name, config, (args) => {
260
+ // Parse with the prompt's own argsSchema, not a shared guess at one. A
261
+ // schema that does not match what the client was told to send silently
262
+ // drops any argument outside it before `prompt.build` ever sees it.
263
+ const parsedArgs = z.object(prompt.argsSchema).parse(args ?? {});
309
264
  logAgentSurfaceEvent("mcp.prompts.get", {
310
- prompt: "luca-api-planner",
311
- hasGuidance: false,
265
+ prompt: prompt.name,
266
+ // Booleans and the arg name only, never guidance text: `guidance` only
267
+ // exists on one prompt's schema, so this reads it without assuming
268
+ // every prompt shares that field.
269
+ hasGuidance: Boolean(parsedArgs.guidance),
312
270
  });
313
271
  return {
314
272
  messages: [
@@ -316,60 +274,44 @@ function registerPrompts(server) {
316
274
  role: "user",
317
275
  content: {
318
276
  type: "text",
319
- text: [
320
- "Use Luca MCP tools only for public API operations.",
321
- "Do not ask for database access, internal endpoints, webhook ingress secrets, or browser cookies.",
322
- "For writes, pass a stable idempotencyKey when retrying the same user intent.",
323
- "Check luca://operations when you need exact tool names, scopes, and paths.",
324
- task ? `User task: ${task}` : undefined,
325
- ]
326
- .filter(Boolean)
327
- .join("\n"),
277
+ text: prompt.build(parsedArgs),
328
278
  },
329
279
  },
330
280
  ],
331
281
  };
332
282
  });
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 };
343
- server.registerPrompt(prompt.name, config, (args) => {
344
- logAgentSurfaceEvent("mcp.prompts.get", {
345
- prompt: prompt.name,
346
- hasGuidance: Boolean(args?.guidance),
347
- });
348
- return {
349
- messages: [
350
- {
351
- role: "user",
352
- content: {
353
- type: "text",
354
- text: prompt.build((args ?? {})),
355
- },
356
- },
357
- ],
358
- };
359
- });
360
- }
361
283
  }
284
+ /**
285
+ * Build the Luca MCP server: registers the operation tools, the task tools, the
286
+ * `luca://…` resources, and the planner prompt against a shared `LucaApi` layer.
287
+ * `options.lucaLayer` swaps the HTTP client (tests inject an in-memory handler);
288
+ * `options.toolset` picks the tool set. `"tasks"` exposes only the task tools
289
+ * plus capability discovery, and `"full"`, the default, exposes everything.
290
+ */
362
291
  export function createLucaServer(options = {}) {
363
292
  const lucaLayer = options.lucaLayer ?? LucaLive;
364
- const server = new McpServer({
365
- name: "luca-mcp",
366
- version: "0.1.0",
293
+ // The default value is only ever compared against `"tasks"` below; any other
294
+ // string (including `""`) takes the full-surface branch, so the literal here
295
+ // cannot change behavior.
296
+ // Stryker disable next-line StringLiteral: only `"tasks"` is compared downstream, so any non-tasks default behaves as full
297
+ const toolset = options.toolset ?? "full";
298
+ const server = new McpServer(LUCA_SERVER_INFO, {
299
+ cacheHints: LUCA_CACHE_HINTS,
367
300
  });
368
- for (const operation of LUCA_OPERATIONS) {
369
- registerLucaOperation(server, operation, lucaLayer);
301
+ // The tasks set carries one operation, capability discovery, so a client can
302
+ // still learn what the workspace supports without the full tool list.
303
+ const operations = toolset === "tasks"
304
+ ? [operationByIdOrThrow("capabilities.get")]
305
+ : LUCA_OPERATIONS;
306
+ for (const operation of operations) {
307
+ registerTool(server, fromOperation(operation), lucaLayer, toolset);
308
+ }
309
+ for (const tool of LUCA_TASK_TOOLS) {
310
+ registerTool(server, fromTaskTool(tool, operationByIdOrThrow), lucaLayer, toolset);
370
311
  }
371
- registerResources(server);
372
- registerLucaResources(server, lucaLayer);
373
- registerPrompts(server);
312
+ registerOperationManifestResource(server);
313
+ const completions = completionCallbacks(operationByIdOrThrow, lucaLayer);
314
+ registerLucaResources(server, lucaLayer, completions);
315
+ registerPrompts(server, LUCA_PROMPTS, completions);
374
316
  return server;
375
317
  }