Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fair-hosts-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@adcp/sdk': patch
Comment thread
bokelley marked this conversation as resolved.
Outdated
---

Make the structured-content text fallback a marked, per-client transport-edge decoration while keeping canonical cached responses and A2A artifacts clean.
5 changes: 5 additions & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1293,6 +1293,7 @@ export {
createA2AAdapter,
A2AInvocationError,
MCP_APP_RESOURCE_MIME_TYPE,
ADCP_MIRRORED_STRUCTURED_CONTENT_META_KEY,
} from './server';
export type {
AdcpErrorOptions,
Expand Down Expand Up @@ -1352,6 +1353,10 @@ export type {
AdcpTestRequest,
AdcpTestToolsCallRequest,
AdcpTestResponse,
AdcpInvokeOptions,
StructuredContentFallbackTransport,
StructuredContentTextFallback,
StructuredContentTextFallbackContext,
CheckGovernanceOptions,
GovernanceCallResult,
GovernanceApproved,
Expand Down
1 change: 1 addition & 0 deletions src/lib/server/a2a-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ class AdcpA2AAgentExecutor implements AgentExecutor {
toolName,
args: invocation.input,
...(authInfo && { authInfo }),
responseContext: { transport: 'a2a' },
});
break;
} catch (err) {
Expand Down
31 changes: 28 additions & 3 deletions src/lib/server/adcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import type { McpToolResponse } from './responses';
import type { AdcpMcpResourceDefinition } from './mcp-app';
import { ADCP_VERSION } from '../version';
import {
applyStructuredContentTextFallback,
type StructuredContentTextFallback,
type StructuredContentTextFallbackContext,
} from './structured-content-fallback';

/**
* Structural shape of an MCP transport the server can connect to.
Expand Down Expand Up @@ -114,6 +119,8 @@ export interface AdcpTestRequestExtras {
authInfo?: AdcpAuthInfo;
sessionId?: string;
signal?: AbortSignal;
/** Override client/transport facts used by response-decoration tests. */
responseContext?: StructuredContentTextFallbackContext;
}

/**
Expand Down Expand Up @@ -144,6 +151,12 @@ export interface AdcpInvokeOptions {
* so failures retain the structured AdCP error envelope.
*/
enforceRequestSchema?: true;
/**
* Transport/session facts used only after the canonical response has been
* finalized and cached. Omit for direct embedding; the fail-safe direct path
* mirrors structured content for compatibility.
*/
responseContext?: StructuredContentTextFallbackContext;
}

