Back to Blog
Feature Release

Why We Don't Let AI Calculate Critical Paths: Server-Side CPM in Ganty's MCP Tools

Ganty Team

Have you ever asked Claude "what's the critical path for this project?" and gotten a confidently wrong answer? That's not the model's fault — it's a design problem. In June 2026 Ganty shipped two MCP tools — get_critical_path and reschedule_and_propagate — that compute the answer on the server and return JSON. This is the architecture deep-dive on why we chose that path.

The Problem: Asking the LLM to Compute CPM

The naïve flow: pass the LLM the task list (via list_tasks), ask "what's the critical path," let the model derive the answer. It rarely works in practice. Why?

  • The graph walk is sloppy. At a merge point, "take the later finisher" is often missed.
  • Progress handling is unstable. progress=100 should stay put; progress=50 should halve remaining work. Models forget.
  • Lag days get dropped. Dependency lag isn't always factored into the date math.
  • Cycles hang. With a cyclic dependency, an LLM may loop indefinitely trying to make the math close.
  • Non-determinism. Same input, slightly different temperature, different answer.

These aren't "wait for a bigger model" problems. They're using a stochastic generator for what needs a deterministic algorithm (forward/backward pass).

The Fix: Server Computes, LLM Relays

Ganty's approach: implement CPM in TypeScript on the server, expose it as an MCP tool. The LLM picks the tool, then verbalizes the result. It doesn't do math.

// What Claude sees (pseudocode)
User: "show me the critical path"
Claude: invokes get_critical_path(project_id: "...")
Ganty: {
  critical_path: [
    { task_id: "...", name: "Requirements", start: "2026-06-01", end: "2026-06-05" },
    { task_id: "...", name: "API design",  start: "2026-06-05", end: "2026-06-12" },
    ...
  ],
  project_end_date: "2026-08-15",
  total_duration_days: 75,
  ...
}
Claude: "The critical path is Requirements → API design → ...
         The project ends on August 15, total 75 days."

The model isn't deriving numbers; it's relaying server-computed values. There's no room to be wrong.

get_critical_path: Progress-Aware Forward/Backward Pass

Standard CPM, implemented in five steps:

  1. Cycle detection first. Iterative DFS; on cycle, return error: 'cyclic_dependency' with the cycle path. No hangs.
  2. Forward pass. Compute earliest start (ES) and earliest finish (EF) in topological order.
  3. Backward pass. Compute latest start (LS) and latest finish (LF) in reverse.
  4. Slack = LS − ES. Slack-zero tasks are critical.
  5. Critical path = slack-zero tasks in topological order.

The progress model: progress=100 tasks are pinned and stay fixed. Others use remaining = round(full_duration × (1 - progress/100)). An optional as_of_date floors ES at max(as_of_date, original_start), projecting overdue work forward realistically.

reschedule_and_propagate: Simulate, Then Apply

"What if I shift this task?" is the PM question of the day. reschedule_and_propagate answers it in two modes:

  • Dry-run (default): no DB writes. Returns each task's before/after, project end delta, and any pin conflicts.
  • Commit: same calculation wrapped in a Prisma $transaction. If any conflict exists, nothing is written — all-or-nothing.

Propagation rules:

  • Push-only cascade. A successor moves only if predecessor.new_end + lag > successor.current_start. Earlier shifts don't pull successors forward (conservative).
  • Merges take the max. A successor with multiple predecessors lines up with the latest finisher.
  • Pin stops propagation. progress=100 tasks (and anything listed in pinned_task_ids) record a conflict instead of moving. Cascade stops there.

The UX: ask "how much does release slip if I push task X by 3 days?" Claude returns "release slips 5 days, but there's a 2-day conflict against pinned task Y." Approve it → mode: 'commit'. Don't approve it → revise plan.

Design Principle: When We Can't Be Right, We Say So

Every response includes a limitations array that names what the calculation did and did NOT account for:

  • Cycleserror: 'cyclic_dependency' + cycle path
  • Other dependency types (SS/FF/SF) → Ganty's schema is FS-only; documented in limitations
  • Holidays → no holiday table; only Sat/Sun skipped when business_days=true. Documented.
  • Multi-period tasks (extraSegments) → ignored in v1. Documented.
  • Resource calendars → not supported in v1. Documented.

Claude reads the limitations array and can warn the user accordingly ("this calculation doesn't account for national holidays").

Test Strategy: Assert Specific Numbers, Not "It Ran"

Correctness is the entire value here, so we shipped 28 golden tests asserting concrete expected dates and day counts:

  • Linear A→B→C, shift A by +3d → C ends +3d, project_end +3d
  • Diamond A→B, A→C, B→D, C→D → D follows the later finisher
  • Cycle A→B→A → cyclic_dependency returned; completes within 2 seconds
  • Push into a progress=100 task → conflicts recorded; commit leaves DB unchanged
  • progress=50 task halves remaining duration
  • Six-task hand-calculated CPM → critical path and per-task slack match exactly
  • business_days=true skips Sat/Sun
  • lagDays respected in both forward pass and propagation
  • respect_dependencies=false moves only the named task
  • Negative shifts don't pull successors (no-pull)

Tests use tsx + Node's built-in node:test. 28 tests pass in under 0.3 seconds.

Try It

MCP integration is free on every plan. Set up in 5 minutes via the MCP integration guide and call get_critical_path from Claude. If setup hangs, check the 9 common MCP setup problems guide. For examples, see Examples 6 and 7 in our updated 7 MCP automation examples.

Related

Related Articles