@oxy.so/core 7.19.1 → 7.20.0

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.
@@ -22,6 +22,12 @@ export type AccessTokenProvider = () => string | null;
22
22
  * — `null` to send it anonymous. See {@link HttpService.setAnonymousAuthProvider}.
23
23
  */
24
24
  export type AnonymousAuthProvider = () => Promise<string | null>;
25
+ /**
26
+ * Told that Oxy answered 401 to a bearer an {@link AnonymousAuthProvider}
27
+ * supplied; `true` when it dropped that bearer, so the request is sent once
28
+ * more with the provider's next one.
29
+ */
30
+ export type AnonymousAuthRejectedHandler = (token: string) => boolean;
25
31
  declare const credentialGenerationBrand: unique symbol;
26
32
  /**
27
33
  * An opaque, immutable token for ONE installation of a bearer in a client.
@@ -241,6 +247,7 @@ export declare class HttpService {
241
247
  private authRefreshHandler;
242
248
  private accessTokenProvider;
243
249
  private anonymousAuthProvider;
250
+ private anonymousAuthRejected;
244
251
  private deviceSecretMintInFlight;
245
252
  /**
246
253
  * Bumped by every {@link endSession}. A re-mint captures it before its first
@@ -557,8 +564,12 @@ export declare class HttpService {
557
564
  * `OxyConfig.serviceIdentity`). A user session always wins: the provider is
558
565
  * consulted only when there is no access token, and never for `skipAuth`
559
566
  * requests. `null` removes it.
567
+ *
568
+ * `onRejected` hears of a 401 to one of the provider's bearers; when it
569
+ * drops that bearer (`true`), the request is sent once more with the
570
+ * provider's next one — a rotated or revoked service token heals itself.
560
571
  */
561
- setAnonymousAuthProvider(provider: AnonymousAuthProvider | null): void;
572
+ setAnonymousAuthProvider(provider: AnonymousAuthProvider | null, onRejected?: AnonymousAuthRejectedHandler): void;
562
573
  clearTokens(): void;
563
574
  /**
564
575
  * Subscribe to access-token changes on this instance.
@@ -6,7 +6,7 @@ import type { HttpMethod } from '../client/context';
6
6
  import type { RequestOptions, ResponsePayload } from '../HttpService';
7
7
  import { type WorkloadCredentialProvider } from './workloadIdentity';
8
8
  import { type OxyAccountEvent, type OxyAccountEventFeedPage, type OxyMiddleware, type ServiceActingAsVerification, type VerifyAccountEventOptions } from './middleware';
9
- import { type AppPermissionsOptions, type AppPermissionsRequest, type OxyAppPermissions } from './appPermissions';
9
+ import { type AppPermissionsOptions, type AppPermissionsSource, type OxyAppPermissions } from './appPermissions';
10
10
  import { ServerAccountsApi, ServerAppsApi, ServerAgencyApi, ServerAssetsApi, ServerLinkedAccountsApi, ServerNotificationsApi, ServerReputationApi } from './namespaces';
11
11
  import { type CallerAccessOptions, type OxyAccountAccess, type OxyApplicationAccess } from './callerAccess';
12
12
  import type { OxyCallerRequest } from './callerAuthority';
@@ -295,12 +295,23 @@ export declare class OxyServer extends OxyServices {
295
295
  requesterContext?: string;
296
296
  serviceTokenScopes?: string[];
297
297
  }): Promise<ResponsePayload<T>>;
298
+ /**
299
+ * Drop every cached entry that holds `token` (and is not minting): `true`
300
+ * when one did, so the next mint asks Oxy again.
301
+ */
302
+ private forgetServiceToken;
298
303
  /**
299
304
  * Which of this application's own declared permissions the PERSON behind
300
305
  * `req` holds (application roles, `docs/auth/application-roles.md`). `req`
301
306
  * must have passed `middleware.auth()`: the answer is read from Oxy with the
302
307
  * request's own verified bearer, for the human operator of its session.
303
308
  *
309
+ * An MCP tool call is asked the same way: pass its `extra`
310
+ * (`CatalogInvocationContext.request`), whose `authInfo.token` is the access
311
+ * token `@oxy.so/mcp` introspected. Oxy answers, for the person who
312
+ * authorized that token, only about the application that registered its
313
+ * resource — this server's own (`APP_PERMISSION_NOT_CONFIGURED` otherwise).
314
+ *
304
315
  * Cached per session for `appPermissionsCacheTtlMs` (30 s); pass
305
316
  * `{ fresh: true }` before a write. Throws `OxyAppPermissionError` — no
306
317
  * session (`APP_PERMISSION_SESSION_REQUIRED`, also for any service-token
@@ -308,7 +319,7 @@ export declare class OxyServer extends OxyServices {
308
319
  * application id (`APP_PERMISSION_NOT_CONFIGURED`) — and never answers
309
320
  * "nothing held" for a failure.
310
321
  */
