Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"name": "@supaku/agentfactory-mcp-server",
"version": "0.7.52",
"type": "module",
"description": "MCP server exposing AgentFactory fleet capabilities to MCP-aware clients",
"author": "Supaku (https://supaku.com)",
"license": "MIT",
"engines": { "node": ">=22.0.0" },
"repository": {
"type": "git",
"url": "https://github.com/supaku/agentfactory",
"directory": "packages/mcp-server"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/src/index.d.ts",
"import": "./dist/src/index.js",
"default": "./dist/src/index.js"
}
},
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts"
},
"main": "./dist/src/index.js",
"module": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./dist/src/index.js",
"default": "./dist/src/index.js"
}
},
"bin": {
"af-mcp-server": "./dist/src/cli.js"
},
"files": ["dist", "README.md", "LICENSE"],
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"@supaku/agentfactory-server": "workspace:*",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.5.4",
"typescript": "^5.7.3",
"vitest": "^3.2.3"
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"clean": "rm -rf dist",
"prepublishOnly": "pnpm clean && pnpm build"
}
}
32 changes: 32 additions & 0 deletions packages/mcp-server/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { extractBearerToken, verifyApiKey, isWorkerAuthConfigured } from '@supaku/agentfactory-server'

const MCP_API_KEY_ENV = 'MCP_API_KEY'

export function isMcpAuthConfigured(): boolean {
return isWorkerAuthConfigured(MCP_API_KEY_ENV) || isWorkerAuthConfigured('WORKER_API_KEY')
}

export function verifyMcpAuth(authHeader: string | null | undefined): { authorized: boolean; error?: string } {
if (!isMcpAuthConfigured()) {
// No auth configured — allow all requests (dev mode)
return { authorized: true }
}

const token = extractBearerToken(authHeader)
if (!token) {
return { authorized: false, error: 'Missing or invalid Authorization header. Expected: Bearer <api-key>' }
}

// Try MCP-specific key first, then fall back to worker key
const mcpKey = process.env[MCP_API_KEY_ENV]
const workerKey = process.env.WORKER_API_KEY

if (mcpKey && verifyApiKey(token, mcpKey)) {
return { authorized: true }
}
if (workerKey && verifyApiKey(token, workerKey)) {
return { authorized: true }
}

return { authorized: false, error: 'Invalid API key' }
}
44 changes: 44 additions & 0 deletions packages/mcp-server/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env node

import { createFleetMcpServer } from './server.js'
import { startStdioTransport, startHttpTransport } from './transport.js'
import { stopResourceNotificationPoller } from './resources.js'

const args = process.argv.slice(2)
const transportType = args.includes('--stdio') ? 'stdio' : 'http'

const server = createFleetMcpServer()

