@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.
- package/CHANGELOG.md +110 -0
- package/README.md +178 -80
- package/dist/annotations.js +45 -0
- package/dist/completions.js +110 -0
- package/dist/config.js +55 -29
- package/dist/errors.js +56 -15
- 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 +97 -103
- package/dist/index.js +16 -10
- package/dist/oauth-resolver.js +59 -37
- package/dist/observability.js +12 -4
- package/dist/openapi-zod.js +138 -72
- 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 +62 -0
- package/dist/pagination.js +165 -0
- package/dist/prompts.js +89 -8
- package/dist/provenance.js +18 -2
- package/dist/remote.js +80 -49
- package/dist/resilience.js +127 -0
- package/dist/resources.js +17 -2
- package/dist/scopes.js +154 -0
- package/dist/serialization.js +36 -0
- package/dist/server.js +258 -316
- package/dist/task-tools/analytics.js +168 -0
- package/dist/task-tools/bookings.js +157 -0
- package/dist/task-tools/cadences.js +65 -0
- package/dist/task-tools/calls.js +202 -0
- package/dist/task-tools/inbox.js +120 -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 +38 -34
package/dist/server.js
CHANGED
|
@@ -1,314 +1,272 @@
|
|
|
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 {
|
|
11
|
-
import {
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
109
|
-
*
|
|
110
|
-
* the
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
279
|
-
server.registerResource(
|
|
280
|
-
title:
|
|
281
|
-
description:
|
|
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:
|
|
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
|
-
}
|
|
204
|
+
}),
|
|
294
205
|
},
|
|
295
206
|
],
|
|
296
207
|
}));
|
|
297
208
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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:
|
|
311
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
}
|