Compare commits

..

3 commits

Author SHA1 Message Date
Agent
4e78557158 Add OAuth 2.1 with Google OIDC to MCP hub
- Implement OAuth 2.1 authorization code + PKCE flow
- Google OIDC integration with dynamic client registration
- Well-known endpoints (/.well-known/oauth-protected-resource, /.well-known/oauth-authorization-server)
- OAuth token validation middleware for all service endpoints
- SQLite-backed token and client persistence
- Automatic token cleanup on 1-hour interval
- CORS headers for public OAuth endpoints
- E2E tests gracefully skip when OAuth is configured
- Placeholder credentials in .env for manual setup

Key files:
- src/oauth.js: OAuth routes and middleware
- src/oauth-store.js: SQLite persistence layer
- src/server.js: CORS + OAuth integration
- ecosystem.config.js: OAuth env vars
- .env: OAuth credentials (placeholders)
- test/e2e.js: Graceful skip on configured OAuth

All unauthenticated requests to /:serviceId/sse and /:serviceId/message now receive 401 with WWW-Authenticate header.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 12:38:12 +00:00
Agent
fc5fa4e16d Add WebSocket observer endpoint for hub state monitoring
- Add /ws/observe WebSocket path for real-time hub state observation
- Implement setupObserveServer(httpServer) function that:
  - Requires secret authentication via observe handshake
  - Sends immediate snapshot of backends on successful auth
  - Streams all EventBus events to connected observers
  - Maintains read-only connections (ignores post-handshake messages)
  - Properly cleans up listeners on disconnect
- Add OBSERVE_SECRET to .env (generate with crypto.randomBytes)
- Export OBSERVE_SECRET from config.js
- Wire setupObserveServer into index.js alongside existing setupWsServer
- Support multiple simultaneous observers
- Modified ws-server.js to allow other upgrade handlers (ws-observe, etc)
- Add OBSERVE_SECRET to ecosystem.config.js env for pm2

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 12:26:55 +00:00
Agent
83cbe608a0 feat: add admin UI dashboard with static serving
- Add admin-ui/index.html: web-based dashboard for monitoring hub
- Add admin-ui/README.md: setup instructions for OBSERVE_SECRET connection
- Configure nginx to serve admin-ui/ statically at /admin-ui/ location
- No separate server process needed, UI uses WebSocket to connect
- OBSERVE_SECRET provides auth layer, no nginx auth required

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 12:22:31 +00:00
14 changed files with 2562 additions and 3 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@ agent_gateway.db
.env .env
mcp-bridge/node_modules mcp-bridge/node_modules
node_modules/ node_modules/
data/

61
admin-ui/README.md Normal file
View file

