@setluca/mcp 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +110 -42
  2. package/dist/annotations.js +45 -0
  3. package/dist/completions.js +90 -0
  4. package/dist/config.js +55 -29
  5. package/dist/errors.js +59 -14
  6. package/dist/generated/openapi-inputs.js +4324 -1429
  7. package/dist/generated/openapi-outputs.js +21992 -8516
  8. package/dist/generated/route-catalog.js +1480 -0
  9. package/dist/http.js +95 -103
  10. package/dist/index.js +16 -10
  11. package/dist/oauth-resolver.js +58 -36
  12. package/dist/observability.js +11 -3
  13. package/dist/openapi-zod.js +129 -65
  14. package/dist/operations/catalog.js +50 -0
  15. package/dist/operations/groups/analytics.js +122 -0
  16. package/dist/operations/groups/bookings.js +189 -0
  17. package/dist/operations/groups/broadcasts.js +106 -0
  18. package/dist/operations/groups/cadences.js +14 -0
  19. package/dist/operations/groups/callEvents.js +131 -0
  20. package/dist/operations/groups/campaigns.js +132 -0
  21. package/dist/operations/groups/capabilities.js +8 -0
  22. package/dist/operations/groups/coach.js +43 -0
  23. package/dist/operations/groups/conversations.js +48 -0
  24. package/dist/operations/groups/insights.js +33 -0
  25. package/dist/operations/groups/integrations.js +80 -0
  26. package/dist/operations/groups/knowledge.js +34 -0
  27. package/dist/operations/groups/leads.js +101 -0
  28. package/dist/operations/groups/learning.js +24 -0
  29. package/dist/operations/groups/memory.js +27 -0
  30. package/dist/operations/groups/reports.js +14 -0
  31. package/dist/operations/groups/reviewQueue.js +57 -0
  32. package/dist/operations/groups/safety.js +8 -0
  33. package/dist/operations/groups/usage.js +13 -0
  34. package/dist/operations/groups/voice.js +14 -0
  35. package/dist/operations/groups/webhooks.js +76 -0
  36. package/dist/operations/manifest.js +40 -0
  37. package/dist/operations/registry.js +331 -0
  38. package/dist/operations.js +11 -780
  39. package/dist/optional-field.js +12 -0
  40. package/dist/page-shape.js +47 -0
  41. package/dist/pagination.js +164 -0
  42. package/dist/prompts.js +63 -8
  43. package/dist/provenance.js +18 -2
  44. package/dist/remote.js +80 -49
  45. package/dist/resilience.js +117 -0
  46. package/dist/serialization.js +36 -0
  47. package/dist/server.js +256 -281
  48. package/dist/task-tools/analytics.js +168 -0
  49. package/dist/task-tools/bookings.js +154 -0
  50. package/dist/task-tools/cadences.js +65 -0
  51. package/dist/task-tools/calls.js +202 -0
  52. package/dist/task-tools/inbox.js +74 -0
  53. package/dist/task-tools/leads.js +123 -0
  54. package/dist/task-tools/runtime.js +92 -0
  55. package/dist/task-tools.js +21 -0
  56. package/dist/tool-cache.js +39 -0
  57. package/dist/tool.js +228 -0
  58. package/dist/tsconfig.tsbuildinfo +1 -1
  59. package/dist/version.js +14 -0
  60. package/package.json +36 -33
package/dist/http.js CHANGED
@@ -1,103 +1,100 @@
1
- import { Context, Effect, Layer } from "effect";
2
- import { LucaConfig } from "./config.js";
1
+ import { Context, Effect, Layer, Match, Option } from "effect";
2
+ import * as z from "zod/v4";
3
+ import { DEFAULT_REQUEST_TIMEOUT_MS, LucaConfig } from "./config.js";
3
4
  import { LucaDecodeError, LucaHttpError, LucaNetworkError, } from "./errors.js";
