tiny-http-mcp-server 0.1.24 → 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.24",
21
+ "version": "0.1.25",
22
22
  "license": "MIT"
23
23
  },
24
24
  {
@@ -84,6 +84,15 @@ registration, token requests and bounded token-body reads. Cancellation retains
84
84
  its original reason and does not retry authorization. Custom providers should
85
85
  observe the supplied signal and pass it to any work they start.
86
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
+
87
96
  Configure `client.metadata.scope` to request a precise scope set; broader
88
97
  discovery metadata does not override it.
89
98
 
@@ -58,7 +58,7 @@ export function createDefaultOAuthClientProvider(options) {
58
58
  if (session === null && !initialGrantConsumed && initialGrant?.resource === requestUrl &&
59
59
  initialGrant.tokens !== undefined && !isExpired(initialGrant.tokens, now)) {
60
60
  input.headers.set("Authorization", `Bearer ${initialGrant.tokens.accessToken}`);
61
- return;
61
+ return { ...initialGrant.tokens };
62
62
  }
63
63
  if (session === null ||
64
64
  accessToken === undefined ||
@@ -68,6 +68,7 @@ export function createDefaultOAuthClientProvider(options) {
68
68
  }
69
69
  assertRequestMatchesResource(requestUrl, session.resource);
70
70
  input.headers.set("Authorization", `Bearer ${accessToken}`);
71
+ return { ...session.tokens };
71
72
  },
72
73
  async handleUnauthorized(input) {
73
74
  try {
@@ -75,12 +76,28 @@ export function createDefaultOAuthClientProvider(options) {
75
76
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
76
77
  const resource = canonicalizeResourceIndicator(input.discovery.resource);
77
78
  assertRequestMatchesResource(requestUrl, resource);
78
- const forceRefresh = (hasCachedAccessToken(await loadSession(resource)) || (!initialGrantConsumed && initialGrant?.resource === resource)) &&
79
- 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));
80
97
  const session = await ensureAuthorizedSession(resource, {
81
98
  ...input.discovery,
82
99
  resource
83
- }, input.fetch, true, forceRefresh, input.signal);
100
+ }, input.fetch, true, forceRefresh, input.signal, presentedTokens);
84
101
  if (session?.tokens?.accessToken === undefined) {
85
102
  return { action: "fail" };
86
103
  }
@@ -95,7 +112,7 @@ export function createDefaultOAuthClientProvider(options) {
95
112
  }
96
113
  }
97
114
  };
98
- async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false, signal) {
115
+ async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false, signal, rejectedTokens) {
99
116
  signal?.throwIfAborted();
100
117
  const canonicalResource = canonicalizeResourceIndicator(resource);
101
118
  let session = await loadSession(canonicalResource);
@@ -120,6 +137,8 @@ export function createDefaultOAuthClientProvider(options) {
120
137
  initialGrantConsumed = true;
121
138
  signal?.throwIfAborted();
122
139
  }
140
+ if (forceRefresh && rejectedTokens !== undefined && (rejectedTokens === null || session?.tokens === undefined || !sameTokenGrant(session.tokens, rejectedTokens)))
141
+ forceRefresh = false;
123
142
  const sessionDiscovery = resolveDiscovery(discovery, session);
124
143
  if (session?.tokens !== undefined && !forceRefresh && !isExpired(session.tokens, now)) {
125
144
  return session;
@@ -454,6 +473,10 @@ function resolveDiscovery(discovery, session) {
454
473
  authorizationServerMetadata: metadata
455
474
  };
456
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
+ }
457
480
  function clearSessionTokens(session) {
458
481
  const nextSession = { ...session };
459
482
  delete nextSession.tokens;
@@ -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;
@@ -4634,13 +4634,14 @@ function createDefaultOAuthClientProvider(options) {
4634
4634
  const accessToken = session?.tokens?.accessToken;
4635
4635
  if (session === null && !initialGrantConsumed && initialGrant?.resource === requestUrl && initialGrant.tokens !== void 0 && !isExpired(initialGrant.tokens, now)) {
4636
4636
  input.headers.set("Authorization", `Bearer ${initialGrant.tokens.accessToken}`);
4637
- return;
4637
+ return { ...initialGrant.tokens };
4638
4638
  }
4639
4639
  if (session === null || accessToken === void 0 || session.tokens === void 0 || isExpired(session.tokens, now)) {
4640
4640
  return;
4641
4641
  }
4642
4642
  assertRequestMatchesResource(requestUrl, session.resource);
4643
4643
  input.headers.set("Authorization", `Bearer ${accessToken}`);
4644
+ return { ...session.tokens };
4644
4645
  },
4645
4646
  async handleUnauthorized(input) {
4646
4647
  try {
@@ -4648,11 +4649,28 @@ function createDefaultOAuthClientProvider(options) {
4648
4649
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
4649
4650
  const resource = canonicalizeResourceIndicator(input.discovery.resource);
4650
4651
  assertRequestMatchesResource(requestUrl, resource);
4651
- 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);
4652
4670
  const session = await ensureAuthorizedSession(resource, {
4653
4671
  ...input.discovery,
4654
4672
  resource
4655
- }, input.fetch, true, forceRefresh, input.signal);
4673
+ }, input.fetch, true, forceRefresh, input.signal, presentedTokens);
4656
4674
  if (session?.tokens?.accessToken === void 0) {
4657
4675
  return { action: "fail" };
4658
4676
  }
@@ -4666,7 +4684,7 @@ function createDefaultOAuthClientProvider(options) {
4666
4684
  }
4667
4685
  }
4668
4686
  };
