Skip to content

Commit 91ce104

Browse files
committed
Fix configurable-scope regression, wire publisher field, and document breaking change
- resolveTokenScopeForServer now accepts sharedScope parameter (defaults to prod ATG constant for backward compat) so V1 fallback scope is derived from mcpPlatformAuthenticationScope rather than a hardcoded constant. This fixes a false migration error when the auth scope is overridden via configuration. - attachPerAudienceTokens reads mcpPlatformAuthenticationScope from config and passes it into resolveTokenScopeForServer, ensuring the scope resolution and the legacy-path guard always compare against the same configured value. - publisher field is now preserved through both gateway and manifest normalization (previously declared in MCPServerConfig but silently dropped). - Updated GetToolingGatewayForDigitalWorker JSDoc example URL to /agents/v2/. - Added Breaking Changes section to CHANGELOG [Unreleased] documenting the listToolServers(agenticAppId, authToken) throw for V2 servers with migration guidance. Added publisher to the Added section. - 5 new tests for resolveTokenScopeForServer with custom sharedScope, including a regression guard for the false migration-error scenario.
1 parent c3ddf4f commit 91ce104

5 files changed

Lines changed: 100 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,28 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability.
4545

4646
## [Unreleased]
4747

48+
### Breaking Changes (`@microsoft/agents-a365-tooling`)
49+
50+
- **`listToolServers(agenticAppId, authToken)` throws for V2 MCP servers** — The deprecated
51+
two-argument overload now throws a hard error if the gateway returns any server whose
52+
`audience` field does not match the shared ATG app ID (i.e. a V2 server). The legacy
53+
signature cannot perform per-audience OBO because it has no `Authorization` object or
54+
`authHandlerName`. Agents whose blueprints only have V1 permissions are unaffected.
55+
56+
**Migration** — switch to the preferred overload which handles both V1 and V2 automatically:
57+
```typescript
58+
// Before (deprecated)
59+
const servers = await service.listToolServers(agenticAppId, authToken);
60+
61+
// After
62+
const servers = await service.listToolServers(turnContext, authorization, 'graph');
63+
// authToken is optional; omit it and the SDK auto-generates it via token exchange.
64+
```
65+
4866
### Added (`@microsoft/agents-a365-tooling`)
4967

5068
- **V1/V2 per-audience token acquisition**`resolveTokenScopeForServer` now supports explicit `scope` field for V2 MCP servers. When a V2 server provides a `scope` value, the token is requested as `{audience}/{scope}`; otherwise falls back to `{audience}/.default` (pre-consented permissions cover both cases).
69+
- **`publisher` field preserved end-to-end**`MCPServerConfig.publisher` is now carried through both gateway and manifest normalization and is available to callers of `listToolServers`.
5170

5271
### Fixed (`@microsoft/agents-a365-tooling-extensions-openai`, `@microsoft/agents-a365-tooling-extensions-langchain`)
5372

