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.
- package/README.md +31 -0
- package/dist/app.js +30 -0
- package/dist/infrastructure/db/index.js +44 -0
- package/dist/infrastructure/db/schema.js +136 -0
- package/dist/infrastructure/redis/index.js +11 -0
- package/dist/modules/event/event.service.js +36 -0
- package/dist/modules/identity/agent.service.js +56 -0
- package/dist/modules/lease/lease.service.js +76 -0
- package/dist/modules/task/task.service.js +40 -0
- package/dist/transport/mcp/index.js +157 -0
- package/dist/workers/outbox-relay.js +57 -0
- package/docker-compose.yml +41 -0
- package/drizzle.config.ts +10 -0
- package/package.json +35 -0
- package/src/app.ts +31 -0
- package/src/infrastructure/db/index.ts +10 -0
- package/src/infrastructure/db/migrations/0000_flawless_vanisher.sql +103 -0
- package/src/infrastructure/db/migrations/0001_fearless_sersi.sql +36 -0
- package/src/infrastructure/db/migrations/meta/0000_snapshot.json +714 -0
- package/src/infrastructure/db/migrations/meta/0001_snapshot.json +947 -0
- package/src/infrastructure/db/migrations/meta/_journal.json +20 -0
- package/src/infrastructure/db/schema.ts +148 -0
- package/src/infrastructure/redis/index.ts +7 -0
- package/src/modules/event/event.service.ts +50 -0
- package/src/modules/identity/agent.service.ts +58 -0
- package/src/modules/lease/lease.service.ts +89 -0
- package/src/modules/task/task.service.ts +46 -0
- package/src/transport/mcp/index.ts +167 -0
- package/src/workers/outbox-relay.ts +61 -0
- package/test_client.ts +34 -0
- package/test_task.ts +68 -0
- 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,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);
|