4669
- async function ensureAuthorizedSession(resource, discovery, fetch2, allowInteractive, forceRefresh = false, signal) {
4687
+ async function ensureAuthorizedSession(resource, discovery, fetch2, allowInteractive, forceRefresh = false, signal, rejectedTokens) {
4670
4688
  signal?.throwIfAborted();
4671
4689
  const canonicalResource = canonicalizeResourceIndicator(resource);
4672
4690
  let session = await loadSession(canonicalResource);
@@ -4693,6 +4711,8 @@ function createDefaultOAuthClientProvider(options) {
4693
4711
  initialGrantConsumed = true;
4694
4712
  signal?.throwIfAborted();
4695
4713
  }
4714
+ if (forceRefresh && rejectedTokens !== void 0 && (rejectedTokens === null || session?.tokens === void 0 || !sameTokenGrant(session.tokens, rejectedTokens)))
4715
+ forceRefresh = false;
4696
4716
  const sessionDiscovery = resolveDiscovery(discovery, session);
4697
4717
  if (session?.tokens !== void 0 && !forceRefresh && !isExpired(session.tokens, now)) {
4698
4718
  return session;
@@ -5014,6 +5034,9 @@ function resolveDiscovery(discovery, session) {
5014
5034
  authorizationServerMetadata: metadata
5015
5035
  };
5016
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
+ }
5017
5040
  function clearSessionTokens(session) {
5018
5041
  const nextSession = { ...session };
5019
5042
  delete nextSession.tokens;
@@ -6789,6 +6812,7 @@ var HttpTransport = class {
6789
6812
  oauthMetadataDiscovery;
6790
6813
  inFlightFetchAbortControllers = /* @__PURE__ */ new Set();
6791
6814
  inFlightOAuthAbortControllers = /* @__PURE__ */ new Set();
6815
+ oauthRequestTokens = /* @__PURE__ */ new WeakMap();
6792
6816
  openResponseReaders = /* @__PURE__ */ new Set();
6793
6817
  modernRequests = /* @__PURE__ */ new Map();
6794
6818
  modernMode = false;
@@ -7059,7 +7083,7 @@ var HttpTransport = class {
7059
7083
  }
7060
7084
  async authorizeRequestHeaders(headers, signal) {
7061
7085
  signal?.throwIfAborted();
7062
- await this.oauthProvider?.authorizeRequest?.({
7086
+ const tokens = await this.oauthProvider?.authorizeRequest?.({
7063
7087
  requestUrl: new URL(this.url),
7064
7088
  headers,
7065
7089
  signal,
@@ -7068,6 +7092,7 @@ var HttpTransport = class {
7068
7092
  signal: signal === void 0 ? init?.signal : init?.signal == null ? signal : AbortSignal.any([signal, init.signal])
7069
7093
  })
7070
7094
  });
7095
+ if (tokens !== void 0) this.oauthRequestTokens.set(headers, { ...tokens });
7071
7096
  signal?.throwIfAborted();
7072
7097
  return headers;
7073
7098
  }
@@ -7211,7 +7236,7 @@ var HttpTransport = class {
7211
7236
  const message = responseBody.length === 0 ? `HTTP transport POST failed (${statusDescriptor})` : `HTTP transport POST failed (${statusDescriptor}): ${responseBody}`;
7212
7237
  throw new HttpTransportError(message, response.status, "POST");
7213
7238
  }
7214
- async maybeHandleUnauthorizedResponse(response, signal) {
7239
+ async maybeHandleUnauthorizedResponse(response, signal, requestHeaders, presentedTokens) {
7215
7240
  if (response.status !== 401 || this.oauthProvider === void 0) {
7216
7241
  return false;
7217
7242
  }
@@ -7232,6 +7257,8 @@ var HttpTransport = class {
7232
7257
  challenge,
7233
7258
  discovery,
7234
7259
  signal,
7260
+ requestHeaders: new Headers(requestHeaders),
7261
+ presentedTokens,
7235
7262
  fetch: (url, init) => fetchMcpResponse(this.fetchImpl, url, {
7236
7263
  ...init,
7237
7264
  signal: init?.signal == null ? signal : AbortSignal.any([signal, init.signal])
@@ -7372,16 +7399,20 @@ var HttpTransport = class {
7372
7399
  const request = async () => {
7373
7400
  controller.signal.throwIfAborted();
7374
7401
  const headers = await input.createHeaders(controller.signal);
7402
+ const headerSnapshot = new Headers(headers);
7403
+ const tokens = this.oauthRequestTokens.get(headers) ?? null;
7375
7404
  controller.signal.throwIfAborted();
7376
- return this.fetchWithAbort(input.url ?? this.url, {
7405
+ const response = await this.fetchWithAbort(input.url ?? this.url, {
7377
7406
  method: input.method,
7378
7407
  headers,
7379
7408
  body: input.body
7380
7409
  }, controller);
7410
+ return { response, headers: headerSnapshot, tokens };
7381
7411
  };
7382
7412
  try {
7383
- let response = await request();
7384
- 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;
7385
7416
  const oauthError = this.oauthProvider === void 0 ? null : this.readOAuthChallengeError(response);
7386
7417
  if (oauthError !== null) {
7387
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.24",
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",