Skip to content

Commit 2ee3636

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 b8e7187 commit 2ee3636

File tree

8 files changed

+1209
-24
lines changed

8 files changed

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

98160
/**
@@ -132,6 +194,7 @@ export class SingleAgentClient {
132194
strictSchemaValidation: config.validation?.strictSchemaValidation !== false, // Default: true
133195
logSchemaViolations: config.validation?.logSchemaViolations !== false, // Default: true
134196
onActivity: config.onActivity,
197+
protocolLogging: config.protocolLogging
135198
});
136199

137200
// Create async handler if handlers are provided

src/lib/core/TaskExecutor.ts

Lines changed: 13 additions & 7 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; (feat: add detailed protocol logging for MCP and A2A requests)
113115
} = {}
114116
) {
115117
this.responseParser = new ProtocolResponseParser();
@@ -210,7 +212,8 @@ export class TaskExecutor {
210212
params,
211213
debugLogs,
212214
webhookUrl,
213-
this.config.webhookSecret
215+
this.config.webhookSecret,
216+
this.config.protocolLogging
214217
);
215218

216219
// Emit protocol_response activity
@@ -226,7 +229,7 @@ export class TaskExecutor {
226229
payload: response,
227230
timestamp: new Date().toISOString(),
228231
});
229-
232+
(feat: add detailed protocol logging for MCP and A2A requests)
230233
// Add initial response message
231234
const responseMessage: Message = {
232235
id: randomUUID(),
@@ -719,7 +722,7 @@ export class TaskExecutor {
719722
*/
720723
async listTasks(agent: AgentConfig): Promise<TaskInfo[]> {
721724
try {
722-
const response = await ProtocolClient.callTool(agent, 'tasks/list', {});
725+
const response = await ProtocolClient.callTool(agent, 'tasks/list', {}, [], undefined, undefined, this.config.protocolLogging);
723726
return response.tasks || [];
724727
} catch (error) {
725728
console.warn('Failed to list tasks:', error);
@@ -728,7 +731,7 @@ export class TaskExecutor {
728731
}
729732

730733
async getTaskStatus(agent: AgentConfig, taskId: string): Promise<TaskInfo> {
731-
const response = await ProtocolClient.callTool(agent, 'tasks/get', { taskId });
734+
const response = await ProtocolClient.callTool(agent, 'tasks/get', { taskId }, [], undefined, undefined, this.config.protocolLogging);
732735
return response.task || response;
733736
}
734737

@@ -823,7 +826,10 @@ export class TaskExecutor {
823826
contextId,
824827
input,
825828
},
826-
debugLogs
829+
debugLogs,
830+
undefined,
831+
undefined,
832+
this.config.protocolLogging
827833
);
828834

829835
// Add response message
@@ -924,7 +930,7 @@ export class TaskExecutor {
924930
const agent = this.findAgentById(agentId);
925931
if (agent) {
926932
try {
927-
const response = await ProtocolClient.callTool(agent, 'tasks/list', {});
933+
const response = await ProtocolClient.callTool(agent, 'tasks/list', {}, [], undefined, undefined, this.config.protocolLogging);
928934
return response.tasks || [];
929935
} catch (error) {
930936
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 (feat: add detailed protocol logging for MCP and A2A requests)
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 (feat: add detailed protocol logging for MCP and A2A requests)
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+
); (feat: add detailed protocol logging for MCP and A2A requests)
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)