@memberjunction/a2aserver
Version:
MemberJunction Agent-To-Agent (A2A) Server Implementation
755 lines • 30.2 kB
JavaScript
import { LogError, LogStatus, Metadata } from "@memberjunction/core";
import { setupSQLServerClient, SQLServerProviderConfigData, UserCache } from "@memberjunction/sqlserver-dataprovider";
import { GetAPIKeyEngine } from "@memberjunction/api-keys";
import express from 'express';
import sql from 'mssql';
import { configInfo, dbDatabase, dbHost, dbPassword, dbPort, dbUsername, dbInstanceName, dbTrustServerCertificate, a2aServerSettings } from './config.js';
import { EntityOperations } from './EntityOperations.js';
import { AgentOperations } from './AgentOperations.js';
import { AIEngine } from "@memberjunction/aiengine";
import { ShutdownRegistry } from "@memberjunction/global";
import { TaskStore } from "./TaskStore.js";
// A2A Server Configuration
const a2aServerPort = a2aServerSettings?.port || 3200;
// Database Configuration
const poolConfig = {
server: dbHost,
port: dbPort,
user: dbUsername,
password: dbPassword,
database: dbDatabase,
requestTimeout: configInfo.databaseSettings.requestTimeout,
connectionTimeout: configInfo.databaseSettings.connectionTimeout,
options: {
encrypt: true,
enableArithAbort: true,
trustServerCertificate: dbTrustServerCertificate === 'Y'
},
};
if (dbInstanceName !== null && dbInstanceName !== undefined && dbInstanceName.trim().length > 0) {
poolConfig.options.instanceName = dbInstanceName;
}
// In-memory storage for tasks. Bounded by the periodic sweep started below;
// in production you'd swap in a database-backed store. See `./TaskStore.ts`
// for the implementation; the module-level `tasks` reference is kept for
// drop-in compatibility with the rest of this file (which uses `tasks.set`
// / `tasks.get` directly).
const taskStore = new TaskStore();
taskStore.Start();
ShutdownRegistry.Instance.Register(taskStore);
const tasks = taskStore;
// Express application
const app = express();
app.use(express.json());
/**
* Extract request context from an Express request for API key logging.
*/
function extractRequestContext(req) {
return {
endpoint: req.path || req.url || '/a2a',
method: req.method || 'POST',
ipAddress: req.ip || req.socket?.remoteAddress || null,
userAgent: req.headers['user-agent'] || null,
};
}
/**
* Authorize an operation against the API key's scope permissions.
* Uses the two-level scope evaluation (application ceiling + key scopes).
*
* @param authContext - The authorization context with API key hash and user
* @param scopePath - The scope path (e.g., 'action:execute', 'agent:execute')
* @param resource - The specific resource being accessed
* @returns Object with allowed flag and error message if denied
*/
async function authorizeOperation(authContext, scopePath, resource) {
const systemUser = UserCache.Instance.Users[0];
if (!systemUser) {
return { allowed: false, error: 'System user not available for authorization' };
}
try {
const apiKeyEngine = GetAPIKeyEngine();
const result = await apiKeyEngine.Authorize(authContext.apiKeyHash, 'A2AServer', scopePath, resource, systemUser, {
endpoint: '/a2a/tasks',
method: 'POST',
operation: scopePath,
});
if (!result.Allowed) {
LogStatus(`A2A Server: Authorization denied - ${result.Reason}`);
return { allowed: false, error: result.Reason };
}
return { allowed: true };
}
catch (error) {
LogError('A2A Server: Authorization error', undefined, error);
return { allowed: false, error: error instanceof Error ? error.message : 'Authorization failed' };
}
}
/**
* Authentication middleware for A2A endpoints.
* Validates MJ API keys (X-API-Key header with mj_sk_* format).
*/
async function authenticateRequest(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
res.status(401).json({
error: {
code: 401,
message: 'Missing API key. Provide X-API-Key header with a valid MJ API key.'
}
});
return;
}
try {
// Get system user for validation context
const systemUser = UserCache.Instance.Users[0];
if (!systemUser) {
LogError('A2A Server: No system user available for API key validation');
res.status(500).json({
error: {
code: 500,
message: 'Server configuration error'
}
});
return;
}
const requestContext = extractRequestContext(req);
const apiKeyEngine = GetAPIKeyEngine();
const validationResult = await apiKeyEngine.ValidateAPIKey({
RawKey: apiKey,
ApplicationName: 'A2AServer', // Check if key is bound to this application
Endpoint: requestContext.endpoint,
Method: requestContext.method,
Operation: undefined, // Could extract from request body if available
StatusCode: 200, // Auth succeeded if we get past validation
ResponseTimeMs: undefined, // Not available at auth time
IPAddress: requestContext.ipAddress ?? undefined,
UserAgent: requestContext.userAgent ?? undefined,
}, systemUser);
if (!validationResult.IsValid) {
LogStatus(`A2A Server: Invalid API key attempt from ${requestContext.ipAddress}`);
res.status(401).json({
error: {
code: 401,
message: 'Invalid API key'
}
});
return;
}
// Store the authenticated user on the request for use in handlers
req.authenticatedUser = validationResult.User;
req.apiKeyId = validationResult.APIKeyId;
req.apiKeyHash = validationResult.APIKeyHash;
next();
}
catch (error) {
LogError('A2A Server: API key validation error', undefined, error);
res.status(500).json({
error: {
code: 500,
message: 'Authentication error'
}
});
}
}
// Initialize A2A server
export async function initializeA2AServer() {
try {
if (!a2aServerSettings?.enableA2AServer) {
console.log("A2A Server is disabled in the configuration.");
throw new Error("A2A Server is disabled in the configuration.");
}
// Initialize database connection
const pool = new sql.ConnectionPool(poolConfig);
await pool.connect();
// Setup SQL Server client
const config = new SQLServerProviderConfigData(pool, configInfo.mjCoreSchema);
await setupSQLServerClient(config);
console.log("Database connection setup completed.");
// Set up routes
setupRoutes();
// Start the server
app.listen(a2aServerPort, () => {
console.log(`MemberJunction A2A Server running on port ${a2aServerPort}`);
console.log(`Agent card available at: http://localhost:${a2aServerPort}/a2a/agent-card`);
});
}
catch (error) {
console.error("Failed to initialize A2A server:", error);
}
}
function setupRoutes() {
// Agent Card endpoint (public - for discovery)
app.get('/a2a/agent-card', async (req, res) => {
const agentCard = await generateAgentCard();
res.json(agentCard);
});
// Apply authentication middleware to all /a2a/tasks routes
app.use('/a2a/tasks', authenticateRequest);
// Send a message to a task
app.post('/a2a/tasks/send', (req, res) => {
const reqWithUser = req;
const authContext = reqWithUser.apiKeyHash && reqWithUser.authenticatedUser
? { apiKeyHash: reqWithUser.apiKeyHash, user: reqWithUser.authenticatedUser }
: undefined;
const result = handleTaskSend(req.body, reqWithUser.authenticatedUser, authContext);
res.json(result);
});
// Send a message to a task with streaming response
app.post('/a2a/tasks/sendSubscribe', (req, res) => {
if (!a2aServerSettings?.streamingEnabled) {
res.status(400).json({
error: {
code: 400,
message: "Streaming is not enabled for this agent"
}
});
return;
}
// Set up SSE connection
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const reqWithUser = req;
const authContext = reqWithUser.apiKeyHash && reqWithUser.authenticatedUser
? { apiKeyHash: reqWithUser.apiKeyHash, user: reqWithUser.authenticatedUser }
: undefined;
handleTaskSendSubscribe(req.body, res, reqWithUser.authenticatedUser, authContext);
});
// Get a task's status
app.get('/a2a/tasks/:taskId', (req, res) => {
const taskId = req.params.taskId;
const task = tasks.get(taskId);
if (!task) {
res.status(404).json({
error: {
code: 404,
message: `Task with ID ${taskId} not found`
}
});
return;
}
res.json(task);
});
// Cancel a task
app.post('/a2a/tasks/:taskId/cancel', (req, res) => {
const taskId = req.params.taskId;
const task = tasks.get(taskId);
if (!task) {
res.status(404).json({
error: {
code: 404,
message: `Task with ID ${taskId} not found`
}
});
return;
}
task.status = 'cancelled';
task.updated = new Date();
res.json({ success: true });
});
}
/**
* Resolve the metadata provider for the current request.
*
* A2AServer doesn't yet expose a per-request `AppContext.providers` array — every request
* currently shares the process-global provider. When per-request provider plumbing lands,
* change THIS function to read from the request, and every downstream operation is
* automatically multi-tenant correct (because EntityOperations / AgentOperations and
* generateAgentCard already accept an injected provider).
*/
function resolveProviderForRequest() {
return Metadata.Provider; // global-provider-ok: A2A server lacks per-request provider plumbing today; centralized boundary so future migration is one-line
}
async function generateAgentCard() {
const contextUser = UserCache.Instance.Users[0];
const md = resolveProviderForRequest();
const entityCapabilities = getEntityCapabilities(md.Entities, contextUser);
const agentCapabilities = await getAgentCapabilities(contextUser);
return {
name: a2aServerSettings?.agentName || "MemberJunction",
description: a2aServerSettings?.agentDescription || "MemberJunction A2A Agent",
version: "1.0.0",
endpoints: {
tasks: `/a2a/tasks`,
agentCard: `/a2a/agent-card`
},
authentication: {
type: "apiKey",
scheme: "X-API-Key"
},
capabilities: {
streaming: !!a2aServerSettings?.streamingEnabled,
asynchronous: false,
multimedia: false,
entities: entityCapabilities,
agents: agentCapabilities
}
};
}
function getEntityCapabilities(allEntities, contextUser) {
const capabilities = [];
const entityCapabilitiesConfig = a2aServerSettings?.entityCapabilities || [];
for (const config of entityCapabilitiesConfig) {
const matchingEntities = getMatchingEntitiesForConfig(allEntities, config);
for (const entity of matchingEntities) {
const operations = [];
if (config.get)
operations.push('get');
if (config.create)
operations.push('create');
if (config.update)
operations.push('update');
if (config.delete)
operations.push('delete');
if (config.runView)
operations.push('query');
if (operations.length > 0) {
capabilities.push({
name: entity.Name,
schema: entity.SchemaName,
operations: operations
});
}
}
}
return capabilities;
}
async function getAgentCapabilities(contextUser) {
const capabilities = [];
const agentCapabilitiesConfig = a2aServerSettings?.agentCapabilities || [];
// Ensure AIEngine is configured
const aiEngine = AIEngine.Instance;
await aiEngine.Config(false, contextUser);
for (const config of agentCapabilitiesConfig) {
const agentPattern = config.agentName || "*";
try {
const allAgents = aiEngine.Agents;
let agents = [];
if (agentPattern === '*') {
agents = allAgents;
}
else {
const isWildcardPattern = agentPattern.includes('*');
if (!isWildcardPattern) {
// Exact match
agents = allAgents.filter(a => a.Name === agentPattern);
}
else {
// Convert wildcard pattern to regex
const regexPattern = agentPattern
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special chars except *
.replace(/\*/g, '.*'); // Convert * to .*
const regex = new RegExp(`^${regexPattern}$`, 'i');
agents = allAgents.filter(a => a.Name && regex.test(a.Name));
}
}
for (const agent of agents) {
const operations = [];
if (config.discover)
operations.push('discover');
if (config.execute)
operations.push('execute');
if (config.monitor)
operations.push('monitor');
if (config.cancel)
operations.push('cancel');
if (operations.length > 0 && agent.Name) {
capabilities.push({
name: agent.Name,
operations: operations
});
}
}
}
catch (error) {
LogError('Failed to discover agents', '', error);
}
}
// Add general agent operations if any capability is enabled
const hasAnyCapability = agentCapabilitiesConfig.some(c => c.discover || c.execute || c.monitor || c.cancel);
if (hasAnyCapability) {
const generalOps = [];
if (agentCapabilitiesConfig.some(c => c.discover))
generalOps.push('discoverAgents');
if (agentCapabilitiesConfig.some(c => c.monitor))
generalOps.push('getAgentRunStatus');
if (agentCapabilitiesConfig.some(c => c.cancel))
generalOps.push('cancelAgentRun');
if (generalOps.length > 0) {
capabilities.push({
name: '_general',
operations: generalOps
});
}
}
return capabilities;
}
function getMatchingEntitiesForConfig(allEntities, config) {
return allEntities.filter((entity) => {
const entityName = entity.Name.toLowerCase();
const schemaName = entity.SchemaName.toLowerCase();
const configEntityName = config.entityName?.trim().toLowerCase() || "*";
const configSchemaName = config.schemaName?.trim().toLowerCase() || "*";
let schemaMatch = false;
let entityMatch = false;
// Schema matching
if (configSchemaName === "*") {
schemaMatch = true;
}
else if (configSchemaName.startsWith("*") && configSchemaName.endsWith("*")) {
schemaMatch = schemaName.includes(configSchemaName.slice(1, -1));
}
else if (configSchemaName.endsWith("*")) {
schemaMatch = schemaName.startsWith(configSchemaName.slice(0, -1));
}
else if (configSchemaName.startsWith("*")) {
schemaMatch = schemaName.endsWith(configSchemaName.slice(1));
}
else {
schemaMatch = schemaName === configSchemaName;
}
// Entity matching (only checked if schema matches)
if (schemaMatch) {
if (configEntityName === "*") {
entityMatch = true;
}
else if (configEntityName.startsWith("*") && configEntityName.endsWith("*")) {
entityMatch = entityName.includes(configEntityName.slice(1, -1));
}
else if (configEntityName.endsWith("*")) {
entityMatch = entityName.startsWith(configEntityName.slice(0, -1));
}
else if (configEntityName.startsWith("*")) {
entityMatch = entityName.endsWith(configEntityName.slice(1));
}
else {
entityMatch = entityName === configEntityName;
}
}
return schemaMatch && entityMatch;
});
}
function handleTaskSend(requestBody, authenticatedUser, authContext) {
const { taskId, message } = requestBody;
if (!taskId) {
// Create a new task
const newTaskId = generateId();
const task = {
id: newTaskId,
status: 'pending',
messages: [],
artifacts: [],
created: new Date(),
updated: new Date()
};
if (message) {
const newMessage = {
id: generateId(),
taskId: newTaskId,
role: 'user',
parts: message.parts || [],
created: new Date()
};
task.messages.push(newMessage);
}
tasks.set(newTaskId, task);
// Process the task asynchronously with the authenticated user
processTask(task, authenticatedUser, authContext);
return {
taskId: newTaskId,
status: task.status
};
}
else {
// Update an existing task
const task = tasks.get(taskId);
if (!task) {
throw {
error: {
code: 404,
message: `Task with ID ${taskId} not found`
}
};
}
if (task.status === 'completed' || task.status === 'cancelled' || task.status === 'failed') {
throw {
error: {
code: 400,
message: `Cannot update a task with status: ${task.status}`
}
};
}
if (message) {
const newMessage = {
id: generateId(),
taskId,
role: 'user',
parts: message.parts || [],
created: new Date()
};
task.messages.push(newMessage);
task.updated = new Date();
}
// Process the updated task with the authenticated user
processTask(task, authenticatedUser, authContext);
return {
taskId: task.id,
status: task.status
};
}
}
function handleTaskSendSubscribe(requestBody, res, authenticatedUser, authContext) {
const result = handleTaskSend(requestBody, authenticatedUser, authContext);
const task = tasks.get(result.taskId);
if (!task) {
res.write(`data: ${JSON.stringify({ error: { code: 404, message: "Task not found" } })}\n\n`);
res.end();
return;
}
// Send initial task state
res.write(`data: ${JSON.stringify({
taskId: task.id,
status: task.status
})}\n\n`);
// Set up a listener for task updates
const updateInterval = setInterval(() => {
const updatedTask = tasks.get(task.id);
if (!updatedTask) {
clearInterval(updateInterval);
res.end();
return;
}
// Send task status updates
res.write(`data: ${JSON.stringify({
taskId: updatedTask.id,
status: updatedTask.status
})}\n\n`);
// If task is completed, send final message and end the stream
if (updatedTask.status === 'completed' || updatedTask.status === 'cancelled' || updatedTask.status === 'failed') {
clearInterval(updateInterval);
// Send final artifacts
if (updatedTask.artifacts.length > 0) {
res.write(`data: ${JSON.stringify({
taskId: updatedTask.id,
artifacts: updatedTask.artifacts
})}\n\n`);
}
res.end();
}
}, 500);
// Handle client disconnect
res.on('close', () => {
clearInterval(updateInterval);
});
}
// Process a task using MemberJunction APIs
async function processTask(task, authenticatedUser, authContext) {
try {
task.status = 'in_progress';
task.updated = new Date();
// Get the last user message
const lastMessage = task.messages.filter(m => m.role === 'user').pop();
if (!lastMessage) {
throw new Error("No user message found");
}
// Use authenticated user or fall back to first cached user (for backwards compatibility)
const contextUser = authenticatedUser || UserCache.Instance.Users[0];
if (!contextUser) {
throw new Error("No user context available for processing task");
}
// Initialize operations handlers with the authenticated user and the request's
// metadata provider so every downstream entity operation routes through the right
// connection (see resolveProviderForRequest for the multi-tenant migration plan).
const provider = resolveProviderForRequest();
const entityOps = new EntityOperations(provider);
const agentOps = new AgentOperations(contextUser, provider);
// Extract text content and parse operation
const textParts = lastMessage.parts.filter(p => p.type === 'text');
const textContent = textParts.map(p => typeof p.content === 'string' ? p.content : JSON.stringify(p.content)).join(" ");
// Extract structured data if any
const dataParts = lastMessage.parts.filter(p => p.type === 'data');
const dataContent = dataParts.length > 0 ? dataParts[0].content : null;
// Parse the command and parameters
let operation = 'unknown';
let entityName = '';
let parameters = {};
// Try to extract command and parameters from structured data first
if (dataContent && typeof dataContent === 'object') {
operation = dataContent.operation || 'unknown';
entityName = dataContent.entity || '';
parameters = dataContent.parameters || {};
}
// Otherwise parse from text
else if (textContent) {
const parsedCommand = entityOps.parseCommandFromText(textContent);
operation = parsedCommand.operation;
entityName = parsedCommand.entityName;
parameters = parsedCommand.parameters;
}
// Perform the operation
let operationResult;
try {
// Check if this is an agent operation
const agentOperations = ['discoverAgents', 'executeAgent', 'getAgentRunStatus', 'cancelAgentRun'];
if (agentOperations.includes(operation)) {
// Authorize agent operations if authContext is provided
if (authContext) {
let scopePath;
let resource = '*';
switch (operation) {
case 'discoverAgents':
scopePath = 'metadata:agents:read';
resource = parameters.pattern || '*';
break;
case 'executeAgent':
scopePath = 'agent:execute';
resource = parameters.agentName || parameters.agentId || '*';
break;
case 'getAgentRunStatus':
scopePath = 'agent:monitor';
resource = parameters.runId || '*';
break;
case 'cancelAgentRun':
scopePath = 'agent:cancel';
resource = parameters.runId || '*';
break;
}
if (scopePath) {
const authResult = await authorizeOperation(authContext, scopePath, resource);
if (!authResult.allowed) {
operationResult = {
success: false,
errorMessage: `Authorization denied: ${authResult.error}`
};
throw new Error(operationResult.errorMessage);
}
}
}
// Agent operation
operationResult = await agentOps.processOperation(operation, parameters);
}
else {
// Authorize entity operation if authContext is provided
if (authContext && entityName) {
// Map entity operations to scopes
const operationScopeMap = {
'get': 'entity:read',
'create': 'entity:create',
'update': 'entity:update',
'delete': 'entity:delete',
'query': 'view:run',
'runView': 'view:run'
};
const scopePath = operationScopeMap[operation];
if (scopePath) {
const authResult = await authorizeOperation(authContext, scopePath, entityName);
if (!authResult.allowed) {
operationResult = {
success: false,
errorMessage: `Authorization denied: ${authResult.error}`
};
throw new Error(operationResult.errorMessage);
}
}
}
// Regular entity operation
if (!entityName) {
throw new Error("Entity name not specified");
}
operationResult = await entityOps.processOperation(operation, entityName, parameters);
}
}
catch (error) {
operationResult = {
success: false,
errorMessage: error instanceof Error ? error.message : String(error)
};
}
// Create response message
const responseMessage = {
id: generateId(),
taskId: task.id,
role: 'agent',
parts: [
{
id: generateId(),
type: 'text',
content: operationResult.success
? `Successfully performed ${operation} operation on ${entityName}`
: `Failed to perform ${operation} operation on ${entityName}: ${operationResult.errorMessage}`
}
],
created: new Date()
};
// Create artifact with result data
const artifact = {
id: generateId(),
taskId: task.id,
name: `${operation}_result`,
parts: [
{
id: generateId(),
type: 'data',
content: {
success: operationResult.success,
operation,
entity: entityName,
result: operationResult.result,
error: operationResult.success ? undefined : operationResult.errorMessage,
timestamp: new Date().toISOString()
}
}
],
created: new Date()
};
// Update the task
task.messages.push(responseMessage);
task.artifacts.push(artifact);
task.status = 'completed';
task.updated = new Date();
}
catch (error) {
console.error("Error processing task:", error);
// Create error response
const errorMessage = {
id: generateId(),
taskId: task.id,
role: 'agent',
parts: [
{
id: generateId(),
type: 'text',
content: `An error occurred while processing your request: ${error instanceof Error ? error.message : String(error)}`
}
],
created: new Date()
};
// Create error artifact
const errorArtifact = {
id: generateId(),
taskId: task.id,
name: 'error',
parts: [
{
id: generateId(),
type: 'data',
content: {
success: false,
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString()
}
}
],
created: new Date()
};
task.messages.push(errorMessage);
task.artifacts.push(errorArtifact);
task.status = 'failed';
task.updated = new Date();
}
}
// Utility function to generate a random ID
function generateId() {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
//# sourceMappingURL=Server.js.map