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
32 changes: 32 additions & 0 deletions packages/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,35 @@ const tools: Tool[] = [
}
},

{
name: 'suggest_task_assignment',
description: 'Suggest the best contributor(s) for open tasks in a graph by combining each contributor\'s expertise (work-type history + completion rate) with their current availability (active workload). Returns ranked, rationale-bearing candidates per task to make AI-as-peer assignment workflows easier.',
inputSchema: {
type: 'object',
properties: {
graph_id: { type: 'string', description: 'Graph/project ID to draw open tasks and contributors from' },
task_ids: {
type: 'array',
items: { type: 'string' },
description: 'Restrict suggestions to these specific task IDs (default: all open tasks in the graph)'
},
open_statuses: {
type: 'array',
items: { type: 'string', enum: ['PROPOSED', 'ACTIVE', 'IN_PROGRESS', 'BLOCKED'] },
description: 'Statuses considered "open" and eligible for assignment'
},
include_assigned: { type: 'boolean', default: false, description: 'Include tasks that already have a contributor' },
expertise_weight: { type: 'number', description: 'Relative weight of expertise match (default 0.6)' },
availability_weight: { type: 'number', description: 'Relative weight of contributor availability (default 0.4)' },
max_candidates_per_task: { type: 'number', default: 3, description: 'Maximum ranked candidates returned per task' },
min_items_threshold: { type: 'number', default: 3, description: 'Minimum items of a work type before a contributor counts as more than a beginner' },
task_limit: { type: 'number', default: 25, description: 'Maximum number of open tasks to rank' }
},
required: ['graph_id'],
additionalProperties: false
}
},