311
- appPermissionsFor(req: AppPermissionsRequest, options?: AppPermissionsOptions): Promise<OxyAppPermissions>;
322
+ appPermissionsFor(req: AppPermissionsSource, options?: AppPermissionsOptions): Promise<OxyAppPermissions>;
312
323
  /**
313
324
  * Drop cached caller-authority answers — application permissions and
314
325
  * account / application access — all of them, or one person's (as the
@@ -383,6 +394,7 @@ export declare class OxyServer extends OxyServices {
383
394
  }): void;
384
395
  private defaultApplicationId;
385
396
  private fetchRolesMe;
397
+ private fetchMcpAppPermissions;
386
398
  /**
387
399
  * Resolve authority live by default. Explicit read-only reuse lasts at most
388
400
  * 60s positive / 10s negative, anchored to request start; errors never cache.
@@ -34,6 +34,23 @@
34
34
  * `X-Oxy-User-Id`, is refused — an operator power is exercised by a person in
35
35
  * a session, never through delegation.
36
36
  *
37
+ * ## An MCP tool call
38
+ *
39
+ * An MCP request carries no session: its bearer is an access token Oxy issued
40
+ * for this app's MCP resource, which `@oxy.so/mcp`'s transport introspected
41
+ * live before the tool ran. Pass the call's `extra` (`context.request` in a
42
+ * catalog handler or `authorize`) the same way:
43
+ *
44
+ * ```ts
45
+ * const held = await oxy.appPermissionsFor(context.request, { fresh: true });
46
+ * ```
47
+ *
48
+ * The SDK presents that token, with THIS process's service token, to
49
+ * `POST /auth/mcp/oauth/app-permissions`. Oxy answers only for a token whose
50
+ * resource this application registered, only about this application, and for
51
+ * the person who authorized the token — whichever account the connection is
52
+ * acting as. Same answer type, cache, `fresh` and errors as a session.
53
+ *
37
54
  * ## Caching, and how soon a revocation lands
38
55
  *
39
56
  * Answers are cached per (application, session, user) for
@@ -54,7 +71,8 @@ export type OxyAppPermissionDenyStatus = 403 | 404;
54
71
  /**
55
72
  * - `APP_PERMISSION_DENIED` — a person in a session, without the permission.
56
73
  * - `APP_PERMISSION_SESSION_REQUIRED` — no user session on the request: anonymous,
57
- * or authenticated by a service token (delegated or not).
74
+ * or authenticated by a service token (delegated or not); for an MCP call, no
75
+ * access token, or one Oxy refused (revoked, expired, another app's resource).
58
76
  * - `APP_PERMISSION_UNAVAILABLE` — Oxy could not be asked or gave no usable
59
77
  * answer. Fails closed; answered 503.
60
78
  * - `APP_PERMISSION_NOT_CONFIGURED` — this server knows no application id, or
@@ -80,6 +98,8 @@ export interface OxyAppPermissions {
80
98
  * The HUMAN Oxy resolved from the session — record THIS as the actor in an
81
99
  * audit log. It differs from `req.userId` when a person acts as an
82
100
  * organization (the session speaks as the org; the operator is the person).
101
+ * For an MCP call it is the person who authorized the token, which may differ
102
+ * from the account the connection is acting as.
83
103
  */
