mcp-agent-broker 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +31 -0
  2. package/dist/app.js +30 -0
  3. package/dist/infrastructure/db/index.js +44 -0
  4. package/dist/infrastructure/db/schema.js +136 -0
  5. package/dist/infrastructure/redis/index.js +11 -0
  6. package/dist/modules/event/event.service.js +36 -0
  7. package/dist/modules/identity/agent.service.js +56 -0
  8. package/dist/modules/lease/lease.service.js +76 -0
  9. package/dist/modules/task/task.service.js +40 -0
  10. package/dist/transport/mcp/index.js +157 -0
  11. package/dist/workers/outbox-relay.js +57 -0
  12. package/docker-compose.yml +41 -0
  13. package/drizzle.config.ts +10 -0
  14. package/package.json +35 -0
  15. package/src/app.ts +31 -0
  16. package/src/infrastructure/db/index.ts +10 -0
  17. package/src/infrastructure/db/migrations/0000_flawless_vanisher.sql +103 -0
  18. package/src/infrastructure/db/migrations/0001_fearless_sersi.sql +36 -0
  19. package/src/infrastructure/db/migrations/meta/0000_snapshot.json +714 -0
  20. package/src/infrastructure/db/migrations/meta/0001_snapshot.json +947 -0
  21. package/src/infrastructure/db/migrations/meta/_journal.json +20 -0
  22. package/src/infrastructure/db/schema.ts +148 -0
  23. package/src/infrastructure/redis/index.ts +7 -0
  24. package/src/modules/event/event.service.ts +50 -0
  25. package/src/modules/identity/agent.service.ts +58 -0
  26. package/src/modules/lease/lease.service.ts +89 -0
  27. package/src/modules/task/task.service.ts +46 -0
  28. package/src/transport/mcp/index.ts +167 -0
  29. package/src/workers/outbox-relay.ts +61 -0
  30. package/test_client.ts +34 -0
  31. package/test_task.ts +68 -0
  32. package/tsconfig.json +14 -0