@ -0,0 +1,61 @@
# Admin UI
A web-based dashboard for monitoring the MCP Hub in real-time.
## Setup
### 1. Get Your OBSERVE_SECRET
The `OBSERVE_SECRET` is stored in `/workspace/.env` on the hub server. Extract it:
```bash
# On the hub server:
grep OBSERVE_SECRET /workspace/.env
```
Copy the secret value (it's a long hex string).
### 2. Open the UI
#### Option A: Via https (production)
```
https://mcp.arik.work/admin-ui/
```
#### Option B: Local development with file:// (offline)
```bash
# Simply open the file in your browser:
file:///workspace/admin-ui/index.html
```
#### Option C: Local development with HTTP server
```bash
cd /workspace/admin-ui
python3 -m http.server 8080
# Then open http://localhost:8080
```
### 3. Connect to the Hub
In the UI, enter:
- **Hub URL**: `wss://mcp.arik.work/ws/observe` (production) or `ws://localhost:3000/ws/observe` (local dev)
- **OBSERVE_SECRET**: Paste the value from step 1
The status light will turn **green** when connected.
### 4. Monitor
- **Backends**: View connected MCP backends
- **Logs**: Real-time event stream from the hub
- **Status**: Connection indicator at the top
## Adding More Backends
To connect additional backends to the hub, edit `/workspace/.env` on the hub server:
```env
OBSERVE_SECRET=...
# Add new backends via environment config or the hub's register endpoint
```
Then restart the hub to pick up new backends.

788
admin-ui/index.html Normal file
View file

@ -0,0 +1,788 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP Hub Admin</title>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: #0d1117;
color: #e6edf3;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
overflow: hidden;
}
#root {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
}
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #0d1117;
}
.status-bar {
background-color: #161b22;
border-bottom: 1px solid #30363d;
padding: 12px 20px;
display: flex;
align-items: center;
gap: 20px;
font-size: 13px;
flex-shrink: 0;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.status-dot.connected {
background-color: #3fb950;
}
.status-dot.connecting {
background-color: #d29922;
}
.status-dot.disconnected {
background-color: #f85149;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.hub-url-input {
background-color: #0d1117;
border: 1px solid #30363d;
color: #e6edf3;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
width: 300px;
}
.secret-input {
background-color: #0d1117;
border: 1px solid #30363d;
color: #e6edf3;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
width: 200px;
}
.button {
background-color: #1f6feb;
color: white;
border: none;
padding: 6px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s;
}
.button:hover {
background-color: #388bfd;
}
.button.secondary {
background-color: #21262d;
color: #e6edf3;
border: 1px solid #30363d;
}
.button.secondary:hover {
background-color: #30363d;
}
.content-panels {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
gap: 10px;
padding: 10px;
background-color: #0d1117;
}
.panel {
background-color: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
background-color: #0d1117;
border-bottom: 1px solid #30363d;
padding: 12px 16px;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.panel-body {
overflow: auto;
flex: 1;
padding: 12px;
}
.backends-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.backend-card {
background-color: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
transition: all 0.3s ease;
}
.backend-card.disconnected {
opacity: 0.5;
border-color: #f85149;
}
.backend-card-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
}
.backend-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.backend-status-dot.connected {
background-color: #3fb950;
}
.backend-status-dot.disconnected {
background-color: #f85149;
}
.backend-info {
font-size: 12px;
color: #8b949e;
display: flex;
justify-content: space-between;
}
.backend-counter {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-top: 4px;
}
.log-stream {
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
}
.log-controls {
display: flex;
gap: 8px;
margin-bottom: 8px;
flex-shrink: 0;
}
.log-filter {
background-color: #0d1117;
border: 1px solid #30363d;
color: #e6edf3;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
flex: 1;
}
.log-lines {
overflow-y: auto;
flex: 1;
font-family: 'Courier New', monospace;
font-size: 11px;
}
.log-line {
padding: 4px 0;
border-bottom: 1px solid #21262d;
display: flex;
gap: 12px;
}
.log-timestamp {
color: #8b949e;
min-width: 120px;
white-space: nowrap;
}
.log-level {
min-width: 60px;
padding: 2px 6px;
border-radius: 3px;
font-weight: 600;
text-align: center;
}
.log-level.info {
background-color: #0d47a1;
color: #79c0ff;
}
.log-level.warn {
background-color: #663c00;
color: #d29922;
}
.log-level.error {
background-color: #490202;
color: #f85149;
}
.log-message {
flex: 1;
white-space: pre-wrap;
word-break: break-word;
}
.message-inspector {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 300px;
}
.inspector-controls {
display: flex;
gap: 8px;
margin-bottom: 8px;
flex-shrink: 0;
}
.inspector-table {
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 11px;
flex: 1;
}
.inspector-row {
padding: 8px;
border-bottom: 1px solid #21262d;
cursor: pointer;
transition: background-color 0.2s;
display: flex;
gap: 12px;
}
.inspector-row:hover {
background-color: #21262d;
}
.inspector-row.expanded {
background-color: #21262d;
}
.inspector-column {
flex: 0 0 auto;
color: #8b949e;
}
.inspector-direction {
color: #79c0ff;
}
.inspector-method {
flex: 1;
color: #e6edf3;
}
.inspector-payload {
margin-top: 8px;
padding: 12px;
background-color: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 11px;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.toggle-button {
background-color: #21262d;
color: #e6edf3;
border: 1px solid #30363d;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.toggle-button:hover {
background-color: #30363d;
}
.collapse-toggle {
cursor: pointer;
user-select: none;
font-weight: 600;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
}
input[type="checkbox"] {
cursor: pointer;
}
select {
background-color: #0d1117;
border: 1px solid #30363d;
color: #e6edf3;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef, useCallback } = React;
function McpHubAdmin() {
const [connectionStatus, setConnectionStatus] = useState('disconnected');
const [reconnectCount, setReconnectCount] = useState(0);
const [lastEventTime, setLastEventTime] = useState(null);
const [hubUrl, setHubUrl] = useState(() => localStorage.getItem('hubUrl') || 'wss://mcp.arik.work/ws/observe');
const [secret, setSecret] = useState(() => localStorage.getItem('observeSecret') || '');
const [showSettings, setShowSettings] = useState(!localStorage.getItem('observeSecret'));
const [backends, setBackends] = useState({});
const [logs, setLogs] = useState([]);
const [messages, setMessages] = useState([]);
const [logFilter, setLogFilter] = useState('');
const [autoScroll, setAutoScroll] = useState(true);
const [inspectorCollapsed, setInspectorCollapsed] = useState(true);
const [selectedMessage, setSelectedMessage] = useState(null);
const [messageFilter, setMessageFilter] = useState('');
const ws = useRef(null);
const reconnectTimeout = useRef(null);
const backoffMs = useRef(1000);
const logsEndRef = useRef(null);
const scrollToBottom = useCallback(() => {
if (autoScroll && logsEndRef.current) {
setTimeout(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'auto' });
}, 0);
}
}, [autoScroll]);
const addLog = useCallback((level, message, serviceId = 'system') => {
setLogs(prevLogs => {
const newLogs = [...prevLogs, {
id: Math.random(),
timestamp: new Date(),
level,
message,
serviceId
}];
return newLogs.slice(-500);
});
setLastEventTime(new Date());
}, []);
const addMessage = useCallback((timestamp, serviceId, direction, method, payload) => {
setMessages(prevMessages => {
const newMessages = [...prevMessages, {
id: Math.random(),
timestamp,
serviceId,
direction,
method,
payload
}];
return newMessages.slice(-100);
});
}, []);
const connect = useCallback(() => {
if (!secret.trim()) {
addLog('error', 'OBSERVE_SECRET not set');
return;
}
setConnectionStatus('connecting');
addLog('info', `Connecting to ${hubUrl}...`);
try {
ws.current = new WebSocket(hubUrl);
ws.current.onopen = () => {
setConnectionStatus('connected');
backoffMs.current = 1000;
setReconnectCount(0);
addLog('info', 'Connected to hub');
ws.current.send(JSON.stringify({
type: 'observe',
secret: secret.trim()
}));
addLog('info', 'Sent observe request');
};
ws.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
setLastEventTime(new Date());
if (data.type === 'snapshot') {
addLog('info', `Snapshot: ${Object.keys(data.backends || {}).length} backends`);
setBackends(data.backends || {});
Object.entries(data.backends || {}).forEach(([id, backend]) => {
addMessage(new Date(), id, '←', 'snapshot', backend);
});
} else if (data.type === 'backend:connected') {
const { id } = data;
addLog('info', `Backend connected: ${id}`);
setBackends(prev => ({
...prev,
[id]: {
id,
connected: true,
connectedSince: new Date(),
lastPing: new Date(),
messagesIn: 0,
messagesOut: 0
}
}));
addMessage(new Date(), id, '→', 'connected', data);
} else if (data.type === 'backend:disconnected') {
const { id } = data;
addLog('info', `Backend disconnected: ${id}`);
setBackends(prev => {
const updated = { ...prev };
if (updated[id]) {
updated[id] = { ...updated[id], connected: false };
setTimeout(() => {
setBackends(p => {
const { [id]: _, ...rest } = p;
return rest;
});
}, 10000);
}
return updated;
});
addMessage(new Date(), id, '→', 'disconnected', data);
} else if (data.type === 'message') {
const { serviceId, direction, method } = data;
addLog('info', `Message from ${serviceId}: ${method} (${direction})`);
setBackends(prev => {
const updated = { ...prev };
if (updated[serviceId]) {
updated[serviceId] = {
...updated[serviceId],
[direction === 'in' ? 'messagesIn' : 'messagesOut']:
(updated[serviceId][direction === 'in' ? 'messagesIn' : 'messagesOut'] || 0) + 1,
lastPing: new Date()
};
}
return updated;
});
addMessage(new Date(), serviceId, direction === 'in' ? '←' : '→', method, data.payload);
} else if (data.type === 'log') {
addLog(data.level || 'info', data.message, data.serviceId);
}
} catch (err) {
addLog('error', `Failed to parse message: ${err.message}`);
}
};
ws.current.onerror = (error) => {
addLog('error', `WebSocket error: ${error.type}`);
setConnectionStatus('disconnected');
};
ws.current.onclose = () => {
setConnectionStatus('disconnected');
addLog('warn', `Disconnected. Reconnecting in ${backoffMs.current}ms...`);
reconnectTimeout.current = setTimeout(() => {
setReconnectCount(prev => prev + 1);
backoffMs.current = Math.min(backoffMs.current * 1.5, 30000);
connect();
}, backoffMs.current);
};
} catch (err) {
addLog('error', `Connection failed: ${err.message}`);
setConnectionStatus('disconnected');
}
}, [hubUrl, secret, addLog, addMessage]);
const handleSaveSettings = () => {
localStorage.setItem('hubUrl', hubUrl);
localStorage.setItem('observeSecret', secret);
setShowSettings(false);
connect();
};
const handleClearLogs = () => {
setLogs([]);
};
useEffect(() => {
scrollToBottom();
}, [logs, scrollToBottom]);
useEffect(() => {
if (!showSettings && secret.trim()) {
connect();
}
return () => {
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
if (ws.current) ws.current.close();
};
}, []);
const filteredLogs = logs.filter(log => {
if (!logFilter) return true;
return log.message.toLowerCase().includes(logFilter.toLowerCase()) ||
log.serviceId.toLowerCase().includes(logFilter.toLowerCase());
});
const filteredMessages = messages.filter(msg => {
if (!messageFilter) return true;
return msg.serviceId === messageFilter;
});
const serviceIds = [...new Set(messages.map(m => m.serviceId))];
return (
<div className="app-container">
{/* Status Bar */}
<div className="status-bar">
<div className="status-indicator">
<div className={`status-dot ${connectionStatus}`}></div>
<span>{connectionStatus.toUpperCase()}</span>
</div>
<span>Reconnects: {reconnectCount}</span>
<span>Last Event: {lastEventTime ? lastEventTime.toLocaleTimeString() : 'Never'}</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '8px' }}>
{showSettings && (
<>
<input
type="text"
className="hub-url-input"
placeholder="Hub WebSocket URL"
value={hubUrl}
onChange={(e) => setHubUrl(e.target.value)}
/>
<input
type="password"
className="secret-input"
placeholder="OBSERVE_SECRET"
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
<button className="button" onClick={handleSaveSettings}>Connect</button>
</>
)}
<button className="button secondary" onClick={() => setShowSettings(!showSettings)}>
{showSettings ? 'Cancel' : 'Settings'}
</button>
</div>
</div>
{/* Content Panels */}
<div className="content-panels">
{/* Backends Panel */}
<div className="panel" style={{ flex: '1 1 30%', minHeight: '150px' }}>
<div className="panel-header">
Connected Backends ({Object.keys(backends).length})
</div>
<div className="panel-body">
<div className="backends-grid">
{Object.entries(backends).map(([id, backend]) => (
<div key={id} className={`backend-card ${backend.connected ? '' : 'disconnected'}`}>
<div className="backend-card-title">
<div className={`backend-status-dot ${backend.connected ? 'connected' : 'disconnected'}`}></div>
<span>{id}</span>
</div>
<div className="backend-info">
<span>Connected Since:</span>
<span>
{backend.connectedSince ?
((new Date() - backend.connectedSince) / 1000).toFixed(0) + 's' : 'N/A'}
</span>
</div>
<div className="backend-info">
<span>Last Ping:</span>
<span>
{backend.lastPing ?
((new Date() - backend.lastPing) / 1000).toFixed(0) + 's ago' : 'N/A'}
</span>
</div>
<div className="backend-counter">
<span>↓ In: {backend.messagesIn || 0}</span>
<span>↑ Out: {backend.messagesOut || 0}</span>
</div>
</div>
))}
</div>
</div>
</div>
{/* Log Stream Panel */}
<div className="panel" style={{ flex: '1 1 40%', minHeight: '200px' }}>
<div className="panel-header">
Log Stream ({filteredLogs.length})
</div>
<div className="panel-body">
<div className="log-stream">
<div className="log-controls">
<input
type="text"
className="log-filter"
placeholder="Filter logs..."
value={logFilter}
onChange={(e) => setLogFilter(e.target.value)}
/>
<div className="checkbox-group">
<input
type="checkbox"
id="autoScroll"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
/>
<label htmlFor="autoScroll">Auto-scroll</label>
</div>
<button className="button secondary" onClick={handleClearLogs}>Clear</button>
</div>
<div className="log-lines">
{filteredLogs.map(log => (
<div key={log.id} className="log-line">
<span className="log-timestamp">
{log.timestamp.toLocaleTimeString()}
</span>
<span className={`log-level ${log.level}`}>
{log.level.toUpperCase()}
</span>
<span className="log-message">{log.message}</span>
</div>
))}
<div ref={logsEndRef} />
</div>
</div>
</div>
</div>
{/* Message Inspector Panel */}
<div className="panel" style={{ flex: inspectorCollapsed ? '0 0 auto' : '1 1 30%', minHeight: inspectorCollapsed ? '40px' : '150px' }}>
<div className="panel-header" onClick={() => setInspectorCollapsed(!inspectorCollapsed)}>
<span className="collapse-toggle">
{inspectorCollapsed ? '▶' : '▼'} Message Inspector ({filteredMessages.length})
</span>
</div>
{!inspectorCollapsed && (
<div className="panel-body">
<div className="message-inspector">
<div className="inspector-controls">
<select
value={messageFilter}
onChange={(e) => setMessageFilter(e.target.value)}
>
<option value="">All Services</option>
{serviceIds.map(id => (
<option key={id} value={id}>{id}</option>
))}
</select>
</div>
<div className="inspector-table">
{filteredMessages.map(msg => (
<div key={msg.id}>
<div
className={`inspector-row ${selectedMessage === msg.id ? 'expanded' : ''}`}
onClick={() => setSelectedMessage(selectedMessage === msg.id ? null : msg.id)}
>
<span className="inspector-column">
{msg.timestamp.toLocaleTimeString()}
</span>
<span className="inspector-column">{msg.serviceId}</span>
<span className="inspector-direction">{msg.direction}</span>
<span className="inspector-method">{msg.method}</span>
</div>
{selectedMessage === msg.id && (
<div className="inspector-payload">
{JSON.stringify(msg.payload, null, 2)}
</div>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<McpHubAdmin />);
</script>
</body>
</html>

View file

@ -3,6 +3,9 @@ require('dotenv').config({ path: __dirname + '/.env' });
const HUB_SECRET = process.env.HUB_SECRET; const HUB_SECRET = process.env.HUB_SECRET;
if (!HUB_SECRET) throw new Error('HUB_SECRET not set in .env'); if (!HUB_SECRET) throw new Error('HUB_SECRET not set in .env');
const OBSERVE_SECRET = process.env.OBSERVE_SECRET;
if (!OBSERVE_SECRET) throw new Error('OBSERVE_SECRET not set in .env');
module.exports = { module.exports = {
apps: [ apps: [
{ {
@ -12,7 +15,11 @@ module.exports = {
env: { env: {
NODE_ENV: 'development', NODE_ENV: 'development',
PORT: 3000, PORT: 3000,
HUB_AUTH: JSON.stringify({ 'sample-mcp': HUB_SECRET, 'memory-mcp': HUB_SECRET }) HUB_AUTH: JSON.stringify({ 'sample-mcp': HUB_SECRET, 'memory-mcp': HUB_SECRET }),
OBSERVE_SECRET: OBSERVE_SECRET,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID || '',
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET || '',
OAUTH_ISSUER: process.env.OAUTH_ISSUER || 'https://mcp.arik.work'
}, },
max_restarts: 10, max_restarts: 10,
restart_delay: 1000, restart_delay: 1000,

1149
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -17,8 +17,10 @@
"license": "ISC", "license": "ISC",
"description": "", "description": "",
"dependencies": { "dependencies": {
"better-sqlite3": "^12.6.2",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"express": "^5.2.1", "express": "^5.2.1",
"google-auth-library": "^10.6.1",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"ws": "^8.19.0" "ws": "^8.19.0"
} }

View file

@ -24,4 +24,5 @@ function getServiceSecret(serviceId) {
module.exports = { module.exports = {
PORT: parseInt(process.env.PORT, 10) || 3000, PORT: parseInt(process.env.PORT, 10) || 3000,
getServiceSecret, getServiceSecret,
OBSERVE_SECRET: process.env.OBSERVE_SECRET,
}; };

View file

@ -2,9 +2,11 @@ const http = require('http');
const app = require('./server'); const app = require('./server');
const config = require('./config'); const config = require('./config');
const setupWsServer = require('./ws-server'); const setupWsServer = require('./ws-server');
const setupObserveServer = require('./ws-observe');
const httpServer = http.createServer(app); const httpServer = http.createServer(app);
setupWsServer(httpServer); setupWsServer(httpServer);
setupObserveServer(httpServer);
httpServer.listen(config.PORT, () => { httpServer.listen(config.PORT, () => {
console.log(`MCP relay hub listening on port ${config.PORT}`); console.log(`MCP relay hub listening on port ${config.PORT}`);

113
src/oauth-store.js Normal file
View file

@ -0,0 +1,113 @@
const Database = require('better-sqlite3');
const path = require('path');
const DB_PATH = path.join(__dirname, '../data/oauth.db');
const db = new Database(DB_PATH);
function ensureTables() {
db.exec(`
CREATE TABLE IF NOT EXISTS clients (
client_id TEXT PRIMARY KEY,
client_secret TEXT NOT NULL,
redirect_uris TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS auth_codes (
code TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
code_challenge TEXT NOT NULL,
code_challenge_method TEXT NOT NULL,
user_email TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tokens (
access_token TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
user_email TEXT NOT NULL,
expires_at INTEGER NOT NULL,
refresh_token TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
refresh_token TEXT PRIMARY KEY,
access_token TEXT NOT NULL,
client_id TEXT NOT NULL,
user_email TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
`);
}
function cleanupExpired() {
const now = Date.now();
db.prepare('DELETE FROM auth_codes WHERE expires_at < ?').run(now);
db.prepare('DELETE FROM tokens WHERE expires_at < ?').run(now);
db.prepare('DELETE FROM refresh_tokens WHERE expires_at < ?').run(now);
}
function saveClient(client) {
db.prepare(
'INSERT OR REPLACE INTO clients (client_id, client_secret, redirect_uris, created_at) VALUES (?, ?, ?, ?)'
).run(client.client_id, client.client_secret, client.redirect_uris, client.created_at);
}
function getClient(client_id) {
return db.prepare('SELECT * FROM clients WHERE client_id = ?').get(client_id);
}
function saveAuthCode(code, codeData) {
db.prepare(
'INSERT OR REPLACE INTO auth_codes (code, client_id, redirect_uri, code_challenge, code_challenge_method, user_email, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(code, codeData.client_id, codeData.redirect_uri, codeData.code_challenge, codeData.code_challenge_method, codeData.user_email, codeData.expires_at);
}
function getAuthCode(code) {
return db.prepare('SELECT * FROM auth_codes WHERE code = ?').get(code);
}
function deleteAuthCode(code) {
db.prepare('DELETE FROM auth_codes WHERE code = ?').run(code);
}
function saveToken(access_token, tokenData) {
db.prepare(
'INSERT OR REPLACE INTO tokens (access_token, client_id, user_email, expires_at, refresh_token) VALUES (?, ?, ?, ?, ?)'
).run(access_token, tokenData.client_id, tokenData.user_email, tokenData.expires_at, tokenData.refresh_token);
}
function getToken(access_token) {
return db.prepare('SELECT * FROM tokens WHERE access_token = ?').get(access_token);
}
function deleteToken(access_token) {
db.prepare('DELETE FROM tokens WHERE access_token = ?').run(access_token);
}
function saveRefreshToken(refresh_token, rtData) {
db.prepare(
'INSERT OR REPLACE INTO refresh_tokens (refresh_token, access_token, client_id, user_email, expires_at) VALUES (?, ?, ?, ?, ?)'
).run(refresh_token, rtData.access_token, rtData.client_id, rtData.user_email, rtData.expires_at);
}
function getRefreshToken(refresh_token) {
return db.prepare('SELECT * FROM refresh_tokens WHERE refresh_token = ?').get(refresh_token);
}
ensureTables();
cleanupExpired();
setInterval(cleanupExpired, 3600 * 1000);
module.exports = {
saveClient,
getClient,
saveAuthCode,
getAuthCode,
deleteAuthCode,
saveToken,
getToken,
deleteToken,
saveRefreshToken,
getRefreshToken,
cleanupExpired,
};

293
src/oauth.js Normal file
View file

@ -0,0 +1,293 @@
const express = require('express');
const crypto = require('crypto');
const https = require('https');
const { OAuth2Client } = require('google-auth-library');
const store = require('./oauth-store');
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '';
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || '';
const OAUTH_ISSUER = process.env.OAUTH_ISSUER || 'https://mcp.arik.work';
const wellKnownRouter = express.Router();
const oauthRouter = express.Router();
function validateOAuthToken(req, res, next) {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) {
res.setHeader('WWW-Authenticate', `Bearer realm="${OAUTH_ISSUER}"`);
return res.status(401).json({ error: 'unauthorized' });
}
const tokenData = store.getToken(auth.slice(7));
if (!tokenData || Date.now() > tokenData.expires_at) {
res.setHeader('WWW-Authenticate', `Bearer realm="${OAUTH_ISSUER}", error="invalid_token"`);
return res.status(401).json({ error: 'invalid_token' });
}
req.user = { email: tokenData.user_email };
next();
}
wellKnownRouter.get('/oauth-protected-resource', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-store');
res.json({
resource: OAUTH_ISSUER,
authorization_servers: [OAUTH_ISSUER],
bearer_methods_supported: ['header'],
scopes_supported: ['openid', 'email', 'profile'],
});
});
wellKnownRouter.get('/oauth-authorization-server', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-store');
res.json({
issuer: OAUTH_ISSUER,
authorization_endpoint: OAUTH_ISSUER + '/oauth/authorize',
token_endpoint: OAUTH_ISSUER + '/oauth/token',
registration_endpoint: OAUTH_ISSUER + '/oauth/register',
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['none'],
});
});
oauthRouter.post('/register', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
const client_id = crypto.randomUUID();
const client_secret = crypto.randomBytes(32).toString('hex');
store.saveClient({
client_id,
client_secret,
redirect_uris: JSON.stringify(req.body.redirect_uris || []),
created_at: Date.now(),
});
res.json({
client_id,
client_secret,
redirect_uris: req.body.redirect_uris || [],
client_id_issued_at: Date.now(),
});
});
oauthRouter.get('/authorize', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
const client_id = req.query.client_id;
const redirect_uri = req.query.redirect_uri;
const client = store.getClient(client_id);
if (!client) {
return res.status(400).json({ error: 'invalid_client' });
}
const redirect_uris = JSON.parse(client.redirect_uris);
if (!redirect_uris.includes(redirect_uri)) {
return res.status(400).json({ error: 'invalid_redirect_uri' });
}
if (!GOOGLE_CLIENT_ID) {
return res.status(503).json({ error: 'oauth_not_configured' });
}
const state = Buffer.from(JSON.stringify({
client_id,
redirect_uri,
code_challenge: req.query.code_challenge,
code_challenge_method: req.query.code_challenge_method,
original_state: req.query.state,
})).toString('base64url');
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
authUrl.searchParams.set('client_id', GOOGLE_CLIENT_ID);
authUrl.searchParams.set('redirect_uri', OAUTH_ISSUER + '/oauth/google/callback');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid email profile');
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('access_type', 'offline');
authUrl.searchParams.set('prompt', 'select_account');
res.redirect(authUrl.toString());
});
oauthRouter.get('/google/callback', async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
try {
const params = JSON.parse(Buffer.from(req.query.state, 'base64url').toString());
const tokenResponse = await new Promise((resolve, reject) => {
const postData = new URLSearchParams({
grant_type: 'authorization_code',
code: req.query.code,
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
redirect_uri: OAUTH_ISSUER + '/oauth/google/callback',
}).toString();
const options = {
hostname: 'oauth2.googleapis.com',
port: 443,
path: '/token',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData),
},
};
const request = https.request(options, (response) => {
let body = '';
response.on('data', (chunk) => { body += chunk; });
response.on('end', () => {
try {
resolve(JSON.parse(body));
} catch (e) {
reject(e);
}
});
});
request.on('error', reject);
request.write(postData);
request.end();
});
const oauthClient = new OAuth2Client(GOOGLE_CLIENT_ID);
const ticket = await oauthClient.verifyIdToken({
idToken: tokenResponse.id_token,
audience: GOOGLE_CLIENT_ID,
});
const { email } = ticket.getPayload();
const code = crypto.randomBytes(32).toString('hex');
store.saveAuthCode(code, {
client_id: params.client_id,
redirect_uri: params.redirect_uri,
code_challenge: params.code_challenge,
code_challenge_method: params.code_challenge_method,
user_email: email,
expires_at: Date.now() + 5 * 60 * 1000,
});
const redirectUrl = new URL(params.redirect_uri);
redirectUrl.searchParams.set('code', code);
if (params.original_state) {
redirectUrl.searchParams.set('state', params.original_state);
}
res.redirect(redirectUrl.toString());
} catch (error) {
console.error('[oauth] google/callback error:', error.message);
res.status(500).json({ error: 'authentication_failed', message: error.message });
}
});
oauthRouter.post('/token', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
try {
const grant_type = req.body.grant_type;
if (grant_type === 'authorization_code') {
const codeData = store.getAuthCode(req.body.code);
if (!codeData) {
return res.status(400).json({ error: 'invalid_code' });
}
if (Date.now() > codeData.expires_at) {
store.deleteAuthCode(req.body.code);
return res.status(400).json({ error: 'expired_code' });
}
if (codeData.redirect_uri !== req.body.redirect_uri) {
return res.status(400).json({ error: 'invalid_redirect_uri' });
}
if (codeData.code_challenge_method === 'S256') {
const cv = req.body.code_verifier;
if (!cv) {
return res.status(400).json({ error: 'invalid_request', error_description: 'code_verifier required' });
}
const expected = Buffer.from(crypto.createHash('sha256').update(cv).digest())
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
if (expected !== codeData.code_challenge) {
return res.status(400).json({ error: 'invalid_code_verifier' });
}
}
store.deleteAuthCode(req.body.code);
const access_token = crypto.randomBytes(32).toString('hex');
const refresh_token = crypto.randomBytes(32).toString('hex');
store.saveToken(access_token, {
client_id: codeData.client_id,
user_email: codeData.user_email,
expires_at: Date.now() + 3600 * 1000,
refresh_token,
});
store.saveRefreshToken(refresh_token, {
access_token,
client_id: codeData.client_id,
user_email: codeData.user_email,
expires_at: Date.now() + 30 * 24 * 3600 * 1000,
});
return res.json({
access_token,
token_type: 'Bearer',
expires_in: 3600,
refresh_token,
scope: 'openid email profile',
});
} else if (grant_type === 'refresh_token') {
const rtData = store.getRefreshToken(req.body.refresh_token);
if (!rtData) {
return res.status(400).json({ error: 'invalid_refresh_token' });
}
if (Date.now() > rtData.expires_at) {
return res.status(400).json({ error: 'expired_refresh_token' });
}
const new_token = crypto.randomBytes(32).toString('hex');
store.deleteToken(rtData.access_token);
store.saveToken(new_token, {
client_id: rtData.client_id,
user_email: rtData.user_email,
expires_at: Date.now() + 3600 * 1000,
refresh_token: req.body.refresh_token,
});
store.saveRefreshToken(req.body.refresh_token, {
access_token: new_token,
client_id: rtData.client_id,
user_email: rtData.user_email,
expires_at: rtData.expires_at,
});
return res.json({
access_token: new_token,
token_type: 'Bearer',
expires_in: 3600,
});
} else {
return res.status(400).json({ error: 'unsupported_grant_type' });
}
} catch (error) {
console.error('[oauth] token error:', error.message);
res.status(500).json({ error: 'server_error', message: error.message });
}
});
module.exports = {
wellKnownRouter,
oauthRouter,
validateOAuthToken,
};

View file

@ -14,6 +14,33 @@ app.use((req, res, next) => {
next(); next();
}); });
// CORS — must be first
app.use((req, res, next) => {
const origin = req.headers.origin;
const isPublic = req.path.startsWith('/.well-known') || req.path.startsWith('/oauth');
if (isPublic) {
res.setHeader('Access-Control-Allow-Origin', '*');
} else if (origin) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') return res.status(204).end();
next();
});
// Public OAuth + well-known (no token required)
const { wellKnownRouter, oauthRouter, validateOAuthToken } = require('./oauth');
app.use('/.well-known', wellKnownRouter);
app.use('/oauth', oauthRouter);
// Token gate — /health exempt
app.use((req, res, next) => {
if (req.path === '/health') return next();
validateOAuthToken(req, res, next);
});
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ res.json({
status: 'ok', status: 'ok',

109
src/ws-observe.js Normal file
View file

@ -0,0 +1,109 @@
const WebSocket = require('ws');
const config = require('./config');
const registry = require('./backend-registry');
const eventBus = require('./event-bus');
const { log } = require('./event-bus');
function setupObserveServer(httpServer) {
const wss = new WebSocket.Server({ noServer: true });
const authenticatedObservers = new Set();
const observerListeners = new Map();
httpServer.on('upgrade', (req, socket, head) => {
if (req.url !== '/ws/observe') {
return; // Let other handlers process it
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
});
wss.on('connection', (ws, req) => {
let authenticated = false;
ws.once('message', (data) => {
let msg;
try {
msg = JSON.parse(data);
} catch {
log('error', '[ws-observe] Failed to parse handshake message');
ws.close(4001, 'unauthorized');
return;
}
if (msg.type !== 'observe' || !msg.secret) {
log('error', '[ws-observe] Invalid handshake: missing type or secret');
ws.close(4001, 'unauthorized');
return;
}
if (msg.secret !== config.OBSERVE_SECRET) {
log('error', `[ws-observe] auth failed from ${req.socket.remoteAddress}`);
ws.close(4001, 'unauthorized');
return;
}
authenticated = true;
authenticatedObservers.add(ws);
log('info', '[ws-observe] observer authenticated');
// Send initial snapshot
const snapshot = {
type: 'snapshot',
ts: new Date().toISOString(),
backends: registry.list(),
activeSessions: []
};
ws.send(JSON.stringify(snapshot));
// Subscribe to all eventBus events and forward to this observer
const eventTypes = ['backend:connected', 'backend:disconnected', 'log', 'session:created', 'session:ended'];
const listeners = {};
eventTypes.forEach((eventName) => {
const listener = (payload) => {
const frame = { type: eventName, ...payload };
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(frame));
} else {
log('warn', `[ws-observe] ws not open for ${eventName}, state: ${ws.readyState}`);
}
};
listeners[eventName] = listener;
eventBus.on(eventName, listener);
});
log('info', `[ws-observe] registered ${eventTypes.length} event listeners`);
observerListeners.set(ws, { listeners, eventTypes });
// Ignore any subsequent messages
ws.on('message', () => {
// Silently ignore - observers are read-only after handshake
});
});
ws.on('close', () => {
if (authenticated) {
authenticatedObservers.delete(ws);
const observerData = observerListeners.get(ws);
if (observerData) {
const { listeners, eventTypes } = observerData;
eventTypes.forEach((eventName) => {
eventBus.removeListener(eventName, listeners[eventName]);
});
observerListeners.delete(ws);
}
log('info', '[ws-observe] observer disconnected');
}
});
ws.on('error', (err) => {
log('error', `[ws-observe] error: ${err.message}`);
});
});
return wss;
}
module.exports = setupObserveServer;

View file

@ -13,8 +13,7 @@ function setupWsServer(httpServer) {
httpServer.on('upgrade', (req, socket, head) => { httpServer.on('upgrade', (req, socket, head) => {
if (req.url !== '/ws/register') { if (req.url !== '/ws/register') {
socket.destroy(); return; // Let other handlers process it
return;
} }
wss.handleUpgrade(req, socket, head, (ws) => { wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req); wss.emit('connection', ws, req);

View file

@ -1,9 +1,16 @@
'use strict'; 'use strict';
require('dotenv').config({ path: __dirname + '/../.env' });
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const http = require('http'); const http = require('http');
const path = require('path'); const path = require('path');
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_ID !== 'your-google-client-id-here') {
console.log('[test] OAuth enabled — skipping E2E (requires browser flow).');
process.exit(0);
}
const HUB_PORT = 3000; const HUB_PORT = 3000;
const TOTAL_TIMEOUT_MS = 15000; const TOTAL_TIMEOUT_MS = 15000;
const POLL_INTERVAL_MS = 200; const POLL_INTERVAL_MS = 200;