tiny-http-mcp-server 0.1.17 → 0.1.18

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.
@@ -166,6 +166,7 @@ async function fetchInMemory(server, url, init) {
166
166
  response.headerValues = new Headers();
167
167
  response.headersSent = false;
168
168
  response.writableEnded = false;
169
+ response.destroyed = false;
169
170
  const setResponseHeader = (name, value) => {
170
171
  response.headerValues.delete(name);
171
172
  if (Array.isArray(value) && name.toLowerCase() === "set-cookie") {
@@ -224,6 +225,22 @@ async function fetchInMemory(server, url, init) {
224
225
  }
225
226
  return response;
226
227
  });
228
+ response.destroy = ((error) => {
229
+ if (!response.destroyed) {
230
+ response.destroyed = true;
231
+ try {
232
+ if (error === undefined)
233
+ streamController?.close();
234
+ else
235
+ streamController?.error(error);
236
+ }
237
+ catch {
238
+ // The consumer may have cancelled the stream already.
239
+ }
240
+ response.emit("close");
241
+ }
242
+ return response;
243
+ });
227
244
  response.flushHeaders = (() => {
228
245
  response.headersSent = true;
229
246
  });
package/dist/testing.d.ts CHANGED
@@ -56,4 +56,4 @@ export declare function createInMemoryTokenVerifier(options?: Partial<{
56
56
  now: () => number;
57
57
  }>): InMemoryTokenVerifier;
58
58
  export declare function createHttpTestPair(server: HttpServer): Promise<HttpTestPair>;
59
- export declare function createHttpTestPairWithTinyClient(server: HttpServer): Promise<TinyHttpTestPair>;
59
+ export declare function createHttpTestPairWithTinyClient(server: HttpServer, clientOptions?: Pick<import("tiny-mcp-client").McpClientOptions, "protocolVersion">): Promise<TinyHttpTestPair>;
package/dist/testing.js CHANGED
@@ -135,7 +135,7 @@ export async function createHttpTestPair(server) {
135
135
  }
136
136
  };
137
137
  }
