@setluca/mcp 0.2.0 → 0.3.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 (72) hide show
  1. package/CHANGELOG.md +426 -0
  2. package/LICENSE +21 -0
  3. package/README.md +19 -258
  4. package/dist/annotations.js +35 -20
  5. package/dist/client-address.js +64 -0
  6. package/dist/completions.js +44 -27
  7. package/dist/config.js +89 -38
  8. package/dist/errors.js +99 -22
  9. package/dist/fields.js +68 -0
  10. package/dist/fixed-window.js +46 -0
  11. package/dist/generated/openapi-inputs.js +216 -5820
  12. package/dist/generated/openapi-outputs.js +19014 -14001
  13. package/dist/generated/route-catalog.js +80 -0
  14. package/dist/http.js +108 -44
  15. package/dist/index.js +4 -1
  16. package/dist/oauth-challenge.js +74 -0
  17. package/dist/oauth-resolver.js +58 -61
  18. package/dist/observability.js +31 -16
  19. package/dist/openapi-schema.js +175 -0
  20. package/dist/operations/catalog.js +17 -0
  21. package/dist/operations/groups/analytics.js +20 -10
  22. package/dist/operations/groups/bookings.js +43 -15
  23. package/dist/operations/groups/broadcasts.js +29 -58
  24. package/dist/operations/groups/cadences.js +3 -1
  25. package/dist/operations/groups/callEvents.js +19 -72
  26. package/dist/operations/groups/campaigns.js +28 -32
  27. package/dist/operations/groups/channels.js +23 -0
  28. package/dist/operations/groups/conversations.js +14 -25
  29. package/dist/operations/groups/insights.js +4 -0
  30. package/dist/operations/groups/integrations.js +15 -17
  31. package/dist/operations/groups/knowledge.js +5 -1
  32. package/dist/operations/groups/leads.js +22 -23
  33. package/dist/operations/groups/learning.js +13 -1
  34. package/dist/operations/groups/memory.js +4 -6
  35. package/dist/operations/groups/reports.js +1 -1
  36. package/dist/operations/groups/reviewQueue.js +17 -14
  37. package/dist/operations/groups/safety.js +1 -1
  38. package/dist/operations/groups/voice.js +0 -1
  39. package/dist/operations/groups/webhooks.js +16 -4
  40. package/dist/operations/manifest.js +9 -8
  41. package/dist/operations/registry.js +110 -116
  42. package/dist/operations.js +6 -5
  43. package/dist/optional-field.js +15 -3
  44. package/dist/page-contract.js +74 -0
  45. package/dist/pagination.js +149 -68
  46. package/dist/prompts.js +139 -88
  47. package/dist/provenance.js +2 -2
  48. package/dist/rate-limit.js +40 -0
  49. package/dist/remote.js +145 -65
  50. package/dist/resilience.js +120 -55
  51. package/dist/resolvers.js +125 -0
  52. package/dist/resources.js +17 -2
  53. package/dist/scopes.js +207 -0
  54. package/dist/serialization.js +32 -22
  55. package/dist/server.js +174 -191
  56. package/dist/standard-schema.js +215 -0
  57. package/dist/task-tools/analytics.js +145 -86
  58. package/dist/task-tools/bookings.js +176 -86
  59. package/dist/task-tools/cadences.js +30 -45
  60. package/dist/task-tools/calls.js +132 -125
  61. package/dist/task-tools/inbox.js +89 -53
  62. package/dist/task-tools/leads.js +49 -77
  63. package/dist/task-tools/runtime.js +80 -20
  64. package/dist/task-tools.js +1 -1
  65. package/dist/token-verification.js +42 -0
  66. package/dist/tool-cache.js +3 -2
  67. package/dist/tool.js +155 -100
  68. package/dist/version.js +1 -1
  69. package/package.json +40 -18
  70. package/dist/openapi-zod.js +0 -333
  71. package/dist/page-shape.js +0 -47
  72. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,74 @@
