n8n-nodes-a2a-protocol
Version:
Agent2Agent (A2A) Protocol nodes for n8n - Enable agent interoperability, communication, and MCP integration
395 lines (394 loc) • 17.8 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.A2AAgentRegistry = void 0;
const n8n_workflow_1 = require("n8n-workflow");
const express_1 = __importDefault(require("express"));
const urlUtils_1 = require("../../utils/urlUtils");
class A2AAgentRegistry {
constructor() {
this.description = {
displayName: 'A2A Agent Registry',
name: 'a2aAgentRegistry',
icon: 'file:a2a-registry.svg',
group: ['trigger'],
version: 1,
subtitle: '={{$parameter["registryName"]}}',
description: 'A2A Agent Registry for discovering and managing agents',
defaults: {
name: 'A2A Agent Registry',
},
inputs: [],
outputs: ["main" /* NodeConnectionType.Main */],
credentials: [],
properties: [
{
displayName: 'Registry Name',
name: 'registryName',
type: 'string',
default: 'N8N A2A Registry',
description: 'Name of the agent registry',
required: true,
},
{
displayName: 'Port',
name: 'port',
type: 'number',
default: urlUtils_1.DEFAULT_A2A_PORTS.REGISTRY,
description: '⚠️ Port to listen on for registry requests. NOTE: Port conflicts will be detected during workflow activation, not save. Run "node validate-a2a-ports.js" to check for conflicts before saving.',
required: true,
},
{
displayName: 'Enable Agent Discovery',
name: 'enableDiscovery',
type: 'boolean',
default: true,
description: 'Allow agents to be discovered through this registry',
},
{
displayName: 'Authentication Mode',
name: 'authMode',
type: 'options',
options: [
{
name: 'None',
value: 'none',
description: 'No authentication required',
},
{
name: 'Bearer Token',
value: 'bearer_token',
description: 'Require Bearer token authentication',
},
{
name: 'API Key',
value: 'api_key',
description: 'Require API key authentication',
},
],
default: 'none',
description: 'Authentication method for registry requests',
},
{
displayName: 'Registry Configuration',
name: 'registryConfig',
type: 'json',
default: `{
"description": "N8N-powered A2A Agent Registry",
"version": "1.0.0",
"supported_protocols": ["a2a-v1"],
"features": ["agent_discovery", "capability_indexing", "health_monitoring"]
}`,
description: 'Registry configuration in JSON format',
},
],
};
}
async trigger() {
const port = this.getNodeParameter('port');
const registryName = this.getNodeParameter('registryName');
const enableDiscovery = this.getNodeParameter('enableDiscovery');
const authMode = this.getNodeParameter('authMode');
const registryConfigStr = this.getNodeParameter('registryConfig');
// Generate stable node ID for this configuration (without timestamp)
const nodeId = `registry_${registryName}_${port}`;
// ✅ VALIDATE PORT BEFORE STARTING - This will show popup errors that prevent workflow activation
const portValidation = await (0, urlUtils_1.validateNodePort)(port, 'registry', `A2A Registry (${registryName})`, nodeId);
if (!portValidation.isValid && portValidation.errorMessage) {
throw new n8n_workflow_1.NodeOperationError(this.getNode(), `❌ Port ${port} is already in use. Please choose a different port for A2A Registry (${registryName}).`);
}
// Stop previous server instance if it exists (safe - only closes Express servers)
await (0, urlUtils_1.stopPreviousServerInstance)(nodeId);
// Parse registry configuration
let parsedConfig;
try {
parsedConfig = JSON.parse(registryConfigStr);
}
catch (error) {
throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Invalid registry configuration JSON: ${error.message}`);
}
// Initialize storage
const agentRegistry = new Map();
const capabilityIndex = new Map(); // capability -> agent_ids
const eventQueue = []; // Queue for events to emit to workflow
// Get dynamic endpoints
const endpoints = (0, urlUtils_1.createAgentEndpoints)(port);
const urlConfig = (0, urlUtils_1.detectInstanceUrl)();
const app = (0, express_1.default)();
app.use(express_1.default.json());
// Process event queue and emit to workflow
const processEventQueue = () => {
if (eventQueue.length > 0) {
const eventsToEmit = eventQueue.splice(0); // Get all events and clear queue
eventsToEmit.forEach(eventData => {
this.emit([this.helpers.returnJsonArray([eventData])]);
});
}
};
// Check for new events every 100ms
const eventProcessor = setInterval(processEventQueue, 100);
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-A2A-*');
if (req.method === 'OPTIONS')
return res.sendStatus(200);
next();
});
// Authentication middleware
if (authMode !== 'none') {
app.use((req, res, next) => {
if (req.path === '/health' || req.path === '/info') {
return next();
}
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({
error: 'Authentication required',
auth_mode: authMode
});
}
if (authMode === 'bearer_token' && !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: 'Bearer token required'
});
}
if (authMode === 'api_key' && !authHeader.startsWith('ApiKey ')) {
return res.status(401).json({
error: 'API key required'
});
}
next();
});
}
// Helper function to update capability index
const updateCapabilityIndex = (agentId, capabilities) => {
// Remove agent from all capability sets first
capabilityIndex.forEach(agentSet => agentSet.delete(agentId));
// Add agent to capability sets
if (capabilities && Array.isArray(capabilities)) {
capabilities.forEach(cap => {
const capName = typeof cap === 'string' ? cap : cap.name;
if (capName) {
if (!capabilityIndex.has(capName)) {
capabilityIndex.set(capName, new Set());
}
capabilityIndex.get(capName).add(agentId);
}
});
}
};
// Registry info endpoint
app.get('/info', (req, res) => {
const registryInfo = parsedConfig.registry_info || {};
res.json(Object.assign(Object.assign({}, registryInfo), { endpoint: endpoints.endpoint, total_agents: agentRegistry.size, active_agents: Array.from(agentRegistry.values()).filter(a => a.status === 'active').length, capabilities_indexed: capabilityIndex.size, timestamp: new Date().toISOString() }));
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
registry_name: registryName,
total_agents: agentRegistry.size,
uptime: process.uptime(),
timestamp: new Date().toISOString(),
});
});
// Agent registration endpoint
app.post('/v1/agents', (req, res) => {
try {
const agentData = req.body;
const agentId = agentData.agent_id;
if (!agentId) {
return res.status(400).json({
error: 'Missing agent_id in registration data'
});
}
// Store agent data with registration timestamp
const registrationData = Object.assign(Object.assign({}, agentData), { status: 'active', registered_at: new Date().toISOString(), last_heartbeat: new Date().toISOString(), registry_endpoint: endpoints.endpoint });
agentRegistry.set(agentId, registrationData);
// Update capability index
updateCapabilityIndex(agentId, agentData.capabilities);
// Add registration event to queue
eventQueue.push({
event_type: 'agent_registered',
agent_id: agentId,
agent_data: registrationData,
registry_name: registryName,
total_agents: agentRegistry.size,
timestamp: new Date().toISOString(),
});
res.status(201).json({
success: true,
message: 'Agent registered successfully',
agent_id: agentId,
registry_endpoint: endpoints.endpoint,
timestamp: new Date().toISOString(),
});
}
catch (error) {
res.status(500).json({
error: 'Agent registration failed',
message: error.message,
});
}
});
// Agent discovery endpoint
app.get('/v1/agents/discover', (req, res) => {
if (!enableDiscovery) {
return res.status(403).json({
error: 'Agent discovery is disabled',
});
}
const capability = req.query.capability;
const status = req.query.status || 'active';
const limit = parseInt(req.query.limit) || 50;
let agents = Array.from(agentRegistry.values());
// Filter by status
if (status) {
agents = agents.filter(agent => agent.status === status);
}
// Filter by capability
if (capability) {
const agentIdsWithCapability = capabilityIndex.get(capability);
if (agentIdsWithCapability) {
agents = agents.filter(agent => agentIdsWithCapability.has(agent.agent_id));
}
else {
agents = [];
}
}
// Apply limit
agents = agents.slice(0, limit);
// Add discovery event to queue
eventQueue.push({
event_type: 'agents_discovered',
query: { capability, status, limit },
agents_found: agents.length,
registry_name: registryName,
timestamp: new Date().toISOString(),
});
res.json({
agents,
total_found: agents.length,
query: { capability, status, limit },
registry_info: {
name: registryName,
endpoint: endpoints.endpoint,
total_agents: agentRegistry.size,
},
timestamp: new Date().toISOString(),
});
});
// Get specific agent info
app.get('/v1/agents/:agentId', (req, res) => {
const agentId = req.params.agentId;
const agent = agentRegistry.get(agentId);
if (!agent) {
return res.status(404).json({
error: 'Agent not found',
agent_id: agentId,
});
}
res.json(Object.assign(Object.assign({}, agent), { timestamp: new Date().toISOString() }));
});
// Agent heartbeat endpoint
app.post('/v1/agents/:agentId/heartbeat', (req, res) => {
const agentId = req.params.agentId;
const agent = agentRegistry.get(agentId);
if (!agent) {
return res.status(404).json({
error: 'Agent not found',
agent_id: agentId,
});
}
// Update heartbeat timestamp
agent.last_heartbeat = new Date().toISOString();
agent.status = 'active';
agentRegistry.set(agentId, agent);
res.json({
success: true,
message: 'Heartbeat received',
agent_id: agentId,
timestamp: new Date().toISOString(),
});
});
// Unregister agent
app.delete('/v1/agents/:agentId', (req, res) => {
const agentId = req.params.agentId;
const agent = agentRegistry.get(agentId);
if (!agent) {
return res.status(404).json({
error: 'Agent not found',
agent_id: agentId,
});
}
// Remove from registry and capability index
agentRegistry.delete(agentId);
capabilityIndex.forEach(agentSet => agentSet.delete(agentId));
// Add unregistration event to queue
eventQueue.push({
event_type: 'agent_unregistered',
agent_id: agentId,
agent_data: agent,
registry_name: registryName,
total_agents: agentRegistry.size,
timestamp: new Date().toISOString(),
});
res.json({
success: true,
message: 'Agent unregistered successfully',
agent_id: agentId,
timestamp: new Date().toISOString(),
});
});
// Registry statistics
app.get('/v1/stats', (req, res) => {
const stats = {
registry_name: registryName,
total_agents: agentRegistry.size,
active_agents: Array.from(agentRegistry.values()).filter(a => a.status === 'active').length,
capabilities: Array.from(capabilityIndex.keys()),
capability_distribution: Object.fromEntries(Array.from(capabilityIndex.entries()).map(([cap, agents]) => [cap, agents.size])),
agent_status_distribution: Array.from(agentRegistry.values()).reduce((acc, agent) => {
const status = agent.status || 'unknown';
const currentCount = acc[status] || 0;
acc[status] = currentCount + 1;
return acc;
}, {}),
endpoint: endpoints.endpoint,
tasks: endpoints.tasks,
health: endpoints.health,
timestamp: new Date().toISOString(),
};
res.json(stats);
});
const server = app.listen(port, () => {
// Store the server reference for this node instance
(0, urlUtils_1.storeActiveServer)(nodeId, server, port, 'registry');
});
// Handle server startup errors
server.on('error', async (error) => {
if (error.code === 'EADDRINUSE') {
throw new n8n_workflow_1.NodeOperationError(this.getNode(), `❌ Port ${port} is already in use. Please choose a different port for A2A Registry (${registryName}).`);
}
else {
throw new n8n_workflow_1.NodeOperationError(this.getNode(), `❌ A2A Registry server error: ${error.message}`);
}
});
return {
closeFunction: async () => {
server.close();
agentRegistry.clear();
capabilityIndex.clear();
clearInterval(eventProcessor);
eventQueue.length = 0; // Clear event queue
(0, urlUtils_1.cleanupActiveServer)(nodeId); // Clean up stored server reference
},
manualTriggerFunction: async () => {
this.emit([this.helpers.returnJsonArray([Object.assign(Object.assign({ event_type: 'registry_ready', message: 'A2A Registry is ready to accept agent registrations', registry_name: registryName }, endpoints), { total_agents: agentRegistry.size, discovery_enabled: enableDiscovery, auth_mode: authMode, timestamp: new Date().toISOString() })])]);
},
};
}
}
exports.A2AAgentRegistry = A2AAgentRegistry;