tiny-http-mcp-server 0.1.31 → 0.1.32

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.31",
21
+ "version": "0.1.32",
22
22
  "license": "MIT"
23
23
  },
24
24
  {
@@ -96,7 +96,14 @@ on 401 even when the server omits `error="invalid_token"`. Invalid provenance
96
96
  fails without quoting token values.
97
97
 
98
98
  Configure `client.metadata.scope` to request a precise scope set; broader
99
- discovery metadata does not override it.
99
+ discovery metadata does not override it. Explicit scopes must match the cached
100
+ or imported grant's scope set; ordering, repeated spaces and duplicates are
101
+ normalized. An imported grant must declare its scope when a scope is configured.
102
+ Authorization records the requested set when the endpoint omits scope, and
103
+ refresh retains the previous granted set. Mismatched responses never activate
104
+ credentials; an unusable refresh response retains the pending refresh record.
105
+ Select a separate persistence namespace for another scope profile. No scope is
106
+ invented when the client does not configure one.
100
107
 
101
108
  Imported `initialGrant.tokens` use `accessToken`, optional `refreshToken`,
102
109
  `tokenType: "Bearer"`, `expiresAt` (Unix epoch milliseconds or `null` if unknown),
@@ -144,6 +144,7 @@ function isStoredOAuthSession(value) {
144
144
  isNonBlankOwnString(value, "authorizationServer") &&
145
145
  isStoredOAuthClient(getOwnEntry(value, "client")) &&
146
146
  isStoredOAuthDiscovery(getOwnEntry(value, "discovery")) &&
147
+ (getOwnEntry(value, "requestedScope") === undefined || isNonBlankOwnString(value, "requestedScope")) &&
147
148
  (getOwnEntry(value, "refreshState") === undefined ||
148
149
  (getOwnEntry(value, "refreshState") === "pending" && getOwnEntry(value, "tokens") === undefined)) &&
149
150
  isStoredOAuthTokensOrMissing(getOwnEntry(value, "tokens")));
@@ -1,3 +1,4 @@
1
+ import { normalizeOAuthScope } from "./scope.js";
1
2
  import { isIP } from "node:net";
2
3
  import { fetchMcpResponse } from "../http-fetch.js";
3
4
  import { URL } from "node:url";
@@ -18,6 +19,8 @@ export function createOAuthClientProvider(options) {
18
19
  export function createDefaultOAuthClientProvider(options) {
19
20
  loopbackTarget(options.browser);
20
21
  assertPersistenceNamespace(options.persistenceNamespace);
22
+ const clientMetadata = getClientMetadata(options.client);
23
+ const requestedScope = clientMetadata?.scope;
21
24
  const sessionStore = options.sessionStore ?? createAuthStoreSessionStore(options.authStore, options.persistenceNamespace);
22
25
  const clientStore = options.authStore === undefined ? null : createAuthStoreClientStore(options.authStore, options.persistenceNamespace);
23
26
  const now = options.now ?? Date.now;
@@ -41,6 +44,8 @@ export function createDefaultOAuthClientProvider(options) {
41
44
  if (initialGrant !== undefined && (initialGrant.tokens === undefined || initialGrant.client === null))
42
45
  throw new Error("OAuth initial grant requires valid tokens and the original client ID");
43
46
  if (initialGrant?.tokens !== undefined) {
47
+ if (requestedScope !== undefined && initialGrant.tokens.scope !== requestedScope)
48
+ throw new Error("OAuth initial grant does not match the requested OAuth scope");
44
49
  try {
45
50
  new Headers({ Authorization: `Bearer ${initialGrant.tokens.accessToken}` });
46
51
  }
@@ -133,7 +138,8 @@ export function createDefaultOAuthClientProvider(options) {
133
138
  initialGrant.tokens !== undefined && initialGrant.client !== null) {
134
139
  assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
135
140
  session = { resource: canonicalResource, authorizationServer: discovery.authorizationServer,
136
- client: initialGrant.client, tokens: initialGrant.tokens, discovery: toStoredDiscovery(discovery) };
141
+ client: initialGrant.client, tokens: initialGrant.tokens,
142
+ ...(requestedScope === undefined ? {} : { requestedScope }), discovery: toStoredDiscovery(discovery) };
137
143
  await saveSession(canonicalResource, session);
138
144
  initialGrantConsumed = true;
139
145
  signal?.throwIfAborted();
@@ -146,6 +152,8 @@ export function createDefaultOAuthClientProvider(options) {
146
152
  if (configured === null || configured.clientId !== session.client.clientId || configured.clientSecret !== session.client.clientSecret)
147
153
  throw new Error("Stored session belongs to a different OAuth client; use separate persistence or explicitly reset it");
148
154
  }
155
+ if (requestedScope !== undefined && session?.tokens !== undefined && normalizeOAuthScope(session.tokens.scope ?? session.requestedScope) !== requestedScope)
156
+ throw new Error("Stored session does not match the requested OAuth scope; authorize again or select separate persistence");
149
157
  if (session?.refreshState === "pending") {
150
158
  if (!allowInteractive || options.allowInteractive === false || sessionDiscovery === undefined)
151
159
  throw new Error("OAuth refresh outcome is unknown; authorize again before using this resource");
@@ -226,10 +234,13 @@ export function createDefaultOAuthClientProvider(options) {
226
234
  ...session,
227
235
  tokens: {
228
236
  ...refreshedTokens,
229
- refreshToken: refreshedTokens.refreshToken ?? session.tokens.refreshToken
237
+ refreshToken: refreshedTokens.refreshToken ?? session.tokens.refreshToken,
238
+ scope: refreshedTokens.scope ?? session.tokens.scope
230
239
  },
231
240
  discovery: toStoredDiscovery(discovery)
232
241
  };
242
+ if (requestedScope !== undefined && normalizeOAuthScope(updatedSession.tokens?.scope ?? session.requestedScope) !== requestedScope)
243
+ throw new Error("OAuth refresh response does not match the requested OAuth scope; authorize again");
233
244
  await saveSession(resource, updatedSession);
234
245
  return updatedSession;
235
246
  }
@@ -257,6 +268,7 @@ export function createDefaultOAuthClientProvider(options) {
257
268
  resource,
258
269
  authorizationServer: discovery.authorizationServer,
259
270
  client: resolvedClient.client,
271
+ ...(requestedScope === undefined ? {} : { requestedScope }),
260
272
  discovery: toStoredDiscovery(discovery)
261
273
  };
262
274
  await saveSession(resource, sessionWithoutTokens);
@@ -268,7 +280,7 @@ export function createDefaultOAuthClientProvider(options) {
268
280
  clientId: resolvedClient.client.clientId,
269
281
  redirectUri: loopback.redirectUri,
270
282
  codeChallenge: challenge,
271
- clientMetadata: getClientMetadata(options.client)
283
+ clientMetadata
272
284
  });
273
285
  const code = await loopback.waitForCode(authorizationUrl);
274
286
  const tokens = await exchangeAuthorizationCode({
@@ -282,6 +294,8 @@ export function createDefaultOAuthClientProvider(options) {
282
294
  fetch, signal,
283
295
  now
284
296
  });
297
+ if (requestedScope !== undefined && tokens.scope !== undefined && normalizeOAuthScope(tokens.scope) !== requestedScope)
298
+ throw new Error("OAuth authorization response does not match the requested OAuth scope");
285
299
  const session = {
286
300
  ...sessionWithoutTokens,
287
301
  tokens
@@ -363,7 +377,7 @@ export function createDefaultOAuthClientProvider(options) {
363
377
  };
364
378
  }
365
379
  }
366
- const registrationBody = buildClientRegistrationBody(getClientMetadata(options.client), redirectUri);
380
+ const registrationBody = buildClientRegistrationBody(clientMetadata, redirectUri);
367
381
  const deadline = AbortSignal.timeout(30_000);
368
382
  const signal = parentSignal === undefined ? deadline : AbortSignal.any([parentSignal, deadline]);
369
383
  const response = await fetchMcpResponse(fetch, registrationEndpoint, {
@@ -526,10 +540,10 @@ function normalizeStoredTokens(value) {
526
540
  const tokenType = getOwnString(value, "tokenType");
527
541
  const expiresAt = getOwnEntry(value, "expiresAt");
528
542
  const refreshToken = getOwnEntry(value, "refreshToken");
529
- const scope = getOwnString(value, "scope");
543
+ const scope = getOwnEntry(value, "scope");
530
544
  const normalizedAccessToken = accessToken?.trim();
531
545
  const normalizedRefreshToken = typeof refreshToken === "string" ? refreshToken.trim() : undefined;
532
- const normalizedScope = scope?.trim();
546
+ const normalizedScope = normalizeOAuthScope(scope);
533
547
  if (accessToken === undefined ||
534
548
  normalizedAccessToken === undefined ||
535
549
  normalizedAccessToken.length === 0 ||
@@ -561,7 +575,7 @@ function getClientMetadata(client) {
561
575
  }
562
576
  return {
563
577
  clientName: normalizeOptionalOAuthString(client.metadata.clientName),
564
- scope: normalizeOptionalOAuthString(client.metadata.scope),
578
+ scope: normalizeOAuthScope(client.metadata.scope),
565
579
  softwareId: normalizeOptionalOAuthString(client.metadata.softwareId),
566
580
  softwareVersion: normalizeOptionalOAuthString(client.metadata.softwareVersion)
567
581
  };
@@ -0,0 +1,2 @@
1
+ /** Compare OAuth scope sets without accepting controls or changing token case. */
2
+ export declare function normalizeOAuthScope(scope: unknown): string | undefined;
@@ -0,0 +1,9 @@
1
+ /** Compare OAuth scope sets without accepting controls or changing token case. */
2
+ export function normalizeOAuthScope(scope) {
3
+ if (scope === undefined)
4
+ return undefined;
5
+ if (typeof scope !== "string" || [...scope].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) > 126 || char === '"' || char === "\\"))
6
+ throw new Error("Invalid OAuth scope syntax");
7
+ const normalized = [...new Set(scope.split(" ").filter(Boolean))].sort().join(" ");
8
+ return normalized || undefined;
9
+ }
@@ -1,6 +1,7 @@
1
1
  import { canonicalizeResourceIndicator } from "../resource-indicator.js";
2
2
  import { readBoundedResponseText } from "../http-response.js";
3
3
  import { fetchMcpResponse } from "../http-fetch.js";
4
+ import { normalizeOAuthScope } from "./scope.js";
4
5
  const MAX_JS_DATE_MS = 8_640_000_000_000_000;
5
6
  export class OAuthError extends Error {
6
7
  error;
@@ -117,7 +118,9 @@ async function requestTokens(input) {
117
118
  const normalizedRefreshToken = typeof refreshToken === "string" && refreshToken.trim().length > 0
118
119
  ? refreshToken.trim()
119
120
  : undefined;
120
- const normalizedScope = typeof scope === "string" && scope.trim().length > 0 ? scope.trim() : undefined;
121
+ const normalizedScope = normalizeOAuthScope(scope);
122
+ if (scope !== undefined && normalizedScope === undefined)
123
+ throw new Error("Invalid OAuth scope syntax in token response");
121
124
  return {
122
125
  accessToken: normalizedAccessToken,
123
126
  refreshToken: normalizedRefreshToken === undefined ? undefined : normalizedRefreshToken,
@@ -80,6 +80,8 @@ export interface StoredOAuthSession {
80
80
  tokens?: StoredOAuthTokens;
81
81
  /** A refresh was begun; its winning response may not have been persisted. */
82
82
  refreshState?: "pending";
83
+ /** Canonical explicitly requested scope set when the server omits token scope. */
84
+ requestedScope?: string;
83
85
  discovery: {
84
86
  resourceMetadataUrl: string;
85
87
  resourceMetadata: Record<string, unknown>;
@@ -183,6 +183,8 @@ interface StoredOAuthSession {
183
183
  tokens?: StoredOAuthTokens;
184
184
  /** A refresh was begun; its winning response may not have been persisted. */
185
185
  refreshState?: "pending";
186
+ /** Canonical explicitly requested scope set when the server omits token scope. */
187
+ requestedScope?: string;
186
188
  discovery: {
187
189
  resourceMetadataUrl: string;
188
190
  resourceMetadata: Record<string, unknown>;
@@ -4201,7 +4201,7 @@ function isStoredOAuthSession(value) {
4201
4201
  if (!isObjectRecord(value)) {
4202
4202
  return false;
4203
4203
  }
4204
- return isNonBlankOwnString(value, "resource") && isNonBlankOwnString(value, "authorizationServer") && isStoredOAuthClient(getOwnEntry3(value, "client")) && isStoredOAuthDiscovery(getOwnEntry3(value, "discovery")) && (getOwnEntry3(value, "refreshState") === void 0 || getOwnEntry3(value, "refreshState") === "pending" && getOwnEntry3(value, "tokens") === void 0) && isStoredOAuthTokensOrMissing(getOwnEntry3(value, "tokens"));
4204
+ return isNonBlankOwnString(value, "resource") && isNonBlankOwnString(value, "authorizationServer") && isStoredOAuthClient(getOwnEntry3(value, "client")) && isStoredOAuthDiscovery(getOwnEntry3(value, "discovery")) && (getOwnEntry3(value, "requestedScope") === void 0 || isNonBlankOwnString(value, "requestedScope")) && (getOwnEntry3(value, "refreshState") === void 0 || getOwnEntry3(value, "refreshState") === "pending" && getOwnEntry3(value, "tokens") === void 0) && isStoredOAuthTokensOrMissing(getOwnEntry3(value, "tokens"));
4205
4205
  }
4206
4206
  function isStoredOAuthClient(value) {
4207
4207
  if (!isObjectRecord(value) || !isNonBlankOwnString(value, "clientId")) {
@@ -4242,6 +4242,16 @@ function isNonBlankOwnString(record2, key2) {
4242
4242
  return value !== void 0 && value.trim().length > 0;
4243
4243
  }
4244
4244
 
4245
+ // ../mcp-oauth/dist/client/scope.js
4246
+ function normalizeOAuthScope(scope) {
4247
+ if (scope === void 0)
4248
+ return void 0;
4249
+ if (typeof scope !== "string" || [...scope].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) > 126 || char === '"' || char === "\\"))
4250
+ throw new Error("Invalid OAuth scope syntax");
4251
+ const normalized = [...new Set(scope.split(" ").filter(Boolean))].sort().join(" ");
4252
+ return normalized || void 0;
4253
+ }
4254
+
4245
4255
  // ../mcp-oauth/dist/client/default-oauth-client-provider.js
4246
4256
  import { isIP } from "node:net";
4247
4257
 
@@ -4726,7 +4736,9 @@ async function requestTokens(input) {
4726
4736
  const refreshToken = getOwnEntry5(payload, "refresh_token");
4727
4737
  const scope = getOwnEntry5(payload, "scope");
4728
4738
  const normalizedRefreshToken = typeof refreshToken === "string" && refreshToken.trim().length > 0 ? refreshToken.trim() : void 0;
4729
- const normalizedScope = typeof scope === "string" && scope.trim().length > 0 ? scope.trim() : void 0;
4739
+ const normalizedScope = normalizeOAuthScope(scope);
4740
+ if (scope !== void 0 && normalizedScope === void 0)
4741
+ throw new Error("Invalid OAuth scope syntax in token response");
4730
4742
  return {
4731
4743
  accessToken: normalizedAccessToken,
4732
4744
  refreshToken: normalizedRefreshToken === void 0 ? void 0 : normalizedRefreshToken,
@@ -4847,6 +4859,8 @@ function createOAuthClientProvider(options) {
4847
4859
  function createDefaultOAuthClientProvider(options) {
4848
4860
  loopbackTarget(options.browser);
4849
4861
  assertPersistenceNamespace(options.persistenceNamespace);
4862
+ const clientMetadata = getClientMetadata(options.client);
4863
+ const requestedScope = clientMetadata?.scope;
4850
4864
  const sessionStore = options.sessionStore ?? createAuthStoreSessionStore(options.authStore, options.persistenceNamespace);
4851
4865
  const clientStore = options.authStore === void 0 ? null : createAuthStoreClientStore(options.authStore, options.persistenceNamespace);
4852
4866
  const now = options.now ?? Date.now;
@@ -4869,6 +4883,8 @@ function createDefaultOAuthClientProvider(options) {
4869
4883
  if (initialGrant !== void 0 && (initialGrant.tokens === void 0 || initialGrant.client === null))
4870
4884
  throw new Error("OAuth initial grant requires valid tokens and the original client ID");
4871
4885
  if (initialGrant?.tokens !== void 0) {
4886
+ if (requestedScope !== void 0 && initialGrant.tokens.scope !== requestedScope)
4887
+ throw new Error("OAuth initial grant does not match the requested OAuth scope");
4872
4888
  try {
4873
4889
  new Headers({ Authorization: `Bearer ${initialGrant.tokens.accessToken}` });
4874
4890
  } catch {
@@ -4956,6 +4972,7 @@ function createDefaultOAuthClientProvider(options) {
4956
4972
  authorizationServer: discovery.authorizationServer,
4957
4973
  client: initialGrant.client,
4958
4974
  tokens: initialGrant.tokens,
4975
+ ...requestedScope === void 0 ? {} : { requestedScope },
4959
4976
  discovery: toStoredDiscovery(discovery)
4960
4977
  };
4961
4978
  await saveSession(canonicalResource, session);
@@ -4970,6 +4987,8 @@ function createDefaultOAuthClientProvider(options) {
4970
4987
  if (configured === null || configured.clientId !== session.client.clientId || configured.clientSecret !== session.client.clientSecret)
4971
4988
  throw new Error("Stored session belongs to a different OAuth client; use separate persistence or explicitly reset it");
4972
4989
  }
4990
+ if (requestedScope !== void 0 && session?.tokens !== void 0 && normalizeOAuthScope(session.tokens.scope ?? session.requestedScope) !== requestedScope)
4991
+ throw new Error("Stored session does not match the requested OAuth scope; authorize again or select separate persistence");
4973
4992
  if (session?.refreshState === "pending") {
4974
4993
  if (!allowInteractive || options.allowInteractive === false || sessionDiscovery === void 0)
4975
4994
  throw new Error("OAuth refresh outcome is unknown; authorize again before using this resource");
@@ -5046,10 +5065,13 @@ function createDefaultOAuthClientProvider(options) {
5046
5065
  ...session,
5047
5066
  tokens: {
5048
5067
  ...refreshedTokens,
5049
- refreshToken: refreshedTokens.refreshToken ?? session.tokens.refreshToken
5068
+ refreshToken: refreshedTokens.refreshToken ?? session.tokens.refreshToken,
5069
+ scope: refreshedTokens.scope ?? session.tokens.scope
5050
5070
  },
5051
5071
  discovery: toStoredDiscovery(discovery)
5052
5072
  };
5073
+ if (requestedScope !== void 0 && normalizeOAuthScope(updatedSession.tokens?.scope ?? session.requestedScope) !== requestedScope)
5074
+ throw new Error("OAuth refresh response does not match the requested OAuth scope; authorize again");
5053
5075
  await saveSession(resource, updatedSession);
5054
5076
  return updatedSession;
5055
5077
  }
@@ -5077,6 +5099,7 @@ function createDefaultOAuthClientProvider(options) {
5077
5099
  resource,
5078
5100
  authorizationServer: discovery.authorizationServer,
5079
5101
  client: resolvedClient.client,
5102
+ ...requestedScope === void 0 ? {} : { requestedScope },
5080
5103
  discovery: toStoredDiscovery(discovery)
5081
5104
  };
5082
5105
  await saveSession(resource, sessionWithoutTokens);
@@ -5088,7 +5111,7 @@ function createDefaultOAuthClientProvider(options) {
5088
5111
  clientId: resolvedClient.client.clientId,
5089
5112
  redirectUri: loopback.redirectUri,
5090
5113
  codeChallenge: challenge,
5091
- clientMetadata: getClientMetadata(options.client)
5114
+ clientMetadata
5092
5115
  });
5093
5116
  const code = await loopback.waitForCode(authorizationUrl);
5094
5117
  const tokens = await exchangeAuthorizationCode({
@@ -5103,6 +5126,8 @@ function createDefaultOAuthClientProvider(options) {
5103
5126
  signal,
5104
5127
  now
5105
5128
  });
5129
+ if (requestedScope !== void 0 && tokens.scope !== void 0 && normalizeOAuthScope(tokens.scope) !== requestedScope)
5130
+ throw new Error("OAuth authorization response does not match the requested OAuth scope");
5106
5131
  const session = {
5107
5132
  ...sessionWithoutTokens,
5108
5133
  tokens
@@ -5180,7 +5205,7 @@ function createDefaultOAuthClientProvider(options) {
5180
5205
  };
5181
5206
  }
5182
5207
  }
5183
- const registrationBody = buildClientRegistrationBody(getClientMetadata(options.client), redirectUri);
5208
+ const registrationBody = buildClientRegistrationBody(clientMetadata, redirectUri);
5184
5209
  const deadline = AbortSignal.timeout(3e4);
5185
5210
  const signal = parentSignal === void 0 ? deadline : AbortSignal.any([parentSignal, deadline]);
5186
5211
  const response = await fetchMcpResponse(fetch2, registrationEndpoint, {
@@ -5335,10 +5360,10 @@ function normalizeStoredTokens(value) {
5335
5360
  const tokenType = getOwnString2(value, "tokenType");
5336
5361
  const expiresAt = getOwnEntry6(value, "expiresAt");
5337
5362
  const refreshToken = getOwnEntry6(value, "refreshToken");
5338
- const scope = getOwnString2(value, "scope");
5363
+ const scope = getOwnEntry6(value, "scope");
5339
5364
  const normalizedAccessToken = accessToken?.trim();
5340
5365
  const normalizedRefreshToken = typeof refreshToken === "string" ? refreshToken.trim() : void 0;
5341
- const normalizedScope = scope?.trim();
5366
+ const normalizedScope = normalizeOAuthScope(scope);
5342
5367
  if (accessToken === void 0 || normalizedAccessToken === void 0 || normalizedAccessToken.length === 0 || tokenType !== "Bearer" || !(expiresAt === null || typeof expiresAt === "number" && Number.isSafeInteger(expiresAt) && expiresAt <= MAX_JS_DATE_MS3 && Number.isFinite(new Date(expiresAt).getTime())) || refreshToken !== void 0 && (typeof refreshToken !== "string" || normalizedRefreshToken === void 0 || normalizedRefreshToken.length === 0)) {
5343
5368
  return void 0;
5344
5369
  }
@@ -5356,7 +5381,7 @@ function getClientMetadata(client) {
5356
5381
  }
5357
5382
  return {
5358
5383
  clientName: normalizeOptionalOAuthString(client.metadata.clientName),
5359
- scope: normalizeOptionalOAuthString(client.metadata.scope),
5384
+ scope: normalizeOAuthScope(client.metadata.scope),
5360
5385
  softwareId: normalizeOptionalOAuthString(client.metadata.softwareId),
5361
5386
  softwareVersion: normalizeOptionalOAuthString(client.metadata.softwareVersion)
5362
5387
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tiny-http-mcp-server",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "description": "Minimal MCP server over HTTP built on tiny-stdio-mcp-server",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",