@@ -0,0 +1,20 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1784615292798,
9
+ "tag": "0000_flawless_vanisher",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1784617991306,
16
+ "tag": "0001_fearless_sersi",
17
+ "breakpoints": true
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,148 @@
1
+ import {
2
+ pgTable, uuid, text, jsonb, smallint, bigint, timestamp, integer, pgEnum,
3
+ unique, bigserial, primaryKey, index
4
+ } from "drizzle-orm/pg-core";
5
+
6
+ export const taskStateEnum = pgEnum('task_state', [
7
+ 'draft', 'ready', 'leased', 'running', 'blocked',
8
+ 'awaiting_review', 'changes_requested', 'completed',
9
+ 'failed', 'canceled', 'superseded'
10
+ ]);
11
+
12
+ export const workspaceStatusEnum = pgEnum('workspace_status', ['active', 'archived']);
13
+ export const agentStatusEnum = pgEnum('agent_status', ['connected', 'degraded', 'disconnected', 'revoked']);
14
+
15
+ export const leaseStatusEnum = pgEnum('lease_status', ['active', 'released', 'expired', 'revoked']);
16
+ export const leaseTypeEnum = pgEnum('lease_type', ['execution', 'review']);
17
+
18
+ export const workspaces = pgTable('workspaces', {
19
+ id: uuid('id').primaryKey(),
20
+ organizationId: uuid('organization_id'),
21
+ name: text('name').notNull(),
22
+ status: workspaceStatusEnum('status').notNull().default('active'),
23
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
24
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
25
+ });
26
+
27
+ export const agentDefinitions = pgTable('agent_definitions', {
28
+ id: uuid('id').primaryKey(),
29
+ name: text('name').notNull(),
30
+ vendor: text('vendor'),
31
+ version: text('version').notNull(),
32
+ integrationType: text('integration_type'),
33
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
34
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
35
+ });
36
+
37
+ export const agentInstances = pgTable('agent_instances', {
38
+ id: uuid('id').primaryKey(),
39
+ agentDefinitionId: uuid('agent_definition_id').notNull().references(() => agentDefinitions.id),
40
+ workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id),
41
+ principalId: uuid('principal_id').notNull(),
42
+ status: agentStatusEnum('status').notNull().default('connected'),
43
+ capabilitiesJson: jsonb('capabilities_json').notNull().default('[]'),
44
+ lastHeartbeatAt: timestamp('last_heartbeat_at', { withTimezone: true }),
45
+ revokedAt: timestamp('revoked_at', { withTimezone: true }),
46
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
47
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
48
+ });
49
+
50
+ export const tasks = pgTable('tasks', {
51
+ id: uuid('id').primaryKey(),
52
+ workspaceId: uuid('workspace_id').notNull(),
53
+ repositoryId: uuid('repository_id').notNull(),
54
+ sessionId: uuid('session_id'),
55
+ objective: text('objective').notNull(),
56
+ description: text('description').notNull().default(''),
57
+ acceptanceCriteria: jsonb('acceptance_criteria').notNull().default('[]'),
58
+ constraints: jsonb('constraints').notNull().default('[]'),
59
+ priority: smallint('priority').notNull().default(3),
60
+ state: taskStateEnum('state').notNull().default('draft'),
61
+ baseCommitSha: text('base_commit_sha'),
62
+ activeExecutionLeaseId: uuid('active_execution_lease_id'),
63
+ activeReviewLeaseId: uuid('active_review_lease_id'),
64
+ nextFencingToken: bigint('next_fencing_token', { mode: 'number' }).notNull().default(0),
65
+ version: bigint('version', { mode: 'number' }).notNull().default(1),
66
+ createdBy: uuid('created_by').notNull(),
67
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
68
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
69
+ completedAt: timestamp('completed_at', { withTimezone: true }),
70
+ });
71
+
72
+ export const taskAttempts = pgTable('task_attempts', {
73
+ id: uuid('id').primaryKey(),
74
+ workspaceId: uuid('workspace_id').notNull(),
75
+ taskId: uuid('task_id').notNull().references(() => tasks.id),
76
+ attemptNumber: integer('attempt_number').notNull(),
77
+ agentInstanceId: uuid('agent_instance_id').notNull(),
78
+ status: text('status').notNull(),
79
+ startedAt: timestamp('started_at', { withTimezone: true }),
80
+ endedAt: timestamp('ended_at', { withTimezone: true }),
81
+ outcome: text('outcome'),
82
+ }, (table) => ({
83
+ unq: unique().on(table.taskId, table.attemptNumber)
84
+ }));
85
+
86
+ export const leases = pgTable('leases', {
87
+ id: uuid('id').primaryKey(),
88
+ workspaceId: uuid('workspace_id').notNull(),
89
+ taskId: uuid('task_id').notNull().references(() => tasks.id),
90
+ taskAttemptId: uuid('task_attempt_id').references(() => taskAttempts.id),
91
+ agentInstanceId: uuid('agent_instance_id').notNull(),
92
+ leaseType: leaseTypeEnum('lease_type').notNull(),
93
+ status: leaseStatusEnum('status').notNull(),
94
+ fencingToken: bigint('fencing_token', { mode: 'number' }).notNull(),
95
+ acquiredAt: timestamp('acquired_at', { withTimezone: true }).notNull(),
96
+ renewedAt: timestamp('renewed_at', { withTimezone: true }).notNull(),
97
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
98
+ releasedAt: timestamp('released_at', { withTimezone: true }),
99
+ }, (table) => ({
100
+ unq: unique().on(table.taskId, table.leaseType, table.fencingToken),
101
+ // Note: Drizzle partial unique index would be handled in migrations or sql
102
+ }));
103
+
104
+ export const events = pgTable('events', {
105
+ eventSeq: bigserial('event_seq', { mode: 'number' }).primaryKey(),
106
+ eventId: uuid('event_id').notNull().unique(),
107
+ eventType: text('event_type').notNull(),
108
+ eventVersion: integer('event_version').notNull(),
109
+ workspaceId: uuid('workspace_id').notNull(),
110
+ aggregateType: text('aggregate_type').notNull(),
111
+ aggregateId: uuid('aggregate_id').notNull(),
112
+ aggregateVersion: bigint('aggregate_version', { mode: 'number' }).notNull(),
113
+ actorType: text('actor_type').notNull(),
114
+ actorId: uuid('actor_id'),
115
+ correlationId: uuid('correlation_id').notNull(),
116
+ causationId: uuid('causation_id'),
117
+ payload: jsonb('payload').notNull(),
118
+ occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().defaultNow(),
119
+ }, (table) => ({
120
+ unq: unique().on(table.aggregateType, table.aggregateId, table.aggregateVersion),
121
+ idx: index('events_workspace_cursor').on(table.workspaceId, table.eventSeq)
122
+ }));
123
+
124
+ export const outbox = pgTable('outbox', {
125
+ eventId: uuid('event_id').primaryKey().references(() => events.eventId, { onDelete: 'cascade' }),
126
+ availableAt: timestamp('available_at', { withTimezone: true }).notNull().defaultNow(),
127
+ attempts: integer('attempts').notNull().default(0),
128
+ lockedBy: text('locked_by'),
129
+ lockedUntil: timestamp('locked_until', { withTimezone: true }),
130
+ publishedAt: timestamp('published_at', { withTimezone: true }),
131
+ lastError: text('last_error'),
132
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
133
+ });
134
+
135
+ export const idempotencyRecords = pgTable('idempotency_records', {
136
+ principalId: uuid('principal_id').notNull(),
137
+ operation: text('operation').notNull(),
138
+ idempotencyKey: text('idempotency_key').notNull(),
139
+ requestHash: text('request_hash').notNull(),
140
+ responseJson: jsonb('response_json'),
141
+ responseStatus: text('response_status').notNull(),
142
+ resourceId: uuid('resource_id'),
143
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
144
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
145
+ }, (table) => ({
146
+ pk: primaryKey({ columns: [table.principalId, table.operation, table.idempotencyKey] }),
147
+ idx: index('idempotency_expiry').on(table.expiresAt)
148
+ }));
@@ -0,0 +1,7 @@
1
+ import Redis from 'ioredis';
2
+
3
+ const redisUrl = process.env.REDIS_URL || 'redis://127.0.0.1:6379';
4
+
5
+ export const redis = new Redis(redisUrl, {
6
+ maxRetriesPerRequest: null
7
+ });
@@ -0,0 +1,50 @@
1
+ import { db } from '../../infrastructure/db/index.js';
2
+ import { events, outbox } from '../../infrastructure/db/schema.js';
3
+ import { randomUUID } from 'crypto';
4
+
5
+ export class EventService {
6
+ /**
7
+ * Appends an event to the events table and inserts a corresponding outbox record
8
+ * as part of a transaction.
9
+ */
10
+ async appendEvent(
11
+ tx: any,
12
+ params: {
13
+ workspaceId: string;
14
+ eventType: string;
15
+ aggregateType: string;
16
+ aggregateId: string;
17
+ aggregateVersion: number;
18
+ actorType: string;
19
+ actorId?: string;
20
+ payload: any;
21
+ }
22
+ ) {
23
+ const eventId = randomUUID();
24
+
25
+ // Insert into Event Store
26
+ await tx.insert(events).values({
27
+ eventId,
28
+ eventType: params.eventType,
29
+ eventVersion: 1,
30
+ workspaceId: params.workspaceId,
31
+ aggregateType: params.aggregateType,
32
+ aggregateId: params.aggregateId,
33
+ aggregateVersion: params.aggregateVersion,
34
+ actorType: params.actorType,
35
+ actorId: params.actorId || null,
36
+ correlationId: eventId, // Base correlation id
37
+ payload: params.payload,
38
+ });
39
+
40
+ // Insert into Outbox
41
+ await tx.insert(outbox).values({
42
+ eventId,
43
+ attempts: 0
44
+ });
45
+
46
+ return eventId;
47
+ }
48
+ }
49
+
50
+ export const eventService = new EventService();
@@ -0,0 +1,58 @@
1
+ import { db } from '../../infrastructure/db/index.js';
2
+ import { agentDefinitions, agentInstances, workspaces } from '../../infrastructure/db/schema.js';
3
+ import { randomUUID } from 'crypto';
4
+ import { eq } from 'drizzle-orm';
5
+
6
+ export class AgentService {
7
+ async ensureDefaultWorkspace() {
8
+ let [workspace] = await db.select().from(workspaces).limit(1);
9
+ if (!workspace) {
10
+ [workspace] = await db.insert(workspaces).values({
11
+ id: randomUUID(),
12
+ name: "Default Workspace",
13
+ }).returning();
14
+ }
15
+ return workspace;
16
+ }
17
+
18
+ async registerAgent(params: { name: string; vendor?: string; version: string; capabilities: string[] }) {
19
+ const workspace = await this.ensureDefaultWorkspace();
20
+
21
+ // 1. Create or find agent definition
22
+ let [definition] = await db.select().from(agentDefinitions)
23
+ .where(eq(agentDefinitions.name, params.name))
24
+ .limit(1);
25
+
26
+ if (!definition) {
27
+ [definition] = await db.insert(agentDefinitions).values({
28
+ id: randomUUID(),
29
+ name: params.name,
30
+ vendor: params.vendor || "unknown",
31
+ version: params.version,
32
+ integrationType: "mcp-client"
33
+ }).returning();
34
+ } else {
35
+ // Update version if changed
36
+ if (definition.version !== params.version) {
37
+ [definition] = await db.update(agentDefinitions)
38
+ .set({ version: params.version, updatedAt: new Date() })
39
+ .where(eq(agentDefinitions.id, definition.id))
40
+ .returning();
41
+ }
42
+ }
43
+
44
+ // 2. Create agent instance
45
+ const [instance] = await db.insert(agentInstances).values({
46
+ id: randomUUID(),
47
+ agentDefinitionId: definition.id,
48
+ workspaceId: workspace.id,
49
+ principalId: randomUUID(), // Mocking principal ID for Phase 0
50
+ capabilitiesJson: params.capabilities,
51
+ status: "connected",
52
+ }).returning();
53
+
54
+ return instance;
55
+ }
56
+ }
57
+
58
+ export const agentService = new AgentService();
@@ -0,0 +1,89 @@
1
+ import { db } from '../../infrastructure/db/index.js';
2
+ import { tasks, leases } from '../../infrastructure/db/schema.js';
3
+ import { randomUUID } from 'crypto';
4
+ import { eq } from 'drizzle-orm';
5
+ import { eventService } from '../event/event.service.js';
6
+
7
+ export class LeaseService {
8
+ async acquireLease(params: {
9
+ taskId: string;
10
+ workspaceId: string;
11
+ agentInstanceId: string;
12
+ leaseType: 'execution' | 'review';
13
+ ttlMs?: number;
14
+ }) {
15
+ const ttlMs = params.ttlMs || 30 * 60 * 1000; // default 30 mins
16
+
17
+ // We use a transaction to guarantee atomic lease acquisition (fencing token increment)
18
+ return await db.transaction(async (tx) => {
19
+ // 1. Lock the task row for update
20
+ const [task] = await tx.select().from(tasks).where(eq(tasks.id, params.taskId)).for('update');
21
+
22
+ if (!task) throw new Error("Task not found");
23
+
24
+ // Check if lease is already active
25
+ const activeLeaseId = params.leaseType === 'execution' ? task.activeExecutionLeaseId : task.activeReviewLeaseId;
26
+ if (activeLeaseId) {
27
+ const [activeLease] = await tx.select().from(leases).where(eq(leases.id, activeLeaseId));
28
+ if (activeLease && activeLease.status === 'active' && new Date() < activeLease.expiresAt) {
29
+ throw new Error("Task is already actively leased");
30
+ }
31
+ }
32
+
33
+ // 2. Increment fencing token
34
+ const nextFencingToken = task.nextFencingToken + 1;
35
+
36
+ // 3. Create the lease
37
+ const now = new Date();
38
+ const expiresAt = new Date(now.getTime() + ttlMs);
39
+
40
+ const [lease] = await tx.insert(leases).values({
41
+ id: randomUUID(),
42
+ workspaceId: params.workspaceId,
43
+ taskId: params.taskId,
44
+ agentInstanceId: params.agentInstanceId,
45
+ leaseType: params.leaseType,
46
+ status: 'active',
47
+ fencingToken: nextFencingToken,
48
+ acquiredAt: now,
49
+ renewedAt: now,
50
+ expiresAt: expiresAt,
51
+ }).returning();
52
+
53
+ // 4. Update task with active lease ID and incremented token, and set state to 'leased'
54
+ const updateData: any = {
55
+ nextFencingToken: nextFencingToken,
56
+ version: task.version + 1,
57
+ state: 'leased'
58
+ };
59
+
60
+ if (params.leaseType === 'execution') {
61
+ updateData.activeExecutionLeaseId = lease.id;
62
+ } else {
63
+ updateData.activeReviewLeaseId = lease.id;
64
+ }
65
+
66
+ await tx.update(tasks).set(updateData).where(eq(tasks.id, params.taskId));
67
+
68
+ await eventService.appendEvent(tx, {
69
+ workspaceId: params.workspaceId,
70
+ eventType: 'LeaseAcquired',
71
+ aggregateType: 'Task',
72
+ aggregateId: task.id,
73
+ aggregateVersion: updateData.version,
74
+ actorType: 'AgentInstance',
75
+ actorId: params.agentInstanceId,
76
+ payload: {
77
+ leaseId: lease.id,
78
+ leaseType: params.leaseType,
79
+ fencingToken: nextFencingToken,
80
+ expiresAt: expiresAt.toISOString()
81
+ }
82
+ });
83
+
84
+ return lease;
85
+ });
86
+ }
87
+ }
88
+
89
+ export const leaseService = new LeaseService();
@@ -0,0 +1,46 @@
1
+ import { db } from '../../infrastructure/db/index.js';
2
+ import { tasks } from '../../infrastructure/db/schema.js';
3
+ import { randomUUID } from 'crypto';
4
+ import { eventService } from '../event/event.service.js';
5
+
6
+ export class TaskService {
7
+ async createTask(params: {
8
+ workspaceId: string;
9
+ repositoryId: string;
10
+ objective: string;
11
+ description?: string;
12
+ createdBy: string;
13
+ }) {
14
+ return await db.transaction(async (tx) => {
15
+ const taskId = randomUUID();
16
+ const [task] = await tx.insert(tasks).values({
17
+ id: taskId,
18
+ workspaceId: params.workspaceId,
19
+ repositoryId: params.repositoryId,
20
+ objective: params.objective,
21
+ description: params.description || '',
22
+ createdBy: params.createdBy,
23
+ state: 'ready',
24
+ }).returning();
25
+
26
+ await eventService.appendEvent(tx, {
27
+ workspaceId: params.workspaceId,
28
+ eventType: 'TaskCreated',
29
+ aggregateType: 'Task',
30
+ aggregateId: task.id,
31
+ aggregateVersion: task.version,
32
+ actorType: 'User',
33
+ actorId: params.createdBy,
34
+ payload: {
35
+ objective: task.objective,
36
+ repositoryId: task.repositoryId,
37
+ state: task.state
38
+ }
39
+ });
40
+
41
+ return task;
42
+ });
43
+ }
44
+ }
45
+
46
+ export const taskService = new TaskService();
@@ -0,0 +1,167 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
3
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
4
+ import { FastifyInstance } from "fastify";
5
+ import { agentService } from "../../modules/identity/agent.service.js";
6
+ import { taskService } from "../../modules/task/task.service.js";
7
+ import { leaseService } from "../../modules/lease/lease.service.js";
8
+
9
+ export async function setupMcpTransport(fastify: FastifyInstance) {
10
+ // Store transports for the POST /mcp/message route
11
+ const transports = new Map<string, SSEServerTransport>();
12
+
13
+ fastify.get("/mcp/sse", async (request, reply) => {
14
+ // Hijack the Fastify response so we can stream SSE headers manually
15
+ reply.hijack();
16
+
17
+ const transport = new SSEServerTransport("/mcp/message", reply.raw);
18
+ const sessionId = transport.sessionId;
19
+ console.log(`New SSE connection: ${sessionId}`);
20
+ transports.set(sessionId, transport);
21
+
22
+ // Create a new MCP Server instance per connection
23
+ const mcpServer = new Server(
24
+ { name: "mcp-agent-broker", version: "1.0.0" },
25
+ { capabilities: { tools: {} } }
26
+ );
27
+
28
+ // Setup tools
29
+ mcpServer.setRequestHandler(ListToolsRequestSchema, async () => {
30
+ return {
31
+ tools: [
32
+ {
33
+ name: "register_agent",
34
+ description: "Register a new coding agent instance",
35
+ inputSchema: {
36
+ type: "object",
37
+ properties: {
38
+ name: { type: "string" },
39
+ version: { type: "string" },
40
+ capabilities: {
41
+ type: "array",
42
+ items: { type: "string" }
43
+ }
44
+ },
45
+ required: ["name", "version", "capabilities"]
46
+ }
47
+ },
48
+ {
49
+ name: "create_task",
50
+ description: "Create a new coding task",
51
+ inputSchema: {
52
+ type: "object",
53
+ properties: {
54
+ workspaceId: { type: "string" },
55
+ repositoryId: { type: "string" },
56
+ objective: { type: "string" },
57
+ description: { type: "string" },
58
+ createdBy: { type: "string" }
59
+ },
60
+ required: ["workspaceId", "repositoryId", "objective", "createdBy"]
61
+ }
62
+ },
63
+ {
64
+ name: "acquire_task_lease",
65
+ description: "Acquire an atomic lease for a task (execution or review)",
66
+ inputSchema: {
67
+ type: "object",
68
+ properties: {
69
+ taskId: { type: "string" },
70
+ workspaceId: { type: "string" },
71
+ agentInstanceId: { type: "string" },
72
+ leaseType: { type: "string", enum: ["execution", "review"] }
73
+ },
74
+ required: ["taskId", "workspaceId", "agentInstanceId", "leaseType"]
75
+ }
76
+ }
77
+ ]
78
+ };
79
+ });
80
+
81
+ mcpServer.setRequestHandler(CallToolRequestSchema, async (req) => {
82
+ if (req.params.name === "register_agent") {
83
+ try {
84
+ const args = req.params.arguments as any;
85
+ const instance = await agentService.registerAgent({
86
+ name: args.name,
87
+ version: args.version,
88
+ capabilities: args.capabilities,
89
+ });
90
+
91
+ return {
92
+ content: [
93
+ {
94
+ type: "text",
95
+ text: `Agent registered successfully! Instance ID: ${instance.id}`
96
+ }
97
+ ]
98
+ };
99
+ } catch (e: any) {
100
+ console.error("Error in register_agent:", e);
101
+ throw e;
102
+ }
103
+ } else if (req.params.name === "create_task") {
104
+ try {
105
+ const args = req.params.arguments as any;
106
+ const task = await taskService.createTask({
107
+ workspaceId: args.workspaceId,
108
+ repositoryId: args.repositoryId,
109
+ objective: args.objective,
110
+ description: args.description,
111
+ createdBy: args.createdBy
112
+ });
113
+
114
+ return {
115
+ content: [
116
+ { type: "text", text: `Task created successfully! Task ID: ${task.id}` }
117
+ ]
118
+ };
119
+ } catch (e: any) {
120
+ console.error("Error in create_task:", e);
121
+ throw e;
122
+ }
123
+ } else if (req.params.name === "acquire_task_lease") {
124
+ try {
125
+ const args = req.params.arguments as any;
126
+ const lease = await leaseService.acquireLease({
127
+ taskId: args.taskId,
128
+ workspaceId: args.workspaceId,
129
+ agentInstanceId: args.agentInstanceId,
130
+ leaseType: args.leaseType as "execution" | "review"
131
+ });
132
+
133
+ return {
134
+ content: [
135
+ { type: "text", text: `Lease acquired! Lease ID: ${lease.id}, Fencing Token: ${lease.fencingToken}` }
136
+ ]
137
+ };
138
+ } catch (e: any) {
139
+ console.error("Error in acquire_task_lease:", e);
140
+ throw e;
141
+ }
142
+ }
143
+ throw new Error(`Tool not found: ${req.params.name}`);
144
+ });
145
+
146
+ await mcpServer.connect(transport);
147
+
148
+ request.raw.on("close", () => {
149
+ console.log(`SSE connection closed: ${sessionId}`);
150
+ transports.delete(sessionId);
151
+ });
152
+ });
153
+
154
+ fastify.post<{ Querystring: { sessionId: string } }>("/mcp/message", async (request, reply) => {
155
+ const sessionId = request.query.sessionId;
156
+ const transport = transports.get(sessionId);
157
+
158
+ if (!transport) {
159
+ reply.code(404).send({ error: "Session not found" });
160
+ return;
161
+ }
162
+
163
+ // Fastify parses the body automatically
164
+ await transport.handleMessage(request.body as any);
165
+ reply.code(202).send("Accepted");
166
+ });
167
+ }
@@ -0,0 +1,61 @@
1
+ import { db } from '../infrastructure/db/index.js';
2
+ import { events, outbox } from '../infrastructure/db/schema.js';
3
+ import { redis } from '../infrastructure/redis/index.js';
4
+ import { inArray, asc } from 'drizzle-orm';
5
+
6
+ export class OutboxRelayWorker {
7
+ private timer: NodeJS.Timeout | null = null;
8
+ private isRunning = false;
9
+
10
+ start() {
11
+ console.log("Starting Outbox Relay Worker...");
12
+ this.timer = setInterval(() => this.processOutbox(), 2000);
13
+ }
14
+
15
+ stop() {
16
+ if (this.timer) clearInterval(this.timer);
17
+ }
18
+
19
+ async processOutbox() {
20
+ if (this.isRunning) return;
21
+ this.isRunning = true;
22
+
23
+ try {
24
+ // 1. Fetch pending outbox events
25
+ const pendingRecords = await db.select()
26
+ .from(outbox)
27
+ .orderBy(asc(outbox.createdAt))
28
+ .limit(50);
29
+
30
+ if (pendingRecords.length === 0) {
31
+ this.isRunning = false;
32
+ return;
33
+ }
34
+
35
+ const eventIds = pendingRecords.map(r => r.eventId);
36
+
37
+ // 2. Fetch the actual event data
38
+ const eventData = await db.select().from(events).where(inArray(events.eventId, eventIds));
39
+
40
+ // 3. Publish to Redis Pub/Sub
41
+ for (const record of pendingRecords) {
42
+ const evt = eventData.find(e => e.eventId === record.eventId);
43
+ if (evt) {
44
+ const channel = `workspace:${evt.workspaceId}:events`;
45
+ await redis.publish(channel, JSON.stringify(evt));
46
+ console.log(`[Event Relay] Published ${evt.eventType} to ${channel}`);
47
+ }
48
+ }
49
+
50
+ // 4. Delete processed outbox records
51
+ await db.delete(outbox).where(inArray(outbox.eventId, eventIds));
52
+
53
+ } catch (e) {
54
+ console.error("Outbox Relay Error:", e);
55
+ } finally {
56
+ this.isRunning = false;
57
+ }
58
+ }
59
+ }
60
+
61
+ export const outboxRelay = new OutboxRelayWorker();
package/test_client.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
3
+ import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
4
+
5
+ async function run() {
6
+ const transport = new SSEClientTransport(new URL("http://127.0.0.1:3000/mcp/sse"));
7
+ const client = new Client(
8
+ { name: "test-client", version: "1.0.0" },
9
+ { capabilities: {} }
10
+ );
11
+
12
+ await client.connect(transport);
13
+ console.log("Connected to MCP Server");
14
+
15
+ const result = await client.request(
16
+ {
17
+ method: "tools/call",
18
+ params: {
19
+ name: "register_agent",
20
+ arguments: {
21
+ name: "Claude Code",
22
+ version: "0.2.0",
23
+ capabilities: ["read", "write", "execute"]
24
+ }
25
+ }
26
+ },
27
+ CallToolResultSchema
28
+ );
29
+
30
+ console.log("Result:", JSON.stringify(result, null, 2));
31
+ process.exit(0);
32
+ }
33
+
34
+ run().catch(console.error);