packages/agents-a365-tooling/src/McpToolServerConfigurationService.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,13 @@ export class McpToolServerConfigurationService {
163163
servers: MCPServerConfig[],
164164
acquire: TokenAcquirer
165165
): Promise<MCPServerConfig[]> {
166+
// Fetch once so scope resolution and the legacy-path guard use the same value.
167+
const sharedScope = this.configProvider.getConfiguration().mcpPlatformAuthenticationScope;
166168
const tokenCache = new Map<string, string | null>(); // scope → token (null = no token available)
167169

168170
const result: MCPServerConfig[] = [];
169171
for (const server of servers) {
170-
const scope = resolveTokenScopeForServer(server);
172+
const scope = resolveTokenScopeForServer(server, sharedScope);
171173
if (!tokenCache.has(scope)) {
172174
tokenCache.set(scope, await acquire(server, scope));
173175
}
@@ -404,6 +406,7 @@ export class McpToolServerConfigurationService {
404406
headers: s.headers,
405407
audience: s.audience,
406408
scope: s.scope,
409+
publisher: s.publisher,
407410
}));
408411
} catch (err: unknown) {
409412
const error = err as Error & { code?: string };
@@ -465,6 +468,7 @@ export class McpToolServerConfigurationService {
465468
headers: s.headers,
466469
audience: s.audience,
467470
scope: s.scope,
471+
publisher: s.publisher,
468472
};
469473
});
470474
} catch (err: unknown) {

packages/agents-a365-tooling/src/Utility.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ export class Utility {
153153
*
154154
* Example:
155155
* Utility.GetToolingGatewayForDigitalWorker(agenticAppId)
156-
* // => "https://agent365.svc.cloud.microsoft/agents/{agenticAppId}/mcpServers"
156+
* // => "https://agent365.svc.cloud.microsoft/agents/v2/{agenticAppId}/mcpServers"
157157
*
158158
* @param agenticAppId - The unique identifier for the agent identity.
159159
* @param configProvider - Optional configuration provider. Defaults to defaultToolingConfigurationProvider.

packages/agents-a365-tooling/src/configuration/ToolingConfiguration.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,42 @@ import { MCPServerConfig } from '../contracts';
88
// Constants for tooling-specific settings
99
const MCP_PLATFORM_PROD_BASE_URL = 'https://agent365.svc.cloud.microsoft';
1010
const PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE = 'ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default';
11-
const ATG_APP_ID = 'ea9ffc3e-8a23-4a7d-836d-234d7c7565c1';
12-
const ATG_APP_ID_URI = `api://${ATG_APP_ID}`; // "api://ea9ffc3e-8a23-4a7d-836d-234d7c7565c1"
1311

1412
/**
1513
* Resolve the OAuth scope to request for a given MCP server.
16-
* V2 servers carry their own audience GUID in the `audience` field; V1 servers (no audience,
17-
* or audience matching the shared ATG AppId or its api:// URI form) fall back to the shared ATG scope.
14+
*
15+
* V2 servers carry their own audience in the `audience` field and get a per-audience token.
16+
* V1 servers (no `audience`, or audience matching the shared scope's own audience in plain
17+
* or api:// form) fall back to `sharedScope` — the configured mcpPlatformAuthenticationScope.
18+
*
19+
* @param server The MCP server config returned by the gateway or manifest.
20+
* @param sharedScope The configured shared scope (mcpPlatformAuthenticationScope).
21+
* Defaults to the prod ATG scope so that external callers without a custom config
22+
* continue to work without passing the argument.
1823
*/
19-
export function resolveTokenScopeForServer(server: MCPServerConfig): string {
20-
if (server.audience &&
21-
server.audience !== ATG_APP_ID &&
22-
server.audience !== ATG_APP_ID_URI) {
23-
// V2 server: use explicit scope if provided, otherwise fall back to /.default
24-
if (server.scope) {
25-
return `${server.audience}/${server.scope}`;
24+
export function resolveTokenScopeForServer(
25+
server: MCPServerConfig,
26+
sharedScope: string = PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE
27+
): string {
28+
if (server.audience) {
29+
// Extract the audience portion of sharedScope (everything before the last '/').
30+
// e.g. 'ea9ffc3e-.../.default' → 'ea9ffc3e-...'
31+
// 'api://ea9ffc3e-.../.default' → 'api://ea9ffc3e-...'
32+
const sharedAudience = sharedScope.slice(0, sharedScope.lastIndexOf('/'));
33+
// Build the alternate form so we match both 'guid' and 'api://guid'.
34+
const sharedAudienceAlt = sharedAudience.startsWith('api://')
35+
? sharedAudience.slice(6) // 'api://guid' → 'guid'
36+
: `api://${sharedAudience}`; // 'guid' → 'api://guid'
37+
38+
if (server.audience !== sharedAudience && server.audience !== sharedAudienceAlt) {
39+
// V2 server: use its own audience with explicit scope or /.default fallback.
40+
return server.scope
41+
? `${server.audience}/${server.scope}`
42+
: `${server.audience}/.default`;
2643
}
27-
return `${server.audience}/.default`;
2844
}
29-
return `${ATG_APP_ID}/.default`;
45+
// V1 server: no audience, or audience matches the shared ATG audience.
46+
return sharedScope;
3047
}
3148

3249
/**

tests/tooling/configuration/ToolingConfiguration.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,51 @@ describe('resolveTokenScopeForServer', () => {
325325
scope: 'Tools.ListInvoke.All'
326326
})).toBe(`${v2AppId}/Tools.ListInvoke.All`);
327327
});
328+
329+
describe('custom sharedScope (configurable mcpPlatformAuthenticationScope)', () => {
330+
const customScope = 'api://custom-atg/.default';
331+
const customAudience = 'api://custom-atg';
332+
333+
it('should return customScope for a V1 server with no audience when sharedScope is overridden', () => {
334+
expect(resolveTokenScopeForServer(
335+
{ mcpServerName: 'mail', url: 'https://mail.example.com' },
336+
customScope
337+
)).toBe(customScope);
338+
});
339+
340+
it('should return customScope when server audience matches the custom shared audience (api:// form)', () => {
341+
expect(resolveTokenScopeForServer(
342+
{ mcpServerName: 'mail', url: 'https://mail.example.com', audience: customAudience },
343+
customScope
344+
)).toBe(customScope);
345+
});
346+
347+
it('should return customScope when server audience matches the custom shared audience (plain form)', () => {
348+
// audience field may arrive as plain GUID/id even when sharedScope uses api:// prefix
349+
expect(resolveTokenScopeForServer(
350+
{ mcpServerName: 'mail', url: 'https://mail.example.com', audience: 'custom-atg' },
351+
customScope
352+
)).toBe(customScope);
353+
});
354+
355+
it('should still treat a V2 server as V2 even when sharedScope is custom', () => {
356+
const v2Audience = 'aaaabbbb-1234-5678-abcd-111122223333';
357+
expect(resolveTokenScopeForServer(
358+
{ mcpServerName: 'tools', url: 'https://tools.example.com', audience: v2Audience },
359+
customScope
360+
)).toBe(`${v2Audience}/.default`);
361+
});
362+
363+
it('should not raise false migration error in legacy prod acquirer when sharedScope is overridden', () => {
364+
// Regression guard: with the old hardcoded constant, resolveTokenScopeForServer returned
365+
// 'ea9ffc3e-.../.default' while createLegacyProdTokenAcquirer compared against the custom
366+
// scope — mismatch → false throw. Now both sides use the same configured value.
367+
expect(resolveTokenScopeForServer(
368+
{ mcpServerName: 'mail', url: 'https://mail.example.com' },
369+
customScope
370+
)).toBe(customScope); // returned scope === sharedScope → no throw
371+
});
372+
});
328373
});
329374

330375
describe('defaultToolingConfigurationProvider', () => {

0 commit comments

Comments
 (0)