@oxy.so/core 7.19.0 → 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.
package/README.md CHANGED
@@ -172,9 +172,12 @@ router.get('/orgs/:accountId', async (req, res) => {
172
172
 
173
173
  `createOxyAuthMiddleware(oxy)` resolves the bearer session and requires a user
174
174
  in one middleware; `createOptionalOxyAuth(oxy)` is the optional form. The
175
- permission questions (`appPermissionsFor`, `accountAccessFor`,
176
- `applicationAccessFor`), user clients (`withUser`), files and outbound HTTP are
177
- in [Building a backend](./docs/backend.mdx).
175
+ permission questions (`appPermissionsFor` — for a session or an MCP tool call —
176
+ `accountAccessFor`, `applicationAccessFor`), user clients (`withUser`), per-call options
177
+ (`oxy.withCallOptions({ timeout, deadline, retry, signal })`), files and
178
+ outbound HTTP (`safeFetch`, with `validated` to reuse an `assertSafePublicUrl`
179
+ check and `requireHttps` for an all-https chain) are in
180
+ [Building a backend](./docs/backend.mdx).
178
181
 
179
182
  ## Security Headers And CSP
180
183
 
@@ -373,6 +373,7 @@ class HttpService {
373
373
  this.authRefreshHandler = null;
374
374
  this.accessTokenProvider = null;
375
375
  this.anonymousAuthProvider = null;
376
+ this.anonymousAuthRejected = null;
376
377
  this.deviceSecretMintInFlight = null;
377
378
  /**
378
379
  * Bumped by every {@link endSession}. A re-mint captures it before its first
@@ -819,6 +820,32 @@ class HttpService {
819
820
  }
820
821
  // Handle response
821
822
  if (!response.ok) {
823
+ // A 401 to the bearer the anonymous provider supplied (a backend's
824
+ // own service token, no user session): once its owner has dropped
825
+ // it, send the request once more with the provider's next one. A
826
+ // 401 means the request never ran, so this is safe for any method.
827
+ if (response.status === 401 &&
828
+ !contextToken &&
829
+ usesSessionAuth &&
830
+ !config.expectedCredentialGeneration &&
831
+ !config._isAuthRetry &&
832
+ authHeader?.startsWith('Bearer ') &&
833
+ this.anonymousAuthRejected?.(authHeader.slice('Bearer '.length))) {
834
+ void discardResponseBody(response);
835
+ if (domain.aborted)
836
+ throw (0, errorUtils_1.createCancelledError)();
837
+ // Same reasons as the session retry below: no dedupe (the original
838
+ // is still in flight under its key), no queue slot (this attempt
839
+ // holds one).
840
+ return this.request({
841
+ ...config,
842
+ _isAuthRetry: true,
843
+ retry: false,
844
+ deduplicate: false,
845
+ bypassQueue: true,
846
+ signal: domain,
847
+ });
848
+ }
822
849
  // On 401, delegate to the installed auth refresh handler and retry
823
850
  // once before giving up. HttpService deliberately does not know any
824
851
  // session routes; the refresh handler owns session rotation.
@@ -1842,9 +1869,14 @@ class HttpService {
1842
1869
  * `OxyConfig.serviceIdentity`). A user session always wins: the provider is
1843
1870
  * consulted only when there is no access token, and never for `skipAuth`
1844
1871
  * requests. `null` removes it.
1872
+ *
1873
+ * `onRejected` hears of a 401 to one of the provider's bearers; when it
1874
+ * drops that bearer (`true`), the request is sent once more with the
1875
+ * provider's next one — a rotated or revoked service token heals itself.
1845
1876
  */
1846
- setAnonymousAuthProvider(provider) {
1877
+ setAnonymousAuthProvider(provider, onRejected) {
1847
1878
  this.anonymousAuthProvider = provider;
1879
+ this.anonymousAuthRejected = provider ? (onRejected ?? null) : null;
1848
1880
  }
1849
1881
  clearTokens() {
1850
1882
  if (this.tokenStore.getAccessToken())
@@ -155,7 +155,7 @@ class OxyServer extends OxyServices_1.OxyServices {
155
155
  };
156
156
  this.context.service = serviceLane;
157
157
  if (config.serviceIdentity === 'when-anonymous') {
158
- this.http.setAnonymousAuthProvider(() => this.serviceTokenForAnonymousRequest());
158
+ this.http.setAnonymousAuthProvider(() => this.serviceTokenForAnonymousRequest(), (token) => this.forgetServiceToken(token));
159
159
  }
160
160
  const ttl = config.appPermissionsCacheTtlMs ?? appPermissions_1.APP_PERMISSIONS_CACHE_TTL_MS;
161
161
  if (!Number.isFinite(ttl) || ttl < 0) {
@@ -171,6 +171,7 @@ class OxyServer extends OxyServices_1.OxyServices {
171
171
  ttlMs: ttl,
172
172
  defaultApplicationId: () => this.defaultApplicationId(),
173
173
  fetchRolesMe: (applicationId, accessToken) => this.fetchRolesMe(applicationId, accessToken),
174
+ fetchMcpAppPermissions: (mcpAccessToken) => this.fetchMcpAppPermissions(mcpAccessToken),
174
175
  });
175
176
  this.callerAccess = new callerAccess_1.OxyCallerAccessResolver({
176
177
  ttlMs: ttl,
@@ -558,8 +559,7 @@ class OxyServer extends OxyServices_1.OxyServices {
558
559
  throw new Error('Service requests must target the configured API origin');
559
560
  }
560
561
  const { actAs, requesterToken, taskId, capabilityTicket, requesterContext, headers, serviceTokenScopes, ...rest } = options;
561
- const token = await this.platformServiceToken({ scopes: serviceTokenScopes });
562
- const merged = { ...headers, Authorization: `Bearer ${token}` };
562
+ const merged = { ...headers };
563
563
  if (actAs)
564
564
  merged['X-Oxy-User-Id'] = actAs;
565
565
  if ([requesterToken, taskId, capabilityTicket, requesterContext].filter((proof) => proof !== undefined).length > 1)
@@ -572,14 +572,48 @@ class OxyServer extends OxyServices_1.OxyServices {
572
572
  merged['X-Oxy-Capability-Ticket'] = capabilityTicket;
573
573
  if (requesterContext)
574
574
  merged['X-Oxy-Requester-Context'] = requesterContext;
575
- return this.request(method, target.href, data, {
575
+ const send = (token) => this.request(method, target.href, data, {
576
576
  cache: false,
577
577
  ...rest,
578
578
  ...([requesterToken, taskId, capabilityTicket, requesterContext].some((proof) => proof !== undefined)
579
579
  ? { cache: false, deduplicate: false, retry: false }
580
580
  : {}),
581
- headers: merged,
581
+ headers: { ...merged, Authorization: `Bearer ${token}` },
582
582
  });
583
+ const token = await this.platformServiceToken({ scopes: serviceTokenScopes });
584
+ try {
585
+ return await send(token);
586
+ }
587
+ catch (error) {
588
+ // A 401 refused the request before it ran, so sending it again is safe
589
+ // whatever its method. A cached token Oxy no longer accepts (keys
590
+ // rotated, credential revoked) is dropped and the request sent once
591
+ // more with a freshly minted one: once, never after an abort, and only
592
+ // when the refused token was this server's cached one.
593
+ if ((0, errorUtils_1.extractErrorStatus)(error) !== 401 ||
594
+ rest.signal?.aborted ||
595
+ !this.forgetServiceToken(token)) {
596
+ throw error;
597
+ }
598
+ const fresh = await this.platformServiceToken({ scopes: serviceTokenScopes });
599
+ if (fresh === token)
600
+ throw error;
601
+ return send(fresh);
602
+ }
603
+ }
604
+ /**
605
+ * Drop every cached entry that holds `token` (and is not minting): `true`
606
+ * when one did, so the next mint asks Oxy again.
607
+ */
608
+ forgetServiceToken(token) {
609
+ let forgot = false;
610
+ for (const [cacheKey, entry] of this.serviceTokens) {
611
+ if (entry.token === token && !entry.pending) {
612
+ this.serviceTokens.delete(cacheKey);
613
+ forgot = true;
614
+ }
615
+ }
616
+ return forgot;
583
617
  }
584
618
  // ── Application roles ────────────────────────────────────────────────────
585
619
  /**
@@ -588,6 +622,12 @@ class OxyServer extends OxyServices_1.OxyServices {
588
622
  * must have passed `middleware.auth()`: the answer is read from Oxy with the
589
623
  * request's own verified bearer, for the human operator of its session.
590
624
  *
625
+ * An MCP tool call is asked the same way: pass its `extra`
626
+ * (`CatalogInvocationContext.request`), whose `authInfo.token` is the access
627
+ * token `@oxy.so/mcp` introspected. Oxy answers, for the person who
628
+ * authorized that token, only about the application that registered its
629
+ * resource — this server's own (`APP_PERMISSION_NOT_CONFIGURED` otherwise).
630
+ *
591
631
  * Cached per session for `appPermissionsCacheTtlMs` (30 s); pass
592
632
  * `{ fresh: true }` before a write. Throws `OxyAppPermissionError` — no
593
633
  * session (`APP_PERMISSION_SESSION_REQUIRED`, also for any service-token
@@ -700,6 +740,9 @@ class OxyServer extends OxyServices_1.OxyServices {
700
740
  }
701
741
  return this.request('GET', `/applications/${encodeURIComponent(applicationId)}/roles/me`, undefined, { cache: false, retry: false, headers: { Authorization: `Bearer ${accessToken}` } });
702
742
  }
743
+ fetchMcpAppPermissions(mcpAccessToken) {
744
+ return this.serviceRequest('POST', '/auth/mcp/oauth/app-permissions', { token: mcpAccessToken }, { cache: false, deduplicate: false, retry: false });
745
+ }
703
746
  // ── Delegation and account events ────────────────────────────────────────
704
747
  /**
705
748
  * Resolve authority live by default. Explicit read-only reuse lasts at most
@@ -3,10 +3,13 @@
3
3
  * Application roles, from the application's side: which of THIS application's
4
4
  * declared permissions does the person behind a request hold?
5
5
  *
6
- * Oxy decides who holds an app's operator powers (`docs/auth/application-roles.md`).
7
- * An app declares its keys (`payments.operate`, …) in the Console, its owners
8
- * grant them to people, and its backend asks — so no app keeps a
9
- * `*_OPERATOR_OXY_USER_IDS` allow-list in its environment.
6
+ * Oxy decides who holds an app's operator powers (`docs/auth/application-roles.md`),
7
+ * and the backend asks — so no app keeps a `*_OPERATOR_OXY_USER_IDS` allow-list
8
+ * in its environment. One of Oxy's own applications declares its keys
9
+ * (`payments.operate`, …) in Oxy's first-party registry, each held by roles of
10
+ * the owning organization (ADR 0056); a third-party app declares them in the
11
+ * Console and its owners grant them to people. The answer has the same shape
12
+ * either way.
10
13
  *
11
14
  * ```ts
12
15
  * const oxy = new OxyServer({ baseURL: 'https://api.oxy.so', applicationId: process.env.OXY_APPLICATION_ID });
@@ -32,6 +35,23 @@
32
35
  * `X-Oxy-User-Id`, is refused — an operator power is exercised by a person in
33
36
  * a session, never through delegation.
34
37
  *
38
+ * ## An MCP tool call
39
+ *
40
+ * An MCP request carries no session: its bearer is an access token Oxy issued
41
+ * for this app's MCP resource, which `@oxy.so/mcp`'s transport introspected
42
+ * live before the tool ran. Pass the call's `extra` (`context.request` in a
43
+ * catalog handler or `authorize`) the same way:
44
+ *
45
+ * ```ts
46
+ * const held = await oxy.appPermissionsFor(context.request, { fresh: true });
47
+ * ```
48
+ *
49
+ * The SDK presents that token, with THIS process's service token, to
50
+ * `POST /auth/mcp/oauth/app-permissions`. Oxy answers only for a token whose
51
+ * resource this application registered, only about this application, and for
52
+ * the person who authorized the token — whichever account the connection is
53
+ * acting as. Same answer type, cache, `fresh` and errors as a session.
54
+ *
35
55
  * ## Caching, and how soon a revocation lands
36
56
  *
37
57
  * Answers are cached per (application, session, user) for
@@ -48,6 +68,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
48
68
  exports.OxyAppPermissionsResolver = exports.appPermissionErrorBody = exports.OxyAppPermissionError = exports.APP_PERMISSIONS_CACHE_TTL_MS = void 0;
49
69
  exports.appPermissionErrorStatus = appPermissionErrorStatus;
50
70
  exports.assertApplicationPermissionKey = assertApplicationPermissionKey;
71
+ const node_crypto_1 = require("node:crypto");
51
72
  const contracts_1 = require("@oxy.so/contracts");
52
73
  const logger_1 = require("../logger");
53
74
  const callerAuthority_1 = require("./callerAuthority");
@@ -64,6 +85,9 @@ class OxyAppPermissionError extends Error {
64
85
  }
65
86
  }
66
87
  exports.OxyAppPermissionError = OxyAppPermissionError;
88
+ function isMcpCallerRequest(source) {
89
+ return source.authInfo !== undefined;
90
+ }
67
91
  /** The status the middleware answers an error with, under `deny`. */
68
92
  function appPermissionErrorStatus(error, deny = 404) {
69
93
  switch (error.code) {
@@ -98,16 +122,48 @@ class OxyAppPermissionsResolver {
98
122
  this.cache = new callerAuthority_1.CallerAuthorityCache(host.ttlMs, (value, userId) => value.operatorId === userId);
99
123
  }
100
124
  /**
101
- * What the person behind `req` holds. Throws {@link OxyAppPermissionError};
125
+ * What the person behind `source` holds. Throws {@link OxyAppPermissionError};
102
126
  * never answers "nothing held" for a failure.
103
127
  */
104
- async resolve(req, options = {}) {
105
- const caller = (0, callerAuthority_1.readCallerSession)(req);
128
+ async resolve(source, options = {}) {
129
+ if (isMcpCallerRequest(source))
130
+ return this.resolveMcp(source, options);
131
+ const caller = (0, callerAuthority_1.readCallerSession)(source);
106
132
  if (!caller.ok) {
107
133
  throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', caller.reason === 'service'
108
134
  ? 'Application permissions are held by a person in a session; this request is authenticated by a service token'
109
135
  : 'No verified user session on this request; mount middleware.auth() first');
110
136
  }
137
+ const appId = await this.applicationIdFor(options);
138
+ return this.cache.read([appId], caller.session, options.fresh === true, () => this.lookup(appId, caller.session.accessToken));
139
+ }
140
+ /**
141
+ * Forget cached answers: every one, or those about one person (matched as
142
+ * either the session's user or the resolved operator).
143
+ */
144
+ invalidate(filter = {}) {
145
+ this.cache.invalidate(filter);
146
+ }
147
+ /**
148
+ * An MCP call: Oxy names the application (the one that registered the token's
149
+ * resource) and the person (who authorized the token); the answer is refused
150
+ * unless the application is the one this server expects.
151
+ */
152
+ async resolveMcp(source, options) {
153
+ const token = source.authInfo?.token;
154
+ if (typeof token !== 'string' || !(0, callerAuthority_1.isBearerToken)(token)) {
155
+ throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', 'No verified MCP access token on this call');
156
+ }
157
+ const appId = await this.applicationIdFor(options);
158
+ // The person is known only once Oxy answers; the token's digest keys the
159
+ // entry, so the bearer itself is never held as a cache key.
160
+ const cacheKey = {
161
+ sessionId: `mcp:${(0, node_crypto_1.createHash)('sha256').update(token).digest('base64url')}`,
162
+ userId: null,
163
+ };
164
+ return this.cache.read([appId], cacheKey, options.fresh === true, () => this.lookupMcp(appId, token));
165
+ }
166
+ async applicationIdFor(options) {
111
167
  let applicationId = options.applicationId ?? null;
112
168
  if (!applicationId) {
113
169
  try {
@@ -120,15 +176,43 @@ class OxyAppPermissionsResolver {
120
176
  if (!applicationId) {
121
177
  throw new OxyAppPermissionError('APP_PERMISSION_NOT_CONFIGURED', 'No application id: set OxyServerConfig.applicationId, configure service auth, or pass { applicationId }');
122
178
  }
123
- const appId = applicationId;
124
- return this.cache.read([appId], caller.session, options.fresh === true, () => this.lookup(appId, caller.session.accessToken));
179
+ return applicationId;
125
180
  }
126
- /**
127
- * Forget cached answers: every one, or those about one person (matched as
128
- * either the session's user or the resolved operator).
129
- */
130
- invalidate(filter = {}) {
131
- this.cache.invalidate(filter);
181
+ async lookupMcp(applicationId, mcpAccessToken) {
182
+ let answer;
183
+ try {
184
+ answer = await this.host.fetchMcpAppPermissions(mcpAccessToken);
185
+ }
186
+ catch (error) {
187
+ const status = (0, callerAuthority_1.errorStatus)(error);
188
+ if (status === 401 && error.code === 'invalid_grant') {
189
+ throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', 'Oxy refused the MCP access token while resolving application permissions', { cause: error });
190
+ }
191
+ if (status === 403) {
192
+ throw new OxyAppPermissionError('APP_PERMISSION_DENIED', 'Oxy refused this application’s permissions for the MCP access token', { cause: error });
193
+ }
194
+ // Anything else — this service's own credential refused (401
195
+ // `invalid_client`), an Oxy without the route, a 5xx, a timeout — is not
196
+ // an answer about the person.
197
+ logger_1.logger.warn('[oxy.appPermissions] Could not resolve application permissions for MCP', {
198
+ component: 'auth',
199
+ method: 'appPermissions',
200
+ applicationId,
201
+ status,
202
+ });
203
+ throw new OxyAppPermissionError('APP_PERMISSION_UNAVAILABLE', 'Could not resolve application permissions from Oxy', { cause: error });
204
+ }
205
+ const parsed = contracts_1.applicationRolesMeSchema.safeParse(answer);
206
+ if (!parsed.success) {
207
+ throw new OxyAppPermissionError('APP_PERMISSION_UNAVAILABLE', 'Oxy answered with a malformed permission set');
208
+ }
209
+ if (parsed.data.applicationId !== applicationId) {
210
+ // Oxy answers about the application behind this server's service token;
211
+ // a different one means the configured id names another application.
212
+ logger_1.logger.error(`[oxy.appPermissions] Oxy answered for application ${parsed.data.applicationId}, not ${applicationId}; check OxyServerConfig.applicationId`, undefined, { component: 'auth', method: 'appPermissions' });
213
+ throw new OxyAppPermissionError('APP_PERMISSION_NOT_CONFIGURED', 'An MCP access token answers only for the application behind this server’s service token');
214
+ }
215
+ return heldPermissions(applicationId, parsed.data.userId, parsed.data.permissions);
132
216
  }
133
217
  async lookup(applicationId, accessToken) {
134
218
  let answer;
@@ -369,6 +369,7 @@ export class HttpService {
369
369
  this.authRefreshHandler = null;
370
370
  this.accessTokenProvider = null;
371
371
  this.anonymousAuthProvider = null;
372
+ this.anonymousAuthRejected = null;
372
373
  this.deviceSecretMintInFlight = null;
373
374
  /**
374
375
  * Bumped by every {@link endSession}. A re-mint captures it before its first
@@ -815,6 +816,32 @@ export class HttpService {
815
816
  }
816
817
  // Handle response
817
818
  if (!response.ok) {
819
+ // A 401 to the bearer the anonymous provider supplied (a backend's
820
+ // own service token, no user session): once its owner has dropped
821
+ // it, send the request once more with the provider's next one. A
822
+ // 401 means the request never ran, so this is safe for any method.
823
+ if (response.status === 401 &&
824
+ !contextToken &&
825
+ usesSessionAuth &&
826
+ !config.expectedCredentialGeneration &&
827
+ !config._isAuthRetry &&
828
+ authHeader?.startsWith('Bearer ') &&
829
+ this.anonymousAuthRejected?.(authHeader.slice('Bearer '.length))) {
830
+ void discardResponseBody(response);
831
+ if (domain.aborted)
832
+ throw createCancelledError();
833
+ // Same reasons as the session retry below: no dedupe (the original
834
+ // is still in flight under its key), no queue slot (this attempt
835
+ // holds one).
836
+ return this.request({
837
+ ...config,
838
+ _isAuthRetry: true,
839
+ retry: false,
840
+ deduplicate: false,
841
+ bypassQueue: true,
842
+ signal: domain,
843
+ });
844
+ }
818
845
  // On 401, delegate to the installed auth refresh handler and retry
819
846
  // once before giving up. HttpService deliberately does not know any
820
847
  // session routes; the refresh handler owns session rotation.
@@ -1838,9 +1865,14 @@ export class HttpService {
1838
1865
  * `OxyConfig.serviceIdentity`). A user session always wins: the provider is
1839
1866
  * consulted only when there is no access token, and never for `skipAuth`
1840
1867
  * requests. `null` removes it.
1868
+ *
1869
+ * `onRejected` hears of a 401 to one of the provider's bearers; when it
1870
+ * drops that bearer (`true`), the request is sent once more with the
1871
+ * provider's next one — a rotated or revoked service token heals itself.
1841
1872
  */
1842
- setAnonymousAuthProvider(provider) {
1873
+ setAnonymousAuthProvider(provider, onRejected) {
1843
1874
  this.anonymousAuthProvider = provider;
1875
+ this.anonymousAuthRejected = provider ? (onRejected ?? null) : null;
1844
1876
  }
1845
1877
  clearTokens() {
1846
1878
  if (this.tokenStore.getAccessToken())
@@ -46,7 +46,7 @@ import { OxyServices } from '../OxyServices.js';
46
46
  import { HttpService } from '../HttpService.js';
47
47
  import { createAuthenticatedFetch } from '../authenticatedFetch.js';
48
48
  import { awaitRequestSignal } from '../utils/requestSignal.js';
49
- import { createCancelledError } from '../utils/errorUtils.js';
49
+ import { createCancelledError, extractErrorStatus } from '../utils/errorUtils.js';
50
50
  import { logger } from '../logger/index.js';
51
51
  import { canAttestWorkloadIdentity, requestWorkloadServiceToken, } from './workloadIdentity.js';
52
52
  import { createOxyMiddleware, OXY_ACCOUNT_DELETED_EVENT_URI, OXY_JWT_ISSUER, OxyAccountEventError, parseJsonSegment, resolveServiceTokenPublicKey, } from './middleware.js';
@@ -151,7 +151,7 @@ export class OxyServer extends OxyServices {
151
151
  };
152
152
  this.context.service = serviceLane;
153
153
  if (config.serviceIdentity === 'when-anonymous') {
154
- this.http.setAnonymousAuthProvider(() => this.serviceTokenForAnonymousRequest());
154
+ this.http.setAnonymousAuthProvider(() => this.serviceTokenForAnonymousRequest(), (token) => this.forgetServiceToken(token));
155
155
  }
156
156
  const ttl = config.appPermissionsCacheTtlMs ?? APP_PERMISSIONS_CACHE_TTL_MS;
157
157
  if (!Number.isFinite(ttl) || ttl < 0) {
@@ -167,6 +167,7 @@ export class OxyServer extends OxyServices {
167
167
  ttlMs: ttl,
168
168
  defaultApplicationId: () => this.defaultApplicationId(),
169
169
  fetchRolesMe: (applicationId, accessToken) => this.fetchRolesMe(applicationId, accessToken),
170
+ fetchMcpAppPermissions: (mcpAccessToken) => this.fetchMcpAppPermissions(mcpAccessToken),
170
171
  });
171
172
  this.callerAccess = new OxyCallerAccessResolver({
172
173
  ttlMs: ttl,
@@ -554,8 +555,7 @@ export class OxyServer extends OxyServices {
554
555
  throw new Error('Service requests must target the configured API origin');
555
556
  }
556
557
  const { actAs, requesterToken, taskId, capabilityTicket, requesterContext, headers, serviceTokenScopes, ...rest } = options;
557
- const token = await this.platformServiceToken({ scopes: serviceTokenScopes });
558
- const merged = { ...headers, Authorization: `Bearer ${token}` };
558
+ const merged = { ...headers };
559
559
  if (actAs)
560
560
  merged['X-Oxy-User-Id'] = actAs;
561
561
  if ([requesterToken, taskId, capabilityTicket, requesterContext].filter((proof) => proof !== undefined).length > 1)
@@ -568,14 +568,48 @@ export class OxyServer extends OxyServices {
568
568
  merged['X-Oxy-Capability-Ticket'] = capabilityTicket;
569
569
  if (requesterContext)
570
570
  merged['X-Oxy-Requester-Context'] = requesterContext;
571
- return this.request(method, target.href, data, {
571
+ const send = (token) => this.request(method, target.href, data, {
572
572
  cache: false,
573
573
  ...rest,
574
574
  ...([requesterToken, taskId, capabilityTicket, requesterContext].some((proof) => proof !== undefined)
575
575
  ? { cache: false, deduplicate: false, retry: false }
576
576
  : {}),
577
- headers: merged,
577
+ headers: { ...merged, Authorization: `Bearer ${token}` },
578
578
  });
579
+ const token = await this.platformServiceToken({ scopes: serviceTokenScopes });
580
+ try {
581
+ return await send(token);
582
+ }
583
+ catch (error) {
584
+ // A 401 refused the request before it ran, so sending it again is safe
585
+ // whatever its method. A cached token Oxy no longer accepts (keys
586
+ // rotated, credential revoked) is dropped and the request sent once
587
+ // more with a freshly minted one: once, never after an abort, and only
588
+ // when the refused token was this server's cached one.
589
+ if (extractErrorStatus(error) !== 401 ||
590
+ rest.signal?.aborted ||
591
+ !this.forgetServiceToken(token)) {
592
+ throw error;
593
+ }
594
+ const fresh = await this.platformServiceToken({ scopes: serviceTokenScopes });
595
+ if (fresh === token)
596
+ throw error;
597
+ return send(fresh);
598
+ }
599
+ }
600
+ /**
601
+ * Drop every cached entry that holds `token` (and is not minting): `true`
602
+ * when one did, so the next mint asks Oxy again.
603
+ */
604
+ forgetServiceToken(token) {
605
+ let forgot = false;
606
+ for (const [cacheKey, entry] of this.serviceTokens) {
607
+ if (entry.token === token && !entry.pending) {
608
+ this.serviceTokens.delete(cacheKey);
609
+ forgot = true;
610
+ }
611
+ }
612
+ return forgot;
579
613
  }
580
614
  // ── Application roles ────────────────────────────────────────────────────
581
615
  /**
@@ -584,6 +618,12 @@ export class OxyServer extends OxyServices {
584
618
  * must have passed `middleware.auth()`: the answer is read from Oxy with the
585
619
  * request's own verified bearer, for the human operator of its session.
586
620
  *
621
+ * An MCP tool call is asked the same way: pass its `extra`
622
+ * (`CatalogInvocationContext.request`), whose `authInfo.token` is the access
623
+ * token `@oxy.so/mcp` introspected. Oxy answers, for the person who
624
+ * authorized that token, only about the application that registered its
625
+ * resource — this server's own (`APP_PERMISSION_NOT_CONFIGURED` otherwise).
626
+ *
587
627
  * Cached per session for `appPermissionsCacheTtlMs` (30 s); pass
588
628
  * `{ fresh: true }` before a write. Throws `OxyAppPermissionError` — no
589
629
  * session (`APP_PERMISSION_SESSION_REQUIRED`, also for any service-token
@@ -696,6 +736,9 @@ export class OxyServer extends OxyServices {
696
736
  }
697
737
  return this.request('GET', `/applications/${encodeURIComponent(applicationId)}/roles/me`, undefined, { cache: false, retry: false, headers: { Authorization: `Bearer ${accessToken}` } });
698
738
  }
739
+ fetchMcpAppPermissions(mcpAccessToken) {
740
+ return this.serviceRequest('POST', '/auth/mcp/oauth/app-permissions', { token: mcpAccessToken }, { cache: false, deduplicate: false, retry: false });
741
+ }
699
742
  // ── Delegation and account events ────────────────────────────────────────
700
743
  /**
701
744
  * Resolve authority live by default. Explicit read-only reuse lasts at most