@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/dist/server.js
CHANGED
|
@@ -1,281 +1,190 @@
|
|
|
1
|
-
import { McpServer, ResourceTemplate, } from "@modelcontextprotocol/
|
|
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 {
|
|
10
|
-
import {
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
119
|
-
return
|
|
120
|
-
|
|
121
|
-
:
|
|
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
|
-
*
|
|
125
|
-
*
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
/**
|
|
176
|
-
function
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
/**
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
227
|
-
function
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
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:
|
|
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
|
-
}
|
|
202
|
+
}),
|
|
294
203
|
},
|
|
295
204
|
],
|
|
296
205
|
}));
|
|
297
206
|
}
|
|
298
|
-
|
|
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
|
-
}, (
|
|
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
|
|
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
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
}
|