toolcraft 0.0.308 → 0.0.310

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/composition.json CHANGED
@@ -98,7 +98,7 @@
98
98
  },
99
99
  {
100
100
  "name": "tiny-http-mcp-server",
101
- "version": "0.1.23",
101
+ "version": "0.1.25",
102
102
  "license": "MIT"
103
103
  },
104
104
  {
@@ -113,7 +113,7 @@
113
113
  },
114
114
  {
115
115
  "name": "toolcraft",
116
- "version": "0.0.308",
116
+ "version": "0.0.310",
117
117
  "license": "MIT"
118
118
  },
119
119
  {
@@ -123,7 +123,7 @@
123
123
  },
124
124
  {
125
125
  "name": "toolcraft-schema",
126
- "version": "0.0.308",
126
+ "version": "0.0.310",
127
127
  "license": "MIT"
128
128
  },
129
129
  {
@@ -98,7 +98,7 @@
98
98
  },
99
99
  {
100
100
  "name": "tiny-http-mcp-server",
101
- "version": "0.1.23",
101
+ "version": "0.1.25",
102
102
  "license": "MIT"
103
103
  },
104
104
  {
@@ -113,7 +113,7 @@
113
113
  },
114
114
  {
115
115
  "name": "toolcraft",
116
- "version": "0.0.308",
116
+ "version": "0.0.310",
117
117
  "license": "MIT"
118
118
  },
119
119
  {
@@ -123,7 +123,7 @@
123
123
  },
124
124
  {
125
125
  "name": "toolcraft-schema",
126
- "version": "0.0.308",
126
+ "version": "0.0.310",
127
127
  "license": "MIT"
128
128
  },