/**
Expand Down Expand Up @@ -709,10 +722,12 @@ export function wrapSdkRequestHandler(
export function wrapMcpServer(
inner: McpServer | AdcpServerInternal,
compliance?: AdcpServerComplianceApi,
adcpVersion: string = ADCP_VERSION
adcpVersion: string = ADCP_VERSION,
options: { structuredContentTextFallback?: StructuredContentTextFallback } = {}
): AdcpServerInternal {
if (isAdcpServer(inner)) return inner;
const mcp = inner as McpServer;
const structuredContentTextFallback = options.structuredContentTextFallback ?? 'always';
const resolvedCompliance: AdcpServerComplianceApi = compliance ?? {
async reset() {
throw new Error(
Expand All @@ -737,7 +752,12 @@ export function wrapMcpServer(
if (!tool) {
throw new Error(`dispatchTestRequest: tool "${params.name}" is not registered`);
}
return tool.handler(params.arguments ?? {}, extra);
const result = await tool.handler(params.arguments ?? {}, extra);
return applyStructuredContentTextFallback(
result,
structuredContentTextFallback,
extras?.responseContext ?? { transport: 'mcp' }
);
}

const handler = getRequestHandler(mcp, request.method);
Expand All @@ -756,7 +776,12 @@ export function wrapMcpServer(
};
if (options.authInfo) extra.authInfo = options.authInfo;
if (options.enforceRequestSchema === true) extra.enforceRequestSchema = true;
return (await tool.handler(options.args, extra)) as McpToolResponse;
const response = (await tool.handler(options.args, extra)) as McpToolResponse;
return applyStructuredContentTextFallback(
response,
structuredContentTextFallback,
options.responseContext ?? { transport: 'direct' }
);
};
const wrapper: AdcpServerInternal = {
[ADCP_SDK_SERVER]: mcp,
Expand Down
69 changes: 56 additions & 13 deletions src/lib/server/create-adcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { ZodRawShapeCompat, AnySchema } from '@modelcontextprotocol/sdk/server/zod-compat.js';
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
import { applyStructuredContentTextFallback, type StructuredContentTextFallback } from './structured-content-fallback';
import {
ADCP_CAPABILITIES,
ADCP_STATE_STORE,
Expand Down Expand Up @@ -1954,6 +1955,22 @@ export interface AdcpServerConfig<TAccount = unknown> {
* responses registered by `createAdcpServer`.
*/
responseEnhancer?: (response: McpToolResponse) => void;
/**
* Mirror final MCP `structuredContent` into a marked compact-JSON text block
* for hosts that do not expose the structured channel to the model.
*
* Defaults to `always`, matching MCP's backwards-compatibility guidance.
* `never` is a deployment-owned opt-out. `auto` currently behaves like
* `always` until MCP defines a client capability for structured-result
* consumption. A predicate receives one extensible bag with the negotiated
* client facts and transport; missing client facts are legitimate and the
* named/default modes fail safe to mirroring. A2A named/default modes do not
* mirror because its Task artifact already carries the typed DataPart.
*
* Decoration happens after response finalization and idempotency caching, so
* a replay can be shaped independently for each client session.
*/
structuredContentTextFallback?: StructuredContentTextFallback;
/**
* Auto-wire the RFC 9421 request-signature verifier onto the HTTP transport.
* When set together with `capabilities.specialisms` containing
Expand Down Expand Up @@ -4609,6 +4626,7 @@ export function createAdcpServer<TAccount = unknown>(config: AdcpServerConfig<TA
credentialPolicy,
testController: testControllerBridge,
responseEnhancer,
structuredContentTextFallback = 'always',
} = config;
if (taskRegistry !== undefined && taskRegistry.scopeVersion !== 1) {
throw new Error(
Expand All @@ -4620,6 +4638,14 @@ export function createAdcpServer<TAccount = unknown>(config: AdcpServerConfig<TA
`createAdcpServer: mcpToolProfile must be "auto", "media-buy", or "all"; got ${JSON.stringify(mcpToolProfile)}`
);
}
if (
typeof structuredContentTextFallback !== 'function' &&
!['always', 'never', 'auto'].includes(structuredContentTextFallback)
) {
throw new Error(
'createAdcpServer: structuredContentTextFallback must be "always", "never", "auto", or a predicate'
);
}
const notificationHandlerConfigured = typeof config.protocol?.syncAgentNotificationConfigs === 'function';
const notificationCapabilitySupported = capConfig?.capability_changes?.notifications?.supported === true;
if (notificationHandlerConfigured !== notificationCapabilitySupported) {
Expand Down Expand Up @@ -5201,18 +5227,6 @@ export function createAdcpServer<TAccount = unknown>(config: AdcpServerConfig<TA

const applyResponseEnhancer = (response: McpToolResponse): McpToolResponse => {
responseEnhancer?.(response);
const structuredContent = response.structuredContent;
if (structuredContent !== undefined) {
const serialized = JSON.stringify(structuredContent);
const hasSerializedFallback = response.content.some(block => block.type === 'text' && block.text === serialized);
if (!hasSerializedFallback) {
// MCP recommends mirroring structuredContent into a TextContent block
// for clients that do not expose the structured channel to the model.
// Preserve any adopter-authored summary as the first block and append
// the exact final wire object after every framework/enhancer rewrite.
response.content.push({ type: 'text', text: serialized });
}
}
return response;
};

Expand Down Expand Up @@ -8405,6 +8419,33 @@ export function createAdcpServer<TAccount = unknown>(config: AdcpServerConfig<TA
);
}

// Keep the canonical handler result (and idempotency cache entry) free of
// client-specific compatibility decoration. The low-level tools/call seam is
// after the registered handler has finalized/cached its response and is the
// first point where the legacy MCP SDK exposes negotiated clientInfo.
const wrappedToolsCallForStructuredFallback = wrapSdkRequestHandler(
server,
'tools/call',
async (original, request, extra) => {
const response = await original(request, extra);
const clientInfo = server.server.getClientVersion();
const clientCapabilities = server.server.getClientCapabilities();
return applyStructuredContentTextFallback(response, structuredContentTextFallback, {
transport: 'mcp',
...(clientInfo !== undefined && { clientInfo }),
...(clientCapabilities !== undefined && {
clientCapabilities: clientCapabilities as Readonly<Record<string, unknown>>,
}),
});
}
);
if (!wrappedToolsCallForStructuredFallback) {
throw new Error(
'createAdcpServer: failed to install MCP structured-content fallback decoration; ' +
'the MCP SDK request-handler internals may have changed'
);
}

// Validate `credentialPolicy.tools` keys against the FULL registered
// tool set, including `get_adcp_capabilities` (registered just above).
// Earlier placement (before this tool was added) made
Expand Down Expand Up @@ -8469,7 +8510,9 @@ export function createAdcpServer<TAccount = unknown>(config: AdcpServerConfig<TA
if (taskRegistry?.clear) await taskRegistry.clear();
},
};
const wrapped: AdcpServerInternal = wrapMcpServer(server, compliance, adcpVersion);
const wrapped: AdcpServerInternal = wrapMcpServer(server, compliance, adcpVersion, {
structuredContentTextFallback,
});
setToolVersionAvailabilityResolver(wrapped, toolAvailableForRelease);
setDiscoveryVersionResolver(wrapped, requestedVersion => {
if (requestedVersion === undefined) return adcpVersion;
Expand Down
8 changes: 8 additions & 0 deletions src/lib/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,13 @@ export {
} from './responses';
export type { McpToolResponse } from './responses';

export { ADCP_MIRRORED_STRUCTURED_CONTENT_META_KEY } from './structured-content-fallback';
export type {
StructuredContentFallbackTransport,
StructuredContentTextFallback,
StructuredContentTextFallbackContext,
} from './structured-content-fallback';

export { validActionsForStatus } from './media-buy-helpers';
export type { ValidAction, CancelMediaBuyInput } from './media-buy-helpers';
export { assertUpdateMediaBuyAllowed } from './media-buy-actions';
Expand Down Expand Up @@ -357,6 +364,7 @@ export type {
AdcpTestRequest,
AdcpTestToolsCallRequest,
AdcpTestResponse,
AdcpInvokeOptions,
} from './adcp-server';
// Handler-bag types describe the raw v5 server surface. Primary-barrel names
// are explicit Legacy aliases; the legacy/v5 subpath retains the originals.
Expand Down
24 changes: 24 additions & 0 deletions src/lib/server/mcp-modern-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
type ServerContext,
type Tool as ModernTool,
type ToolAnnotations,
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
} from '@modelcontextprotocol/server';
import { toNodeHandler, toWebRequest, type NodeMcpRequestHandler } from '@modelcontextprotocol/node';
import type { IncomingMessage } from 'http';
Expand All @@ -39,6 +41,7 @@ import { ADCP_INSTRUCTIONS_RESOLVER, MEDIA_BUY_MCP_TOOL_PROFILE } from './create
import { mcpAppResourceMetadata, readMcpAppResource } from './mcp-app';
import { getMcpToolSchema, getMcpToolSummary, getToolSchemaDocument } from '../validation/schema-loader';
import { isAdcpVersionAtLeast } from '../utils/adcp-version-config';
import type { StructuredContentTextFallbackContext } from './structured-content-fallback';

export interface ModernMcpServerAdapter {
handle: NodeMcpRequestHandler;
Expand All @@ -57,6 +60,26 @@ function toAdcpAuthInfo(authInfo: ModernAuthInfo | undefined): AdcpAuthInfo | un
};
}

function structuredContentFallbackContext(ctx: ServerContext): StructuredContentTextFallbackContext {
const envelope = ctx.mcpReq.envelope as unknown as Record<string, unknown> | undefined;
const clientInfo = envelope?.[CLIENT_INFO_META_KEY];
const clientCapabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY];
return {
transport: 'mcp',
...(clientInfo != null &&
typeof clientInfo === 'object' &&
typeof (clientInfo as { name?: unknown }).name === 'string' &&
typeof (clientInfo as { version?: unknown }).version === 'string' && {
clientInfo: clientInfo as StructuredContentTextFallbackContext['clientInfo'],
}),
...(clientCapabilities != null &&
typeof clientCapabilities === 'object' &&
!Array.isArray(clientCapabilities) && {
clientCapabilities: clientCapabilities as Record<string, unknown>,
}),
};
}

function linkedMcpAppResourceUri(tool: { _meta?: Record<string, unknown> }): string | undefined {
const ui = tool._meta?.['ui'];
if (ui === null || typeof ui !== 'object') return undefined;
Expand Down Expand Up @@ -217,6 +240,7 @@ export function createModernMcpServerAdapter(agentServer: AdcpServer): ModernMcp
args,
authInfo: toAdcpAuthInfo(ctx.http?.authInfo),
signal: ctx.mcpReq.signal,
responseContext: structuredContentFallbackContext(ctx),
// Only strengthen calls for tools whose exact official schema we
// advertise. Hidden compatibility tools remain directly callable
// and retain the adopter's configured validation mode.
Expand Down
7 changes: 6 additions & 1 deletion src/lib/server/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,12 @@ import type {
*/
export interface McpToolResponse {
[key: string]: unknown;
content: Array<{ type: 'text'; text: string }>;
content: Array<{
type: 'text';
text: string;
/** Optional MCP content-block metadata. */
_meta?: Record<string, unknown>;
}>;
structuredContent?: Record<string, unknown>;
}

Expand Down
Loading
Loading