1
+ import * as HashMap from "effect/HashMap";
2
+ import * as Option from "effect/Option";
3
+ import * as R from "effect/Record";
4
+ import { OPENAPI_CURSOR_QUERY_PARAMS } from "./generated/openapi-inputs.js";
5
+ import { OPENAPI_OUTPUTS } from "./generated/openapi-outputs.js";
6
+ const CURSOR_QUERY_PARAMS_BY_ROUTE = HashMap.fromIterable(R.toEntries(OPENAPI_CURSOR_QUERY_PARAMS));
7
+ const OUTPUTS_BY_ROUTE = HashMap.fromIterable(R.toEntries(OPENAPI_OUTPUTS));
8
+ /**
9
+ * The records field for a page whose body declares more than one array. Every
10
+ * other paging route declares exactly one, so it needs no entry here.
11
+ */
12
+ // oxlint-disable-next-line anti-slop/no-known-value-widening -- looked up by a route string built at runtime, so the keys must stay open
13
+ const ITEMS_KEY_BY_ROUTE = {
14
+ // `availableChannels` is the filter's option list, not a page of records.
15
+ "GET /api/conversations": "conversations",
16
+ };
17
+ /**
18
+ * The array field a page carries its records in, absent when the body hands
19
+ * back no cursor or declares no array.
20
+ *
21
+ * A body with one array has an obvious answer. A body with more than one needs
22
+ * `declared` to name it: guessing by declaration order would start merging the
23
+ * wrong array the day the API adds a field above the records. An undeclared
24
+ * tie, or a declared key the body does not carry as an array, throws while the
25
+ * catalog is built, so the mistake fails every test instead of reaching a
26
+ * client.
27
+ */
28
+ export function itemsKeyOf(body, declared) {
29
+ if (body?.type !== "object" || body.properties?.nextCursor === undefined) {
30
+ return;
31
+ }
32
+ const arrays = R.toEntries(body.properties)
33
+ .filter(([key, schema]) => key !== "nextCursor" && schema?.type === "array")
34
+ .map(([key]) => key);
35
+ if (declared !== undefined) {
36
+ if (!arrays.includes(declared)) {
37
+ // oxlint-disable-next-line effect/avoid-untagged-errors -- a stale catalog entry is a programming error, not a runtime failure
38
+ throw new Error(`Page items key "${declared}" is not an array field of this response (arrays: ${arrays.join(", ") || "none"}).`);
39
+ }
40
+ return declared;
41
+ }
42
+ if (arrays.length > 1) {
43
+ // oxlint-disable-next-line effect/avoid-untagged-errors -- an ambiguous page shape is a programming error, not a runtime failure
44
+ throw new Error(`A paged response declares several arrays (${arrays.join(", ")}). Name its records field in ITEMS_KEY_BY_ROUTE.`);
45
+ }
46
+ return arrays[0];
47
+ }
48
+ /**
49
+ * The page shape of a route, absent when the route does not paginate.
50
+ *
51
+ * Walking a list needs both halves: a body that hands back a cursor and an
52
+ * array to merge, and a query field to send the cursor in. Guessing from the
53
+ * operation id used to answer this, and was wrong fourteen times: twelve routes
54
+ * that answer with a plain array and no cursor, and two that paginate under an
55
+ * id that does not end in `.list`.
56
+ */
57
+ export function pageContractFor(method, path) {
58
+ const route = `${method} ${path}`;
59
+ return pageContractOf(Option.getOrUndefined(HashMap.get(CURSOR_QUERY_PARAMS_BY_ROUTE, route)), Option.getOrUndefined(HashMap.get(OUTPUTS_BY_ROUTE, route)), ITEMS_KEY_BY_ROUTE[route]);
60
+ }
61
+ /**
62
+ * The page shape a cursor query field and a response schema describe, absent
63
+ * when either half is missing. Split from {@link pageContractFor} so both halves can be
64
+ * withheld independently: the manifest happens to carry no route that takes a
65
+ * cursor and answers without one, and that asymmetry is a fact about today's
66
+ * API rather than a rule this function may assume.
67
+ */
68
+ export function pageContractOf(cursorQueryParam, body, declaredItemsKey) {
69
+ const itemsKey = itemsKeyOf(body, declaredItemsKey);
70
+ if (cursorQueryParam === undefined || itemsKey === undefined) {
71
+ return;
72
+ }
73
+ return { cursorQueryParam, itemsKey };
74
+ }
@@ -1,127 +1,195 @@
1
- import { Effect, Option, Record } from "effect";
2
- import * as z from "zod/v4";
1
+ import * as Arr from "effect/Array";
2
+ import * as Duration from "effect/Duration";
3
+ import * as Effect from "effect/Effect";
4
+ import * as HashSet from "effect/HashSet";
5
+ import * as Option from "effect/Option";
6
+ import * as R from "effect/Record";
7
+ import * as Ref from "effect/Ref";
8
+ import * as Result from "effect/Result";
9
+ import * as Schema from "effect/Schema";
10
+ import { formatError, isAuthorizationFailure, } from "./errors.js";
11
+ import { NonEmptyString } from "./fields.js";
3
12
  import { LucaApi } from "./http.js";