84
104
  readonly operatorId: string;
85
105
  /** The held keys, sorted. */
@@ -89,7 +109,11 @@ export interface OxyAppPermissions {
89
109
  export interface AppPermissionsOptions {
90
110
  /** Skip the cache and ask Oxy (the answer still refreshes the cache). Use on writes. */
91
111
  fresh?: boolean;
92
- /** Ask about another application than the server's configured one. */
112
+ /**
113
+ * Ask about another application than the server's configured one. Not for an
114
+ * MCP call: Oxy answers it only about the application that registered the
115
+ * token's resource, and any other id is `APP_PERMISSION_NOT_CONFIGURED`.
116
+ */
93
117
  applicationId?: string;
94
118
  }
95
119
  export interface RequireAppPermissionOptions extends AppPermissionsOptions {
@@ -112,6 +136,18 @@ export interface RequireAppPermissionOptions extends AppPermissionsOptions {
112
136
  export interface AppPermissionsRequest extends OxyCallerRequest {
113
137
  oxyAppPermissions?: OxyAppPermissions;
114
138
  }
139
+ /**
140
+ * An MCP tool call: the `extra` (`RequestHandlerExtra`) an MCP handler receives,
141
+ * whose `authInfo` `@oxy.so/mcp`'s transport set after Oxy introspected the
142
+ * access token live. `CatalogInvocationContext.request` is one.
143
+ */
144
+ export interface OxyMcpCallerRequest {
145
+ readonly authInfo?: {
146
+ readonly token: string;
147
+ };
148
+ }
149
+ /** What `appPermissionsFor` reads: a request after `middleware.auth()`, or an MCP tool call. */
150
+ export type AppPermissionsSource = AppPermissionsRequest | OxyMcpCallerRequest;
115
151
  /** The status the middleware answers an error with, under `deny`. */
116
152
  export declare function appPermissionErrorStatus(error: OxyAppPermissionError, deny?: OxyAppPermissionDenyStatus): number;
117
153
  /** The response body for a status: the API error envelope (`docs/api-conventions.md`). */
@@ -122,6 +158,11 @@ export declare function assertApplicationPermissionKey(permission: unknown): ass
122
158
  export interface AppPermissionsHost {
123
159
  /** `GET /applications/:appId/roles/me` with this bearer, uncached. Rejects with the API error. */
124
160
  fetchRolesMe(applicationId: string, accessToken: string): Promise<unknown>;
161
+ /**
162
+ * `POST /auth/mcp/oauth/app-permissions` with this MCP access token, as this
163
+ * server's service, uncached. Rejects with the API error.
164
+ */
165
+ fetchMcpAppPermissions(mcpAccessToken: string): Promise<unknown>;
125
166
  /** The configured application id, or the one this server's service token names. */
126
167
  defaultApplicationId(): Promise<string | null>;
127
168
  readonly ttlMs: number;
@@ -135,10 +176,10 @@ export declare class OxyAppPermissionsResolver {
135
176
  private readonly cache;
136
177
  constructor(host: AppPermissionsHost);
137
178
  /**
138
- * What the person behind `req` holds. Throws {@link OxyAppPermissionError};
179
+ * What the person behind `source` holds. Throws {@link OxyAppPermissionError};
139
180
  * never answers "nothing held" for a failure.
140
181
  */
141
- resolve(req: AppPermissionsRequest, options?: AppPermissionsOptions): Promise<OxyAppPermissions>;
182
+ resolve(source: AppPermissionsSource, options?: AppPermissionsOptions): Promise<OxyAppPermissions>;
142
183
  /**
143
184
  * Forget cached answers: every one, or those about one person (matched as
144
185
  * either the session's user or the resolved operator).
@@ -146,5 +187,13 @@ export declare class OxyAppPermissionsResolver {
146
187
  invalidate(filter?: {
147
188
  userId?: string;
148
189
  }): void;
190
+ /**
191
+ * An MCP call: Oxy names the application (the one that registered the token's
192
+ * resource) and the person (who authorized the token); the answer is refused
193
+ * unless the application is the one this server expects.
194
+ */
195
+ private resolveMcp;
196
+ private applicationIdFor;
197
+ private lookupMcp;
149
198
  private lookup;
150
199
  }
@@ -36,6 +36,16 @@ export interface OxyCallerSession {
36
36
  readonly sessionId: string;
37
37
  readonly accessToken: string;
38
38
  }
39
+ /**
40
+ * Whose answer a cache entry is: the session (or other credential) it was read
41
+ * with, and the person, when known before asking. A caller whose person only
42
+ * Oxy's answer names (an MCP access token) passes `userId: null`; the reader's
43
+ * `isAbout` still matches the answer for {@link CallerAuthorityCache.invalidate}.
44
+ */
45
+ export interface OxyCallerCacheKey {
46
+ readonly sessionId: string;
47
+ readonly userId: string | null;
48
+ }
39
49
  /**
40
50
  * The person behind `req`, or why there is none: `service` for a request
41
51
  * authenticated by a service token (delegated or not — its bearer is the
@@ -113,7 +123,7 @@ export declare class CallerAuthorityCache<T> {
113
123
  * resolved operator), for {@link invalidate}.
114
124
  */
115
125
  constructor(ttlMs: number, isAbout?: (value: T, userId: string) => boolean);
116
- read(resourceKey: readonly string[], session: OxyCallerSession, fresh: boolean, lookup: () => Promise<T>): Promise<T>;
126
+ read(resourceKey: readonly string[], session: OxyCallerCacheKey, fresh: boolean, lookup: () => Promise<T>): Promise<T>;
117
127
  /** Forget every answer, or those about one person. */
118
128
  invalidate(filter?: {
119
129
  userId?: string;
@@ -49,7 +49,7 @@ export type { AliaMachineCredentialIntrospection, OxyAliaMachinePrincipal, OxyAl
49
49
  export type { CapabilityTicketGrant, CapabilityTicketIntrospection, CreateExecutionAuthorizationInput, ServiceCallOptions, ServiceCapabilityCatalog, ServiceUploadBody, ServiceUploadDescription, } from './namespaces';
50
50
  export { OxyAccountEventError, OXY_ACCOUNT_DELETED_EVENT_URI } from './middleware';
51
51
  export { APP_PERMISSIONS_CACHE_TTL_MS, OxyAppPermissionError, appPermissionErrorStatus, } from './appPermissions';
52
- export type { AppPermissionsOptions, OxyAppPermissionDenyStatus, OxyAppPermissionErrorCode, OxyAppPermissions, RequireAppPermissionOptions, } from './appPermissions';
52
+ export type { AppPermissionsOptions, AppPermissionsSource, OxyMcpCallerRequest, OxyAppPermissionDenyStatus, OxyAppPermissionErrorCode, OxyAppPermissions, RequireAppPermissionOptions, } from './appPermissions';
53
53
  export type { AuthMiddlewareOptions, OxyAccountEvent, OxyAccountEventFeedItem, OxyAccountEventFeedPage, OxyMiddleware, RequireAccessOptions, ServiceActingAsVerification, ServiceApp, VerifyAccountEventOptions, } from './middleware';
54
54
  export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyActor, getOxyAuthRefusal, getOxyBillingPrincipal, getOxyDelegatedUserId, getOxyRequestAttribution, getOxyUserId, getRequiredOxyBillingPrincipal, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth';
55
55
  export type { OxyAuthHost } from './auth';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "7.19.1",
3
+ "version": "7.20.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -55,6 +55,12 @@ export type AccessTokenProvider = () => string | null;
55
55
  * — `null` to send it anonymous. See {@link HttpService.setAnonymousAuthProvider}.
56
56
  */
57
57
  export type AnonymousAuthProvider = () => Promise<string | null>;
58
+ /**
59
+ * Told that Oxy answered 401 to a bearer an {@link AnonymousAuthProvider}
60
+ * supplied; `true` when it dropped that bearer, so the request is sent once
61
+ * more with the provider's next one.
62
+ */
63
+ export type AnonymousAuthRejectedHandler = (token: string) => boolean;
58
64
 
59
65
  declare const credentialGenerationBrand: unique symbol;
60
66
  /**
@@ -604,6 +610,7 @@ export class HttpService {
604
610
  private authRefreshHandler: AuthRefreshHandler | null = null;
605
611
  private accessTokenProvider: AccessTokenProvider | null = null;
606
612
  private anonymousAuthProvider: AnonymousAuthProvider | null = null;
613
+ private anonymousAuthRejected: AnonymousAuthRejectedHandler | null = null;
607
614
  private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
608
615
  /**
609
616
  * Bumped by every {@link endSession}. A re-mint captures it before its first
@@ -1145,6 +1152,33 @@ export class HttpService {
1145
1152
 
1146
1153
  // Handle response
1147
1154
  if (!response.ok) {
1155
+ // A 401 to the bearer the anonymous provider supplied (a backend's
1156
+ // own service token, no user session): once its owner has dropped
1157
+ // it, send the request once more with the provider's next one. A
1158
+ // 401 means the request never ran, so this is safe for any method.
1159
+ if (
1160
+ response.status === 401 &&
1161
+ !contextToken &&
1162
+ usesSessionAuth &&
1163
+ !config.expectedCredentialGeneration &&
1164
+ !config._isAuthRetry &&
1165
+ authHeader?.startsWith('Bearer ') &&
1166
+ this.anonymousAuthRejected?.(authHeader.slice('Bearer '.length))
1167
+ ) {
1168
+ void discardResponseBody(response);
1169
+ if (domain.aborted) throw createCancelledError();
1170
+ // Same reasons as the session retry below: no dedupe (the original
1171
+ // is still in flight under its key), no queue slot (this attempt
1172
+ // holds one).
1173
+ return this.request<T>({
1174
+ ...config,
1175
+ _isAuthRetry: true,
1176
+ retry: false,
1177
+ deduplicate: false,
1178
+ bypassQueue: true,
1179
+ signal: domain,
1180
+ });
1181
+ }
1148
1182
  // On 401, delegate to the installed auth refresh handler and retry
1149
1183
  // once before giving up. HttpService deliberately does not know any
1150
1184
  // session routes; the refresh handler owns session rotation.
@@ -2319,9 +2353,17 @@ export class HttpService {
2319
2353
  * `OxyConfig.serviceIdentity`). A user session always wins: the provider is
2320
2354
  * consulted only when there is no access token, and never for `skipAuth`
2321
2355
  * requests. `null` removes it.
2356
+ *
2357
+ * `onRejected` hears of a 401 to one of the provider's bearers; when it
2358
+ * drops that bearer (`true`), the request is sent once more with the
2359
+ * provider's next one — a rotated or revoked service token heals itself.
2322
2360
  */
2323
- setAnonymousAuthProvider(provider: AnonymousAuthProvider | null): void {
2361
+ setAnonymousAuthProvider(
2362
+ provider: AnonymousAuthProvider | null,
2363
+ onRejected?: AnonymousAuthRejectedHandler,
2364
+ ): void {
2324
2365
  this.anonymousAuthProvider = provider;
2366
+ this.anonymousAuthRejected = provider ? (onRejected ?? null) : null;
2325
2367
  }
2326
2368
 
2327
2369
  clearTokens(): void {
@@ -66,7 +66,7 @@ import { OxyServices, type OxyConfig } from '../OxyServices';
66
66
  import { HttpService } from '../HttpService';
67
67
  import { createAuthenticatedFetch } from '../authenticatedFetch';
68
68
  import { awaitRequestSignal } from '../utils/requestSignal';
69
- import { createCancelledError } from '../utils/errorUtils';
69
+ import { createCancelledError, extractErrorStatus } from '../utils/errorUtils';
70
70
  import type { HttpMethod, ServiceLane } from '../client/context';
71
71
  import type { RequestOptions, ResponsePayload } from '../HttpService';
72
72
  import { logger } from '../logger';
@@ -94,7 +94,7 @@ import {
94
94
  APP_PERMISSIONS_CACHE_TTL_MS,
95
95
  OxyAppPermissionsResolver,
96
96
  type AppPermissionsOptions,
97
- type AppPermissionsRequest,
97
+ type AppPermissionsSource,
98
98
  type OxyAppPermissions,
99
99
  } from './appPermissions';
100
100
  import {
@@ -365,7 +365,10 @@ export class OxyServer extends OxyServices {
365
365
  };
366
366
  this.context.service = serviceLane;
367
367
  if (config.serviceIdentity === 'when-anonymous') {
368
- this.http.setAnonymousAuthProvider(() => this.serviceTokenForAnonymousRequest());
368
+ this.http.setAnonymousAuthProvider(
369
+ () => this.serviceTokenForAnonymousRequest(),
370
+ (token) => this.forgetServiceToken(token),
371
+ );
369
372
  }
370
373
  const ttl = config.appPermissionsCacheTtlMs ?? APP_PERMISSIONS_CACHE_TTL_MS;
371
374
  if (!Number.isFinite(ttl) || ttl < 0) {
@@ -381,6 +384,7 @@ export class OxyServer extends OxyServices {
381
384
  ttlMs: ttl,
382
385
  defaultApplicationId: () => this.defaultApplicationId(),
383
386
  fetchRolesMe: (applicationId, accessToken) => this.fetchRolesMe(applicationId, accessToken),
387
+ fetchMcpAppPermissions: (mcpAccessToken) => this.fetchMcpAppPermissions(mcpAccessToken),
384
388
  });
385
389
  this.callerAccess = new OxyCallerAccessResolver({
386
390
  ttlMs: ttl,
@@ -873,8 +877,7 @@ export class OxyServer extends OxyServices {
873
877
  serviceTokenScopes,
874
878
  ...rest
875
879
  } = options;
876
- const token = await this.platformServiceToken({ scopes: serviceTokenScopes });
877
- const merged: Record<string, string> = { ...headers, Authorization: `Bearer ${token}` };
880
+ const merged: Record<string, string> = { ...headers };
878
881
  if (actAs) merged['X-Oxy-User-Id'] = actAs;
879
882
  if (
880
883
  [requesterToken, taskId, capabilityTicket, requesterContext].filter(
@@ -886,16 +889,52 @@ export class OxyServer extends OxyServices {
886
889
  if (taskId) merged['X-Oxy-Task-Id'] = taskId;
887
890
  if (capabilityTicket) merged['X-Oxy-Capability-Ticket'] = capabilityTicket;
888
891
  if (requesterContext) merged['X-Oxy-Requester-Context'] = requesterContext;
889
- return this.request<T>(method, target.href, data, {
890
- cache: false,
891
- ...rest,
892
- ...([requesterToken, taskId, capabilityTicket, requesterContext].some(
893
- (proof) => proof !== undefined,
894
- )
895
- ? { cache: false, deduplicate: false, retry: false }
896
- : {}),
897
- headers: merged,
898
- });
892
+ const send = (token: string) =>
893
+ this.request<T>(method, target.href, data, {
894
+ cache: false,
895
+ ...rest,
896
+ ...([requesterToken, taskId, capabilityTicket, requesterContext].some(
897
+ (proof) => proof !== undefined,
898
+ )
899
+ ? { cache: false, deduplicate: false, retry: false }
900
+ : {}),
901
+ headers: { ...merged, Authorization: `Bearer ${token}` },
902
+ });
903
+ const token = await this.platformServiceToken({ scopes: serviceTokenScopes });
904
+ try {
905
+ return await send(token);
906
+ } catch (error) {
907
+ // A 401 refused the request before it ran, so sending it again is safe
908
+ // whatever its method. A cached token Oxy no longer accepts (keys
909
+ // rotated, credential revoked) is dropped and the request sent once
910
+ // more with a freshly minted one: once, never after an abort, and only
911
+ // when the refused token was this server's cached one.
912
+ if (
913
+ extractErrorStatus(error) !== 401 ||
914
+ rest.signal?.aborted ||
915
+ !this.forgetServiceToken(token)
916
+ ) {
917
+ throw error;
918
+ }
919
+ const fresh = await this.platformServiceToken({ scopes: serviceTokenScopes });
920
+ if (fresh === token) throw error;
921
+ return send(fresh);
922
+ }
923
+ }
924
+
925
+ /**
926
+ * Drop every cached entry that holds `token` (and is not minting): `true`
927
+ * when one did, so the next mint asks Oxy again.
928
+ */
929
+ private forgetServiceToken(token: string): boolean {
930
+ let forgot = false;
931
+ for (const [cacheKey, entry] of this.serviceTokens) {
932
+ if (entry.token === token && !entry.pending) {
933
+ this.serviceTokens.delete(cacheKey);
934
+ forgot = true;
935
+ }
936
+ }
937
+ return forgot;
899
938
  }
900
939
 
901
940
  // ── Application roles ────────────────────────────────────────────────────
@@ -906,6 +945,12 @@ export class OxyServer extends OxyServices {
906
945
  * must have passed `middleware.auth()`: the answer is read from Oxy with the
907
946
  * request's own verified bearer, for the human operator of its session.
908
947
  *
948
+ * An MCP tool call is asked the same way: pass its `extra`
949
+ * (`CatalogInvocationContext.request`), whose `authInfo.token` is the access
950
+ * token `@oxy.so/mcp` introspected. Oxy answers, for the person who
951
+ * authorized that token, only about the application that registered its
952
+ * resource — this server's own (`APP_PERMISSION_NOT_CONFIGURED` otherwise).
953
+ *
909
954
  * Cached per session for `appPermissionsCacheTtlMs` (30 s); pass
910
955
  * `{ fresh: true }` before a write. Throws `OxyAppPermissionError` — no
911
956
  * session (`APP_PERMISSION_SESSION_REQUIRED`, also for any service-token
@@ -914,7 +959,7 @@ export class OxyServer extends OxyServices {
914
959
  * "nothing held" for a failure.
915
960
  */
916
961
  appPermissionsFor(
917
- req: AppPermissionsRequest,
962
+ req: AppPermissionsSource,
918
963
  options: AppPermissionsOptions = {},
919
964
  ): Promise<OxyAppPermissions> {
920
965
  return this.appPermissions.resolve(req, options);
@@ -1047,6 +1092,15 @@ export class OxyServer extends OxyServices {
1047
1092
  );
1048
1093
  }
1049
1094
 
1095
+ private fetchMcpAppPermissions(mcpAccessToken: string): Promise<unknown> {
1096
+ return this.serviceRequest<unknown>(
1097
+ 'POST',
1098
+ '/auth/mcp/oauth/app-permissions',
1099
+ { token: mcpAccessToken },
1100
+ { cache: false, deduplicate: false, retry: false },
1101
+ );
1102
+ }
1103
+
1050
1104
  // ── Delegation and account events ────────────────────────────────────────
1051
1105
 
1052
1106
  /**