Skip to content

Commit b48e170

Browse files
feat: add detailed protocol logging for MCP and A2A requests
Add configurable wire-level logging to capture exact HTTP requests and responses for both MCP and A2A protocols. Features: - Log exact request/response payloads, headers, and timing - Configurable granularity (requests, responses, bodies) - Auth header redaction for security (enabled by default) - Body size limits to prevent log spam - Latency tracking for performance monitoring Configuration example: protocolLogging: { enabled: true, logRequests: true, logResponses: true, logRequestBodies: true, logResponseBodies: true, maxBodySize: 50000, redactAuthHeaders: true } Use cases: debugging, monitoring, troubleshooting Files: ADCPClient.ts, TaskExecutor.ts, mcp.ts, a2a.ts, protocols/index.ts Performance impact: ~3-7ms per request with full logging
1 parent 51755f8 commit b48e170

File tree

8 files changed

+1210
-23
lines changed

8 files changed

+1210
-23
lines changed

docs/PROTOCOL-LOGGING.md

Lines changed: 447 additions & 0 deletions
Large diffs are not rendered by default.

examples/protocol-logging.ts

Lines changed: 420 additions & 0 deletions
Large diffs are not rendered by default.

src/lib/core/SingleAgentClient.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,68 @@ export interface SingleAgentClientConfig extends ConversationConfig {
9494
*/
9595
logSchemaViolations?: boolean;
9696
};
97+
/**
98+
* Detailed protocol logging configuration
99+
*
100+
* Enables logging of exact wire-level protocol requests and responses.
101+
* Useful for debugging, monitoring, and understanding protocol interactions.
102+
*
103+
* @example
104+
* ```typescript
105+
* const client = new ADCPClient(agent, {
106+
* protocolLogging: {
107+
* enabled: true,
108+
* logRequests: true,
109+
* logResponses: true,
110+
* logRequestBodies: true,
111+
* logResponseBodies: true,
112+
* maxBodySize: 10000
113+
* }
114+
* });
115+
* ```
116+
*/
117+
protocolLogging?: {
118+
/**
119+
* Enable detailed protocol logging (default: false)
120+
*
121+
* When enabled, logs exact HTTP requests and responses for both MCP and A2A protocols
122+
*/
123+
enabled?: boolean;
124+
/**
125+
* Log request details including method, URL, and headers (default: true if enabled)
126+
*/
127+
logRequests?: boolean;
128+
/**
129+
* Log response details including status, headers, and body (default: true if enabled)
130+
*/
131+
logResponses?: boolean;
132+
/**
133+
* Log request bodies/payloads (default: true if enabled)
134+
*
135+
* For MCP: Logs JSON-RPC request payloads
136+
* For A2A: Logs message payloads with skill name and parameters
137+
*/
138+
logRequestBodies?: boolean;
139+
/**
140+
* Log response bodies/payloads (default: true if enabled)
141+
*
142+
* For MCP: Logs JSON-RPC response content
143+
* For A2A: Logs message response data
144+
*/
145+
logResponseBodies?: boolean;
146+
/**
147+
* Maximum body size to log in bytes (default: 50000 / 50KB)
148+
*
149+
* Bodies larger than this will be truncated with a note
150+
*/
151+
maxBodySize?: number;
152+
/**
153+
* Redact sensitive headers from logs (default: true)
154+
*
155+
* When true, masks Authorization and x-adcp-auth header values
156+
*/
157+
redactAuthHeaders?: boolean;
158+
};
97159
}
98160