4
13
  import { logAgentSurfaceEvent } from "./observability.js";
5
14
  import { DEFAULT_MAX_PAGES, } from "./operations.js";
6
- import { JsonValueSchema, JsonObjectSchema, } from "./serialization.js";
7
- const JsonArraySchema = z.array(JsonValueSchema);
15
+ import { optionalField } from "./optional-field.js";
16
+ import { isJsonObject, JsonValue } from "./serialization.js";
17
+ const decodeItems = Schema.decodeUnknownOption(Schema.Array(JsonValue));
18
+ const isCursor = Schema.is(NonEmptyString);
8
19
  function pageItems(page, itemsKey) {
9
- const parsed = JsonArraySchema.safeParse(page[itemsKey]);
10
- return parsed.success ? parsed.data : [];
20
+ return Option.getOrElse(decodeItems(page[itemsKey]), () => []);
11
21
  }
12
22
  /**
13
- * The page as something the walk can follow, or nothing when the body is not an
14
- * object or declares no cursor. An `Option` rather than a nullable, because both
15
- * call sites below branch on it and would otherwise each have to lift it first.
23
+ * The page as something the walk can merge, or nothing when the body is not an
24
+ * object or does not carry the array the route's page shape names. Merging a
25
+ * shape the paginator cannot read would corrupt it.
16
26
  */
