@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.
- package/CHANGELOG.md +426 -0
- package/LICENSE +21 -0
- package/README.md +19 -258
- package/dist/annotations.js +35 -20
- package/dist/client-address.js +64 -0
- package/dist/completions.js +44 -27
- package/dist/config.js +89 -38
- package/dist/errors.js +99 -22
- package/dist/fields.js +68 -0
- package/dist/fixed-window.js +46 -0
- package/dist/generated/openapi-inputs.js +216 -5820
- package/dist/generated/openapi-outputs.js +19014 -14001
- package/dist/generated/route-catalog.js +80 -0
- package/dist/http.js +108 -44
- package/dist/index.js +4 -1
- package/dist/oauth-challenge.js +74 -0
- package/dist/oauth-resolver.js +58 -61
- package/dist/observability.js +31 -16
- package/dist/openapi-schema.js +175 -0
- package/dist/operations/catalog.js +17 -0
- package/dist/operations/groups/analytics.js +20 -10
- package/dist/operations/groups/bookings.js +43 -15
- package/dist/operations/groups/broadcasts.js +29 -58
- package/dist/operations/groups/cadences.js +3 -1
- package/dist/operations/groups/callEvents.js +19 -72
- package/dist/operations/groups/campaigns.js +28 -32
- package/dist/operations/groups/channels.js +23 -0
- package/dist/operations/groups/conversations.js +14 -25
- package/dist/operations/groups/insights.js +4 -0
- package/dist/operations/groups/integrations.js +15 -17
- package/dist/operations/groups/knowledge.js +5 -1
- package/dist/operations/groups/leads.js +22 -23
- package/dist/operations/groups/learning.js +13 -1
- package/dist/operations/groups/memory.js +4 -6
- package/dist/operations/groups/reports.js +1 -1
- package/dist/operations/groups/reviewQueue.js +17 -14
- package/dist/operations/groups/safety.js +1 -1
- package/dist/operations/groups/voice.js +0 -1
- package/dist/operations/groups/webhooks.js +16 -4
- package/dist/operations/manifest.js +9 -8
- package/dist/operations/registry.js +110 -116
- package/dist/operations.js +6 -5
- package/dist/optional-field.js +15 -3
- package/dist/page-contract.js +74 -0
- package/dist/pagination.js +149 -68
- package/dist/prompts.js +139 -88
- package/dist/provenance.js +2 -2
- package/dist/rate-limit.js +40 -0
- package/dist/remote.js +145 -65
- package/dist/resilience.js +120 -55
- package/dist/resolvers.js +125 -0
- package/dist/resources.js +17 -2
- package/dist/scopes.js +207 -0
- package/dist/serialization.js +32 -22
- package/dist/server.js +174 -191
- package/dist/standard-schema.js +215 -0
- package/dist/task-tools/analytics.js +145 -86
- package/dist/task-tools/bookings.js +176 -86
- package/dist/task-tools/cadences.js +30 -45
- package/dist/task-tools/calls.js +132 -125
- package/dist/task-tools/inbox.js +89 -53
- package/dist/task-tools/leads.js +49 -77
- package/dist/task-tools/runtime.js +80 -20
- package/dist/task-tools.js +1 -1
- package/dist/token-verification.js +42 -0
- package/dist/tool-cache.js +3 -2
- package/dist/tool.js +155 -100
- package/dist/version.js +1 -1
- package/package.json +40 -18
- package/dist/openapi-zod.js +0 -333
- package/dist/page-shape.js +0 -47
- 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
|
|
2
|
-
import * as
|
|
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 {
|
|
17
|
+
import { parseJson, toJsonPayload } from "./serialization.js";
|
|
8
18
|
import { LUCA_MCP_USER_AGENT } from "./version.js";
|
|
9
|
-
export const LucaApi = Context.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
51
|
-
if (seconds
|
|
52
|
-
return seconds.
|
|
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
|
|
153
|
+
message: `Could not reach Luca API at ${describeUrl(url)}`,
|
|
95
154
|
cause,
|
|
96
155
|
}),
|
|
97
156
|
});
|
|
98
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
184
|
+
return yield* Option.liftPredicate(text, (raw) => Str.isNonEmpty(raw)).pipe(Option.match({
|
|
108
185
|
onNone: () => Effect.succeed(null),
|
|
109
|
-
onSome: (raw) => Effect.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/oauth-resolver.js
CHANGED
|
@@ -1,77 +1,74 @@
|
|
|
1
|
-
import
|
|
2
|
-
import * as
|
|
3
|
-
import
|
|
4
|
-
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
12
|
-
|
|
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.
|
|
18
|
-
*
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
35
|
-
* token maps to. A
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
40
|
-
return
|
|
41
|
-
|
|
42
|
-
headers: { authorization: `Bearer ${token}` },
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
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(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
}
|
package/dist/observability.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
}
|