129
129
  {
@@ -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
  {
@@ -41,6 +41,7 @@ const verifier = createJwksTokenVerifier({
41
41
  - `mode: "dynamic"` with optional `metadata`
42
42
  - `mode: "static"` with `clientId`, optional `clientSecret`, optional `metadata`
43
43
  - `allowInteractive: false` prevents interactive login while retaining cached tokens and silent refresh
44
+ - `initialGrant: { resource, tokens }` optionally imports an existing Bearer grant for one HTTP resource; requires the original client ID
44
45
  - `browser.openBrowser(url)` optional
45
46
  - `browser.readLine()` optional
46
47
  - `browser.createServer()` optional
@@ -69,7 +70,10 @@ const verifier = createJwksTokenVerifier({
69
70
  Fixed redirects support `localhost`, `127.0.0.1`, and `::1` over HTTP. Their
70
71
  exact spelling, port, path and query are preserved through registration,
71
72
  authorization and code exchange. Credentials, fragments, port zero and reserved
72
- 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
73
77
  `redirectUri` to allocate a random loopback port. Standalone callback sessions
74
78
  accept the same `redirectUri`, `signal` and `timeoutMs` options. Cancellation,
75
79
  timeout and explicit close settle pending code waits and release listeners.
@@ -80,9 +84,26 @@ registration, token requests and bounded token-body reads. Cancellation retains
80
84
  its original reason and does not retry authorization. Custom providers should
81
85
  observe the supplied signal and pass it to any work they start.
82
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
+
83
96
  Configure `client.metadata.scope` to request a precise scope set; broader
84
97
  discovery metadata does not override it.
85
98
 
99
+ Imported `initialGrant.tokens` use `accessToken`, optional `refreshToken`,
100
+ `tokenType: "Bearer"`, `expiresAt` (Unix epoch milliseconds or `null` if unknown),
101
+ and optional `scope`. A fresh imported token is used only for its resource.
102
+ Discovery binds an expired or explicitly rejected grant before silent refresh,
103
+ using the original configured client. Persisted sessions take precedence,
104
+ including sessions whose tokens have been cleared; an import cannot revive them.
105
+ Input tokens are copied and invalid expiry values fail before authorization.
106
+
86
107
  `createAuthStoreSessionStore(options)` accepts the standard `auth-store` config.
87
108
 
88
109
  ## Environment Variables
@@ -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,18 +15,51 @@ 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;
21
22
  const registeredClients = new Map();
22
23
  const refreshPromises = new Map();
23
24
  const authorizationPromises = new Map();
25
+ if (options.initialGrant !== undefined) {
26
+ let resource;
27
+ try {
28
+ resource = new URL(options.initialGrant.resource);
29
+ }
30
+ catch {
31
+ throw new Error("OAuth initial grant resource must be an absolute HTTP URL");
32
+ }
33
+ if ((resource.protocol !== "http:" && resource.protocol !== "https:") || resource.username || resource.password || resource.hash)
34
+ throw new Error("OAuth initial grant resource must be an HTTP URL without credentials or fragments");
35
+ }
36
+ const initialGrant = options.initialGrant === undefined ? undefined : {
37
+ resource: canonicalizeResourceIndicator(options.initialGrant.resource),
38
+ tokens: normalizeStoredTokens(options.initialGrant.tokens),
39
+ client: normalizeConfiguredClient(options.client)
40
+ };
41
+ if (initialGrant !== undefined && (initialGrant.tokens === undefined || initialGrant.client === null))
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
+ }
51
+ let initialGrantConsumed = false;
24
52
  return {
25
53
  async authorizeRequest(input) {
26
54
  assertNoAccessTokenInUrl(input.requestUrl, "Protected resource request URL");
27
55
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
28
56
  const session = await ensureAuthorizedSession(requestUrl, undefined, input.fetch, false, false, input.signal);
29
57
  const accessToken = session?.tokens?.accessToken;
58
+ if (session === null && !initialGrantConsumed && initialGrant?.resource === requestUrl &&
59
+ initialGrant.tokens !== undefined && !isExpired(initialGrant.tokens, now)) {
60
+ input.headers.set("Authorization", `Bearer ${initialGrant.tokens.accessToken}`);
61
+ return { ...initialGrant.tokens };
62
+ }
30
63
  if (session === null ||
31
64
  accessToken === undefined ||
32
65
  session.tokens === undefined ||
@@ -35,6 +68,7 @@ export function createDefaultOAuthClientProvider(options) {
35
68
  }
36
69
  assertRequestMatchesResource(requestUrl, session.resource);
37
70
  input.headers.set("Authorization", `Bearer ${accessToken}`);
71
+ return { ...session.tokens };
38
72
  },
39
73
  async handleUnauthorized(input) {
40
74
  try {
@@ -42,12 +76,28 @@ export function createDefaultOAuthClientProvider(options) {
42
76
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
43
77
  const resource = canonicalizeResourceIndicator(input.discovery.resource);
44
78
  assertRequestMatchesResource(requestUrl, resource);
45
- const forceRefresh = hasCachedAccessToken(await loadSession(resource)) &&
46
- 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));
47
97
  const session = await ensureAuthorizedSession(resource, {
48
98
  ...input.discovery,
49
99
  resource
50
- }, input.fetch, true, forceRefresh, input.signal);
100
+ }, input.fetch, true, forceRefresh, input.signal, presentedTokens);
51
101
  if (session?.tokens?.accessToken === undefined) {
52
102
  return { action: "fail" };
53
103
  }
@@ -62,10 +112,12 @@ export function createDefaultOAuthClientProvider(options) {
62
112
  }
63
113
  }
64
114
  };
65
- async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false, signal) {
115
+ async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false, signal, rejectedTokens) {
66
116
  signal?.throwIfAborted();
67
117
  const canonicalResource = canonicalizeResourceIndicator(resource);
68
118
  let session = await loadSession(canonicalResource);
119
+ if (session !== null && initialGrant?.resource === canonicalResource)
120
+ initialGrantConsumed = true;
69
121
  signal?.throwIfAborted();
70
122
  if (discovery !== undefined && getOwnString(discovery.authorizationServerMetadata, "issuer") !== discovery.authorizationServer) {
71
123
  throw new Error("OAuth discovery authorization-server issuer mismatch");
@@ -76,6 +128,17 @@ export function createDefaultOAuthClientProvider(options) {
76
128
  await clearSession(canonicalResource);
77
129
  session = null;
78
130
  }
131
+ if (session === null && discovery !== undefined && !initialGrantConsumed && initialGrant?.resource === canonicalResource &&
132
+ initialGrant.tokens !== undefined && initialGrant.client !== null) {
133
+ assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
134
+ session = { resource: canonicalResource, authorizationServer: discovery.authorizationServer,
135
+ client: initialGrant.client, tokens: initialGrant.tokens, discovery: toStoredDiscovery(discovery) };
136
+ await saveSession(canonicalResource, session);
137
+ initialGrantConsumed = true;
138
+ signal?.throwIfAborted();
139
+ }
140
+ if (forceRefresh && rejectedTokens !== undefined && (rejectedTokens === null || session?.tokens === undefined || !sameTokenGrant(session.tokens, rejectedTokens)))
141
+ forceRefresh = false;
79
142
  const sessionDiscovery = resolveDiscovery(discovery, session);
80
143
  if (session?.tokens !== undefined && !forceRefresh && !isExpired(session.tokens, now)) {
81
144
  return session;
@@ -410,6 +473,10 @@ function resolveDiscovery(discovery, session) {
410
473
  authorizationServerMetadata: metadata
411
474
  };
412
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
+ }
413
480
  function clearSessionTokens(session) {
414
481
  const nextSession = { ...session };
415
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
  } | {
@@ -99,6 +103,11 @@ export interface DefaultOAuthClientProviderOptions {
99
103
  };
100
104
  /** Disable interactive authorization while allowing cached tokens and silent refresh. */
101
105
  allowInteractive?: boolean;
106
+ /** Import an existing grant for one resource. Persisted sessions take precedence. */
107
+ initialGrant?: {
108
+ resource: string;
109
+ tokens: StoredOAuthTokens;
110
+ };
102
111
  browser: {
103
112
  openBrowser?(url: string): Promise<void>;
104
113
  /** Exact registered HTTP loopback redirect URI. */
@@ -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",
@@ -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
  } | {
@@ -176,6 +180,11 @@ interface DefaultOAuthClientProviderOptions {
176
180
  };
177
181
  /** Disable interactive authorization while allowing cached tokens and silent refresh. */
178
182
  allowInteractive?: boolean;
183
+ /** Import an existing grant for one resource. Persisted sessions take precedence. */
184
+ initialGrant?: {
185
+ resource: string;
186
+ tokens: StoredOAuthTokens;
187
+ };
179
188
  browser: {
180
189
  openBrowser?(url: string): Promise<void>;
181
190
  /** Exact registered HTTP loopback redirect URI. */
@@ -644,6 +653,7 @@ declare class HttpTransport implements McpTransport {
644
653
  private readonly oauthMetadataDiscovery;
645
654
  private readonly inFlightFetchAbortControllers;
646
655
  private readonly inFlightOAuthAbortControllers;
656
+ private readonly oauthRequestTokens;
647
657
  private readonly openResponseReaders;
648
658
  private readonly modernRequests;
649
659
  private modernMode;
@@ -4594,23 +4594,54 @@ 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;
4600
4601
  const registeredClients = /* @__PURE__ */ new Map();
4601
4602
  const refreshPromises = /* @__PURE__ */ new Map();
4602
4603
  const authorizationPromises = /* @__PURE__ */ new Map();
4604
+ if (options.initialGrant !== void 0) {
4605
+ let resource;
4606
+ try {
4607
+ resource = new URL2(options.initialGrant.resource);
4608
+ } catch {
4609
+ throw new Error("OAuth initial grant resource must be an absolute HTTP URL");
4610
+ }
4611
+ if (resource.protocol !== "http:" && resource.protocol !== "https:" || resource.username || resource.password || resource.hash)
4612
+ throw new Error("OAuth initial grant resource must be an HTTP URL without credentials or fragments");
4613
+ }
4614
+ const initialGrant = options.initialGrant === void 0 ? void 0 : {
4615
+ resource: canonicalizeResourceIndicator(options.initialGrant.resource),
4616
+ tokens: normalizeStoredTokens(options.initialGrant.tokens),
4617
+ client: normalizeConfiguredClient(options.client)
4618
+ };
4619
+ if (initialGrant !== void 0 && (initialGrant.tokens === void 0 || initialGrant.client === null))
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
+ }
4628
+ let initialGrantConsumed = false;
4603
4629
  return {
4604
4630
  async authorizeRequest(input) {
4605
4631
  assertNoAccessTokenInUrl(input.requestUrl, "Protected resource request URL");
4606
4632
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
4607
4633
  const session = await ensureAuthorizedSession(requestUrl, void 0, input.fetch, false, false, input.signal);
4608
4634
  const accessToken = session?.tokens?.accessToken;
4635
+ if (session === null && !initialGrantConsumed && initialGrant?.resource === requestUrl && initialGrant.tokens !== void 0 && !isExpired(initialGrant.tokens, now)) {
4636
+ input.headers.set("Authorization", `Bearer ${initialGrant.tokens.accessToken}`);
4637
+ return { ...initialGrant.tokens };
4638
+ }
4609
4639
  if (session === null || accessToken === void 0 || session.tokens === void 0 || isExpired(session.tokens, now)) {
4610
4640
  return;
4611
4641
  }
4612
4642
  assertRequestMatchesResource(requestUrl, session.resource);
4613
4643
  input.headers.set("Authorization", `Bearer ${accessToken}`);
4644
+ return { ...session.tokens };
4614
4645
  },
4615
4646
  async handleUnauthorized(input) {
4616
4647
  try {
@@ -4618,11 +4649,28 @@ function createDefaultOAuthClientProvider(options) {
4618
4649
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
4619
4650
  const resource = canonicalizeResourceIndicator(input.discovery.resource);
4620
4651
  assertRequestMatchesResource(requestUrl, resource);
4621
- const forceRefresh = hasCachedAccessToken(await loadSession(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);
4622
4670
  const session = await ensureAuthorizedSession(resource, {
4623
4671
  ...input.discovery,
4624
4672
  resource
4625
- }, input.fetch, true, forceRefresh, input.signal);
4673
+ }, input.fetch, true, forceRefresh, input.signal, presentedTokens);
4626
4674
  if (session?.tokens?.accessToken === void 0) {
4627
4675
  return { action: "fail" };
4628
4676
  }
@@ -4636,10 +4684,12 @@ function createDefaultOAuthClientProvider(options) {
4636
4684
  }
4637
4685
  }
4638
4686
  };
4639
- async function ensureAuthorizedSession(resource, discovery, fetch2, allowInteractive, forceRefresh = false, signal) {
4687
+ async function ensureAuthorizedSession(resource, discovery, fetch2, allowInteractive, forceRefresh = false, signal, rejectedTokens) {
4640
4688
  signal?.throwIfAborted();
4641
4689
  const canonicalResource = canonicalizeResourceIndicator(resource);
4642
4690
  let session = await loadSession(canonicalResource);
4691
+ if (session !== null && initialGrant?.resource === canonicalResource)
4692
+ initialGrantConsumed = true;
4643
4693
  signal?.throwIfAborted();
4644
4694
  if (discovery !== void 0 && getOwnString2(discovery.authorizationServerMetadata, "issuer") !== discovery.authorizationServer) {
4645
4695
  throw new Error("OAuth discovery authorization-server issuer mismatch");
@@ -4648,6 +4698,21 @@ function createDefaultOAuthClientProvider(options) {
4648
4698
  await clearSession(canonicalResource);
4649
4699
  session = null;
4650
4700
  }
4701
+ if (session === null && discovery !== void 0 && !initialGrantConsumed && initialGrant?.resource === canonicalResource && initialGrant.tokens !== void 0 && initialGrant.client !== null) {
4702
+ assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
4703
+ session = {
4704
+ resource: canonicalResource,
4705
+ authorizationServer: discovery.authorizationServer,
4706
+ client: initialGrant.client,
4707
+ tokens: initialGrant.tokens,
4708
+ discovery: toStoredDiscovery(discovery)
4709
+ };
4710
+ await saveSession(canonicalResource, session);
4711
+ initialGrantConsumed = true;
4712
+ signal?.throwIfAborted();
4713
+ }
4714
+ if (forceRefresh && rejectedTokens !== void 0 && (rejectedTokens === null || session?.tokens === void 0 || !sameTokenGrant(session.tokens, rejectedTokens)))
4715
+ forceRefresh = false;
4651
4716
  const sessionDiscovery = resolveDiscovery(discovery, session);
4652
4717
  if (session?.tokens !== void 0 && !forceRefresh && !isExpired(session.tokens, now)) {
4653
4718
  return session;
@@ -4969,6 +5034,9 @@ function resolveDiscovery(discovery, session) {
4969
5034
  authorizationServerMetadata: metadata
4970
5035
  };
4971
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
+ }
4972
5040
  function clearSessionTokens(session) {
4973
5041
  const nextSession = { ...session };
4974
5042
  delete nextSession.tokens;
@@ -6744,6 +6812,7 @@ var HttpTransport = class {
6744
6812
  oauthMetadataDiscovery;
6745
6813
  inFlightFetchAbortControllers = /* @__PURE__ */ new Set();
6746
6814
  inFlightOAuthAbortControllers = /* @__PURE__ */ new Set();
6815
+ oauthRequestTokens = /* @__PURE__ */ new WeakMap();
6747
6816
  openResponseReaders = /* @__PURE__ */ new Set();
6748
6817
  modernRequests = /* @__PURE__ */ new Map();
6749
6818
  modernMode = false;
@@ -7014,7 +7083,7 @@ var HttpTransport = class {
7014
7083
  }
7015
7084
  async authorizeRequestHeaders(headers, signal) {
7016
7085
  signal?.throwIfAborted();
7017
- await this.oauthProvider?.authorizeRequest?.({
7086
+ const tokens = await this.oauthProvider?.authorizeRequest?.({
7018
7087
  requestUrl: new URL(this.url),
7019
7088
  headers,
7020
7089
  signal,
@@ -7023,6 +7092,7 @@ var HttpTransport = class {
7023
7092
  signal: signal === void 0 ? init?.signal : init?.signal == null ? signal : AbortSignal.any([signal, init.signal])
7024
7093
  })
7025
7094
  });
7095
+ if (tokens !== void 0) this.oauthRequestTokens.set(headers, { ...tokens });
7026
7096
  signal?.throwIfAborted();
7027
7097
  return headers;
7028
7098
  }
@@ -7166,7 +7236,7 @@ var HttpTransport = class {
7166
7236
  const message = responseBody.length === 0 ? `HTTP transport POST failed (${statusDescriptor})` : `HTTP transport POST failed (${statusDescriptor}): ${responseBody}`;
7167
7237
  throw new HttpTransportError(message, response.status, "POST");
7168
7238
  }
7169
- async maybeHandleUnauthorizedResponse(response, signal) {
7239
+ async maybeHandleUnauthorizedResponse(response, signal, requestHeaders, presentedTokens) {
7170
7240
  if (response.status !== 401 || this.oauthProvider === void 0) {
7171
7241
  return false;
7172
7242
  }
@@ -7187,6 +7257,8 @@ var HttpTransport = class {
7187
7257
  challenge,
7188
7258
  discovery,
7189
7259
  signal,
7260
+ requestHeaders: new Headers(requestHeaders),
7261
+ presentedTokens,
7190
7262
  fetch: (url, init) => fetchMcpResponse(this.fetchImpl, url, {
7191
7263
  ...init,
7192
7264
  signal: init?.signal == null ? signal : AbortSignal.any([signal, init.signal])
@@ -7327,16 +7399,20 @@ var HttpTransport = class {
7327
7399
  const request = async () => {
7328
7400
  controller.signal.throwIfAborted();
7329
7401
  const headers = await input.createHeaders(controller.signal);
7402
+ const headerSnapshot = new Headers(headers);
7403
+ const tokens = this.oauthRequestTokens.get(headers) ?? null;
7330
7404
  controller.signal.throwIfAborted();
7331
- return this.fetchWithAbort(input.url ?? this.url, {
7405
+ const response = await this.fetchWithAbort(input.url ?? this.url, {
7332
7406
  method: input.method,
7333
7407
  headers,
7334
7408
  body: input.body
7335
7409
  }, controller);
7410
+ return { response, headers: headerSnapshot, tokens };
7336
7411
  };
7337
7412
  try {
7338
- let response = await request();
7339
- 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;
7340
7416
  const oauthError = this.oauthProvider === void 0 ? null : this.readOAuthChallengeError(response);
7341
7417
  if (oauthError !== null) {
7342
7418
  void response.body?.cancel().catch(() => void 0);
@@ -8,7 +8,7 @@
8
8
  },
9
9
  {
10
10
  "name": "toolcraft-schema",
11
- "version": "0.0.308",
11
+ "version": "0.0.310",
12
12
  "license": "MIT"
13
13
  }
14
14
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolcraft-schema",
3
- "version": "0.0.308",
3
+ "version": "0.0.310",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolcraft",
3
- "version": "0.0.308",
3
+ "version": "0.0.310",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -158,7 +158,7 @@
158
158
  "yaml"
159
159
  ],
160
160
  "optionalDependencies": {
161
- "toolcraft-schema": "0.0.308",
161
+ "toolcraft-schema": "0.0.310",
162
162
  "toolcraft-design": "*",
163
163
  "@poe-code/frontmatter": "*",
164
164
  "@poe-code/agent-mcp-config": "*",