17
- function parsePaginatablePage(value) {
18
- const parsed = JsonObjectSchema.safeParse(value);
19
- if (!parsed.success) {
20
- return Option.none();
21
- }
22
- const nextCursor = parsed.data.nextCursor;
23
- if (nextCursor === undefined) {
24
- return Option.none();
25
- }
26
- return Option.some({ ...parsed.data, nextCursor });
27
+ function readablePage(value, itemsKey) {
28
+ return isJsonObject(value) && Array.isArray(value[itemsKey])
29
+ ? Option.some(value)
30
+ : Option.none();
27
31
  }
28
32
  /**
29
- * Whether a page actually carries the array the route's page shape names. A
30
- * body that does not is handed back untouched rather than merged, because
31
- * aggregating a shape the paginator cannot read would corrupt it.
33
+ * The cursor to the page after this one. A page that leaves `nextCursor` out,
34
+ * or sends it null or empty, is the last page.
32
35
  */
33
- function carriesItems(page, itemsKey) {
34
- return Array.isArray(page[itemsKey]);
35
- }
36
36
  function cursorOf(page) {
37
- const parsed = z.string().min(1).safeParse(page.nextCursor);
38
- return parsed.success ? parsed.data : undefined;
37
+ const cursor = page.nextCursor;
38
+ return isCursor(cursor) ? cursor : undefined;
39
39
  }
40
- function walkOf(api, operation, pageShape, baseRequest, maxPages) {
40
+ /**
41
+ * The longest one walk may take across all of its pages. Each request is bound
42
+ * on its own by the transport, so this caps the sum: a slow API cannot hold a
43
+ * tool call open for ten pages at that limit each.
44
+ */
45
+ const WALK_BUDGET_MS = 120_000;
46
+ function walkOf(api, operation, pageContract, baseRequest, maxPages) {
41
47
  return {
42
- itemsKey: pageShape.itemsKey,
48
+ itemsKey: pageContract.itemsKey,
43
49
  maxPages,
44
50
  nextPage: (cursor) => api.request({
45
51
  operation,
46
52
  ...baseRequest,
47
53
  query: {
48
54
  ...baseRequest.query,
49
- [pageShape.cursorQueryParam]: cursor,
55
+ [pageContract.cursorQueryParam]: cursor,
50
56
  },
51
57
  }),
52
58
  };
53
59
  }
54
60
  /**
55
61
  * Follows `nextCursor` from the first page onward, merging each page's items
56
- * until the cursor runs out or the `maxPages` guard is hit. Kept as its own
57
- * Effect generator so the aggregation loop's branching lives apart from the
58
- * outer program's first-page setup and result assembly.
62
+ * until the cursor runs out, the `maxPages` guard is hit, or the walk's time
63
+ * budget is spent. Kept as its own Effect generator so the aggregation loop's
64
+ * branching lives apart from the outer program's first-page setup and result
65
+ * assembly.
66
+ *
67
+ * The state lives in a `Ref` so the budget can cut the loop off mid-page and
68
+ * still hand back the pages collected so far.
59
69
  */
60
70
  function collectRemainingPages(walk, first) {
61
71
  const { itemsKey } = walk;
72
+ const firstCursor = cursorOf(first);
62
73
  const initial = {
63
74
  items: pageItems(first, itemsKey),
64
- cursor: cursorOf(first),
75
+ cursor: firstCursor,
65
76
  pagesFetched: 1,
66
- // A page we cannot read stops the walk while keeping the cursor that led to
67
- // it, so the caller still reports the result as truncated and hands the
68
- // client a cursor to continue from.
69
- stopped: false,
77
+ seen: HashSet.fromIterable(firstCursor === undefined ? [] : [firstCursor]),
78
+ halt: undefined,
70
79
  };
71
- return Effect.iterate(initial, {
72
- while: (state) => !state.stopped &&
73
- state.cursor !== undefined &&
74
- state.pagesFetched < walk.maxPages,
75
- body: (state) => walk
76
- .nextPage(state.cursor)
77
- .pipe(Effect.map((page) => foldPage(state, page, itemsKey))),
80
+ return Ref.make(initial).pipe(Effect.flatMap((ref) => walkPages(walk, ref).pipe(Effect.timeoutOrElse({
81
+ duration: Duration.millis(WALK_BUDGET_MS),
82
+ orElse: () => Ref.get(ref).pipe(Effect.map((state) => haltWalk(state, {
83
+ reason: "time_budget_exceeded",
84
+ what: `the ${WALK_BUDGET_MS / 1000}s time budget for walking pages ran out before page ${state.pagesFetched + 1} arrived`,
85
+ continuable: true,
86
+ }))),
87
+ }))));
88
+ }
89
+ /** The page-by-page loop, writing each step to `ref` so a cut-off keeps it. */
90
+ function walkPages(walk, ref) {
91
+ return Effect.gen(function* () {
92
+ // oxlint-disable-next-line effect/imperative-loops -- condition-driven loop over a mutable cursor; not a collection transform
93
+ while (true) {
94
+ const state = yield* Ref.get(ref);
95
+ if (state.halt !== undefined ||
96
+ state.cursor === undefined ||
97
+ state.pagesFetched >= walk.maxPages) {
98
+ return state;
99
+ }
100
+ // A failed later page must not throw away the pages already fetched.
101
+ // The result says it is partial and carries the cursor to retry from.
102
+ const page = yield* Effect.result(walk.nextPage(state.cursor));
103
+ // A refused token or a missing scope fails the call instead. A partial
104
+ // list would read as "page 3 is down", and only a failed tool result
105
+ // carries the challenge that sends the client back through sign-in.
106
+ if (Result.isFailure(page) && isAuthorizationFailure(page.failure)) {
107
+ return yield* Effect.fail(page.failure);
108
+ }
109
+ yield* Ref.set(ref, Result.match(page, {
110
+ onFailure: (error) => failedPage(state, formatError(error)),
111
+ onSuccess: (body) => foldPage(state, body, walk.itemsKey),
112
+ }));
113
+ }
114
+ });
115
+ }
116
+ function failedPage(state, reason) {
117
+ return haltWalk(state, {
118
+ reason,
119
+ what: `page ${state.pagesFetched + 1} failed (${reason})`,
120
+ continuable: true,
78
121
  });
79
122
  }
80
123
  /**
81
124
  * One more page folded into the walk's accumulated state, or a halt when the
82
- * page came back in a shape the paginator cannot read.
125
+ * page came back in a shape the paginator cannot read or broke the cursor
126
+ * chain.
83
127
  */
84
128
  function foldPage(state, page, itemsKey) {
85
- return parsePaginatablePage(page).pipe(Option.match({
86
- onNone: () => haltWalk(state),
129
+ return readablePage(page, itemsKey).pipe(Option.match({
130
+ onNone: () => haltWalk(state, {
131
+ reason: "unreadable_page",
132
+ what: `page ${state.pagesFetched + 1} came back in a shape that could not be read (unreadable_page)`,
133
+ continuable: true,
134
+ }),
87
135
  onSome: (readable) => mergePage(state, readable, itemsKey),
88
136
  }));
89
137
  }
90
138
  /** The walk stopped where it stands, keeping the cursor that led to the halt. */
91
- function haltWalk(state) {
92
- return {
93
- items: state.items,
94
- cursor: state.cursor,
95
- pagesFetched: state.pagesFetched,
96
- stopped: true,
97
- };
139
+ function haltWalk(state, halt) {
140
+ return { ...state, halt };
98
141
  }
99
- /** The walk carried one page further. */
142
+ /**
143
+ * The walk carried one page further. A server that answers an empty page with a
144
+ * cursor, or hands back a cursor it already sent, would have the walk spin until
145
+ * `maxPages`, so either one halts it instead.
146
+ */
100
147
  function mergePage(state, page, itemsKey) {
101
- return {
102
- items: [...state.items, ...pageItems(page, itemsKey)],
103
- cursor: cursorOf(page),
148
+ const items = pageItems(page, itemsKey);
149
+ const cursor = cursorOf(page);
150
+ const advanced = {
151
+ items: [...state.items, ...items],
152
+ cursor,
104
153
  pagesFetched: state.pagesFetched + 1,
105
- stopped: false,
154
+ seen: cursor === undefined ? state.seen : HashSet.add(state.seen, cursor),
155
+ halt: undefined,
106
156
  };
157
+ if (cursor === undefined) {
158
+ return advanced;
159
+ }
160
+ if (HashSet.has(state.seen, cursor)) {
161
+ return haltWalk(advanced, {
162
+ reason: "repeated_cursor",
163
+ what: `page ${advanced.pagesFetched} sent back a cursor the walk had already followed (repeated_cursor)`,
164
+ continuable: false,
165
+ });
166
+ }
167
+ return Arr.isReadonlyArrayNonEmpty(items)
168
+ ? advanced
169
+ : haltWalk(advanced, {
170
+ reason: "empty_page_with_cursor",
171
+ what: `page ${advanced.pagesFetched} was empty but offered another cursor (empty_page_with_cursor)`,
172
+ continuable: false,
173
+ });
107
174
  }
108
175
  /**
109
176
  * Bounded server-side auto-pagination for list tools. Follows nextCursor up to
110
177
  * maxPages, merging the items array, then returns a single aggregated result.
111
178
  * If more pages remain past the guard, the result is explicitly marked
112
- * `truncated` with a `nextCursor` to continue — never a silent partial result.
179
+ * `truncated` and carries a `nextCursor` to continue from, so a partial result
180
+ * always says it is one.
113
181
  */
114
- export function paginatedProgram(operation, pageShape, baseRequest, requestedMaxPages) {
182
+ export function paginatedProgram(operation, pageContract, baseRequest, requestedMaxPages) {
115
183
  return Effect.gen(function* () {
116
184
  const api = yield* LucaApi;
117
- const walk = walkOf(api, operation, pageShape, baseRequest, requestedMaxPages ?? DEFAULT_MAX_PAGES);
185
+ const walk = walkOf(api, operation, pageContract, baseRequest, requestedMaxPages ?? DEFAULT_MAX_PAGES);
118
186
  const first = yield* api.request({ operation, ...baseRequest });
119
- const walkable = parsePaginatablePage(first).pipe(Option.filter((page) => carriesItems(page, pageShape.itemsKey)));
187
+ // A first page that carries no `nextCursor` key at all, or none of the
188
+ // array its page shape names, is returned exactly as the API sent it.
189
+ const walkable = readablePage(first, pageContract.itemsKey).pipe(Option.filter((page) => "nextCursor" in page));
120
190
  return yield* Option.match(walkable, {
121
- // A response that carries no cursor, or none of the array its page shape
122
- // names, is returned exactly as the API sent it.
123
191
  onNone: () => Effect.succeed(first),
124
- onSome: (page) => collectRemainingPages(walk, page).pipe(Effect.map((collected) => aggregatePages(operation, page, pageShape.itemsKey, collected))),
192
+ onSome: (page) => collectRemainingPages(walk, page).pipe(Effect.map((collected) => aggregatePages(operation, page, pageContract.itemsKey, collected))),
125
193
  });
126
194
  });
127
195
  }
@@ -130,6 +198,11 @@ function aggregatePages(operation, first, itemsKey, collected) {
130
198
  const truncated = collected.cursor !== undefined;
131
199
  logPaginationTruncation(operation, collected.pagesFetched, truncated);
132
200
  return {
201
+ // Placed first so it leads the JSON text a model reads. A list that stopped
202
+ // on a failed page must not read as the whole list.
203
+ ...optionalField("warning", collected.halt === undefined
204
+ ? undefined
205
+ : incompleteWarning(collected, collected.halt, itemsKey)),
133
206
  // Preserve every non-item, non-cursor top-level field from the first page
134
207
  // (e.g. analytics totals on comment-event lists). The first page's own
135
208
  // `nextCursor` is dropped in favor of the aggregated `pagination` below,
@@ -140,16 +213,24 @@ function aggregatePages(operation, first, itemsKey, collected) {
140
213
  pagesFetched: collected.pagesFetched,
141
214
  truncated,
142
215
  nextCursor: collected.cursor ?? null,
216
+ ...optionalField("error", collected.halt?.reason),
143
217
  },
144
218
  };
145
219
  }
220
+ /** What a caller reads first when the walk stopped early. */
221
+ function incompleteWarning(collected, halt, itemsKey) {
222
+ const continueFrom = collected.cursor === undefined || !halt.continuable
223
+ ? ""
224
+ : ` Call again with cursor "${collected.cursor}" to fetch the rest.`;
225
+ return `INCOMPLETE LIST: ${halt.what}. \`${itemsKey}\` holds only the ${collected.items.length} items from the first ${collected.pagesFetched} page(s); do not treat it as the full list.${continueFrom}`;
226
+ }
146
227
  /** Top-level first-page fields to carry through, minus items and nextCursor. */
147
228
  function passthroughFields(first, itemsKey) {
148
229
  // Dropping the `key !== itemsKey` guard is unobservable: the sole caller
149
230
  // spreads this result and then re-assigns `[itemsKey]: items` immediately
150
231
  // after (see the aggregation assembly), so any items value copied through here
151
232
  // is overwritten before it can reach the output.
152
- return Record.filter(first,
233
+ return R.filter(first,
153
234
  // Stryker disable next-line ConditionalExpression: the items key is overwritten by the caller, so excluding it here changes nothing
154
235
  (_value, key) => key !== itemsKey && key !== "nextCursor");
155
236
  }