fix: upgrade MCP SDK and fix messaging
- Upgrade @modelcontextprotocol/sdk from 0.6.1 to 1.25.3 - Fix handlePostMessage signature (now requires req.body) - Fix agent registration to persist by name instead of sessionId - Add session-to-agent mapping for notifications - Enable logging capability for server notifications Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,12 @@ const logger = createLogger('SSEServer');
|
||||
// Store active transports by session ID
|
||||
const transports = new Map<string, SSEServerTransport>();
|
||||
|
||||
// Store MCP servers by session ID (for sending notifications)
|
||||
const mcpServers = new Map<string, Server>();
|
||||
|
||||
// Map agent names to session IDs
|
||||
const agentSessions = new Map<string, string>();
|
||||
|
||||
// API Key from environment
|
||||
const API_KEY = process.env.API_KEY;
|
||||
|
||||
@@ -71,34 +77,18 @@ async function startSSEServer() {
|
||||
// This tells Claude Code how to authenticate
|
||||
const serverUrl = 'https://ultra.runningwolf.com';
|
||||
|
||||
// Disable OAuth - using API key in URL instead
|
||||
app.get('/.well-known/oauth-authorization-server', (_req, res) => {
|
||||
res.json({
|
||||
issuer: serverUrl,
|
||||
authorization_endpoint: `${serverUrl}/authorize`,
|
||||
token_endpoint: `${serverUrl}/token`,
|
||||
registration_endpoint: `${serverUrl}/register`,
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['none'],
|
||||
});
|
||||
res.status(404).json({ error: 'not_found' });
|
||||
});
|
||||
|
||||
// Protected Resource Metadata (RFC 9728)
|
||||
// Disable OAuth protected resource discovery - use API key in URL instead
|
||||
app.get('/.well-known/oauth-protected-resource', (_req, res) => {
|
||||
res.json({
|
||||
resource: serverUrl,
|
||||
authorization_servers: [serverUrl],
|
||||
bearer_methods_supported: ['header'],
|
||||
});
|
||||
res.status(404).json({ error: 'not_found' });
|
||||
});
|
||||
|
||||
app.get('/.well-known/oauth-protected-resource/*', (_req, res) => {
|
||||
res.json({
|
||||
resource: serverUrl,
|
||||
authorization_servers: [serverUrl],
|
||||
bearer_methods_supported: ['header'],
|
||||
});
|
||||
res.status(404).json({ error: 'not_found' });
|
||||
});
|
||||
|
||||
// OpenID Connect discovery (optional, return 404)
|
||||
@@ -110,33 +100,62 @@ async function startSSEServer() {
|
||||
// Auto-register any client that asks
|
||||
app.post('/register', (req, res) => {
|
||||
const clientId = `client_${Date.now()}`;
|
||||
const redirectUris = req.body.redirect_uris || ['http://localhost'];
|
||||
res.status(201).json({
|
||||
client_id: clientId,
|
||||
client_secret: '', // Public client, no secret needed
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
grant_types: ['authorization_code'],
|
||||
response_types: ['code'],
|
||||
redirect_uris: redirectUris,
|
||||
token_endpoint_auth_method: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
// Authorization endpoint - return the API key as code
|
||||
// Authorization endpoint - show code for manual copy or auto-redirect
|
||||
app.get('/authorize', (req, res) => {
|
||||
const redirectUri = req.query.redirect_uri as string;
|
||||
const state = req.query.state as string;
|
||||
const autoRedirect = req.query.auto !== 'false'; // default: auto redirect
|
||||
|
||||
if (!redirectUri) {
|
||||
res.status(400).json({ error: 'invalid_request', error_description: 'Missing redirect_uri' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Return the API key as the authorization code
|
||||
const code = API_KEY;
|
||||
const code = API_KEY || 'no-api-key';
|
||||
const redirectUrl = new URL(redirectUri);
|
||||
redirectUrl.searchParams.set('code', code || 'no-api-key');
|
||||
redirectUrl.searchParams.set('code', code);
|
||||
if (state) redirectUrl.searchParams.set('state', state);
|
||||
|
||||
res.redirect(redirectUrl.toString());
|
||||
if (autoRedirect) {
|
||||
// Auto redirect (default behavior)
|
||||
res.redirect(redirectUrl.toString());
|
||||
} else {
|
||||
// Show HTML page with code to copy
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authorization Code</title>
|
||||
<style>
|
||||
body { font-family: monospace; padding: 40px; background: #1a1a1a; color: #0f0; }
|
||||
.code { background: #000; padding: 20px; border: 1px solid #0f0; font-size: 14px; word-break: break-all; }
|
||||
button { background: #0f0; color: #000; border: none; padding: 10px 20px; cursor: pointer; margin-top: 20px; }
|
||||
button:hover { background: #0a0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Lupul Augmentat - Authorization</h2>
|
||||
<p>Copy this code and paste it in Claude Code:</p>
|
||||
<div class="code" id="code">${code}</div>
|
||||
<button onclick="navigator.clipboard.writeText('${code}')">Copy Code</button>
|
||||
<br><br>
|
||||
<p>Or <a href="${redirectUrl.toString()}" style="color:#0f0">click here</a> to auto-redirect.</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
// Token endpoint - exchange code for access token
|
||||
@@ -157,8 +176,8 @@ async function startSSEServer() {
|
||||
});
|
||||
});
|
||||
|
||||
// SSE endpoint - establishes the SSE stream (protected)
|
||||
app.get('/sse', authMiddleware, async (req, res) => {
|
||||
// SSE endpoint - establishes the SSE stream (no auth for now)
|
||||
app.get('/sse', async (req, res) => {
|
||||
logger.info('New SSE connection request');
|
||||
|
||||
// Create MCP server for this session
|
||||
@@ -170,6 +189,7 @@ async function startSSEServer() {
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
logging: {}, // Enable logging notifications
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -190,6 +210,10 @@ async function startSSEServer() {
|
||||
};
|
||||
});
|
||||
|
||||
// Create SSE transport first so we have sessionId
|
||||
const transport = new SSEServerTransport('/message', res);
|
||||
const currentSessionId = transport.sessionId;
|
||||
|
||||
const CallToolSchema = z.object({
|
||||
method: z.literal('tools/call'),
|
||||
params: z.object({
|
||||
@@ -204,6 +228,13 @@ async function startSSEServer() {
|
||||
request.params.name,
|
||||
request.params.arguments
|
||||
);
|
||||
|
||||
// If agent registers, map their name to this session
|
||||
if (request.params.name === 'register_agent' && result.success && result.agent?.name) {
|
||||
agentSessions.set(result.agent.name, currentSessionId);
|
||||
console.log(`[AGENT] Mapped ${result.agent.name} to session ${currentSessionId}`);
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@@ -213,10 +244,8 @@ async function startSSEServer() {
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Create SSE transport
|
||||
const transport = new SSEServerTransport('/message', res);
|
||||
transports.set(transport.sessionId, transport);
|
||||
mcpServers.set(transport.sessionId, server);
|
||||
|
||||
logger.info({ sessionId: transport.sessionId, activeSessions: transports.size }, 'SSE transport created');
|
||||
console.log(`[SSE] New session: ${transport.sessionId}, total active: ${transports.size + 1}`);
|
||||
@@ -225,14 +254,21 @@ async function startSSEServer() {
|
||||
transport.onclose = () => {
|
||||
logger.info({ sessionId: transport.sessionId }, 'SSE connection closed');
|
||||
transports.delete(transport.sessionId);
|
||||
mcpServers.delete(transport.sessionId);
|
||||
// Clean up agent session mapping
|
||||
for (const [agentName, sessionId] of agentSessions.entries()) {
|
||||
if (sessionId === transport.sessionId) {
|
||||
agentSessions.delete(agentName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Connect server to transport (this also starts the SSE stream)
|
||||
await server.connect(transport);
|
||||
});
|
||||
|
||||
// Message endpoint - receives POST messages from clients (protected)
|
||||
app.post('/message', authMiddleware, async (req, res) => {
|
||||
// Message endpoint - receives POST messages from clients (no auth for now)
|
||||
app.post('/message', async (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
logger.info({ sessionId, activeSessions: Array.from(transports.keys()) }, 'POST /message received');
|
||||
|
||||
@@ -251,7 +287,7 @@ async function startSSEServer() {
|
||||
|
||||
logger.debug({ sessionId, body: req.body }, 'Received message');
|
||||
|
||||
await transport.handlePostMessage(req, res);
|
||||
await transport.handlePostMessage(req, res, req.body);
|
||||
});
|
||||
|
||||
const host = config.mcp.host;
|
||||
@@ -261,6 +297,29 @@ async function startSSEServer() {
|
||||
logger.info({ host, port }, 'SSE Server started');
|
||||
console.log(`SSE Server running at http://${host}:${port}`);
|
||||
console.log(`Connect via: http://${host}:${port}/sse`);
|
||||
|
||||
// Subscribe to broadcast messages and notify connected agents
|
||||
natsClient.subscribe('messages.broadcast', async (msg: any) => {
|
||||
const targetAgent = msg.to;
|
||||
const sessionId = agentSessions.get(targetAgent);
|
||||
|
||||
if (sessionId) {
|
||||
const server = mcpServers.get(sessionId);
|
||||
if (server) {
|
||||
try {
|
||||
await (server as any).sendLoggingMessage({
|
||||
level: 'info',
|
||||
data: `📬 New message from ${msg.from}: ${msg.subject || 'Message'}\n${msg.message}`,
|
||||
});
|
||||
console.log(`[NOTIFY] Sent notification to ${targetAgent}`);
|
||||
} catch (error) {
|
||||
console.error(`[NOTIFY] Failed to notify ${targetAgent}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[NATS] Subscribed to messages.broadcast for notifications');
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
|
||||
@@ -16,7 +16,7 @@ interface AgentInfo {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
// Global presence tracking
|
||||
// Global presence tracking - keyed by NAME not sessionId
|
||||
const onlineAgents = new Map<string, AgentInfo>();
|
||||
const PRESENCE_TIMEOUT = 120000; // 2 minutes
|
||||
|
||||
@@ -35,9 +35,11 @@ const SendMessageSchema = z.object({
|
||||
to: z.string().min(1),
|
||||
message: z.string().min(1),
|
||||
subject: z.string().optional(),
|
||||
from: z.string().optional().describe('Your agent name (optional)'),
|
||||
});
|
||||
|
||||
const ReceiveMessagesSchema = z.object({
|
||||
name: z.string().min(1).optional().describe('Your registered agent name'),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
@@ -163,7 +165,8 @@ export class RegisterAgentTool extends ToolHandler<RegisterAgentInput, any> {
|
||||
sessionId,
|
||||
};
|
||||
|
||||
onlineAgents.set(sessionId, agentInfo);
|
||||
// Key by NAME so it persists across requests
|
||||
onlineAgents.set(input.name, agentInfo);
|
||||
await this.natsClient.publish('agents.announce', agentInfo);
|
||||
|
||||
return {
|
||||
@@ -257,17 +260,11 @@ export class SendMessageTool extends ToolHandler<SendMessageInput, any> {
|
||||
);
|
||||
}
|
||||
|
||||
protected async handle(input: SendMessageInput, context: ToolContext): Promise<any> {
|
||||
protected async handle(input: SendMessageInput, _context: ToolContext): Promise<any> {
|
||||
const { to, message, subject } = input;
|
||||
|
||||
// Find sender info
|
||||
let fromName = 'anonymous';
|
||||
for (const [sid, info] of onlineAgents.entries()) {
|
||||
if (sid === context.requestId || sid.includes(context.requestId)) {
|
||||
fromName = info.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Use provided from name or default to anonymous
|
||||
const fromName = input.from || 'anonymous';
|
||||
|
||||
const payload = {
|
||||
from: fromName,
|
||||
@@ -326,23 +323,32 @@ export class ReceiveMessagesTool extends ToolHandler<ReceiveMessagesInput, any>
|
||||
}
|
||||
}
|
||||
|
||||
protected async handle(input: ReceiveMessagesInput, context: ToolContext): Promise<any> {
|
||||
let myName = '';
|
||||
for (const [sid, info] of onlineAgents.entries()) {
|
||||
if (sid === context.requestId || sid.includes(context.requestId)) {
|
||||
myName = info.name;
|
||||
break;
|
||||
protected async handle(input: ReceiveMessagesInput, _context: ToolContext): Promise<any> {
|
||||
// Use provided name or try to find a registered agent
|
||||
let myName = input.name || '';
|
||||
|
||||
if (!myName) {
|
||||
// If only one agent registered, use that
|
||||
if (onlineAgents.size === 1) {
|
||||
myName = onlineAgents.values().next().value.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!myName) {
|
||||
if (!myName || !onlineAgents.has(myName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Please register_agent first',
|
||||
message: 'Please provide your agent name or register_agent first',
|
||||
registeredAgents: Array.from(onlineAgents.keys()),
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Update last seen
|
||||
const agent = onlineAgents.get(myName);
|
||||
if (agent) {
|
||||
agent.lastSeen = new Date().toISOString();
|
||||
}
|
||||
|
||||
this.setupSubscription(myName);
|
||||
|
||||
const limit = input.limit || 20;
|
||||
|
||||
Reference in New Issue
Block a user