138
- export async function createHttpTestPairWithTinyClient(server) {
138
+ export async function createHttpTestPairWithTinyClient(server, clientOptions = {}) {
139
139
  let tinyMcpClient;
140
140
  try {
141
141
  tinyMcpClient = await import("tiny-mcp-client");
@@ -147,7 +147,8 @@ export async function createHttpTestPairWithTinyClient(server) {
147
147
  const handle = await server.listenHttp({ port: 0 });
148
148
  const requests = [];
149
149
  const client = new tinyMcpClient.McpClient({
150
- clientInfo: { name: "tiny-http-test-client", version: "1.0.0" }
150
+ clientInfo: { name: "tiny-http-test-client", version: "1.0.0" },
151
+ ...clientOptions
151
152
  });
152
153
  const transport = new tinyMcpClient.HttpTransport({
153
154
  url: handle.url,
@@ -1,3 +1,5 @@
1
+ import { isIP } from "node:net";
2
+ import { fetchMcpResponse } from "../http-fetch.js";
1
3
  import { URL } from "node:url";
2
4
  import { createAuthStoreClientStore, createAuthStoreSessionStore } from "./auth-store-session-store.js";
3
5
  import { createLoopbackAuthorizationSession } from "./loopback-authorization.js";
@@ -62,6 +64,15 @@ export function createDefaultOAuthClientProvider(options) {
62
64
  async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false) {
63
65
  const canonicalResource = canonicalizeResourceIndicator(resource);
64
66
  let session = await loadSession(canonicalResource);
67
+ if (discovery !== undefined && getOwnString(discovery.authorizationServerMetadata, "issuer") !== discovery.authorizationServer) {
68
+ throw new Error("OAuth discovery authorization-server issuer mismatch");
69
+ }
70
+ if (session !== null && (canonicalizeResourceIndicator(session.resource) !== canonicalResource
71
+ || getOwnString(session.discovery.authorizationServerMetadata, "issuer") !== session.authorizationServer
72
+ || (discovery !== undefined && discovery.authorizationServer !== session.authorizationServer))) {
73
+ await clearSession(canonicalResource);
74
+ session = null;
75
+ }
65
76
  const sessionDiscovery = resolveDiscovery(discovery, session);
66
77
  if (session?.tokens !== undefined && !forceRefresh && !isExpired(session.tokens, now)) {
67
78
  return session;
@@ -281,14 +292,16 @@ export function createDefaultOAuthClientProvider(options) {
281
292
  }
282
293
  }
283
294
  const registrationBody = buildClientRegistrationBody(getClientMetadata(options.client), redirectUri);
284
- const response = await fetch(registrationEndpoint, {
295
+ const signal = AbortSignal.timeout(30_000);
296
+ const response = await fetchMcpResponse(fetch, registrationEndpoint, {
285
297
  method: "POST",
286
298
  headers: {
287
299
  "Content-Type": "application/json"
288
300
  },
289
- body: JSON.stringify(registrationBody)
301
+ body: JSON.stringify(registrationBody),
302
+ signal
290
303
  });
291
- const payload = await readOAuthJsonObjectResponse(response);
304
+ const payload = await readOAuthJsonObjectResponse(response, signal);
292
305
  const clientId = getOwnString(payload, "client_id");
293
306
  if (clientId === undefined || clientId.trim().length === 0) {
294
307
  throw new Error("OAuth client registration response missing client_id");
@@ -545,10 +558,14 @@ function isLoopbackHostname(hostname) {
545
558
  const normalizedHostname = normalizeHostname(hostname);
546
559
  return (normalizedHostname === "localhost" ||
547
560
  normalizedHostname === "::1" ||
548
- normalizedHostname.startsWith("127."));
561
+ normalizedHostname === "[::1]" ||
562
+ (isIP(normalizedHostname) === 4 && normalizedHostname.startsWith("127.")));
549
563
  }
550
564
  function assertSecureUrl(value, label) {
551
565
  const url = new URL(value);
566
+ if (url.username !== "" || url.password !== "" || url.hash !== "") {
567
+ throw new Error(`${label} must not include credentials or fragment`);
568
+ }
552
569
  if (url.protocol === "https:") {
553
570
  return;
554
571
  }
@@ -36,5 +36,5 @@ export declare function refreshAccessToken(input: {
36
36
  fetch: OAuthMetadataFetch;
37
37
  now: () => number;
38
38
  }): Promise<StoredOAuthTokens>;
39
- export declare function readOAuthJsonObjectResponse(response: Response): Promise<Record<string, unknown>>;
39
+ export declare function readOAuthJsonObjectResponse(response: Response, signal?: AbortSignal): Promise<Record<string, unknown>>;
40
40
  export {};
@@ -1,4 +1,6 @@
1
1
  import { canonicalizeResourceIndicator } from "../resource-indicator.js";
2
+ import { readBoundedResponseText } from "../http-response.js";
3
+ import { fetchMcpResponse } from "../http-fetch.js";
2
4
  const MAX_JS_DATE_MS = 8_640_000_000_000_000;
3
5
  export class OAuthError extends Error {
4
6
  error;
@@ -68,14 +70,16 @@ async function requestTokens(input) {
68
70
  if (input.clientSecret !== undefined) {
69
71
  body.set("client_secret", input.clientSecret);
70
72
  }
71
- const response = await input.fetch(input.tokenEndpoint, {
73
+ const signal = AbortSignal.timeout(30_000);
74
+ const response = await fetchMcpResponse(input.fetch, input.tokenEndpoint, {
72
75
  method: "POST",
73
76
  headers: {
74
77
  "Content-Type": "application/x-www-form-urlencoded"
75
78
  },
76
- body: body.toString()
79
+ body: body.toString(),
80
+ signal
77
81
  });
78
- const payload = await readOAuthJsonObjectResponse(response);
82
+ const payload = await readOAuthJsonObjectResponse(response, signal);
79
83
  const accessToken = getOwnEntry(payload, "access_token");
80
84
  if (typeof accessToken !== "string" || accessToken.trim().length === 0) {
81
85
  throw new Error("OAuth token response missing access_token");
@@ -115,13 +119,17 @@ async function requestTokens(input) {
115
119
  scope: normalizedScope === undefined ? undefined : normalizedScope
116
120
  };
117
121
  }
118
- export async function readOAuthJsonObjectResponse(response) {
122
+ export async function readOAuthJsonObjectResponse(response, signal) {
119
123
  const fallbackError = createFallbackOAuthError(response.status);
120
124
  let payload;
121
125
  try {
122
- payload = await response.json();
126
+ payload = JSON.parse(await readBoundedResponseText(response, 1024 * 1024, undefined, signal));
123
127
  }
124
- catch {
128
+ catch (error) {
129
+ if (signal?.aborted)
130
+ throw signal.reason;
131
+ if (error instanceof Error && error.message.startsWith("HTTP response exceeds "))
132
+ throw error;
125
133
  if (!response.ok) {
126
134
  throw fallbackError;
127
135
  }
@@ -0,0 +1 @@
1
+ export declare function fetchMcpResponse(fetchImplementation: (input: string | URL, init?: RequestInit) => Promise<Response>, input: string | URL, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,8 @@
1
+ export async function fetchMcpResponse(fetchImplementation, input, init = {}) {
2
+ const response = await fetchImplementation(input, { ...init, redirect: "error" });
3
+ if (response.redirected || response.type === "opaqueredirect") {
4
+ void response.body?.cancel().catch(() => undefined);
5
+ throw new Error("MCP HTTP redirects are not allowed");
6
+ }
7
+ return response;
8
+ }
@@ -0,0 +1 @@
1
+ export declare function readBoundedResponseText(response: Response, maxBytes: number, readers?: Set<ReadableStreamDefaultReader<Uint8Array>>, signal?: AbortSignal): Promise<string>;
@@ -0,0 +1,50 @@
1
+ export async function readBoundedResponseText(response, maxBytes, readers, signal) {
2
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
3
+ throw new Error("HTTP response byte limit must be a positive safe integer");
4
+ if (signal?.aborted) {
5
+ void response.body?.cancel().catch(() => undefined);
6
+ throw signal.reason;
7
+ }
8
+ const length = response.headers.get("Content-Length");
9
+ if (length !== null &&
10
+ length.length > 0 &&
11
+ [...length].every((character) => character >= "0" && character <= "9") &&
12
+ Number(length) > maxBytes) {
13
+ void response.body?.cancel().catch(() => undefined);
14
+ throw new Error(`HTTP response exceeds ${maxBytes} bytes`);
15
+ }
16
+ if (response.body === null)
17
+ return "";
18
+ const reader = response.body.getReader();
19
+ readers?.add(reader);
20
+ const abort = () => {
21
+ void reader.cancel().catch(() => undefined);
22
+ };
23
+ signal?.addEventListener("abort", abort, { once: true });
24
+ if (signal?.aborted)
25
+ abort();
26
+ const decoder = new TextDecoder("utf-8", { fatal: true });
27
+ let bytes = 0;
28
+ let text = "";
29
+ try {
30
+ while (true) {
31
+ const chunk = await reader.read();
32
+ signal?.throwIfAborted();
33
+ if (chunk.done)
34
+ return text + decoder.decode();
35
+ bytes += chunk.value.byteLength;
36
+ if (bytes > maxBytes)
37
+ throw new Error(`HTTP response exceeds ${maxBytes} bytes`);
38
+ text += decoder.decode(chunk.value, { stream: true });
39
+ }
40
+ }
41
+ catch (error) {
42
+ void reader.cancel().catch(() => undefined);
43
+ throw error;
44
+ }
45
+ finally {
46
+ signal?.removeEventListener("abort", abort);
47
+ readers?.delete(reader);
48
+ reader.releaseLock();
49
+ }
50
+ }
@@ -8,3 +8,5 @@ export { createJwksTokenVerifier, } from "./server/jwks-token-verifier.js";
8
8
  export type { DefaultOAuthClientProviderOptions, OAuthAuthorizationServerMetadata, OAuthClientMetadata, OAuthClientProvider, OAuthClientProviderOptions, OAuthDiscoveryResult, OAuthMetadataFetch, OAuthProtectedResourceMetadata, OAuthSessionStore, OAuthUnauthorizedChallenge, StoredOAuthSession, StoredOAuthTokens, } from "./client/types.js";
9
9
  export type { JwksTokenVerifier, JwksTokenVerifierOptions, JwksVerifiedAccessToken, } from "./server/jwks-token-verifier.js";
10
10
  export type { LoopbackAuthorizationOptions, LoopbackAuthorizationSession, OAuthLandingPage, } from "./client/loopback-authorization.js";
11
+ export { readBoundedResponseText } from "./http-response.js";
12
+ export { fetchMcpResponse } from "./http-fetch.js";
@@ -5,3 +5,5 @@ export { generateCodeChallenge, generateCodeVerifier, } from "./client/pkce.js";
5
5
  export { OAuthError, } from "./client/token-endpoint.js";
6
6
  export { canonicalizeResourceIndicator, } from "./resource-indicator.js";
7
7
  export { createJwksTokenVerifier, } from "./server/jwks-token-verifier.js";
8
+ export { readBoundedResponseText } from "./http-response.js";
9
+ export { fetchMcpResponse } from "./http-fetch.js";
@@ -1,3 +1,5 @@
1
+ import { readBoundedResponseText } from "../http-response.js";
2
+ import { fetchMcpResponse } from "../http-fetch.js";
1
3
  import { decodeProtectedHeader, errors, importJWK, jwtVerify } from "jose";
2
4
  import { canonicalizeResourceIndicator } from "../resource-indicator.js";
3
5
  const DEFAULT_ALLOWED_ALGORITHMS = [
@@ -158,16 +160,18 @@ function normalizeVerificationError(error) {
158
160
  }
159
161
  async function loadJwks(jwksUrl, fetchImplementation, timeoutMs) {
160
162
  try {
161
- const response = await fetchImplementation(jwksUrl, {
163
+ const signal = AbortSignal.timeout(timeoutMs);
164
+ const response = await fetchMcpResponse(fetchImplementation, jwksUrl, {
162
165
  headers: {
163
166
  Accept: "application/json"
164
167
  },
165
- signal: AbortSignal.timeout(timeoutMs)
168
+ signal
166
169
  });
167
170
  if (!response.ok) {
171
+ await response.body?.cancel().catch(() => undefined);
168
172
  throw createTemporarilyUnavailableError();
169
173
  }
170
- const payload = (await response.json());
174
+ const payload = JSON.parse(await readBoundedResponseText(response, 1024 * 1024, undefined, signal));
171
175
  const keys = isObjectRecord(payload) ? getOwnEntry(payload, "keys") : undefined;
172
176
  if (!isObjectRecord(payload) || !Array.isArray(keys) || !keys.every(isObjectRecord)) {
173
177
  throw createTemporarilyUnavailableError();
@@ -280,6 +284,9 @@ function isLoopbackHostname(hostname) {
280
284
  }
281
285
  export function createJwksTokenVerifier(options) {
282
286
  const jwksUrl = toUrl(options.jwksUrl, "jwksUrl");
287
+ if ((jwksUrl.protocol !== "http:" && jwksUrl.protocol !== "https:") || jwksUrl.username !== "" || jwksUrl.password !== "") {
288
+ throw new Error("jwksUrl must be an HTTP or HTTPS URL without credentials");
289
+ }
283
290
  const clockSkewSeconds = options.clockSkewSeconds ?? 30;
284
291
  const allowedAlgorithms = options.allowedAlgorithms ?? DEFAULT_ALLOWED_ALGORITHMS;
285
292
  const jwksCacheTtlMs = options.jwksCacheTtlMs ?? 300_000;
@@ -287,6 +294,17 @@ export function createJwksTokenVerifier(options) {
287
294
  const jwksRefreshCooldownMs = options.jwksRefreshCooldownMs ?? 30_000;
288
295
  const requireAccessTokenType = options.requireAccessTokenType ?? false;
289
296
  const fetchImplementation = options.fetch ?? globalThis.fetch;
297
+ if (!Number.isFinite(clockSkewSeconds) || clockSkewSeconds < 0) {
298
+ throw new Error("clockSkewSeconds must be a finite non-negative number");
299
+ }
300
+ for (const key of ["jwksCacheTtlMs", "jwksRefreshCooldownMs"]) {
301
+ const value = key === "jwksCacheTtlMs" ? jwksCacheTtlMs : jwksRefreshCooldownMs;
302
+ if (!Number.isSafeInteger(value) || value < 0)
303
+ throw new Error(`${key} must be a non-negative safe integer`);
304
+ }
305
+ if (!Number.isInteger(jwksFetchTimeoutMs) || jwksFetchTimeoutMs <= 0 || jwksFetchTimeoutMs > 2_147_483_647) {
306
+ throw new Error("jwksFetchTimeoutMs must be a positive integer no greater than 2147483647");
307
+ }
290
308
  let cachedJwks;
291
309
  let pendingJwks;
292
310
  let pendingForcedRefresh;
@@ -88,6 +88,7 @@ You can also call `discoverOAuthMetadata(resourceUrl, options)` directly, or ins
88
88
  | `fetch` | `(input, init?) => Promise<Response>` | Custom fetch implementation. |
89
89
  | `oauth` | `OAuthClientProviderOptions` | Enables OAuth authorization handling. |
90
90
  | `oauthDiscoveryCache` | `OAuthDiscoveryCache` | Optional shared metadata cache. |
91
+ | `maxResponseBytes` | `number` | Maximum JSON/error body or retained SSE event bytes; defaults to 16 MiB and must be a positive safe integer. |
91
92
 
92
93
  ### `StdioTransportOptions`
93
94