5
+ import { optionalField } from "./optional-field.js";
6
+ import { resilient } from "./resilience.js";
7
+ import { fromJson, toJsonPayload } from "./serialization.js";
8
+ import { LUCA_MCP_USER_AGENT } from "./version.js";
4
9
  export const LucaApi = Context.GenericTag("@luca/mcp/LucaApi");
5
10
  function isPresent(value) {
6
11
  return value !== undefined && value !== null && value !== "";
7
12
  }
8
13
  function encodePath(path, params) {
9
- return path.replace(/\{([^}]+)\}/g, (_, key) => {
10
- const value = params?.[key];
11
- if (!value) {
12
- throw new Error(`Missing path parameter: ${key}`);
13
- }
14
- return encodeURIComponent(value);
15
- });
14
+ return path.replaceAll(/\{([^}]+)\}/g, (_, key) => Option.fromNullable(params?.[key]).pipe(Option.filter((value) => value.length > 0), Option.map(encodeURIComponent), Option.getOrThrowWith(() => new Error(`Missing path parameter: ${key}`))));
16
15
  }
17
16
  function appendQuery(url, query) {
18
- if (!query) {
19
- return;
20
- }
21
- for (const [key, value] of Object.entries(query)) {
22
- if (Array.isArray(value)) {
23
- for (const item of value) {
24
- if (isPresent(item)) {
25
- url.searchParams.append(key, String(item));
26
- }
27
- }
28
- }
29
- else if (isPresent(value)) {
30
- url.searchParams.set(key, String(value));
17
+ for (const [key, value] of Object.entries(query ?? {})) {
18
+ // A scalar is a one-element list here, so array and single-value params take
19
+ // the same path: append every value the caller actually set.
20
+ const values = Array.isArray(value) ? value : [value];
21
+ for (const item of values.filter(isPresent)) {
22
+ url.searchParams.append(key, String(item));
31
23
  }
32
24
  }
33
25
  }
34
- function parseJson(text) {
35
- if (!text) {
36
- return null;
37
- }
38
- try {
39
- return JSON.parse(text);
40
- }
41
- catch {
42
- throw new LucaDecodeError({
43
- message: "Luca API returned non-JSON content",
44
- body: text,
45
- });
46
- }
47
- }
48
26
  function buildUrl(config, input) {
49
27
  const path = encodePath(input.operation.path, input.pathParams);
50
28
  const url = new URL(path, `${config.apiBaseUrl}/`);
51
29
  appendQuery(url, input.query);
52
30
  return url;
53
31
  }
54
- function buildHeaders(config, input) {
55
- const headers = new Headers({
56
- accept: "application/json",
57
- "user-agent": "luca-mcp/0.1.0",
58
- });
59
- setAuthHeaders(headers, config);
60
- setWorkspaceHeaders(headers, config, input);
61
- setPayloadHeaders(headers, input);
62
- return headers;
32
+ /** The API key, under whichever header this config authenticates with. */
33
+ function authFields(config) {
34
+ return Match.value(config.authHeader).pipe(Match.when("authorization", () => ({
35
+ authorization: `Bearer ${config.apiKey}`,
36
+ })), Match.orElse(() => ({ "x-api-key": config.apiKey })));
63
37
  }
64
- function setAuthHeaders(headers, config) {
65
- if (config.authHeader === "authorization") {
66
- headers.set("authorization", `Bearer ${config.apiKey}`);
38
+ const RETRY_AFTER_MS_PER_SECOND = 1000;
39
+ /**
40
+ * The wait a `Retry-After` header asks for, in milliseconds. The header comes
41
+ * in two forms — delta-seconds and an HTTP date — and both are read here so the
42
+ * retry rules never have to look at a header again. An unreadable value is the
43
+ * same as no header: the client falls back to its own backoff.
44
+ */
45
+ function retryAfterMs(response, now) {
46
+ const header = response.headers.get("retry-after")?.trim();
47
+ if (!header) {
48
+ return;
67
49
  }
68
- else {
69
- headers.set("x-api-key", config.apiKey);
50
+ const seconds = z.coerce.number().int().min(0).safeParse(header);
51
+ if (seconds.success) {
52
+ return seconds.data * RETRY_AFTER_MS_PER_SECOND;
70
53
  }
54
+ const at = Date.parse(header);
55
+ return Number.isNaN(at) ? undefined : Math.max(0, at - now);
71
56
  }
72
- function setWorkspaceHeaders(headers, config, input) {
73
- const workspaceId = input.workspace?.workspaceId ?? config.workspaceId;
74
- const workspaceSlug = input.workspace?.workspaceSlug ?? config.workspaceSlug;
75
- if (workspaceId) {
76
- headers.set("x-luca-workspace-id", workspaceId);
77
- }
78
- if (workspaceSlug) {
79
- headers.set("x-luca-workspace-slug", workspaceSlug);
80
- }
57
+ /** The JSON payload of a request; absent for a GET or a bodiless call. */
58
+ function requestBody(input) {
59
+ return Option.liftPredicate(input, (candidate) => candidate.operation.method !== "GET" && candidate.body !== undefined).pipe(Option.map((candidate) => toJsonPayload(candidate.body)));
81
60
  }
82
- function setPayloadHeaders(headers, input) {
83
- const hasBody = input.operation.method !== "GET" && input.body !== undefined;
84
- if (hasBody) {
85
- headers.set("content-type", "application/json");
86
- }
87
- if (input.operation.idempotencyRequired) {
88
- headers.set("idempotency-key", input.idempotencyKey ?? crypto.randomUUID());
89
- }
90
- }
91
- function buildRequestInit(input, headers) {
92
- const hasBody = input.operation.method !== "GET" && input.body !== undefined;
61
+ function buildRequestInit(config, input, requestIdempotencyKey) {
62
+ // The body decides its own content-type header, so both are read from the one
63
+ // value rather than from two independent recomputations of "does this have a
64
+ // body" that could drift apart.
65
+ const body = requestBody(input);
66
+ const headers = new Headers({
67
+ accept: "application/json",
68
+ "user-agent": LUCA_MCP_USER_AGENT,
69
+ ...authFields(config),
70
+ ...optionalField("x-luca-workspace-id", input.workspace?.workspaceId ?? config.workspaceId),
71
+ ...optionalField("x-luca-workspace-slug", input.workspace?.workspaceSlug ?? config.workspaceSlug),
72
+ ...optionalField("content-type", Option.getOrUndefined(Option.map(body, () => "application/json"))),
73
+ ...optionalField("idempotency-key", requestIdempotencyKey),
74
+ });
93
75
  return {
94
76
  method: input.operation.method,
95
77
  headers,
96
- ...(hasBody ? { body: JSON.stringify(input.body) } : {}),
78
+ ...optionalField("body", Option.getOrUndefined(body)),
97
79
  };
98
80
  }
99
- function readResponseBody(response) {
81
+ /** One trip to the API, with no retry and no timeout of its own. */
82
+ function sendOnce(config, input, requestIdempotencyKey) {
100
83
  return Effect.gen(function* () {
84
+ const url = buildUrl(config, input);
85
+ const init = buildRequestInit(config, input, requestIdempotencyKey);
86
+ const response = yield* Effect.tryPromise({
87
+ try: (signal) =>
88
+ // fallow-ignore-next-line security-sink -- the origin is the configured Luca api base url and the path comes from the generated operation manifest with its params encoded, so a tool argument can never move the destination host
89
+ fetch(url, {
90
+ ...init,
91
+ signal,
92
+ }),
93
+ catch: (cause) => new LucaNetworkError({
94
+ message: `Could not reach Luca API at ${url.toString()}`,
95
+ cause,
96
+ }),
97
+ });
101
98
  const text = yield* Effect.tryPromise({
102
99
  try: () => response.text(),
103
100
  catch: (cause) => new LucaNetworkError({
@@ -105,41 +102,36 @@ function readResponseBody(response) {
105
102
  cause,
106
103
  }),
107
104
  });
108
- return yield* Effect.try({
109
- try: () => parseJson(text),
110
- catch: (cause) => cause instanceof LucaDecodeError
111
- ? cause
112
- : new LucaDecodeError({ message: String(cause), body: text }),
113
- });
105
+ // An empty body is not a decode failure — several endpoints answer with
106
+ // no content at all, and null is the JSON value that stands for it.
107
+ const json = yield* Option.liftPredicate(text, (raw) => raw.length > 0).pipe(Option.match({
108
+ onNone: () => Effect.succeed(null),
109
+ onSome: (raw) => Effect.try({
110
+ try: () => fromJson(raw),
111
+ catch: () => new LucaDecodeError({
112
+ message: "Luca API returned non-JSON content",
113
+ body: raw,
114
+ }),
115
+ }),
116
+ }));
117
+ if (response.ok) {
118
+ return json;
119
+ }
120
+ return yield* Effect.fail(new LucaHttpError({
121
+ status: response.status,
122
+ statusText: response.statusText,
123
+ body: json,
124
+ ...optionalField("requestId", response.headers.get("x-request-id")),
125
+ ...optionalField("retryAfterMs", retryAfterMs(response, Date.now())),
126
+ }));
114
127
  });
115
128
  }
116
129
  export function makeLucaApi(config) {
130
+ const timeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
117
131
  return {
118
- request: (input) => Effect.gen(function* () {
119
- const url = buildUrl(config, input);
120
- const init = buildRequestInit(input, buildHeaders(config, input));
121
- const response = yield* Effect.tryPromise({
122
- try: (signal) => fetch(url, {
123
- ...init,
124
- signal,
125
- }),
126
- catch: (cause) => new LucaNetworkError({
127
- message: `Could not reach Luca API at ${url.toString()}`,
128
- cause,
129
- }),
130
- });
131
- const json = yield* readResponseBody(response);
132
- if (!response.ok) {
133
- const requestId = response.headers.get("x-request-id") ?? undefined;
134
- return yield* Effect.fail(new LucaHttpError({
135
- status: response.status,
136
- statusText: response.statusText,
137
- body: json,
138
- ...(requestId ? { requestId } : {}),
139
- }));
140
- }
141
- return json;
142
- }),
132
+ // The key each attempt carries is minted by `resilient`, which also decides
133
+ // whether a failure may be replayed under it.
134
+ request: (input) => resilient(input, timeoutMs, (key) => sendOnce(config, input, key)),
143
135
  };
144
136
  }
145
137
  export const LucaApiLive = Layer.effect(LucaApi, Effect.gen(function* () {
package/dist/index.js CHANGED
@@ -1,12 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
- import { createLucaServer } from "./server.js";
4
- async function main() {
5
- const server = createLucaServer();
6
- const transport = new StdioServerTransport();
7
- await server.connect(transport);
8
- }
9
- main().catch((error) => {
10
- console.error("Fatal error in Luca MCP server:", error);
11
- process.exit(1);
2
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
3
+ import { createLucaServer, parseToolset } from "./server.js";
4
+ /**
5
+ * `serveStdio` owns the era decision for the connection: the opening exchange
6
+ * picks either the 2026-07-28 protocol or the 2025 `initialize` handshake, and
7
+ * one instance from this factory is pinned for the connection's lifetime. The
8
+ * factory registers the same surface either way, so a client on either
9
+ * revision sees an identical toolset.
10
+ */
11
+ serveStdio(() => createLucaServer({ toolset: parseToolset(process.env.LUCA_TOOLSET) }), {
12
+ // stdout carries the protocol, so every diagnostic goes to stderr. These
13
+ // are out-of-band errors: reporting them never changes what the client
14
+ // receives, and the connection stays up.
15
+ onerror: (error) => {
16
+ console.error("Luca MCP server error:", error);
17
+ },
12
18
  });
@@ -1,3 +1,6 @@
1
+ import { Duration, Effect, Option } from "effect";
2
+ import * as z from "zod/v4";
3
+ import { DEFAULT_REQUEST_TIMEOUT_MS, parseTimeoutMs, } from "./config.js";
1
4
  const TRAILING_SLASHES = /\/+$/;
2
5
  /**
3
6
  * Developer API keys are handled by apiKeyResolver and never leave this
@@ -5,9 +8,47 @@ const TRAILING_SLASHES = /\/+$/;
5
8
  * developer keys — they fall through to the resolver, which rejects them
6
9
  * server-side (fail closed, no client-side oracle).
7
10
  */
8
- const API_KEY_SHAPE = /^luca_(?!ort_)[a-f0-9]/i;
11
+ const API_KEY_FIELDS = /^luca_(?!ort_)[a-f0-9]/i;
12
+ const ResolvePayloadSchema = z.object({
13
+ apiKey: z.string().optional(),
14
+ scopes: z.array(z.string()).optional(),
15
+ });
9
16
  /**
10
- * Resolver for OAuth access tokens (docs/specs/mcp-oauth-authorization-server.md
17
+ * The body of a resolve response, absent when the API refused the token. A
18
+ * refusal is not read at all: its body carries nothing this resolver acts on.
19
+ */
20
+ function resolvePayload(response) {
21
+ return Option.match(Option.liftPredicate(response, (res) => res.ok), {
22
+ onNone: () => Effect.succeed(Option.none()),
23
+ onSome: (res) => Effect.tryPromise(() => res.json()).pipe(Effect.map((payload) => ResolvePayloadSchema.parse(payload)), Effect.map(Option.some)),
24
+ });
25
+ }
26
+ /** The identity a payload names, absent when it names no vended key. */
27
+ function identityFromPayload(apiBaseUrl, authHeader, payload) {
28
+ return Option.map(Option.fromNullable(payload.apiKey), (apiKey) => ({
29
+ config: { apiBaseUrl, apiKey, authHeader },
30
+ scopes: payload.scopes ?? [],
31
+ }));
32
+ }
33
+ /**
34
+ * One authenticated call to POST /oauth/resolve, answered with the identity the
35
+ * token maps to. A network failure, a refusal, and an unreadable body are all
36
+ * the same answer — no identity — because the transport replies 401 to each of
37
+ * them and telling them apart would hand a caller an oracle.
38
+ */
39
+ function requestIdentity(apiBaseUrl, authHeader, fetchImpl, token, timeoutMs) {
40
+ return Effect.tryPromise(() => fetchImpl(`${apiBaseUrl}/oauth/resolve`, {
41
+ method: "POST",
42
+ headers: { authorization: `Bearer ${token}` },
43
+ })).pipe(Effect.flatMap(resolvePayload), Effect.map((payload) => Option.flatMap(payload, (body) => identityFromPayload(apiBaseUrl, authHeader, body))),
44
+ // Timeout but no retry. This call sits on the auth path of every request,
45
+ // has no idempotency to reason about, and already answers "no identity" for
46
+ // every failure — a plain `timeout` is enough because `orElseSucceed`
47
+ // discards the error shape anyway.
48
+ Effect.timeout(Duration.millis(timeoutMs)), Effect.orElseSucceed(() => Option.none()));
49
+ }
50
+ /**
51
+ * Resolver for OAuth access tokens (docs/runbooks/mcp-oauth-authorization-server.md
11
52
  * §5.2): one authenticated call to the Luca API's POST /oauth/resolve, which
12
53
  * verifies the JWT and returns the hidden vended key for the token's own
13
54
  * grant. Fail closed: any error, non-200, or malformed body → null → 401.
@@ -15,41 +56,22 @@ const API_KEY_SHAPE = /^luca_(?!ort_)[a-f0-9]/i;
15
56
  export function oauthResolver(env, fetchImpl = fetch) {
16
57
  const apiBaseUrl = (env.LUCA_API_BASE_URL ?? "https://api.setluca.com").replace(TRAILING_SLASHES, "");
17
58
  const authHeader = env.LUCA_AUTH_HEADER ?? "x-api-key";
18
- return async (token) => {
19
- if (API_KEY_SHAPE.test(token)) {
20
- return null;
21
- }
22
- try {
23
- const response = await fetchImpl(`${apiBaseUrl}/oauth/resolve`, {
24
- method: "POST",
25
- headers: { authorization: `Bearer ${token}` },
26
- });
27
- if (!response.ok) {
28
- return null;
29
- }
30
- const payload = (await response.json());
31
- if (!payload.apiKey) {
32
- return null;
33
- }
34
- return {
35
- config: { apiBaseUrl, apiKey: payload.apiKey, authHeader },
36
- scopes: payload.scopes ?? [],
37
- };
38
- }
39
- catch {
40
- return null;
41
- }
42
- };
59
+ // Read through the same parser the API client uses, so the stdio and Worker
60
+ // paths cannot end up bounding their requests differently.
61
+ const timeoutMs = parseTimeoutMs(env.LUCA_REQUEST_TIMEOUT_MS) ?? DEFAULT_REQUEST_TIMEOUT_MS;
62
+ // A developer API key is another resolver's business, so it is refused here
63
+ // without a network call rather than sent to an endpoint that would reject it
64
+ // anyway.
65
+ return (token) => Option.match(Option.liftPredicate(token, (candidate) => !API_KEY_FIELDS.test(candidate)), {
66
+ onNone: () => Effect.succeed(Option.none()),
67
+ onSome: (candidate) => requestIdentity(apiBaseUrl, authHeader, fetchImpl, candidate, timeoutMs),
68
+ }).pipe(Effect.map(Option.getOrNull), Effect.runPromise);
43
69
  }
44
70
  /** Try resolvers in order; the first non-null identity wins. */
45
71
  export function chainResolvers(...resolvers) {
46
- return async (token) => {
47
- for (const resolve of resolvers) {
48
- const identity = await resolve(token);
49
- if (identity) {
50
- return identity;
51
- }
52
- }
53
- return null;
54
- };
72
+ return (token) => Effect.reduce(resolvers, Option.none(), (identity, resolve) => Option.match(identity, {
73
+ // Once one resolver has answered, the rest are never called.
74
+ onSome: (present) => Effect.succeed(Option.some(present)),
75
+ onNone: () => Effect.promise(() => resolve(token)).pipe(Effect.map(Option.fromNullable)),
76
+ })).pipe(Effect.map(Option.getOrNull), Effect.runPromise);
55
77
  }
@@ -9,11 +9,19 @@
9
9
  * contract (event names + payload shape).
10
10
  */
11
11
  export function logAgentSurfaceEvent(event, fields) {
12
- const clean = { msg: "mcp.agent_surface", event };
12
+ const clean = new Map([
13
+ ["msg", "mcp.agent_surface"],
14
+ ["event", event],
15
+ ]);
13
16
  for (const [k, v] of Object.entries(fields)) {
17
+ // Equivalent to always assigning: `JSON.stringify` omits own properties whose
18
+ // value is `undefined`, so guarding here vs. assigning an undefined value to
19
+ // the accumulator key yields byte-identical logged output. The guard keeps
20
+ // the object tidy.
21
+ // Stryker disable next-line ConditionalExpression: JSON.stringify drops undefined-valued keys regardless
14
22
  if (v !== undefined) {
15
- clean[k] = v;
23
+ clean.set(k, v);
16
24
  }
17
25
  }
18
- console.log(JSON.stringify(clean));
26
+ console.log(JSON.stringify(Object.fromEntries(clean)));
19
27
  }