if (transportType === 'stdio') {
console.error('[mcp-server] Starting in STDIO mode')
await startStdioTransport(server)

// Graceful shutdown for STDIO mode
const shutdown = () => {
console.error('[mcp-server] Shutting down...')
stopResourceNotificationPoller()
process.exit(0)
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
} else {
const portIdx = args.indexOf('--port')
const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : undefined
const hostIdx = args.indexOf('--host')
const host = hostIdx !== -1 ? args[hostIdx + 1] : undefined

const httpServer = await startHttpTransport(server, { port, host })

// Graceful shutdown for HTTP mode — close server before exiting
const shutdown = () => {
console.log('[mcp-server] Shutting down...')
stopResourceNotificationPoller()
httpServer.close(() => {
process.exit(0)
})
// Force exit after 5s if connections don't drain
setTimeout(() => process.exit(0), 5000).unref()
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
}
5 changes: 5 additions & 0 deletions packages/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { createFleetMcpServer } from './server.js'
export { registerFleetTools } from './tools.js'
export { registerFleetResources, stopResourceNotificationPoller } from './resources.js'
export { startStdioTransport, startHttpTransport, type HttpTransportOptions } from './transport.js'
export { verifyMcpAuth, isMcpAuthConfigured } from './auth.js'
190 changes: 190 additions & 0 deletions packages/mcp-server/src/resources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
import {
getAllSessions,
getSessionState,
getSessionStateByIssue,
listWorkers,
getTotalCapacity,
} from '@supaku/agentfactory-server'

/**
* Registers MCP resources that expose AgentFactory fleet state to MCP-aware clients.
*
* Resources:
* - fleet://agents — Current fleet state (agents, statuses, costs)
* - fleet://issues/{id} — Issue details with agent progress
* - fleet://logs/{id} — Agent activity logs / session info
*
* Also starts a polling loop that emits resource-update notifications
* when fleet state changes, enabling MCP clients to subscribe to updates.
*/
export function registerFleetResources(server: McpServer): void {
// 1. fleet://agents — Current fleet state
server.resource(
'fleet-agents',
'fleet://agents',
{ description: 'Current fleet state including all agent sessions, workers, and capacity' },
async (uri) => {
const [sessions, workers, capacity] = await Promise.all([
getAllSessions(),
listWorkers(),
getTotalCapacity(),
])

const data = { sessions, workers, capacity }

return {
contents: [
{
uri: uri.href,
text: JSON.stringify(data, null, 2),
mimeType: 'application/json',
},
],
}
},
)

// 2. fleet://issues/{id} — Issue details with agent progress
server.resource(
'fleet-issue',
new ResourceTemplate('fleet://issues/{id}', { list: undefined }),
{ description: 'Issue details with agent session progress' },
async (uri, variables) => {
const id = Array.isArray(variables.id) ? variables.id[0] : variables.id
const session = await getSessionStateByIssue(id)

const data = session
? session
: { error: 'not_found', message: `No agent session found for issue: ${id}` }

return {
contents: [
{
uri: uri.href,
text: JSON.stringify(data, null, 2),
mimeType: 'application/json',
},
],
}
},
)

// 3. fleet://logs/{id} — Agent activity logs
server.resource(
'fleet-logs',
new ResourceTemplate('fleet://logs/{id}', { list: undefined }),
{ description: 'Agent activity logs and session information' },
async (uri, variables) => {
const id = Array.isArray(variables.id) ? variables.id[0] : variables.id

// Try to find the session by linearSessionId first, then fall back to issue ID
let session = await getSessionState(id)
if (!session) {
session = await getSessionStateByIssue(id)
}

if (!session) {
const data = { error: 'not_found', message: `No agent session found for id: ${id}` }
return {
contents: [
{
uri: uri.href,
text: JSON.stringify(data, null, 2),
mimeType: 'application/json',
},
],
}
}

const data: Record<string, unknown> = {
...session,
_logHint: session.worktreePath
? `Activity logs may be available on disk at: ${session.worktreePath}/.agent/state.json`
: 'No worktree path available for this session',
}

return {
contents: [
{
uri: uri.href,
text: JSON.stringify(data, null, 2),
mimeType: 'application/json',
},
],
}
},
)

// ─── Resource update notifications ────────────────────────────────────
// Poll fleet state and emit MCP resource-update notifications when changes
// are detected, so subscribed clients automatically refresh.
startResourceNotificationPoller(server)
}

// ─── Polling-based resource notifications ─────────────────────────────────

const POLL_INTERVAL_MS = 5_000

/** Lightweight snapshot of fleet state for change detection */
interface FleetSnapshot {
sessionCount: number
/** Sorted comma-separated status summary, e.g. "running:3,pending:2" */
statusSummary: string
}

let pollTimer: ReturnType<typeof setInterval> | null = null

function buildSnapshot(sessions: { status: string }[]): FleetSnapshot {
const counts = new Map<string, number>()
for (const s of sessions) {
counts.set(s.status, (counts.get(s.status) ?? 0) + 1)
}
const statusSummary = [...counts.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}:${v}`)
.join(',')
return { sessionCount: sessions.length, statusSummary }
}

function snapshotsEqual(a: FleetSnapshot | null, b: FleetSnapshot): boolean {
if (!a) return false
return a.sessionCount === b.sessionCount && a.statusSummary === b.statusSummary
}

function startResourceNotificationPoller(server: McpServer): void {
let lastSnapshot: FleetSnapshot | null = null

pollTimer = setInterval(async () => {
try {
const sessions = await getAllSessions()
const snapshot = buildSnapshot(sessions)

if (!snapshotsEqual(lastSnapshot, snapshot)) {
lastSnapshot = snapshot
// Notify subscribed clients that fleet://agents has changed
try {
await server.server.sendResourceUpdated({ uri: 'fleet://agents' })
} catch {
// Client may not be subscribed — safe to ignore
}
}
} catch {
// Redis may be unavailable — skip this tick
}
}, POLL_INTERVAL_MS)

// Ensure the timer doesn't prevent process exit
if (pollTimer && typeof pollTimer === 'object' && 'unref' in pollTimer) {
pollTimer.unref()
}
}

/** Stop the resource notification poller (for graceful shutdown) */
export function stopResourceNotificationPoller(): void {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
15 changes: 15 additions & 0 deletions packages/mcp-server/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { registerFleetResources } from './resources.js'
import { registerFleetTools } from './tools.js'

export function createFleetMcpServer(): McpServer {
const server = new McpServer({
name: 'agentfactory-fleet',
version: '0.7.52',
})

registerFleetTools(server)
registerFleetResources(server)

return server
}
Loading
Loading