// Graph Management Tools
{
name: 'create_graph',
Expand Down Expand Up @@ -789,6 +818,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
case 'get_contributor_availability':
return await graphService.getContributorAvailability(args as Record<string, unknown>);

case 'suggest_task_assignment':
return await graphService.suggestTaskAssignment(args || {});

// Graph Management Commands - Type assertions needed for MCP dynamic arguments
case 'create_graph':
return await graphService.createGraph((args || {}) as CreateGraphArgs);
Expand Down
183 changes: 183 additions & 0 deletions packages/mcp-server/src/services/assignment-synthesis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// MCP-1 (#28): pure synthesis logic behind the suggest_task_assignment tool.
// Given contributor expertise + availability and a set of open tasks, rank the
// best contributor for each task. Kept free of Neo4j/IO so it can be unit-tested
// deterministically; the GraphService gathers the inputs and calls this.

export type ExpertiseLevel = 'Expert' | 'Proficient' | 'Beginner';

export type CapacityStatus = 'available' | 'busy' | 'at_capacity' | 'overloaded';

export interface ContributorExpertise {
workType: string;
level: ExpertiseLevel;
completionRate?: number;
}

export interface ContributorProfile {
id: string;
name: string;
activeItems: number;
capacityStatus?: CapacityStatus;
expertise: ContributorExpertise[];
}

export interface OpenTask {
id: string;
title: string;
type: string;
priority?: number;
}

export interface AssignmentWeights {
expertiseWeight: number;
availabilityWeight: number;
}

export interface RankTaskAssignmentsOptions {
weights?: Partial<AssignmentWeights>;
maxCandidatesPerTask?: number;
capacityCeiling?: number;
}

export interface CandidateScore {
contributorId: string;
contributorName: string;
score: number;
expertiseScore: number;
availabilityScore: number;
matchedLevel: ExpertiseLevel | null;
rationale: string;
}

export interface AssignmentSuggestion {
taskId: string;
taskTitle: string;
taskType: string;
bestCandidate: CandidateScore | null;
candidates: CandidateScore[];
}

export const DEFAULT_WEIGHTS: AssignmentWeights = {
expertiseWeight: 0.6,
availabilityWeight: 0.4
};

export const DEFAULT_CAPACITY_CEILING = 15;
export const DEFAULT_MAX_CANDIDATES = 3;

const LEVEL_BASE: Record<ExpertiseLevel, number> = {
Expert: 1,
Proficient: 0.6,
Beginner: 0.3
};

function clamp01(value: number): number {
if (Number.isNaN(value)) return 0;
if (value < 0) return 0;
if (value > 1) return 1;
return value;
}

function normalizeWeights(weights?: Partial<AssignmentWeights>): AssignmentWeights {
const expertiseWeight = weights?.expertiseWeight ?? DEFAULT_WEIGHTS.expertiseWeight;
const availabilityWeight = weights?.availabilityWeight ?? DEFAULT_WEIGHTS.availabilityWeight;
const sum = expertiseWeight + availabilityWeight;
if (sum <= 0) {
return { expertiseWeight: 0.5, availabilityWeight: 0.5 };
}
return {
expertiseWeight: expertiseWeight / sum,
availabilityWeight: availabilityWeight / sum
};
}

function expertiseScoreFor(
task: OpenTask,
contributor: ContributorProfile
): { score: number; matched: ContributorExpertise | null } {
const matched = contributor.expertise.find(e => e.workType === task.type) ?? null;
if (!matched) {
return { score: 0, matched: null };
}
const base = LEVEL_BASE[matched.level];
const completion = matched.completionRate;
const completionFactor =
typeof completion === 'number' ? 0.85 + 0.15 * clamp01(completion) : 1;
return { score: clamp01(base * completionFactor), matched };
}

function availabilityScoreFor(
contributor: ContributorProfile,
capacityCeiling: number
): number {
if (contributor.capacityStatus === 'overloaded') {
return 0;
}
const ceiling = capacityCeiling > 0 ? capacityCeiling : DEFAULT_CAPACITY_CEILING;
return clamp01(1 - contributor.activeItems / ceiling);
}

function buildRationale(
contributor: ContributorProfile,
matched: ContributorExpertise | null,
task: OpenTask
): string {
const expertisePart = matched
? `${matched.level} in ${task.type}`
: `no direct ${task.type} expertise`;
const loadPart =
contributor.capacityStatus === 'overloaded'
? 'currently overloaded'
: `${contributor.activeItems} active item(s)`;
return `${contributor.name}: ${expertisePart}, ${loadPart}.`;
}

function compareCandidates(a: CandidateScore, b: CandidateScore): number {
if (b.score !== a.score) return b.score - a.score;
if (b.expertiseScore !== a.expertiseScore) return b.expertiseScore - a.expertiseScore;
if (b.availabilityScore !== a.availabilityScore) {
return b.availabilityScore - a.availabilityScore;
}
return a.contributorId.localeCompare(b.contributorId);
}

export function rankTaskAssignments(
tasks: OpenTask[],
contributors: ContributorProfile[],
options: RankTaskAssignmentsOptions = {}
): AssignmentSuggestion[] {
const weights = normalizeWeights(options.weights);
const capacityCeiling = options.capacityCeiling ?? DEFAULT_CAPACITY_CEILING;
const maxCandidates = Math.max(1, options.maxCandidatesPerTask ?? DEFAULT_MAX_CANDIDATES);

return tasks.map(task => {
const candidates = contributors
.map<CandidateScore>(contributor => {
const { score: expertiseScore, matched } = expertiseScoreFor(task, contributor);
const availabilityScore = availabilityScoreFor(contributor, capacityCeiling);
const score = clamp01(
weights.expertiseWeight * expertiseScore +
weights.availabilityWeight * availabilityScore
);
return {
contributorId: contributor.id,
contributorName: contributor.name,
score: Number(score.toFixed(4)),
expertiseScore: Number(expertiseScore.toFixed(4)),
availabilityScore: Number(availabilityScore.toFixed(4)),
matchedLevel: matched ? matched.level : null,
rationale: buildRationale(contributor, matched, task)
};
})
.sort(compareCandidates)
.slice(0, maxCandidates);

return {
taskId: task.id,
taskTitle: task.title,
taskType: task.type,
bestCandidate: candidates[0] ?? null,
candidates
};
});
}
156 changes: 156 additions & 0 deletions packages/mcp-server/src/services/graph-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ import { electCoordinator } from '../utils/leader-election.js';
import { withCPUThrottling, isSystemUnderStress } from '../utils/cpu-monitor.js';
import { withConnectionPoolLimit } from '../utils/connection-pool.js';
import { withReadConsistency, withWriteConsistency } from '../utils/consistency-manager.js';
import {
rankTaskAssignments,
ContributorProfile,
ContributorExpertise,
ExpertiseLevel,
CapacityStatus,
OpenTask
} from './assignment-synthesis.js';

export interface PaginationInfo {
total_count: number;
Expand Down Expand Up @@ -3745,4 +3753,152 @@ export class GraphService {
await session.close();
}
}

async suggestTaskAssignment(args: {
graph_id?: string;
task_ids?: string[];
open_statuses?: string[];
include_assigned?: boolean;
expertise_weight?: number;
availability_weight?: number;
max_candidates_per_task?: number;
min_items_threshold?: number;
task_limit?: number;
}): Promise<MCPResponse> {
if (!args.graph_id) {
return {
content: [{
type: 'text',
text: JSON.stringify({ error: 'graph_id is required' })
}],
isError: true
};
}

const session = this.driver.session();
try {
const openStatuses = args.open_statuses?.length
? args.open_statuses
: ['PROPOSED', 'ACTIVE', 'IN_PROGRESS', 'BLOCKED'];
const minThreshold = args.min_items_threshold ?? 3;
const taskLimit = args.task_limit ?? 25;
const includeAssigned = args.include_assigned ?? false;

const tasksQuery = `
MATCH (g:Graph {id: $graphId})<-[:BELONGS_TO]-(w:WorkItem)
WHERE w.status IN $openStatuses
AND ($taskIds IS NULL OR w.id IN $taskIds)
OPTIONAL MATCH (w)<-[:CONTRIBUTES_TO]-(assignee:Contributor)
WITH w, count(assignee) AS assigneeCount
WHERE $includeAssigned OR assigneeCount = 0
RETURN w.id AS id, w.title AS title, w.type AS type, w.priorityComp AS priority
ORDER BY w.priorityComp DESC
LIMIT $taskLimit
`;

const tasksResult = await session.run(tasksQuery, {
graphId: args.graph_id,
openStatuses,
taskIds: args.task_ids?.length ? args.task_ids : null,
includeAssigned,
taskLimit: int(taskLimit)
});

const tasks: OpenTask[] = tasksResult.records.map(record => ({
id: String(record.get('id')),
title: record.get('title') ? String(record.get('title')) : '',
type: record.get('type') ? String(record.get('type')) : 'TASK',
priority: typeof record.get('priority') === 'number'
? (record.get('priority') as number)
: undefined
}));

const contributorsQuery = `
MATCH (g:Graph {id: $graphId})<-[:BELONGS_TO]-(:WorkItem)<-[:CONTRIBUTES_TO]-(c:Contributor)
WITH DISTINCT c
OPTIONAL MATCH (c)-[:CONTRIBUTES_TO]->(active:WorkItem)
WHERE active.status IN ['ACTIVE', 'IN_PROGRESS', 'BLOCKED']
WITH c, count(active) AS activeItems
OPTIONAL MATCH (c)-[:CONTRIBUTES_TO]->(hist:WorkItem)
WITH c, activeItems, hist.type AS workType,
count(hist) AS typeCount,
sum(CASE WHEN hist.status = 'COMPLETED' THEN 1 ELSE 0 END) AS completed
WITH c, activeItems,
collect(CASE WHEN workType IS NULL THEN null
ELSE {workType: workType, count: typeCount, completed: completed} END) AS expertise
RETURN c.id AS contributorId, c.name AS contributorName, activeItems, expertise
`;

const contributorsResult = await session.run(contributorsQuery, {
graphId: args.graph_id
});

const toNum = (v: unknown): number =>
typeof (v as { toNumber?: () => number })?.toNumber === 'function'
? (v as { toNumber: () => number }).toNumber()
: Number(v) || 0;

const capacityFor = (activeItems: number): CapacityStatus => {
if (activeItems >= 15) return 'overloaded';
if (activeItems >= 10) return 'at_capacity';
if (activeItems >= 5) return 'busy';
return 'available';
};

const contributors: ContributorProfile[] = contributorsResult.records.map(record => {
const activeItems = toNum(record.get('activeItems'));
const rawExpertise = (record.get('expertise') || []) as Array<{
workType: string;
count: unknown;
completed: unknown;
} | null>;

const expertise: ContributorExpertise[] = rawExpertise
.filter((e): e is { workType: string; count: unknown; completed: unknown } => !!e && !!e.workType)
.map(e => {
const count = toNum(e.count);
const completed = toNum(e.completed);
const completionRate = count > 0 ? completed / count : 0;
let level: ExpertiseLevel = 'Beginner';
if (count >= minThreshold) {
level = completionRate > 0.8 ? 'Expert' : 'Proficient';
}
return { workType: e.workType, level, completionRate };
});

return {
id: String(record.get('contributorId')),
name: record.get('contributorName') ? String(record.get('contributorName')) : '',
activeItems,
capacityStatus: capacityFor(activeItems),
expertise
};
});

const suggestions = rankTaskAssignments(tasks, contributors, {
weights: {
expertiseWeight: args.expertise_weight,
availabilityWeight: args.availability_weight
},
maxCandidatesPerTask: args.max_candidates_per_task
});

return {
content: [{
type: 'text',
text: JSON.stringify({
graph_id: args.graph_id,
summary: {
open_tasks: tasks.length,
candidate_contributors: contributors.length,
tasks_with_a_suggestion: suggestions.filter(s => s.bestCandidate).length
},
suggestions
}, null, 2)
}]
};
} finally {
await session.close();
}
}
}
Loading
Loading