@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
@@ -141,6 +141,14 @@ export const ROUTE_CATALOG = {
141
141
  ],
142
142
  "idempotencyRequired": true
143
143
  },
144
+ "conversations.summary": {
145
+ "method": "GET",
146
+ "path": "/api/conversations/summary",
147
+ "scopes": [
148
+ "conversations:read"
149
+ ],
150
+ "idempotencyRequired": false
151
+ },
144
152
  "conversations.list": {
145
153
  "method": "GET",
146
154
  "path": "/api/conversations",
@@ -165,6 +173,14 @@ export const ROUTE_CATALOG = {
165
173
  ],
166
174
  "idempotencyRequired": false
167
175
  },
176
+ "conversations.profile_refresh": {
177
+ "method": "POST",
178
+ "path": "/api/conversations/{id}/profile-refresh",
179
+ "scopes": [
180
+ "conversations:write"
181
+ ],
182
+ "idempotencyRequired": true
183
+ },
168
184
  "conversations.send": {
169
185
  "method": "POST",
170
186
  "path": "/api/conversations/{id}/send",
@@ -173,6 +189,22 @@ export const ROUTE_CATALOG = {
173
189
  ],
174
190
  "idempotencyRequired": true
175
191
  },
192
+ "conversations.partial_send_retry": {
193
+ "method": "POST",
194
+ "path": "/api/conversations/{id}/messages/{messageId}/retry",
195
+ "scopes": [
196
+ "conversations:write"
197
+ ],
198
+ "idempotencyRequired": true
199
+ },
200
+ "reviewQueue.composer": {
201
+ "method": "GET",
202
+ "path": "/api/review-queue/{id}/composer",
203
+ "scopes": [
204
+ "review_queue:read"
205
+ ],
206
+ "idempotencyRequired": false
207
+ },
176
208
  "reviewQueue.list": {
177
209
  "method": "GET",
178
210
  "path": "/api/review-queue",
@@ -1117,6 +1149,38 @@ export const ROUTE_CATALOG = {
1117
1149
  ],
1118
1150
  "idempotencyRequired": false
1119
1151
  },
1152
+ "channels.list": {
1153
+ "method": "GET",
1154
+ "path": "/api/channels",
1155
+ "scopes": [
1156
+ "channels:read"
1157
+ ],
1158
+ "idempotencyRequired": false
1159
+ },
1160
+ "channels.catalog": {
1161
+ "method": "GET",
1162
+ "path": "/api/channels/catalog",
1163
+ "scopes": [
1164
+ "channels:read"
1165
+ ],
1166
+ "idempotencyRequired": false
1167
+ },
1168
+ "channels.features": {
1169
+ "method": "GET",
1170
+ "path": "/api/channels/features",
1171
+ "scopes": [
1172
+ "channels:read"
1173
+ ],
1174
+ "idempotencyRequired": false
1175
+ },
1176
+ "channels.get": {
1177
+ "method": "GET",
1178
+ "path": "/api/channels/{id}",
1179
+ "scopes": [
1180
+ "channels:read"
1181
+ ],
1182
+ "idempotencyRequired": false
1183
+ },
1120
1184
  "reports.morning": {
1121
1185
  "method": "GET",
1122
1186
  "path": "/api/reports/morning",
@@ -1197,6 +1261,22 @@ export const ROUTE_CATALOG = {
1197
1261
  ],
1198
1262
  "idempotencyRequired": false
1199
1263
  },
