Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 | 8x 8x 14x 14x 28x 28x 28x 1x 41x 5x 36x 28x 28x 28x 28x 8x 6x 2x 2x 2x 2x 41x 64x 6x 6x 68x 57x 11x 11x 68x 1x 10x 472x 472x 472x 73x 64x 9x 9x 3x 1x 2x 2x 6x 1x 5x 3x 3x 14x 16x 309x 309x 309x 1154x 1154x 21x 13x 13x 1141x 5x 1141x 309x 309x 309x 309x 309x 309x 281x 309x 39x 3x 36x 309x 309x 193x 169x 169x 170x 165x 402x 165x 164x 12x 28x 12x 16x 25x 25x 9x 3x 127x 127x 130x 130x 127x 127x 127x 124x 124x 2x 124x 3x 3x 127x 12x 115x 115x 3x 115x 116x 112x 110x | import { STATUS_CODES } from 'node:http';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import {
StreamableHTTPClientTransport,
StreamableHTTPError,
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { version } from '../package.json';
import type { JsonObject } from './types';
import { StackOneAPIError } from './utils/error-stackone-api';
import { StackOneError } from './utils/error-stackone';
import { ToolSetLoadError } from './utils/error-toolset';
import { fetchWithRetry, retryTiming } from './utils/fetch-retry';
/** A tool exactly as the MCP server listed it. */
export interface McpToolDefinition {
name: string;
description?: string;
inputSchema: Record<string, unknown>;
}
interface McpRequest {
endpoint: string;
/** The complete HTTP headers, credentials and account included. */
headers: Record<string, string>;
/** Deadline for the whole exchange, handshake included, in milliseconds. */
timeout: number;
}
/** Raised by the overall deadline, so it can be told apart from a transport failure. */
class McpDeadlineError extends Error {
constructor(timeout: number) {
super(`no response within ${timeout}ms`);
this.name = 'McpDeadlineError';
}
}
/** The prefix the transport puts in front of the server's response body. */
const TRANSPORT_ERROR_PREFIX = /^Streamable HTTP error: Error POSTing to endpoint:\s*/;
const parseBody = (text: string): unknown => {
Iif (!text) {
return null;
}
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
};
/**
* Turn the MCP client's failure into something a caller can act on.
*
* An HTTP error arrives as the transport's own error type, whose message buries the status in
* boilerplate. Unwrap it into a {@link StackOneAPIError} carrying the status and the response
* body — which is where the server explains itself: a dead account, for example, answers 412
* with "re-link the account to resume". Anything else is reported by its innermost cause.
*/
export function describeMcpFailure(error: unknown, endpoint: string, timeout: number): Error {
if (error instanceof StackOneError) {
return error;
}
if (error instanceof StreamableHTTPError && typeof error.code === 'number' && error.code > 0) {
const text = error.message.replace(TRANSPORT_ERROR_PREFIX, '').trim();
const detail = text ? `: ${text}` : '';
const reason = STATUS_CODES[error.code] ?? '';
return new StackOneAPIError(
`MCP request to ${endpoint} failed with ${error.code} ${reason}`.trimEnd() + detail,
error.code,
parseBody(text),
undefined,
{ cause: error },
);
}
if (isTimeout(error)) {
// In seconds, written as a number (0.5s, 60s), as the Python SDK writes it.
return new ToolSetLoadError(`MCP request to ${endpoint} timed out after ${timeout / 1000}s`, {
cause: error,
});
}
let leaf: unknown = error;
while (leaf instanceof Error && leaf.cause !== undefined) {
leaf = leaf.cause;
}
const description = leaf instanceof Error ? `${leaf.name}: ${leaf.message}` : String(leaf);
return new ToolSetLoadError(`MCP request to ${endpoint} failed: ${description}`, {
cause: error,
});
}
/**
* Whether an MCP request was still rate limited after its retries: an HTTP 429 from the endpoint.
*
* Unlike a dead account, this says nothing about one account and everything about the API key,
* so a fan-out must fail on it rather than skip the account and return a partial catalog. A
* `tools/call` result whose payload reports 429 is not this: the request itself was served.
*/
export function isRateLimitFailure(error: unknown): boolean {
return (
error instanceof StackOneAPIError &&
error.statusCode === 429 &&
(error.cause instanceof StreamableHTTPError ||
error.cause instanceof RateLimitTimeoutError ||
error.cause instanceof HttpRateLimitError)
);
}
/**
* The cause of a `StackOneAPIError` 429 that a plain HTTP request (`GET /accounts`) was answered
* with after its retries, so {@link isRateLimitFailure} reads it as the rate limit it is.
*/
export class HttpRateLimitError extends Error {
constructor(url: string) {
super(`${url} answered 429 after every retry`);
this.name = 'HttpRateLimitError';
}
}
/**
* Whether an MCP request was refused by the API's end-user check on `accountId`: the 400 it
* answers a non-shared account's request with when `x-end-user-id` does not name its end user.
*/
function isEndUserMismatch(error: unknown, accountId: string): boolean {
if (!(error instanceof StackOneAPIError) || error.statusCode !== 400) {
return false;
}
const body = error.responseBody;
const message =
typeof body === 'object' && body !== null && 'message' in body ? body.message : body;
// The guard's fixed wording, ending with this account's id, quoted or not: not an exact string,
// so a change to how the id is written does not silently turn the lookup off.
if (
typeof message !== 'string' ||
!message.startsWith('x-end-user-id header does not match account end user id')
) {
return false;
}
return (
message
.trimEnd()
.split(' ')
.pop()
?.replace(/^["']|["']$/g, '') === accountId
);
}
/** How a request finds the end user to send as `x-end-user-id` for an account. */
export interface EndUserSource {
/** The end user recorded for the account, if any. */
recorded: (accountId: string) => string | undefined;
/** Look the account's end user up with `GET /accounts`, and return what is then recorded. */
lookUp: (accountId: string) => Promise<string | undefined>;
}
/**
* Run an MCP request for `accountId` with its recorded end user, if any.
*
* An account id passed explicitly has no end user recorded until something calls
* `GET /accounts`, so a non-shared one is refused with a 400. When that happens to an account
* with nothing recorded, its end user is looked up once and, if there is one, the request is sent
* again with it. Otherwise the 400 is thrown as it came — with the lookup's error as its `cause`,
* if the lookup failed.
*/
export async function withEndUser<T>(
accountId: string | undefined,
source: EndUserSource | undefined,
request: (endUserId: string | undefined) => Promise<T>,
): Promise<T> {
const recorded = accountId && source ? source.recorded(accountId) : undefined;
try {
return await request(recorded);
} catch (error) {
if (!accountId || !source || recorded !== undefined || !isEndUserMismatch(error, accountId)) {
throw error;
}
// A failed lookup leaves the refusal to be thrown, with the lookup's error as its cause —
// a key without platform.read would otherwise be told only that the header is wrong. A rate
// limit is thrown instead: that is the key's, not the account's, and must stay fatal to a
// fan-out.
let endUserId: string | undefined;
try {
endUserId = await source.lookUp(accountId);
} catch (lookupError) {
if (isRateLimitFailure(lookupError)) {
throw lookupError;
}
const refusal = error as StackOneAPIError;
throw new StackOneAPIError(
refusal.message,
refusal.statusCode,
refusal.responseBody,
refusal.requestBody,
{ cause: lookupError },
);
}
if (endUserId === undefined) {
throw error;
}
return request(endUserId);
}
}
/** A session that timed out while a 429 was being retried. */
class RateLimitTimeoutError extends Error {
constructor(options: ErrorOptions) {
super('timed out while retrying a 429', options);
this.name = 'RateLimitTimeoutError';
}
}
const isTimeout = (error: unknown): boolean =>
error instanceof McpDeadlineError ||
(error instanceof McpError && error.code === ErrorCode.RequestTimeout);
/**
* Open an MCP session, run `work` in it and close it, all within one deadline.
*
* The MCP client's own defaults are generous and apply per request, so a host that accepts the
* connection and never answers would hold a listing open far longer than the toolset's
* `timeout`. The whole exchange is bounded instead, and closing the client on expiry aborts the
* in-flight request.
*/
async function withMcpSession<T>(
{ endpoint, headers, timeout }: McpRequest,
work: (client: Client) => Promise<T>,
): Promise<T> {
const expiresAt = retryTiming.now() + timeout;
// How many requests are waiting out a 429. One leaves the count as soon as a retry is answered
// with anything else, as in Python: a 429 that cleared must not make a later timeout — a
// stalled stream on that same response, or an unrelated request — look like a rate limit. It
// is counted per request because the client's requests overlap: its event-stream GET, say,
// answered while a tools/list retry hangs, must not clear the tools/list's 429.
let rateLimited = 0;
const transport = new StreamableHTTPClientTransport(new URL(endpoint), {
requestInit: { headers },
// Every request the client sends, handshake included, so it never sees a 429 it could
// have waited out — but never one that would outlast the session's deadline.
fetch: async (url, init) => {
let retrying = false;
const response = await fetchWithRetry(url, init, {
deadline: expiresAt,
onRetry: () => {
if (!retrying) {
retrying = true;
rateLimited += 1;
}
},
});
if (retrying && response.status !== 429) {
rateLimited -= 1;
}
return response;
},
});
const client = new Client({ name: 'stackone-ai-node', version });
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new McpDeadlineError(timeout)), timeout);
});
try {
const session = (async () => {
await client.connect(transport, { timeout });
return work(client);
})();
return await Promise.race([session, deadline]);
} catch (error) {
// A timeout while a 429 was being retried is the rate limit's doing, not the account's:
// reported as a timeout, it would skip the account and return a partial result, which a
// 429 must never do.
if (rateLimited > 0 && isTimeout(error)) {
throw new StackOneAPIError(
`MCP request to ${endpoint} was rate limited (429) and timed out after ${timeout / 1000}s while retrying`,
429,
null,
undefined,
{ cause: new RateLimitTimeoutError({ cause: error }) },
);
}
throw describeMcpFailure(error, endpoint, timeout);
} finally {
clearTimeout(timer);
await client.close().catch(() => undefined);
}
}
/**
* List every tool the MCP endpoint serves, following pagination.
*/
export async function listMcpTools(request: McpRequest): Promise<McpToolDefinition[]> {
return withMcpSession(request, async (client) => {
const collected: McpToolDefinition[] = [];
let cursor: string | undefined;
do {
const page = await client.listTools(cursor ? { cursor } : undefined, {
timeout: request.timeout,
});
for (const tool of page.tools) {
collected.push({
name: tool.name,
description: tool.description,
inputSchema: (tool.inputSchema ?? {}) as Record<string, unknown>,
});
}
cursor = page.nextCursor;
} while (cursor);
return collected;
});
}
interface ContentPart {
type: string;
text?: unknown;
}
/**
* Dig the HTTP status out of an MCP error payload.
*
* The transport succeeded, so there is no status on the response itself — but the payload
* carries one, and a caller cannot branch on 0. Both spellings and the wrapper keys the API
* uses are checked.
*/
function statusOf(parsed: JsonObject): number {
for (const candidate of [parsed, parsed.result, parsed.error, parsed.data]) {
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) {
continue;
}
for (const key of ['status_code', 'statusCode']) {
const status = (candidate as JsonObject)[key];
if (typeof status === 'number' && Number.isInteger(status)) {
return status;
}
}
}
return 0;
}
/**
* Turn an MCP `tools/call` result into a plain object.
*
* Text parts are joined and parsed as JSON; a non-object is wrapped as `{ result }`. With no text
* at all, `structuredContent` is used. Either way the result is returned as the server wrote it —
* for an action tool that is `{ isError: false, result, defenderMetadata?, policyMetadata? }`.
* Parts that are not text (images, embedded resources) are kept under `content_parts` rather than
* silently dropped.
*
* @throws StackOneAPIError If the result carries `isError`. A failed tool call comes back as an
* ordinary response with that flag set, so without this check the error body would be handed
* to the caller as though it were a success.
*/
export function parseToolResult(result: Record<string, unknown>, name: string): JsonObject {
const content = (Array.isArray(result.content) ? result.content : []) as ContentPart[];
const texts = content.filter(
(part): part is ContentPart & { text: string } =>
part.type === 'text' && typeof part.text === 'string' && part.text !== '',
);
const nonText = content.filter((part) => !texts.includes(part as ContentPart & { text: string }));
const payload = texts.map((part) => part.text).join('');
let parsed: JsonObject = {};
if (payload) {
let loaded: unknown;
try {
loaded = JSON.parse(payload);
} catch {
loaded = payload;
}
parsed =
typeof loaded === 'object' && loaded !== null && !Array.isArray(loaded)
? (loaded as JsonObject)
: { result: loaded as JsonObject[string] };
E} else if (typeof result.structuredContent === 'object' && result.structuredContent !== null) {
parsed = result.structuredContent as JsonObject;
}
if (result.isError === true) {
throw new StackOneAPIError(
`Tool ${JSON.stringify(name)} failed: ${payload || JSON.stringify(parsed)}`,
statusOf(parsed),
parsed,
);
}
// A copy, so content_parts below never writes into the caller's structuredContent.
const returned = { ...parsed };
if (nonText.length > 0) {
returned.content_parts = nonText as unknown as JsonObject[];
}
return returned;
}
/**
* Invoke a tool over MCP `tools/call`.
*/
export async function callMcpTool(
request: McpRequest,
name: string,
args: JsonObject,
): Promise<JsonObject> {
return withMcpSession(request, async (client) => {
const result = await client.callTool({ name, arguments: args }, undefined, {
timeout: request.timeout,
});
return parseToolResult(result as Record<string, unknown>, name);
});
}
|