tiny-http-mcp-server 0.1.23 → 0.1.25

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.
@@ -18,7 +18,7 @@
18
18
  },
19
19
  {
20
20
  "name": "tiny-http-mcp-server",
21
- "version": "0.1.23",
21
+ "version": "0.1.25",
22
22
  "license": "MIT"
23
23
  },
24
24
  {
@@ -70,7 +70,10 @@ const verifier = createJwksTokenVerifier({
70
70
  Fixed redirects support `localhost`, `127.0.0.1`, and `::1` over HTTP. Their
71
71
  exact spelling, port, path and query are preserved through registration,
72
72
  authorization and code exchange. Credentials, fragments, port zero and reserved
73
- OAuth callback query parameters are rejected before binding a listener. Omit
73
+ OAuth callback query parameters are rejected before binding a listener.
74
+ `createDefaultOAuthClientProvider` also checks the configured redirect before
75
+ creating the provider. Imported tokens that cannot be sent as HTTP header
76
+ values fail with diagnostics that omit their contents. Omit
74
77
  `redirectUri` to allocate a random loopback port. Standalone callback sessions
75
78
  accept the same `redirectUri`, `signal` and `timeoutMs` options. Cancellation,
76
79
  timeout and explicit close settle pending code waits and release listeners.
@@ -81,6 +84,15 @@ registration, token requests and bounded token-body reads. Cancellation retains
81
84
  its original reason and does not retry authorization. Custom providers should
82
85
  observe the supplied signal and pass it to any work they start.
83
86
 
87
+ `authorizeRequest` may return an owned token snapshot for the request it
88
+ authorized. The HTTP client supplies that snapshot as `presentedTokens`, along
89
+ with the actual request's `requestHeaders`, to `handleUnauthorized`. Providers
90
+ that return `void` remain supported. The native provider compares the rejected
91
+ snapshot with persisted credentials: delayed 401s retry with a newer grant
92
+ without redeeming its refresh token again. A proven current token is refreshed
93
+ on 401 even when the server omits `error="invalid_token"`. Invalid provenance
94
+ fails without quoting token values.
95
+
84
96
  Configure `client.metadata.scope` to request a precise scope set; broader
85
97
  discovery metadata does not override it.
86
98
 
@@ -2,7 +2,7 @@ import { isIP } from "node:net";
2
2
  import { fetchMcpResponse } from "../http-fetch.js";
3
3
  import { URL } from "node:url";
4
4
  import { createAuthStoreClientStore, createAuthStoreSessionStore } from "./auth-store-session-store.js";
5
- import { createLoopbackAuthorizationSession } from "./loopback-authorization.js";
5
+ import { createLoopbackAuthorizationSession, loopbackTarget } from "./loopback-authorization.js";
6
6
  import { createAuthorizationState } from "./authorization-state.js";
7
7
  import { generateCodeChallenge, generateCodeVerifier } from "./pkce.js";
8
8
  import { exchangeAuthorizationCode, OAuthError, refreshAccessToken, isRetryableOAuthError, readOAuthJsonObjectResponse } from "./token-endpoint.js";
@@ -15,6 +15,7 @@ export function createOAuthClientProvider(options) {
15
15
  return createDefaultOAuthClientProvider(options);
16
16
  }
17
17
  export function createDefaultOAuthClientProvider(options) {
18
+ loopbackTarget(options.browser);
18
19
  const sessionStore = options.sessionStore ?? createAuthStoreSessionStore(options.authStore);
19
20
  const clientStore = options.authStore === undefined ? null : createAuthStoreClientStore(options.authStore);
20
21
  const now = options.now ?? Date.now;
@@ -39,6 +40,14 @@ export function createDefaultOAuthClientProvider(options) {
39
40
  };
40
41
  if (initialGrant !== undefined && (initialGrant.tokens === undefined || initialGrant.client === null))
41
42
  throw new Error("OAuth initial grant requires valid tokens and the original client ID");
43
+ if (initialGrant?.tokens !== undefined) {
44
+ try {
45
+ new Headers({ Authorization: `Bearer ${initialGrant.tokens.accessToken}` });
46
+ }
47
+ catch {
48
+ throw new Error("OAuth initial grant access token is not a valid HTTP header value");
49
+ }
50
+ }
42
51
  let initialGrantConsumed = false;
43
52
  return {
44
53
  async authorizeRequest(input) {
@@ -49,7 +58,7 @@ export function createDefaultOAuthClientProvider(options) {
49
58
  if (session === null && !initialGrantConsumed && initialGrant?.resource === requestUrl &&
50
59
  initialGrant.tokens !== undefined && !isExpired(initialGrant.tokens, now)) {
51
60
  input.headers.set("Authorization", `Bearer ${initialGrant.tokens.accessToken}`);
52
- return;
61
+ return { ...initialGrant.tokens };
53
62
  }
54
63
  if (session === null ||
55
64
  accessToken === undefined ||
@@ -59,6 +68,7 @@ export function createDefaultOAuthClientProvider(options) {
59
68
  }
60
69
  assertRequestMatchesResource(requestUrl, session.resource);
61
70
  input.headers.set("Authorization", `Bearer ${accessToken}`);
71
+ return { ...session.tokens };
62
72
  },
63
73
  async handleUnauthorized(input) {
64
74
  try {
@@ -66,12 +76,28 @@ export function createDefaultOAuthClientProvider(options) {
66
76
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
67
77
  const resource = canonicalizeResourceIndicator(input.discovery.resource);
68
78
  assertRequestMatchesResource(requestUrl, resource);
69
- const forceRefresh = (hasCachedAccessToken(await loadSession(resource)) || (!initialGrantConsumed && initialGrant?.resource === resource)) &&
70
- input.challenge?.params.error === "invalid_token";
79
+ const cached = await loadSession(resource);
80
+ const currentTokens = cached?.tokens ?? (!initialGrantConsumed && initialGrant?.resource === resource ? initialGrant.tokens : undefined);
81
+ let rejectedCurrentGrant = hasCachedAccessToken(cached) || (!initialGrantConsumed && initialGrant?.resource === resource);
82
+ let presentedTokens = input.presentedTokens;
83
+ if (input.presentedTokens !== undefined) {
84
+ rejectedCurrentGrant = false;
85
+ if (input.presentedTokens !== null) {
86
+ const presented = normalizeStoredTokens(input.presentedTokens);
87
+ const header = input.requestHeaders?.get("Authorization") ?? "";
88
+ const separator = header.indexOf(" ");
89
+ if (presented === undefined || header.slice(0, separator).toLowerCase() !== "bearer" || header.slice(separator + 1).trim() !== presented.accessToken)
90
+ throw new Error("OAuth rejected-request provenance does not match its authorization header");
91
+ presentedTokens = presented;
92
+ rejectedCurrentGrant = currentTokens !== undefined && sameTokenGrant(currentTokens, presented);
93
+ }
94
+ }
95
+ const challengeError = input.challenge?.params.error;
96
+ const forceRefresh = rejectedCurrentGrant && (challengeError === "invalid_token" || (input.presentedTokens !== undefined && challengeError === undefined));
71
97
  const session = await ensureAuthorizedSession(resource, {
72
98
  ...input.discovery,
73
99
  resource
74
- }, input.fetch, true, forceRefresh, input.signal);
100
+ }, input.fetch, true, forceRefresh, input.signal, presentedTokens);
75
101
  if (session?.tokens?.accessToken === undefined) {
76
102
  return { action: "fail" };
77
103
  }
@@ -86,7 +112,7 @@ export function createDefaultOAuthClientProvider(options) {
86
112
  }
87
113
  }
88
114
  };
89
- async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false, signal) {
115
+ async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false, signal, rejectedTokens) {
90
116
  signal?.throwIfAborted();
91
117
  const canonicalResource = canonicalizeResourceIndicator(resource);
92
118
  let session = await loadSession(canonicalResource);
@@ -111,6 +137,8 @@ export function createDefaultOAuthClientProvider(options) {
111
137
  initialGrantConsumed = true;
112
138
  signal?.throwIfAborted();
113
139
  }
140
+ if (forceRefresh && rejectedTokens !== undefined && (rejectedTokens === null || session?.tokens === undefined || !sameTokenGrant(session.tokens, rejectedTokens)))
141
+ forceRefresh = false;
114
142
  const sessionDiscovery = resolveDiscovery(discovery, session);
115
143
  if (session?.tokens !== undefined && !forceRefresh && !isExpired(session.tokens, now)) {
116
144
  return session;
@@ -445,6 +473,10 @@ function resolveDiscovery(discovery, session) {
445
473
  authorizationServerMetadata: metadata
446
474
  };
447
475
  }
476
+ function sameTokenGrant(left, right) {
477
+ return left.accessToken === right.accessToken && left.refreshToken === right.refreshToken &&
478
+ left.tokenType === right.tokenType && left.expiresAt === right.expiresAt && left.scope === right.scope;
479
+ }
448
480
  function clearSessionTokens(session) {
449
481
  const nextSession = { ...session };
450
482
  delete nextSession.tokens;
@@ -21,5 +21,10 @@ export interface LoopbackAuthorizationSession {
21
21
  close(): void;
22
22
  }
23
23
  export declare function createLoopbackAuthorizationSession(options?: LoopbackAuthorizationOptions): Promise<LoopbackAuthorizationSession>;
24
+ export declare function loopbackTarget(options: LoopbackAuthorizationOptions): {
25
+ port: number;
26
+ host: string;
27
+ callbackPath: string;
28
+ };
24
29
  export declare function extractCodeFromInput(input: string): string | null;
25
30
  export declare function buildSuccessPage(landingPage?: OAuthLandingPage): string;
@@ -54,7 +54,7 @@ export async function createLoopbackAuthorizationSession(options = {}) {
54
54
  }
55
55
  };
56
56
  }
57
- function loopbackTarget(options) {
57
+ export function loopbackTarget(options) {
58
58
  if (options.redirectUri !== undefined) {
59
59
  let url;
60
60
  try {
@@ -33,7 +33,7 @@ export interface OAuthClientProvider {
33
33
  headers: Headers;
34
34
  fetch: OAuthMetadataFetch;
35
35
  signal?: AbortSignal;
36
- }): Promise<void> | void;
36
+ }): Promise<StoredOAuthTokens | void> | StoredOAuthTokens | void;
37
37
  handleUnauthorized(input: {
38
38
  requestUrl: URL;
39
39
  response: Response;
@@ -41,6 +41,10 @@ export interface OAuthClientProvider {
41
41
  discovery: OAuthDiscoveryResult;
42
42
  fetch: OAuthMetadataFetch;
43
43
  signal?: AbortSignal;
44
+ /** Headers actually attached to this rejected request. */
45
+ requestHeaders?: Headers;
46
+ /** Owned snapshot returned when this request was authorized, or null if absent. */
47
+ presentedTokens?: StoredOAuthTokens | null;
44
48
  }): Promise<{
45
49
  action: "retry";
46
50
  } | {
@@ -110,7 +110,7 @@ interface OAuthClientProvider {
110
110
  headers: Headers;
111
111
  fetch: OAuthMetadataFetch;
112
112
  signal?: AbortSignal;
113
- }): Promise<void> | void;
113
+ }): Promise<StoredOAuthTokens | void> | StoredOAuthTokens | void;
114
114
  handleUnauthorized(input: {
115
115
  requestUrl: URL;
116
116
  response: Response;
@@ -118,6 +118,10 @@ interface OAuthClientProvider {
118
118
  discovery: OAuthDiscoveryResult;
119
119
  fetch: OAuthMetadataFetch;
120
120
  signal?: AbortSignal;
121
+ /** Headers actually attached to this rejected request. */
122
+ requestHeaders?: Headers;
123
+ /** Owned snapshot returned when this request was authorized, or null if absent. */
124
+ presentedTokens?: StoredOAuthTokens | null;
121
125
  }): Promise<{
122
126
  action: "retry";
123
127
  } | {
@@ -649,6 +653,7 @@ declare class HttpTransport implements McpTransport {
649
653
  private readonly oauthMetadataDiscovery;
650
654
  private readonly inFlightFetchAbortControllers;
651
655
  private readonly inFlightOAuthAbortControllers;
656
+ private readonly oauthRequestTokens;
652
657
  private readonly openResponseReaders;
653
658
  private readonly modernRequests;
654
659
  private modernMode;
@@ -4594,6 +4594,7 @@ function createOAuthClientProvider(options) {
4594
4594
  return createDefaultOAuthClientProvider(options);
4595
4595
  }
4596
4596
  function createDefaultOAuthClientProvider(options) {
4597
+ loopbackTarget(options.browser);
4597
4598
  const sessionStore = options.sessionStore ?? createAuthStoreSessionStore(options.authStore);
4598
4599
  const clientStore = options.authStore === void 0 ? null : createAuthStoreClientStore(options.authStore);
4599
4600
  const now = options.now ?? Date.now;
@@ -4617,6 +4618,13 @@ function createDefaultOAuthClientProvider(options) {
4617
4618
  };
4618
4619
  if (initialGrant !== void 0 && (initialGrant.tokens === void 0 || initialGrant.client === null))
4619
4620
  throw new Error("OAuth initial grant requires valid tokens and the original client ID");
4621
+ if (initialGrant?.tokens !== void 0) {
4622
+ try {
4623
+ new Headers({ Authorization: `Bearer ${initialGrant.tokens.accessToken}` });
4624
+ } catch {
4625
+ throw new Error("OAuth initial grant access token is not a valid HTTP header value");
4626
+ }
4627
+ }
4620
4628
  let initialGrantConsumed = false;
4621
4629
  return {
4622
4630
  async authorizeRequest(input) {
@@ -4626,13 +4634,14 @@ function createDefaultOAuthClientProvider(options) {
4626
4634
  const accessToken = session?.tokens?.accessToken;
4627
4635
  if (session === null && !initialGrantConsumed && initialGrant?.resource === requestUrl && initialGrant.tokens !== void 0 && !isExpired(initialGrant.tokens, now)) {
4628
4636
  input.headers.set("Authorization", `Bearer ${initialGrant.tokens.accessToken}`);
4629
- return;
4637
+ return { ...initialGrant.tokens };
4630
4638
  }
4631
4639
  if (session === null || accessToken === void 0 || session.tokens === void 0 || isExpired(session.tokens, now)) {
4632
4640
  return;
4633
4641
  }
4634
4642
  assertRequestMatchesResource(requestUrl, session.resource);
4635
4643
  input.headers.set("Authorization", `Bearer ${accessToken}`);
4644
+ return { ...session.tokens };
4636
4645
  },
4637
4646
  async handleUnauthorized(input) {
4638
4647
  try {
@@ -4640,11 +4649,28 @@ function createDefaultOAuthClientProvider(options) {
4640
4649
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
4641
4650
  const resource = canonicalizeResourceIndicator(input.discovery.resource);
4642
4651
  assertRequestMatchesResource(requestUrl, resource);
4643
- const forceRefresh = (hasCachedAccessToken(await loadSession(resource)) || !initialGrantConsumed && initialGrant?.resource === resource) && input.challenge?.params.error === "invalid_token";
4652
+ const cached = await loadSession(resource);
4653
+ const currentTokens = cached?.tokens ?? (!initialGrantConsumed && initialGrant?.resource === resource ? initialGrant.tokens : void 0);
4654
+ let rejectedCurrentGrant = hasCachedAccessToken(cached) || !initialGrantConsumed && initialGrant?.resource === resource;
4655
+ let presentedTokens = input.presentedTokens;
4656
+ if (input.presentedTokens !== void 0) {
4657
+ rejectedCurrentGrant = false;
4658
+ if (input.presentedTokens !== null) {
4659
+ const presented = normalizeStoredTokens(input.presentedTokens);
4660
+ const header = input.requestHeaders?.get("Authorization") ?? "";
4661
+ const separator = header.indexOf(" ");
4662
+ if (presented === void 0 || header.slice(0, separator).toLowerCase() !== "bearer" || header.slice(separator + 1).trim() !== presented.accessToken)
4663
+ throw new Error("OAuth rejected-request provenance does not match its authorization header");
4664
+ presentedTokens = presented;
4665
+ rejectedCurrentGrant = currentTokens !== void 0 && sameTokenGrant(currentTokens, presented);
4666
+ }
4667
+ }
4668
+ const challengeError = input.challenge?.params.error;
4669
+ const forceRefresh = rejectedCurrentGrant && (challengeError === "invalid_token" || input.presentedTokens !== void 0 && challengeError === void 0);
4644
4670
  const session = await ensureAuthorizedSession(resource, {
4645
4671
  ...input.discovery,
4646
4672
  resource
4647
- }, input.fetch, true, forceRefresh, input.signal);
4673
+ }, input.fetch, true, forceRefresh, input.signal, presentedTokens);
4648
4674
  if (session?.tokens?.accessToken === void 0) {
4649
4675
  return { action: "fail" };
4650
4676
  }
@@ -4658,7 +4684,7 @@ function createDefaultOAuthClientProvider(options) {
4658
4684
  }
4659
4685
  }
4660
4686
  };
4661
- async function ensureAuthorizedSession(resource, discovery, fetch2, allowInteractive, forceRefresh = false, signal) {
4687
+ async function ensureAuthorizedSession(resource, discovery, fetch2, allowInteractive, forceRefresh = false, signal, rejectedTokens) {
4662
4688
  signal?.throwIfAborted();
4663
4689
  const canonicalResource = canonicalizeResourceIndicator(resource);
4664
4690
  let session = await loadSession(canonicalResource);
@@ -4685,6 +4711,8 @@ function createDefaultOAuthClientProvider(options) {
4685
4711
  initialGrantConsumed = true;
4686
4712
  signal?.throwIfAborted();
4687
4713
  }
4714
+ if (forceRefresh && rejectedTokens !== void 0 && (rejectedTokens === null || session?.tokens === void 0 || !sameTokenGrant(session.tokens, rejectedTokens)))
4715
+ forceRefresh = false;
4688
4716
  const sessionDiscovery = resolveDiscovery(discovery, session);
4689
4717
  if (session?.tokens !== void 0 && !forceRefresh && !isExpired(session.tokens, now)) {
4690
4718
  return session;
@@ -5006,6 +5034,9 @@ function resolveDiscovery(discovery, session) {
5006
5034
  authorizationServerMetadata: metadata
5007
5035
  };
5008
5036
  }
5037
+ function sameTokenGrant(left, right) {
5038
+ return left.accessToken === right.accessToken && left.refreshToken === right.refreshToken && left.tokenType === right.tokenType && left.expiresAt === right.expiresAt && left.scope === right.scope;
5039
+ }
5009
5040
  function clearSessionTokens(session) {
5010
5041
  const nextSession = { ...session };
5011
5042
  delete nextSession.tokens;
@@ -6781,6 +6812,7 @@ var HttpTransport = class {
6781
6812
  oauthMetadataDiscovery;
6782
6813
  inFlightFetchAbortControllers = /* @__PURE__ */ new Set();
6783
6814
  inFlightOAuthAbortControllers = /* @__PURE__ */ new Set();
6815
+ oauthRequestTokens = /* @__PURE__ */ new WeakMap();
6784
6816
  openResponseReaders = /* @__PURE__ */ new Set();
6785
6817
  modernRequests = /* @__PURE__ */ new Map();
6786
6818
  modernMode = false;
@@ -7051,7 +7083,7 @@ var HttpTransport = class {
7051
7083
  }
7052
7084
  async authorizeRequestHeaders(headers, signal) {
7053
7085
  signal?.throwIfAborted();
7054
- await this.oauthProvider?.authorizeRequest?.({
7086
+ const tokens = await this.oauthProvider?.authorizeRequest?.({
7055
7087
  requestUrl: new URL(this.url),
7056
7088
  headers,
7057
7089
  signal,
@@ -7060,6 +7092,7 @@ var HttpTransport = class {
7060
7092
  signal: signal === void 0 ? init?.signal : init?.signal == null ? signal : AbortSignal.any([signal, init.signal])
7061
7093
  })
7062
7094
  });
7095
+ if (tokens !== void 0) this.oauthRequestTokens.set(headers, { ...tokens });
7063
7096
  signal?.throwIfAborted();
7064
7097
  return headers;
7065
7098
  }
@@ -7203,7 +7236,7 @@ var HttpTransport = class {
7203
7236
  const message = responseBody.length === 0 ? `HTTP transport POST failed (${statusDescriptor})` : `HTTP transport POST failed (${statusDescriptor}): ${responseBody}`;
7204
7237
  throw new HttpTransportError(message, response.status, "POST");
7205
7238
  }
7206
- async maybeHandleUnauthorizedResponse(response, signal) {
7239
+ async maybeHandleUnauthorizedResponse(response, signal, requestHeaders, presentedTokens) {
7207
7240
  if (response.status !== 401 || this.oauthProvider === void 0) {
7208
7241
  return false;
7209
7242
  }
@@ -7224,6 +7257,8 @@ var HttpTransport = class {
7224
7257
  challenge,
7225
7258
  discovery,
7226
7259
  signal,
7260
+ requestHeaders: new Headers(requestHeaders),
7261
+ presentedTokens,
7227
7262
  fetch: (url, init) => fetchMcpResponse(this.fetchImpl, url, {
7228
7263
  ...init,
7229
7264
  signal: init?.signal == null ? signal : AbortSignal.any([signal, init.signal])
@@ -7364,16 +7399,20 @@ var HttpTransport = class {
7364
7399
  const request = async () => {
7365
7400
  controller.signal.throwIfAborted();
7366
7401
  const headers = await input.createHeaders(controller.signal);
7402
+ const headerSnapshot = new Headers(headers);
7403
+ const tokens = this.oauthRequestTokens.get(headers) ?? null;
7367
7404
  controller.signal.throwIfAborted();
7368
- return this.fetchWithAbort(input.url ?? this.url, {
7405
+ const response = await this.fetchWithAbort(input.url ?? this.url, {
7369
7406
  method: input.method,
7370
7407
  headers,
7371
7408
  body: input.body
7372
7409
  }, controller);
7410
+ return { response, headers: headerSnapshot, tokens };
7373
7411
  };
7374
7412
  try {
7375
- let response = await request();
7376
- if (await this.maybeHandleUnauthorizedResponse(response, controller.signal)) response = await request();
7413
+ let attempt = await request();
7414
+ if (await this.maybeHandleUnauthorizedResponse(attempt.response, controller.signal, attempt.headers, attempt.tokens)) attempt = await request();
7415
+ const response = attempt.response;
7377
7416
  const oauthError = this.oauthProvider === void 0 ? null : this.readOAuthChallengeError(response);
7378
7417
  if (oauthError !== null) {
7379
7418
  void response.body?.cancel().catch(() => void 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tiny-http-mcp-server",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "Minimal MCP server over HTTP built on tiny-stdio-mcp-server",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",