1264
+ "learning.abTests.list": {
1265
+ "method": "GET",
1266
+ "path": "/api/ab-tests",
1267
+ "scopes": [
1268
+ "learning:read"
1269
+ ],
1270
+ "idempotencyRequired": false
1271
+ },
1272
+ "learning.abTests.get": {
1273
+ "method": "GET",
1274
+ "path": "/api/ab-tests/{id}",
1275
+ "scopes": [
1276
+ "learning:read"
1277
+ ],
1278
+ "idempotencyRequired": false
1279
+ },
1200
1280
  "learning.proposals.list": {
1201
1281
  "method": "GET",
1202
1282
  "path": "/api/learning-proposals",
package/dist/http.js CHANGED
@@ -1,56 +1,92 @@
1
- import { Context, Effect, Layer, Match, Option } from "effect";
2
- import * as z from "zod/v4";
1
+ import * as Arr from "effect/Array";
2
+ import * as Clock from "effect/Clock";
3
+ import * as Context from "effect/Context";
4
+ // oxlint-disable effect/avoid-native-fetch -- MCP remote API transport; its tests assert the native fetch request shape
5
+ import * as Effect from "effect/Effect";
6
+ import * as Layer from "effect/Layer";
7
+ import * as Match from "effect/Match";
8
+ import * as Option from "effect/Option";
9
+ import * as R from "effect/Record";
10
+ import * as Redacted from "effect/Redacted";
11
+ import * as Schema from "effect/Schema";
12
+ import * as Str from "effect/String";
3
13
  import { DEFAULT_REQUEST_TIMEOUT_MS, LucaConfig } from "./config.js";
4
- import { LucaDecodeError, LucaHttpError, LucaNetworkError, } from "./errors.js";
14
+ import { LucaDecodeError, LucaHttpError, LucaNetworkError, LucaToolInputError, } from "./errors.js";
5
15
  import { optionalField } from "./optional-field.js";
6
16
  import { resilient } from "./resilience.js";
7
- import { fromJson, toJsonPayload } from "./serialization.js";
17
+ import { parseJson, toJsonPayload } from "./serialization.js";
8
18
  import { LUCA_MCP_USER_AGENT } from "./version.js";
9
- export const LucaApi = Context.GenericTag("@luca/mcp/LucaApi");
19
+ export const LucaApi = Context.Service("@luca/mcp/LucaApi");
10
20
  function isPresent(value) {
11
21
  return value !== undefined && value !== null && value !== "";
12
22
  }
23
+ const PATH_PARAM = /\{([^}]+)\}/g;
24
+ /**
25
+ * The operation path with each `{param}` filled in and encoded. An absent or
26
+ * empty param fails rather than collapsing the route onto a different
27
+ * endpoint, such as the collection URL in place of one record.
28
+ */
13
29
  function encodePath(path, params) {
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}`))));
30
+ const valueOf = (key) => Option.fromNullishOr(params?.[key]).pipe(Option.filter(Str.isNonEmpty));
31
+ const missing = Arr.findFirst(Arr.map(Arr.fromIterable(path.matchAll(PATH_PARAM)), ([, key = ""]) => key), (key) => Option.isNone(valueOf(key)));
32
+ if (Option.isSome(missing)) {
33
+ return Effect.fail(new LucaToolInputError({
34
+ message: `Missing path parameter: ${missing.value}`,
35
+ }));
36
+ }
37
+ return Effect.succeed(path.replaceAll(PATH_PARAM, (_, key) => encodeURIComponent(Option.getOrElse(valueOf(key), () => ""))));
15
38
  }
16
39
  function appendQuery(url, query) {
17
- for (const [key, value] of Object.entries(query ?? {})) {
40
+ // oxlint-disable-next-line effect/imperative-loops -- loop mutates surrounding state per item in a way a combinator cannot express without changing timing
41
+ for (const [key, value] of R.toEntries(query ?? {})) {
18
42
  // A scalar is a one-element list here, so array and single-value params take
19
43
  // the same path: append every value the caller actually set.
20
44
  const values = Array.isArray(value) ? value : [value];
21
- for (const item of values.filter(isPresent)) {
45
+ Arr.forEach(values.filter(isPresent), (item) => {
22
46
  url.searchParams.append(key, String(item));
23
- }
47
+ });
24
48
  }
25
49
  }
26
50
  function buildUrl(config, input) {
27
- const path = encodePath(input.operation.path, input.pathParams);
28
- const url = new URL(path, `${config.apiBaseUrl}/`);
29
- appendQuery(url, input.query);
30
- return url;
51
+ return encodePath(input.operation.path, input.pathParams).pipe(Effect.map((path) => {
52
+ const url = new URL(path, `${config.apiBaseUrl}/`);
53
+ appendQuery(url, input.query);
54
+ return url;
55
+ }));
31
56
  }
32
57
  /** The API key, under whichever header this config authenticates with. */
33
58
  function authFields(config) {
34
59
  return Match.value(config.authHeader).pipe(Match.when("authorization", () => ({
35
- authorization: `Bearer ${config.apiKey}`,
36
- })), Match.orElse(() => ({ "x-api-key": config.apiKey })));
60
+ authorization: `Bearer ${Redacted.value(config.apiKey)}`,
61
+ })), Match.orElse(() => ({ "x-api-key": Redacted.value(config.apiKey) })));
37
62
  }
38
63
  const RETRY_AFTER_MS_PER_SECOND = 1000;
64
+ /** Delta-seconds: a whole, non-negative number of seconds. */
65
+ const decodeRetryAfterSeconds = Schema.decodeUnknownOption(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)));
66
+ const HTTP_DATE_START = /^[a-z]/i;
39
67
  /**
40
68
  * 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
69
+ * in two forms, delta-seconds and an HTTP date, and both are read here so the
42
70
  * retry rules never have to look at a header again. An unreadable value is the
43
71
  * same as no header: the client falls back to its own backoff.
44
72
  */
45
73
  function retryAfterMs(response, now) {
46
- const header = response.headers.get("retry-after")?.trim();
74
+ // `Headers` has already trimmed the value, so a header of nothing but spaces
75
+ // arrives as the empty string and is treated as no header at all.
76
+ const header = response.headers.get("retry-after");
47
77
  if (!header) {
48
78
  return;
49
79
  }
50
- const seconds = z.coerce.number().int().min(0).safeParse(header);
51
- if (seconds.success) {
52
- return seconds.data * RETRY_AFTER_MS_PER_SECOND;
80
+ const seconds = decodeRetryAfterSeconds(header);
81
+ if (Option.isSome(seconds)) {
82
+ return seconds.value * RETRY_AFTER_MS_PER_SECOND;
83
+ }
84
+ // Every HTTP date form opens with a day name. Without this check `Date.parse`
85
+ // would read a malformed delta such as "1.5" or "-1" as a calendar date.
86
+ if (!HTTP_DATE_START.test(header)) {
87
+ return;
53
88
  }
89
+ // oxlint-disable-next-line effect/use-clock-service -- parse stored timestamp
54
90
  const at = Date.parse(header);
55
91
  return Number.isNaN(at) ? undefined : Math.max(0, at - now);
56
92
  }
@@ -78,55 +114,83 @@ function buildRequestInit(config, input, requestIdempotencyKey) {
78
114
  ...optionalField("body", Option.getOrUndefined(body)),
79
115
  };
80
116
  }
117
+ /**
118
+ * The most of a non-JSON error body a tool error carries. A gateway's HTML page
119
+ * says no more past this, and the whole page would crowd the agent's context.
120
+ */
121
+ const MAX_RAW_ERROR_BODY_LENGTH = 500;
122
+ /**
123
+ * The body of an error response: its JSON when it parses, else the raw text
124
+ * cut to {@link MAX_RAW_ERROR_BODY_LENGTH} characters. An empty body is null,
125
+ * as it is on success.
126
+ */
127
+ function errorBody(text) {
128
+ return Option.liftPredicate(text, (raw) => Str.isNonEmpty(raw)).pipe(Option.match({
129
+ onNone: () => Effect.succeed(null),
130
+ onSome: (raw) => parseJson(raw).pipe(Effect.orElseSucceed(() => raw.slice(0, MAX_RAW_ERROR_BODY_LENGTH))),
131
+ }));
132
+ }
133
+ /**
134
+ * The URL a network error names: origin and path only. The query string can
135
+ * carry a search term such as a lead's name, which a tool error must not echo.
136
+ */
137
+ const describeUrl = (url) => `${url.origin}${url.pathname}`;
81
138
  /** One trip to the API, with no retry and no timeout of its own. */
82
139
  function sendOnce(config, input, requestIdempotencyKey) {
83
140
  return Effect.gen(function* () {
84
- const url = buildUrl(config, input);
141
+ const url = yield* buildUrl(config, input);
85
142
  const init = buildRequestInit(config, input, requestIdempotencyKey);
86
143
  const response = yield* Effect.tryPromise({
87
144
  try: (signal) =>
88
145
  // 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
146
  fetch(url, {
90
147
  ...init,
148
+ // A redirect would carry the API key to a host we never named.
149
+ redirect: "error",
91
150
  signal,
92
151
  }),
93
152
  catch: (cause) => new LucaNetworkError({
94
- message: `Could not reach Luca API at ${url.toString()}`,
153
+ message: `Could not reach Luca API at ${describeUrl(url)}`,
95
154
  cause,
96
155
  }),
97
156
  });
98
- const text = yield* Effect.tryPromise({
157
+ const readText = Effect.tryPromise({
99
158
  try: () => response.text(),
100
159
  catch: (cause) => new LucaNetworkError({
101
160
  message: "Could not read Luca API response body",
102
161
  cause,
103
162
  }),
104
163
  });
105
- // An empty body is not a decode failure — several endpoints answer with
164
+ if (!response.ok) {
165
+ // The status and `Retry-After` are read before the body is. A gateway
166
+ // answers 502, 503 and 504 with HTML and a rate limiter may answer 429
167
+ // with plain text; neither is a decode failure, and both must reach the
168
+ // retry rules with their status and wait intact. A body that cannot be
169
+ // read is treated as empty for the same reason.
170
+ const now = yield* Clock.currentTimeMillis;
171
+ const waitMs = retryAfterMs(response, now);
172
+ const text = yield* readText.pipe(Effect.orElseSucceed(() => ""));
173
+ return yield* Effect.fail(new LucaHttpError({
174
+ status: response.status,
175
+ statusText: response.statusText,
176
+ body: yield* errorBody(text),
177
+ ...optionalField("requestId", response.headers.get("x-request-id")),
178
+ ...optionalField("retryAfterMs", waitMs),
179
+ }));
180
+ }
181
+ const text = yield* readText;
182
+ // An empty body is not a decode failure. Several endpoints answer with
106
183
  // 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({
184
+ return yield* Option.liftPredicate(text, (raw) => Str.isNonEmpty(raw)).pipe(Option.match({
108
185
  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())),
186
+ onSome: (raw) => parseJson(raw).pipe(Effect.mapError(() => new LucaDecodeError({
187
+ message: "Luca API returned non-JSON content",
188
+ body: raw,
189
+ }))),
126
190
  }));
127
191
  });
128
192
  }
129
- export function makeLucaApi(config) {
193
+ export function createLucaApi(config) {
130
194
  const timeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
131
195
  return {
132
196
  // The key each attempt carries is minted by `resilient`, which also decides
@@ -136,5 +200,5 @@ export function makeLucaApi(config) {
136
200
  }
137
201
  export const LucaApiLive = Layer.effect(LucaApi, Effect.gen(function* () {
138
202
  const config = yield* LucaConfig;
139
- return makeLucaApi(config);
203
+ return createLucaApi(config);
140
204
  }));
package/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
3
+ import { setEventSink, stderrEventSink } from "./observability.js";
3
4
  import { createLucaServer, parseToolset } from "./server.js";
5
+ // stdout carries the protocol, so event lines go to stderr.
6
+ setEventSink(stderrEventSink);
4
7
  /**
5
8
  * `serveStdio` owns the era decision for the connection: the opening exchange
6
9
  * picks either the 2026-07-28 protocol or the 2025 `initialize` handshake, and
@@ -13,6 +16,6 @@ serveStdio(() => createLucaServer({ toolset: parseToolset(process.env.LUCA_TOOLS
13
16
  // are out-of-band errors: reporting them never changes what the client
14
17
  // receives, and the connection stays up.
15
18
  onerror: (error) => {
16
- console.error("Luca MCP server error:", error);
19
+ stderrEventSink(`Luca MCP server error: ${error.stack ?? error.message}`);
17
20
  },
18
21
  });
@@ -0,0 +1,74 @@
1
+ import { isScopeRefusal, LucaHttpError } from "./errors.js";
2
+ /**
3
+ * The `_meta` key a tool error uses to ask the client to re-authorize. ChatGPT
4
+ * reads it to show its account-linking UI when a call fails for lack of a
5
+ * valid token or a broad enough scope. The value is a list of RFC 6750
6
+ * `WWW-Authenticate` challenges, the same header a 401 from the transport
7
+ * carries.
8
+ */
9
+ const WWW_AUTHENTICATE_META_KEY = "mcp/www_authenticate";
10
+ /**
11
+ * The OAuth error a failed tool call maps to, or `undefined` when the failure
12
+ * has nothing to do with authorization. A 401 means the token is no longer
13
+ * accepted. A 403 counts only when the API says a scope is missing.
14
+ */
15
+ function oauthErrorFor(error) {
16
+ if (error instanceof LucaHttpError && error.status === 401) {
17
+ return "invalid_token";
18
+ }
19
+ return isScopeRefusal(error) ? "insufficient_scope" : undefined;
20
+ }
21
+ const ERROR_DESCRIPTIONS = {
22
+ invalid_token: "The access token is invalid or expired",
23
+ insufficient_scope: "The connection's scopes do not cover this tool",
24
+ };
25
+ /**
26
+ * An RFC 6750 `Bearer` challenge, for the transport's `WWW-Authenticate`
27
+ * header and for a tool error's `_meta`. Both name the same realm, so a client
28
+ * sees one protected resource however the challenge reached it. A request that
29
+ * sent no token gets no `error`; one whose token was refused gets
30
+ * `invalid_token`, which tells the client to refresh or re-authorize rather
31
+ * than rediscover the server.
32
+ */
33
+ export function bearerChallenge(params) {
34
+ const { error, resourceMetadataUrl, scope } = params;
35
+ const fields = [
36
+ 'realm="luca-mcp"',
37
+ resourceMetadataUrl === undefined
38
+ ? undefined
39
+ : `resource_metadata="${resourceMetadataUrl}"`,
40
+ error === undefined ? undefined : `error="${error}"`,
41
+ error === undefined
42
+ ? undefined
43
+ : `error_description="${ERROR_DESCRIPTIONS[error]}"`,
44
+ scope === undefined ? undefined : `scope="${scope}"`,
45
+ ];
46
+ return `Bearer ${fields.filter((field) => field !== undefined).join(", ")}`;
47
+ }
48
+ /**
49
+ * The `_meta` for a tool error that re-authorizing could fix, or `undefined`
50
+ * for every other failure. An `insufficient_scope` challenge names the scope
51
+ * the tool needs, so the client can ask for it when the coach reconnects. An
52
+ * `invalid_token` challenge names none: the client must refresh or
53
+ * re-authorize, not ask for a different scope, the same rule the transport's
54
+ * 401 follows.
55
+ *
56
+ * It needs the resource-metadata URL, which only the remote Worker knows. A
57
+ * stdio server has no OAuth flow to send the client into, so it passes none
58
+ * and its errors carry no challenge.
59
+ */
60
+ export function authChallengeMeta(error, scope, resourceMetadataUrl) {
61
+ const oauthError = oauthErrorFor(error);
62
+ if (oauthError === undefined || resourceMetadataUrl === undefined) {
63
+ return undefined;
64
+ }
65
+ return {
66
+ [WWW_AUTHENTICATE_META_KEY]: [
67
+ bearerChallenge({
68
+ resourceMetadataUrl,
69
+ error: oauthError,
70
+ scope: oauthError === "insufficient_scope" ? scope : undefined,
71
+ }),
72
+ ],
73
+ };
74
+ }
@@ -1,77 +1,74 @@
1
- import { Duration, Effect, Option } from "effect";
2
- import * as z from "zod/v4";
3
- import { DEFAULT_REQUEST_TIMEOUT_MS, parseTimeoutMs, } from "./config.js";
4
- const TRAILING_SLASHES = /\/+$/;
1
+ import * as Effect from "effect/Effect";
2
+ import * as Option from "effect/Option";
3
+ import * as Redacted from "effect/Redacted";
4
+ import * as Schema from "effect/Schema";
5
+ import { TokenVerificationUnavailable } from "./errors.js";
6
+ import { NonEmptyString } from "./fields.js";
7
+ import { callApi, unavailable, } from "./token-verification.js";
5
8
  /**
6
- * Developer API keys are handled by apiKeyResolver and never leave this
7
- * process via the resolve endpoint. Refresh tokens (luca_ort_) are not
8
- * developer keys — they fall through to the resolver, which rejects them
9
- * server-side (fail closed, no client-side oracle).
9
+ * The body POST /oauth/resolve answers with for a token it accepted. The API
10
+ * also sends the grant's scopes; the Luca API enforces them on every call, so
11
+ * this resolver does not read them. An empty key is an API fault, not a key,
12
+ * so it fails as unavailable instead of reaching the API as a blank header.
10
13
  */
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(),
14
+ const ResolvePayload = Schema.Struct({
15
+ apiKey: Schema.optionalKey(NonEmptyString),
15
16
  });
17
+ const decodeResolvePayload = Schema.decodeUnknownEffect(ResolvePayload);
16
18
  /**
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
+ * The body of a resolve response, absent when the API refused the token. Only
20
+ * a 401 is that verdict: any other non-2xx (a 404 from a misrouted base URL, a
21
+ * 403 from an edge rule, a 429, a 5xx) says nothing about the token, so it must
22
+ * not send the client back through sign-in. A refusal is not read at all. A
23
+ * 2xx with a body this resolver cannot read is an API fault, not a verdict, so
24
+ * it fails as unavailable.
19
25
  */
20
26
  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
- }));
27
+ if (response.status === 401) {
28
+ return Effect.succeed(Option.none());
29
+ }
30
+ if (!response.ok) {
31
+ return Effect.fail(new TokenVerificationUnavailable({
32
+ message: `Token verification answered ${response.status}`,
33
+ }));
34
+ }
35
+ return Effect.tryPromise({
36
+ try: () => response.json(),
37
+ catch: unavailable("Token verification returned non-JSON content"),
38
+ }).pipe(Effect.flatMap((body) => decodeResolvePayload(body).pipe(Effect.mapError(unavailable("Token verification returned an unexpected body")))), Effect.map(Option.some));
32
39
  }
33
40
  /**
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.
41
+ * One authenticated call to POST /oauth/resolve, answered with the config for
42
+ * the vended key the token maps to. A 401, or a payload with no key, is no
43
+ * config, which the transport turns into 401. A network failure, a timeout, or
44
+ * any other non-2xx is {@link TokenVerificationUnavailable}, which the transport
45
+ * turns into 503. Both fail closed. Telling them apart reveals nothing about
46
+ * the token, and it stops an outage from sending every connected client back
47
+ * through sign-in.
38
48
  */
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
+ function requestConfig(settings, fetchImpl, token) {
50
+ return callApi(settings, fetchImpl, {
51
+ path: "/oauth/resolve",
52
+ init: { method: "POST", headers: { authorization: `Bearer ${token}` } },
53
+ }, resolvePayload).pipe(Effect.map((payload) => Option.flatMapNullishOr(payload, (body) => body.apiKey).pipe(Option.map((apiKey) => ({
54
+ ...settings,
55
+ apiKey: Redacted.make(apiKey),
56
+ })))));
49
57
  }
58
+ /** Three base64url segments: the form a JWT access token takes. */
59
+ const JWT_ACCESS_TOKEN = /^[\w-]+\.[\w-]+\.[\w-]+$/;
50
60
  /**
51
61
  * Resolver for OAuth access tokens (docs/runbooks/mcp-oauth-authorization-server.md
52
62
  * §5.2): one authenticated call to the Luca API's POST /oauth/resolve, which
53
63
  * verifies the JWT and returns the hidden vended key for the token's own
54
- * grant. Fail closed: any error, non-200, or malformed body → null → 401.
64
+ * grant. The config it returns carries the same settings the stdio path reads,
65
+ * so the vended key reaches the API with the same base URL, auth header, and
66
+ * timeout. Developer keys never come here: the Worker sends them to
67
+ * `apiKeyResolver` by shape (see `looksLikeDeveloperKey`). A token that is not
68
+ * JWT-shaped is refused without calling the API.
55
69
  */
56
- export function oauthResolver(env, fetchImpl = fetch) {
57
- const apiBaseUrl = (env.LUCA_API_BASE_URL ?? "https://api.setluca.com").replace(TRAILING_SLASHES, "");
58
- const authHeader = env.LUCA_AUTH_HEADER ?? "x-api-key";
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);
69
- }
70
- /** Try resolvers in order; the first non-null identity wins. */
71
- export function chainResolvers(...resolvers) {
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);
70
+ export function oauthResolver(settings, fetchImpl = fetch) {
71
+ return (token) => JWT_ACCESS_TOKEN.test(token)
72
+ ? requestConfig(settings, fetchImpl, token)
73
+ : Effect.succeedNone;
77
74
  }
@@ -1,6 +1,31 @@
1
+ import * as P from "effect/Predicate";
2
+ import * as R from "effect/Record";
3
+ import { toJsonPayload } from "./serialization.js";
4
+ /**
5
+ * Writes one line to stdout or stderr. Event lines are emitted from plain
6
+ * callbacks as well as from Effect programs, so this is the one place the
7
+ * package touches the console directly.
8
+ */
9
+ function writeLine(stream, line) {
10
+ console[stream](line);
11
+ }
12
+ /** The Worker's sink: stdout is the platform log there. */
13
+ export const stdoutEventSink = (line) => writeLine("log", line);
14
+ /** The stdio sink: stdout carries the MCP protocol there, so lines go to stderr. */
15
+ export const stderrEventSink = (line) => writeLine("error", line);
16
+ let sink = stdoutEventSink;
17
+ /**
18
+ * Sends event lines somewhere other than stdout. The stdio entry calls this
19
+ * with a stderr writer before it starts serving, because stdout carries the
20
+ * MCP protocol there and a log line on it corrupts the stream. The Worker
21
+ * keeps the default, where stdout is the platform log.
22
+ */
23
+ export function setEventSink(next) {
24
+ sink = next;
25
+ }
1
26
  /**
2
27
  * One structured line per agent-surface event. PII-safe by contract: pass only
3
- * names, booleans, counts, and already-coach-scoped ids — never message text,
28
+ * names, booleans, counts, and already-coach-scoped ids. Never message text,
4
29
  * guidance text, or reasoning strings.
5
30
  *
6
31
  * A local mirror of `@luca/observability`'s `logAgentSurfaceEvent`: this package
@@ -9,19 +34,9 @@
9
34
  * contract (event names + payload shape).
10
35
  */
11
36
  export function logAgentSurfaceEvent(event, fields) {
12
- const clean = new Map([
13
- ["msg", "mcp.agent_surface"],
14
- ["event", event],
15
- ]);
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
22
- if (v !== undefined) {
23
- clean.set(k, v);
24
- }
25
- }
26
- console.log(JSON.stringify(Object.fromEntries(clean)));
37
+ sink(toJsonPayload({
38
+ msg: "mcp.agent_surface",
39
+ event,
40
+ ...R.filter(fields, P.isNotUndefined),
41
+ }));
27
42
  }