99161
/**
@@ -133,6 +195,7 @@ export class SingleAgentClient {
133195
strictSchemaValidation: config.validation?.strictSchemaValidation !== false, // Default: true
134196
logSchemaViolations: config.validation?.logSchemaViolations !== false, // Default: true
135197
onActivity: config.onActivity,
198+
protocolLogging: config.protocolLogging
136199
});
137200

138201
// Create async handler if handlers are provided

src/lib/core/TaskExecutor.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import { randomUUID } from 'crypto';
55
import type { AgentConfig } from '../types';
6-
import { ProtocolClient } from '../protocols';
6+
import { ProtocolClient, type ProtocolLoggingConfig } from '../protocols';
77
import type { Storage } from '../storage/interfaces';
88
import { responseValidator } from './ResponseValidator';
99
import { unwrapProtocolResponse } from '../utils/response-unwrapper';
@@ -110,6 +110,8 @@ export class TaskExecutor {
110110
logSchemaViolations?: boolean;
111111
/** Global activity callback for observability */
112112
onActivity?: (activity: Activity) => void | Promise<void>;
113+
/** Protocol logging configuration */
114+
protocolLogging?: ProtocolLoggingConfig;
113115
} = {}
114116
) {
115117
this.responseParser = new ProtocolResponseParser();
@@ -210,7 +212,9 @@ export class TaskExecutor {
210212
params,
211213
debugLogs,
212214
webhookUrl,
213-
this.config.webhookSecret
215+
this.config.webhookSecret,
216+
undefined, // webhookToken
217+
this.config.protocolLogging
214218
);
215219

216220
// Emit protocol_response activity
@@ -701,7 +705,7 @@ export class TaskExecutor {
701705
*/
702706
async listTasks(agent: AgentConfig): Promise<TaskInfo[]> {
703707
try {
704-
const response = await ProtocolClient.callTool(agent, 'tasks/list', {});
708+
const response = await ProtocolClient.callTool(agent, 'tasks/list', {}, [], undefined, undefined, undefined, this.config.protocolLogging);
705709
return response.tasks || [];
706710
} catch (error) {
707711
console.warn('Failed to list tasks:', error);
@@ -710,7 +714,7 @@ export class TaskExecutor {
710714
}
711715

712716
async getTaskStatus(agent: AgentConfig, taskId: string): Promise<TaskInfo> {
713-
const response = await ProtocolClient.callTool(agent, 'tasks/get', { taskId });
717+
const response = await ProtocolClient.callTool(agent, 'tasks/get', { taskId }, [], undefined, undefined, undefined, this.config.protocolLogging);
714718
return response.task || response;
715719
}
716720

@@ -805,7 +809,11 @@ export class TaskExecutor {
805809
contextId,
806810
input,
807811
},
808-
debugLogs
812+
debugLogs,
813+
undefined,
814+
undefined,
815+
undefined, // webhookToken
816+
this.config.protocolLogging
809817
);
810818

811819
// Add response message
@@ -906,7 +914,7 @@ export class TaskExecutor {
906914
const agent = this.findAgentById(agentId);
907915
if (agent) {
908916
try {
909-
const response = await ProtocolClient.callTool(agent, 'tasks/list', {});
917+
const response = await ProtocolClient.callTool(agent, 'tasks/list', {}, [], undefined, undefined, undefined, this.config.protocolLogging);
910918
return response.tasks || [];
911919
} catch (error) {
912920
console.warn('Failed to get remote task list:', error);

src/lib/protocols/a2a.ts

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,32 @@ if (!A2AClient) {
88
throw new Error('A2A SDK client is required. Please install @a2a-js/sdk');
99
}
1010

11+
import { logger } from '../utils/logger';
12+
13+
export interface ProtocolLoggingConfig {
14+
enabled?: boolean;
15+
logRequests?: boolean;
16+
logResponses?: boolean;
17+
logRequestBodies?: boolean;
18+
logResponseBodies?: boolean;
19+
maxBodySize?: number;
20+
redactAuthHeaders?: boolean;
21+
}
22+
1123
export async function callA2ATool(
1224
agentUrl: string,
1325
toolName: string,
1426
parameters: Record<string, any>,
1527
authToken?: string,
1628
debugLogs: any[] = [],
17-
pushNotificationConfig?: PushNotificationConfig
29+
pushNotificationConfig?: PushNotificationConfig,
30+
loggingConfig?: ProtocolLoggingConfig
1831
): Promise<any> {
1932
// Create authenticated fetch that wraps native fetch
2033
// This ensures ALL requests (including agent card fetching) include auth headers
2134
const fetchImpl = async (url: string | URL | Request, options?: RequestInit) => {
35+
const startTime = Date.now();
36+
2237
// Build headers - always start with existing headers, then add auth if available
2338
const existingHeaders: Record<string, string> = {};
2439
if (options?.headers) {
@@ -44,6 +59,48 @@ export async function callA2ATool(
4459
}),
4560
};
4661

62+
// Log request details if protocol logging is enabled
63+
const shouldLog = loggingConfig?.enabled === true;
64+
const shouldLogRequest = shouldLog && (loggingConfig?.logRequests !== false);
65+
const shouldLogRequestBody = shouldLog && (loggingConfig?.logRequestBodies !== false);
66+
const shouldRedact = loggingConfig?.redactAuthHeaders !== false;
67+
const maxBodySize = loggingConfig?.maxBodySize || 50000;
68+
69+
if (shouldLogRequest) {
70+
const urlString = typeof url === 'string' ? url : url.toString();
71+
const method = options?.method || 'POST';
72+
73+
// Prepare headers for logging (redact sensitive ones)
74+
const headersForLog = { ...headers };
75+
if (shouldRedact) {
76+
if (headersForLog['Authorization']) headersForLog['Authorization'] = '***REDACTED***';
77+
if (headersForLog['x-adcp-auth']) headersForLog['x-adcp-auth'] = '***REDACTED***';
78+
}
79+
80+
let requestBody: any = null;
81+
if (shouldLogRequestBody && options?.body) {
82+
const bodyStr = typeof options.body === 'string' ? options.body : JSON.stringify(options.body);
83+
if (bodyStr.length > maxBodySize) {
84+
requestBody = bodyStr.substring(0, maxBodySize) + `... [TRUNCATED: ${bodyStr.length - maxBodySize} bytes]`;
85+
} else {
86+
try {
87+
requestBody = JSON.parse(bodyStr);
88+
} catch {
89+
requestBody = bodyStr;
90+
}
91+
}
92+
}
93+
94+
logger.debug('[A2A Request]', {
95+
protocol: 'a2a',
96+
method,
97+
url: urlString,
98+
headers: headersForLog,
99+
body: requestBody,
100+
timestamp: new Date().toISOString()
101+
});
102+
}
103+
47104
debugLogs.push({
48105
type: 'info',
49106
message: `A2A: Fetch to ${typeof url === 'string' ? url : url.toString()}`,
@@ -52,10 +109,55 @@ export async function callA2ATool(
52109
headers: authToken ? { ...headers, Authorization: 'Bearer ***', 'x-adcp-auth': '***' } : headers,
53110
});
54111

55-
return fetch(url, {
112+
const response = await fetch(url, {
56113
...options,
57114
headers,
58115
});
116+
117+
const latency = Date.now() - startTime;
118+
119+
// Log response details if protocol logging is enabled
120+
const shouldLogResponse = shouldLog && (loggingConfig?.logResponses !== false);
121+
const shouldLogResponseBody = shouldLog && (loggingConfig?.logResponseBodies !== false);
122+
123+
if (shouldLogResponse) {
124+
const responseHeadersObj: Record<string, string> = {};
125+
response.headers.forEach((value, key) => {
126+
responseHeadersObj[key] = value;
127+
});
128+
129+
let responseBody: any = null;
130+
if (shouldLogResponseBody && response.body) {
131+
// Clone response to read body without consuming it
132+
const clonedResponse = response.clone();
133+
try {
134+
const bodyText = await clonedResponse.text();
135+
if (bodyText.length > maxBodySize) {
136+
responseBody = bodyText.substring(0, maxBodySize) + `... [TRUNCATED: ${bodyText.length - maxBodySize} bytes]`;
137+
} else {
138+
try {
139+
responseBody = JSON.parse(bodyText);
140+
} catch {
141+
responseBody = bodyText;
142+
}
143+
}
144+
} catch (err) {
145+
responseBody = '[Could not read response body]';
146+
}
147+
}
148+
149+
logger.debug('[A2A Response]', {
150+
protocol: 'a2a',
151+
status: response.status,
152+
statusText: response.statusText,
153+
headers: responseHeadersObj,
154+
body: responseBody,
155+
latency: `${latency}ms`,
156+
timestamp: new Date().toISOString()
157+
});
158+
}
159+
160+
return response;
59161
};
60162

61163
// Create A2A client using the recommended fromCardUrl method

src/lib/protocols/index.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// Unified Protocol Interface for AdCP
2-
export { callMCPTool } from './mcp';
3-
export { callA2ATool } from './a2a';
2+
export { callMCPTool, type ProtocolLoggingConfig as MCPLoggingConfig } from './mcp';
3+
export { callA2ATool, type ProtocolLoggingConfig as A2ALoggingConfig } from './a2a';
4+
export type { ProtocolLoggingConfig } from './mcp'; // Re-export for convenience
45

5-
import { callMCPTool } from './mcp';
6+
import { callMCPTool, type ProtocolLoggingConfig } from './mcp';
67
import { callA2ATool } from './a2a';
78
import type { AgentConfig } from '../types';
89
import type { PushNotificationConfig } from '../types/tools.generated';
@@ -34,7 +35,8 @@ export class ProtocolClient {
3435
debugLogs: any[] = [],
3536
webhookUrl?: string,
3637
webhookSecret?: string,
37-
webhookToken?: string
38+
webhookToken?: string,
39+
loggingConfig?: ProtocolLoggingConfig
3840
): Promise<any> {
3941
validateAgentUrl(agent.agent_uri);
4042

@@ -59,7 +61,14 @@ export class ProtocolClient {
5961
const argsWithWebhook = pushNotificationConfig
6062
? { ...args, push_notification_config: pushNotificationConfig }
6163
: args;
62-
return callMCPTool(agent.agent_uri, toolName, argsWithWebhook, authToken, debugLogs);
64+
return callMCPTool(
65+
agent.agent_uri,
66+
toolName,
67+
argsWithWebhook,
68+
authToken,
69+
debugLogs,
70+
loggingConfig
71+
);
6372
} else if (agent.protocol === 'a2a') {
6473
// For A2A, pass pushNotificationConfig separately (not in skill parameters)
6574
return callA2ATool(
@@ -68,7 +77,8 @@ export class ProtocolClient {
6877
args, // This maps to 'parameters' in callA2ATool
6978
authToken,
7079
debugLogs,
71-
pushNotificationConfig
80+
pushNotificationConfig,
81+
loggingConfig
7282
);
7383
} else {
7484
throw new Error(`Unsupported protocol: ${agent.protocol}`);

src/lib/protocols/mcp.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,25 @@ import { Client as MCPClient } from '@modelcontextprotocol/sdk/client/index.js';
33
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
44
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
55
import { createMCPAuthHeaders } from '../auth';
6+
import { logger } from '../utils/logger';
7+
8+
export interface ProtocolLoggingConfig {
9+
enabled?: boolean;
10+
logRequests?: boolean;
11+
logResponses?: boolean;
12+
logRequestBodies?: boolean;
13+
logResponseBodies?: boolean;
14+
maxBodySize?: number;
15+
redactAuthHeaders?: boolean;
16+
}
617

718
export async function callMCPTool(
819
agentUrl: string,
920
toolName: string,
1021
args: any,
1122
authToken?: string,
12-
debugLogs: any[] = []
23+
debugLogs: any[] = [],
24+
loggingConfig?: ProtocolLoggingConfig
1325
): Promise<any> {
1426
let mcpClient: MCPClient | undefined = undefined;
1527
const baseUrl = new URL(agentUrl);
@@ -34,14 +46,6 @@ export async function callMCPTool(
3446
});
3547
}
3648

37-
// Log auth configuration
38-
debugLogs.push({
39-
type: 'info',
40-
message: `MCP: Auth configuration`,
41-
timestamp: new Date().toISOString(),
42-
hasAuth: !!authToken,
43-
headers: authToken ? { 'x-adcp-auth': '***' } : {},
44-
});
4549

4650
try {
4751
// First, try to connect using StreamableHTTPClientTransport

0 commit comments

Comments
 (0)