tiny-http-mcp-server 0.1.22 → 0.1.24

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.22",
21
+ "version": "0.1.24",
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.
@@ -83,6 +87,14 @@ observe the supplied signal and pass it to any work they start.
83
87
  Configure `client.metadata.scope` to request a precise scope set; broader
84
88
  discovery metadata does not override it.
85
89
 
90
+ Imported `initialGrant.tokens` use `accessToken`, optional `refreshToken`,
91
+ `tokenType: "Bearer"`, `expiresAt` (Unix epoch milliseconds or `null` if unknown),
92
+ and optional `scope`. A fresh imported token is used only for its resource.
93
+ Discovery binds an expired or explicitly rejected grant before silent refresh,
94
+ using the original configured client. Persisted sessions take precedence,
95
+ including sessions whose tokens have been cleared; an import cannot revive them.
96
+ Input tokens are copied and invalid expiry values fail before authorization.
97
+
86
98
  `createAuthStoreSessionStore(options)` accepts the standard `auth-store` config.
87
99
 
88
100
  ## 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;
62
+ }
30
63
  if (session === null ||
31
64
  accessToken === undefined ||
32
65
  session.tokens === undefined ||
@@ -42,7 +75,7 @@ export function createDefaultOAuthClientProvider(options) {
42
75
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
43
76
  const resource = canonicalizeResourceIndicator(input.discovery.resource);
44
77
  assertRequestMatchesResource(requestUrl, resource);
45
- const forceRefresh = hasCachedAccessToken(await loadSession(resource)) &&
78
+ const forceRefresh = (hasCachedAccessToken(await loadSession(resource)) || (!initialGrantConsumed && initialGrant?.resource === resource)) &&
46
79
  input.challenge?.params.error === "invalid_token";
47
80
  const session = await ensureAuthorizedSession(resource, {
48
81
  ...input.discovery,
@@ -66,6 +99,8 @@ export function createDefaultOAuthClientProvider(options) {
66
99
  signal?.throwIfAborted();
67
100
  const canonicalResource = canonicalizeResourceIndicator(resource);
68
101
  let session = await loadSession(canonicalResource);
102
+ if (session !== null && initialGrant?.resource === canonicalResource)
103
+ initialGrantConsumed = true;
69
104
  signal?.throwIfAborted();
70
105
  if (discovery !== undefined && getOwnString(discovery.authorizationServerMetadata, "issuer") !== discovery.authorizationServer) {
71
106
  throw new Error("OAuth discovery authorization-server issuer mismatch");
@@ -76,6 +111,15 @@ export function createDefaultOAuthClientProvider(options) {
76
111
  await clearSession(canonicalResource);
77
112
  session = null;
78
113
  }
114
+ if (session === null && discovery !== undefined && !initialGrantConsumed && initialGrant?.resource === canonicalResource &&
115
+ initialGrant.tokens !== undefined && initialGrant.client !== null) {
116
+ assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
117
+ session = { resource: canonicalResource, authorizationServer: discovery.authorizationServer,
118
+ client: initialGrant.client, tokens: initialGrant.tokens, discovery: toStoredDiscovery(discovery) };
119
+ await saveSession(canonicalResource, session);
120
+ initialGrantConsumed = true;
121
+ signal?.throwIfAborted();
122
+ }
79
123
  const sessionDiscovery = resolveDiscovery(discovery, session);
80
124
  if (session?.tokens !== undefined && !forceRefresh && !isExpired(session.tokens, now)) {
81
125
  return session;
@@ -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 {
@@ -99,6 +99,11 @@ export interface DefaultOAuthClientProviderOptions {
99
99
  };
100
100
  /** Disable interactive authorization while allowing cached tokens and silent refresh. */
101
101
  allowInteractive?: boolean;
102
+ /** Import an existing grant for one resource. Persisted sessions take precedence. */
103
+ initialGrant?: {
104
+ resource: string;
105
+ tokens: StoredOAuthTokens;
106
+ };
102
107
  browser: {
103
108
  openBrowser?(url: string): Promise<void>;
104
109
  /** Exact registered HTTP loopback redirect URI. */
@@ -176,6 +176,11 @@ interface DefaultOAuthClientProviderOptions {
176
176
  };
177
177
  /** Disable interactive authorization while allowing cached tokens and silent refresh. */
178
178
  allowInteractive?: boolean;
179
+ /** Import an existing grant for one resource. Persisted sessions take precedence. */
180
+ initialGrant?: {
181
+ resource: string;
182
+ tokens: StoredOAuthTokens;
183
+ };
179
184
  browser: {
180
185
  openBrowser?(url: string): Promise<void>;
181
186
  /** Exact registered HTTP loopback redirect URI. */
@@ -152,11 +152,17 @@ var HttpResponseMessages = class {
152
152
  };
153
153
 
154
154
  // ../toolcraft-schema/dist/json.js
155
- function isJsonValue(value) {
155
+ function isJsonValue(value, options = {}) {
156
+ const maxNodes = options.maxNodes ?? 1e4;
157
+ const maxDepth = options.maxDepth ?? 64;
158
+ if (!Number.isSafeInteger(maxNodes) || maxNodes < 1)
159
+ throw new Error("maxNodes must be a positive safe integer");
160
+ if (!Number.isSafeInteger(maxDepth) || maxDepth < 0 || maxDepth > 256)
161
+ throw new Error("maxDepth must be an integer between 0 and 256");
156
162
  const ancestors = /* @__PURE__ */ new Set();
157
163
  let nodes = 0;
158
164
  const visit = (item, depth) => {
159
- if (++nodes > 1e4 || depth > 64)
165
+ if (++nodes > maxNodes || depth > maxDepth)
160
166
  return false;
161
167
  if (item === null || typeof item === "string" || typeof item === "boolean")
162
168
  return true;
@@ -182,7 +188,7 @@ function isJsonValue(value) {
182
188
  ancestors.add(item);
183
189
  let valid = true;
184
190
  if (Array.isArray(item)) {
185
- if (item.length > 1e4)
191
+ if (item.length > maxNodes)
186
192
  valid = false;
187
193
  else
188
194
  for (let index = 0; index < item.length; index++) {
@@ -4588,18 +4594,48 @@ function createOAuthClientProvider(options) {
4588
4594
  return createDefaultOAuthClientProvider(options);
4589
4595
  }
4590
4596
  function createDefaultOAuthClientProvider(options) {
4597
+ loopbackTarget(options.browser);
4591
4598
  const sessionStore = options.sessionStore ?? createAuthStoreSessionStore(options.authStore);
4592
4599
  const clientStore = options.authStore === void 0 ? null : createAuthStoreClientStore(options.authStore);
4593
4600
  const now = options.now ?? Date.now;
4594
4601
  const registeredClients = /* @__PURE__ */ new Map();
4595
4602
  const refreshPromises = /* @__PURE__ */ new Map();
4596
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;
4597
4629
  return {
4598
4630
  async authorizeRequest(input) {
4599
4631
  assertNoAccessTokenInUrl(input.requestUrl, "Protected resource request URL");
4600
4632
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
4601
4633
  const session = await ensureAuthorizedSession(requestUrl, void 0, input.fetch, false, false, input.signal);
4602
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;
4638
+ }
4603
4639
  if (session === null || accessToken === void 0 || session.tokens === void 0 || isExpired(session.tokens, now)) {
4604
4640
  return;
4605
4641
  }
@@ -4612,7 +4648,7 @@ function createDefaultOAuthClientProvider(options) {
4612
4648
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
4613
4649
  const resource = canonicalizeResourceIndicator(input.discovery.resource);
4614
4650
  assertRequestMatchesResource(requestUrl, resource);
4615
- const forceRefresh = hasCachedAccessToken(await loadSession(resource)) && input.challenge?.params.error === "invalid_token";
4651
+ const forceRefresh = (hasCachedAccessToken(await loadSession(resource)) || !initialGrantConsumed && initialGrant?.resource === resource) && input.challenge?.params.error === "invalid_token";
4616
4652
  const session = await ensureAuthorizedSession(resource, {
4617
4653
  ...input.discovery,
4618
4654
  resource
@@ -4634,6 +4670,8 @@ function createDefaultOAuthClientProvider(options) {
4634
4670
  signal?.throwIfAborted();
4635
4671
  const canonicalResource = canonicalizeResourceIndicator(resource);
4636
4672
  let session = await loadSession(canonicalResource);
4673
+ if (session !== null && initialGrant?.resource === canonicalResource)
4674
+ initialGrantConsumed = true;
4637
4675
  signal?.throwIfAborted();
4638
4676
  if (discovery !== void 0 && getOwnString2(discovery.authorizationServerMetadata, "issuer") !== discovery.authorizationServer) {
4639
4677
  throw new Error("OAuth discovery authorization-server issuer mismatch");
@@ -4642,6 +4680,19 @@ function createDefaultOAuthClientProvider(options) {
4642
4680
  await clearSession(canonicalResource);
4643
4681
  session = null;
4644
4682
  }
4683
+ if (session === null && discovery !== void 0 && !initialGrantConsumed && initialGrant?.resource === canonicalResource && initialGrant.tokens !== void 0 && initialGrant.client !== null) {
4684
+ assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
4685
+ session = {
4686
+ resource: canonicalResource,
4687
+ authorizationServer: discovery.authorizationServer,
4688
+ client: initialGrant.client,
4689
+ tokens: initialGrant.tokens,
4690
+ discovery: toStoredDiscovery(discovery)
4691
+ };
4692
+ await saveSession(canonicalResource, session);
4693
+ initialGrantConsumed = true;
4694
+ signal?.throwIfAborted();
4695
+ }
4645
4696
  const sessionDiscovery = resolveDiscovery(discovery, session);
4646
4697
  if (session?.tokens !== void 0 && !forceRefresh && !isExpired(session.tokens, now)) {
4647
4698
  return session;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tiny-http-mcp-server",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Minimal MCP server over HTTP built on tiny-stdio-mcp-server",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",