@roarkanalytics/sdk-mcp 4.12.0 → 4.12.2

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/src/code-tool.ts CHANGED
@@ -205,14 +205,71 @@ const remoteStainlessHandler = async ({
205
205
  const DENO_WORKER_SOCKET_SUFFIX = '-deno-http.sock';
206
206
 
207
207
  /**
208
- * Adds the worker's Unix socket to the existing `--allow-net` allowlist.
208
+ * Whether this Deno wants the worker's Unix socket named in `--allow-net`.
209
209
  *
210
- * deno-http-worker generates the socket path itself and passes it to the Deno
211
- * process, so it can only be read off the spawn arguments. Throws rather than
212
- * falling back to an unrestricted `--allow-net`, so a change in how the socket is
213
- * passed fails closed instead of silently widening what the sandbox can reach.
210
+ * Deno changed its mind about this, and the two behaviors are mutually exclusive:
211
+ *
212
+ * Deno 2.7 and earlier a Unix socket is a file. Listening on it is covered by
213
+ * --allow-read/--allow-write on its path, and --allow-net
214
+ * parses every entry as `host[:port]`, so a `unix:` entry
215
+ * is a hard parse error: "invalid port in 'unix:/tmp/...'".
216
+ * Deno 2.9 and later listening additionally requires --allow-net=unix:<path>,
217
+ * and without it the worker dies with NotCapable.
218
+ *
219
+ * There is therefore no single flag string that works on both, and this package is
220
+ * published for users to run against whatever Deno they have. So ask the binary
221
+ * instead of guessing from a version string: run a no-op script with the flag and
222
+ * see whether it is accepted. The probe binds nothing (the script is `0`), and the
223
+ * result is cached per executable because the answer cannot change under us.
224
+ */
225
+ const unixAllowNetSupport = new Map<string, boolean>();
226
+
227
+ export const denoAcceptsUnixAllowNet = (
228
+ denoPath: string,
229
+ probe: (command: string, args: string[]) => { status: number | null },
230
+ ): boolean => {
231
+ const cached = unixAllowNetSupport.get(denoPath);
232
+ if (cached !== undefined) return cached;
233
+
234
+ // A path that is never created: --allow-net is parsed before the script runs, which
235
+ // is the only thing being tested here.
236
+ const { status } = probe(denoPath, [
237
+ 'run',
238
+ '--allow-net=unix:/nonexistent-roark-deno-capability-probe.sock',
239
+ 'data:text/javascript,0',
240
+ ]);
241
+
242
+ // A null status means the probe never ran at all - the binary could not be spawned,
243
+ // or it was killed by the timeout or a signal - as opposed to running and rejecting
244
+ // the flag, which is an exit code. Answer conservatively for this call but do NOT
245
+ // cache it: a single transient failure would otherwise pin the answer to
246
+ // "unsupported" for the life of the process, and on Deno 2.9+ that means every
247
+ // later execute call dies with NotCapable.
248
+ if (status === null) return false;
249
+
250
+ const accepted = status === 0;
251
+ unixAllowNetSupport.set(denoPath, accepted);
252
+ return accepted;
253
+ };
254
+
255
+ /**
256
+ * Grants the Deno worker its own Unix socket, and nothing else.
257
+ *
258
+ * deno-http-worker generates the socket path while it builds the spawn arguments, so
259
+ * this is the first point at which it can be read at all. Two things happen here:
260
+ *
261
+ * - The socket is added to `--allow-net` only on the Deno versions that require it
262
+ * (see denoAcceptsUnixAllowNet). Adding it unconditionally is what broke the
263
+ * published image, whose Deno rejected the syntax and exited before serving
264
+ * anything, surfacing only as the far vaguer "Deno exited before being ready".
265
+ * - The grant deno-http-worker makes itself, `--allow-read`/`--allow-write` on the
266
+ * socket, is asserted rather than assumed, so a future version that stops making
267
+ * it fails here instead of at runtime.
268
+ *
269
+ * Throws rather than falling back to an unrestricted `--allow-net`, so anything
270
+ * unexpected fails closed instead of silently widening what the sandbox can reach.
214
271
  */
215
- export const scopeAllowNetToWorkerSocket = (spawnArgs: string[]): string[] => {
272
+ export const scopeAllowNetToWorkerSocket = (spawnArgs: string[], addSocketToAllowNet: boolean): string[] => {
216
273
  // Skip flags: deno-http-worker also appends the socket to `--allow-read`/`--allow-write`,
217
274
  // so match only the bare path it passes through to the bootstrap script.
218
275
  const socketPath = spawnArgs.find((arg) => !arg.startsWith('-') && arg.endsWith(DENO_WORKER_SOCKET_SUFFIX));
@@ -221,6 +278,19 @@ export const scopeAllowNetToWorkerSocket = (spawnArgs: string[]): string[] => {
221
278
  'Could not find the Deno worker socket path in its spawn arguments, so network access could not be scoped to it.',
222
279
  );
223
280
  }
281
+
282
+ const grants = (flag: string) =>
283
+ spawnArgs.some((arg) => arg.startsWith(flag) && arg.slice(flag.length).split(',').includes(socketPath));
284
+
285
+ const missing = ['--allow-read=', '--allow-write='].filter((flag) => !grants(flag));
286
+ if (missing.length > 0) {
287
+ throw new Error(
288
+ `deno-http-worker did not grant the worker socket ${socketPath} on ${missing.join(' or ')}, ` +
289
+ 'so the Deno sandbox would not be able to serve requests.',
290
+ );
291
+ }
292
+
293
+ if (!addSocketToAllowNet) return spawnArgs;
224
294
  return spawnArgs.map((arg) => (arg.startsWith('--allow-net=') ? `${arg},unix:${socketPath}` : arg));
225
295
  };
226
296
 
@@ -248,7 +318,7 @@ const localDenoHandler = async ({
248
318
  const packageNodeModulesPath = path.resolve(packageRoot, 'node_modules');
249
319
 
250
320
  // Check if deno is in PATH
251
- const { execSync, spawn } = await import('node:child_process');
321
+ const { execSync, spawn, spawnSync } = await import('node:child_process');
252
322
  try {
253
323
  execSync('command -v deno', { stdio: 'ignore' });
254
324
  denoPath = 'deno';
@@ -286,24 +356,36 @@ const localDenoHandler = async ({
286
356
 
287
357
  const allowRead = allowReadPaths.join(',');
288
358
 
359
+ const addSocketToAllowNet = denoAcceptsUnixAllowNet(denoPath, (command, probeArgs) =>
360
+ spawnSync(command, probeArgs, {
361
+ stdio: 'ignore',
362
+ // spawnSync blocks, so an unbounded probe would hang the server rather than
363
+ // fail one call. Deno's first run can populate its cache directory, hence
364
+ // seconds rather than milliseconds, and DENO_NO_UPDATE_CHECK keeps a version
365
+ // check off that path.
366
+ timeout: 10_000,
367
+ env: { ...process.env, DENO_NO_UPDATE_CHECK: '1' },
368
+ }),
369
+ );
370
+
289
371
  const worker = await newDenoHTTPWorker(url.pathToFileURL(workerPath), {
290
372
  denoExecutable: denoPath,
291
373
  runFlags: [
292
374
  `--node-modules-dir=manual`,
293
375
  `--allow-read=${allowRead}`,
294
376
  // Only the Roark API is reachable from the sandbox. The worker's Unix socket
295
- // is added to this same allowlist by scopeAllowNetToWorkerSocket below.
377
+ // is added to this same allowlist by scopeAllowNetToWorkerSocket below, on the
378
+ // Deno versions that require it.
296
379
  `--allow-net=${baseURLHostname}`,
297
380
  // Allow environment variables because instantiating the client will try to read from them,
298
381
  // even though they are not set.
299
382
  '--allow-env',
300
383
  ],
301
384
  // deno-http-worker picks the worker's Unix socket path while it builds the spawn
302
- // arguments, so it is not known when runFlags are constructed above. Deno gates
303
- // listening on a Unix socket behind --allow-net, so intercept the spawn to grant
304
- // that one socket rather than opening up network access to every host.
385
+ // arguments, so it is not known when runFlags are constructed above. Intercept the
386
+ // spawn to grant that one socket rather than opening up network access to every host.
305
387
  spawnFunc: (command, spawnArgs, spawnOptions) =>
306
- spawn(command, scopeAllowNetToWorkerSocket(spawnArgs), spawnOptions),
388
+ spawn(command, scopeAllowNetToWorkerSocket(spawnArgs, addSocketToAllowNet), spawnOptions),
307
389
  printOutput: true,
308
390
  spawnOptions: {
309
391
  cwd: path.dirname(workerPath),
package/src/http.ts CHANGED
@@ -15,6 +15,8 @@ import {
15
15
  ORIGIN_CONNECTOR,
16
16
  protectedResourceMetadata,
17
17
  requireBearer,
18
+ requireLiveBearer,
19
+ resolveApiBaseUrl,
18
20
  UnauthorizedError,
19
21
  } from './oauth';
20
22
  import { initMcpServer, newMcpServer } from './server';
@@ -52,7 +54,20 @@ const newServer = async ({
52
54
  // not be able to make us do that.
53
55
  let authOptions: Partial<ClientOptions>;
54
56
  if (mcpOptions.oauth) {
55
- authOptions = requireBearer(req, mcpOptions.oauth, connectorFrom(req));
57
+ const connector = connectorFrom(req);
58
+ const authorized = requireBearer(req, mcpOptions.oauth, connector);
59
+ // Presence is not validity. Ask customer-api whether the token is still live, so a revoked or
60
+ // expired one is answered with 401 + WWW-Authenticate here - the signal a client needs to
61
+ // re-run the OAuth flow - rather than completing the handshake and failing later inside a
62
+ // tool result, where nothing is listening for it.
63
+ //
64
+ // The base URL is resolved the way the SDK resolves it, NOT defaulted to prod: this server is
65
+ // launched without `clientOptions`, so on any stage but prod a prod default would check the
66
+ // token against a server that never issued it and refuse every request.
67
+ await requireLiveBearer(authorized.bearerToken, mcpOptions.oauth, connector, {
68
+ baseURL: resolveApiBaseUrl(clientOptions),
69
+ });
70
+ authOptions = authorized;
56
71
  } else {
57
72
  authOptions = parseClientAuthHeaders(req, false);
58
73
  }
package/src/oauth.ts CHANGED
@@ -9,8 +9,10 @@
9
9
  // rotated by the client's refresh token), so it is forwarded to customer-api as
10
10
  // the SDK bearer unchanged: customer-api validates it on every call.
11
11
 
12
+ import { createHash } from 'node:crypto';
12
13
  import { IncomingMessage } from 'node:http';
13
14
  import { ClientOptions } from '@roarkanalytics/sdk';
15
+ import { readEnv } from './util';
14
16
 
15
17
  export type OAuthConfig = {
16
18
  /** Authorization server issuer, e.g. https://mcp-oauth.api.roark.ai */
@@ -121,7 +123,7 @@ export const requireBearer = (
121
123
  req: IncomingMessage,
122
124
  config: OAuthConfig,
123
125
  connector: Connector = ORIGIN_CONNECTOR,
124
- ): Partial<ClientOptions> => {
126
+ ): Partial<ClientOptions> & { bearerToken: string } => {
125
127
  const token = extractBearer(req);
126
128
  if (!token) {
127
129
  throw new UnauthorizedError('Missing bearer token', bearerChallenge(config, connector));
@@ -129,9 +131,176 @@ export const requireBearer = (
129
131
  // Assigned rather than conditionally spread: a spread of `cond && { project }` is not checked
130
132
  // against `ClientOptions`, so on an SDK without the option the pin would compile and then
131
133
  // silently vanish. This way the dependency is enforced by the build.
132
- const options: Partial<ClientOptions> = { bearerToken: token };
134
+ const options: Partial<ClientOptions> & { bearerToken: string } = { bearerToken: token };
133
135
  if (connector.kind === 'project') {
134
136
  options.project = connector.projectId;
135
137
  }
136
138
  return options;
137
139
  };
140
+
141
+ /**
142
+ * What customer-api says about a bearer: `unknown` means it could not be asked, not that the
143
+ * token is fine.
144
+ */
145
+ export type BearerVerdict = 'valid' | 'invalid' | 'unknown';
146
+
147
+ /** The SDK's own default, for when neither an option nor the environment names one. */
148
+ export const DEFAULT_API_BASE_URL = 'https://api.roark.ai';
149
+
150
+ /**
151
+ * The base URL the SDK will actually call, resolved exactly the way the SDK resolves it:
152
+ * explicit option, then `ROARK_BASE_URL`, then the default.
153
+ *
154
+ * This has to agree with the SDK or the liveness check asks the wrong host. `launchStreamableHTTPServer`
155
+ * passes no `clientOptions` at all, so for the deployed server the answer always comes from the
156
+ * environment: a hardcoded prod default would send every check to prod while data calls went to
157
+ * whatever `ROARK_BASE_URL` names. On any stage but prod that turns a valid token into a 401 from
158
+ * a server it was never issued for, and this file would then refuse every request - a dead
159
+ * connector, which is strictly worse than the bug it is here to fix.
160
+ */
161
+ export const resolveApiBaseUrl = (clientOptions: Pick<ClientOptions, 'baseURL'> = {}): string =>
162
+ clientOptions.baseURL ?? readEnv('ROARK_BASE_URL') ?? DEFAULT_API_BASE_URL;
163
+
164
+ /**
165
+ * How long a verdict is reused. `newServer` runs per POST, not per session, so an uncached check
166
+ * would add a round trip to every single MCP request. The window is what a revoked token buys:
167
+ * it can still complete a handshake and read the tool list for up to this long. It cannot read
168
+ * anything, because every data path carries the same token to customer-api and is refused there
169
+ * immediately.
170
+ */
171
+ const BEARER_CACHE_TTL_MS = 60_000;
172
+
173
+ /**
174
+ * An unreachable customer-api is handled by the catch below, but a customer-api that accepts the
175
+ * connection and then never answers is not: without a deadline every MCP request would hang on
176
+ * it, which is worse than the failure this check exists to report. A hang becomes `unknown` like
177
+ * any other transport failure.
178
+ */
179
+ const BEARER_CHECK_TIMEOUT_MS = 3_000;
180
+
181
+ /**
182
+ * Bounded so that spraying invented tokens cannot grow the process's memory. Keys are hashes,
183
+ * not the tokens themselves: these are live credentials and there is no reason to keep a second
184
+ * copy of them in a long-lived map.
185
+ */
186
+ const BEARER_CACHE_MAX = 1_000;
187
+
188
+ const bearerVerdicts = new Map<string, { verdict: 'valid' | 'invalid'; expiresAt: number }>();
189
+
190
+ /**
191
+ * Checks already in flight, so concurrent requests bearing the same uncached token make one call
192
+ * rather than one each. That is the common shape right after a verdict expires, and whenever a
193
+ * client opens several requests at once.
194
+ */
195
+ const inFlightChecks = new Map<string, Promise<BearerVerdict>>();
196
+
197
+ /** Test seam. */
198
+ export const clearBearerVerdictCache = (): void => {
199
+ bearerVerdicts.clear();
200
+ inFlightChecks.clear();
201
+ };
202
+
203
+ const hashToken = (token: string): string => createHash('sha256').update(token).digest('hex');
204
+
205
+ const rememberVerdict = (key: string, verdict: 'valid' | 'invalid', expiresAt: number): void => {
206
+ // Evict the oldest entry rather than clearing the map. A `clear()` would let anyone spraying
207
+ // invented tokens flush every valid verdict too, making legitimate callers pay a round trip
208
+ // each and handing customer-api the extra work. Map iterates in insertion order, so the first
209
+ // key is the oldest.
210
+ if (bearerVerdicts.size >= BEARER_CACHE_MAX) {
211
+ const oldest = bearerVerdicts.keys().next();
212
+ if (!oldest.done) bearerVerdicts.delete(oldest.value);
213
+ }
214
+ bearerVerdicts.set(key, { verdict, expiresAt });
215
+ };
216
+
217
+ export interface VerifyBearerDeps {
218
+ /** customer-api base URL. Use {@link resolveApiBaseUrl} rather than passing a literal. */
219
+ baseURL: string;
220
+ fetchImpl?: typeof fetch;
221
+ now?: () => number;
222
+ ttlMs?: number;
223
+ timeoutMs?: number;
224
+ }
225
+
226
+ /**
227
+ * Asks customer-api whether a bearer is still live, via the `/v1/me` endpoint it already exposes
228
+ * for exactly this ("who is this credential"), and caches the answer briefly.
229
+ *
230
+ * Only a 401 counts as invalid. A 403 means the token authenticated and then failed a permission
231
+ * check, which is a live token; treating it as invalid would tell a client to re-authorize when
232
+ * re-authorizing cannot help. Anything else - a 5xx, a timeout, a DNS failure - is `unknown`.
233
+ */
234
+ export const verifyBearer = async (token: string, deps: VerifyBearerDeps): Promise<BearerVerdict> => {
235
+ const {
236
+ baseURL,
237
+ fetchImpl = fetch,
238
+ now = Date.now,
239
+ ttlMs = BEARER_CACHE_TTL_MS,
240
+ timeoutMs = BEARER_CHECK_TIMEOUT_MS,
241
+ } = deps;
242
+ const key = hashToken(token);
243
+
244
+ const cached = bearerVerdicts.get(key);
245
+ if (cached && cached.expiresAt > now()) return cached.verdict;
246
+
247
+ const alreadyRunning = inFlightChecks.get(key);
248
+ if (alreadyRunning) return alreadyRunning;
249
+
250
+ const check = (async (): Promise<BearerVerdict> => {
251
+ let response: Response;
252
+ try {
253
+ response = await fetchImpl(`${stripSlash(baseURL)}/v1/me`, {
254
+ method: 'GET',
255
+ headers: { Authorization: `Bearer ${token}` },
256
+ signal: AbortSignal.timeout(timeoutMs),
257
+ });
258
+ } catch {
259
+ return 'unknown';
260
+ }
261
+
262
+ const verdict: BearerVerdict =
263
+ response.status === 401 ? 'invalid'
264
+ : response.ok ? 'valid'
265
+ : 'unknown';
266
+ // An `unknown` is never cached: a blip would otherwise pin the answer for the whole TTL.
267
+ if (verdict !== 'unknown') rememberVerdict(key, verdict, now() + ttlMs);
268
+ return verdict;
269
+ })();
270
+
271
+ inFlightChecks.set(key, check);
272
+ try {
273
+ return await check;
274
+ } finally {
275
+ inFlightChecks.delete(key);
276
+ }
277
+ };
278
+
279
+ /**
280
+ * Rejects a bearer customer-api has already disowned, with the 401 + WWW-Authenticate that tells
281
+ * an MCP client to re-run the OAuth flow.
282
+ *
283
+ * Without this the resource server only checked that a token was PRESENT. A revoked, expired or
284
+ * entirely invented token completed `initialize` and `tools/list`, and the caller learned nothing
285
+ * until a tool call failed with an opaque 401 in its result text. No data was reachable - every
286
+ * data path revalidates at customer-api - but a client that never sees a 401 from the resource
287
+ * server has no reason to refresh, which for a 24h token means a connector that looks connected
288
+ * and silently cannot work.
289
+ *
290
+ * `unknown` is allowed through deliberately. Failing closed would turn a customer-api blip into a
291
+ * dead connector for everyone, and it would buy no confidentiality: the token still has to pass
292
+ * customer-api on the very next call that touches data.
293
+ */
294
+ export const requireLiveBearer = async (
295
+ token: string,
296
+ config: OAuthConfig,
297
+ connector: Connector,
298
+ deps: VerifyBearerDeps,
299
+ ): Promise<void> => {
300
+ const verdict = await verifyBearer(token, deps);
301
+ if (verdict !== 'invalid') return;
302
+ throw new UnauthorizedError(
303
+ 'The access token is expired or has been revoked',
304
+ bearerChallenge(config, connector, 'invalid_token', 'The access token is expired or has been revoked'),
305
+ );
306
+ };
package/src/server.ts CHANGED
@@ -28,7 +28,7 @@ export const newMcpServer = async ({
28
28
  new McpServer(
29
29
  {
30
30
  name: 'roarkanalytics_sdk_api',
31
- version: '4.12.0', // x-release-please-version
31
+ version: '4.12.2', // x-release-please-version
32
32
  },
33
33
  {
34
34
  instructions: await getInstructions({ stainlessApiKey, customInstructionsPath }),