ServicesWorkJournalAboutContactAI Consulting
Start a project

Building a route optimizer that always returns its best answer

Clientinternal tooling
ServicesMCP
StackTypeScript on Node.js 22, official @modelcontextprotocol/sdk, zod, OSRM, Nominatim, Google or Mapbox geocoding
Building a route optimizer that always returns its best answer

The problem

Routing problems show up in ordinary business work far more often than the phrase "vehicle routing" suggests. A field team has twelve site visits to place across a week. A courier has sixty drops and three vans. A sales lead wants to know which customers are actually clustered near each other before committing to a territory plan.

In every one of those cases, someone ends up doing it by hand in a spreadsheet, or in Google Maps, dragging stops around until the order looks reasonable. The result is usually fine and occasionally very wrong, and there is no way to tell which one you got.

The other end of the spectrum is a full routing suite: a solver, a database, a server, and a licence. That is a lot of machinery for a question that is often "what order should these twenty stops go in".

So the requirement was to sit in the middle. An MCP server that puts real optimization inside the AI client you already use, so a question asked in plain language becomes a solved route, with the tool telling you honestly when the answer is approximate.

What the server does

mcp-server-geo-optimizer is a geospatial server (routing, geocoding, clustering), not an AI search optimization tool despite the name. It exposes six tools:

ToolPurpose
optimize_route TSP or VRP over 2 to 200 waypoints (capped at OSRM_MAX_TABLE_SIZE, default 100, when using road distances), with vehicles, capacity, service times and time windows
geocodeForward geocoding from an address, or reverse geocoding from coordinates
distance_matrixPairwise distances and durations, either great-circle or road-network
cluster_pointsDeterministic k-means or DBSCAN over up to 500 points
boundary_checkPoint in polygon, convex hull, bounding box over up to 5,000 points
geojson_utilsValidate, simplify and convert GeoJSON

All six are read-only and idempotent. Three of them, optimize_route, geocode and distance_matrix, may call an external service and carry openWorldHint: true. The rest are purely local computation, which means they are fast, free, and work with no network at all.

Results come back as compact JSON with fixed precision: distances rounded to metres as 3 decimal kilometres, durations to 2 decimal minutes, coordinates to 6 decimals. Fixed precision matters more than it sounds. An agent comparing two route options needs to be able to compare the numbers, not parse nine decimal places of floating point noise.

The decision that shaped everything: a time budget, not a timeout

Most solvers treat a hard routing problem as a binary: they either return an optimal answer, or they give up. A tool call inside an AI conversation cannot work that way. The user is waiting, and "this problem was too hard" is not an answer.

So the solver has a budget instead. ROUTE_SEARCH_TIME_BUDGET_MS defaults to 1500 ms and accepts anything from 50 to 30000. When the budget runs out, the best route found so far is returned, and the caller gets a real answer they can act on.

The budget is shared across vehicles, not granted per vehicle. That is the important detail. Ten vehicles each getting 1.5 seconds means a fifteen second call, which is not what a default is supposed to mean. The implementation divides the remaining time across the vehicles still to be solved, so the whole request stays inside its bound.

Because local search is time-bounded, two runs of the same large time-window problem can return slightly different routes. That is documented rather than papered over. A tool that quietly returns a different answer each time and pretends it does not is harder to build on than one that says so.

How the solver actually works

The solver picks its strategy from the problem size, and the threshold is explicit: DEFAULT_EXACT_MAX_SIZE = 8.

Route optimizer MCP server architecture: transport layer over MCP tools over services over pure domain algorithms, with HTTP adapters to the side

Up to 8 stops, it solves exactly by permuting every possible order and evaluating each one. For 8 stops that is 5040 permutations of the remaining points, which is trivial on modern hardware and guarantees the optimal order for whatever objective is in play.

Above 8 stops, it falls back to a nearest-neighbour construction followed by local search. The construction walks from the depot to the closest unused stop, repeatedly, which produces a valid route immediately and a mediocre one.

Then two improvement passes run against it:

  • 2-opt, which reverses a segment of the tour and keeps the change if the result is shorter. The implementation tracks the forward and reverse cost of the segment incrementally as the second index advances, so each candidate is evaluated in constant time instead of re-summing the whole segment.

  • Or-opt, which lifts a run of one to three consecutive stops and reinserts it elsewhere. For open routes it also tries moving a whole tail, which is what handles a route whose worst leg is at the very end.

Both passes repeat until neither finds an improvement, capped at MAX_IMPROVEMENT_PASSES = 1000 and bounded by the deadline. Where time windows are involved, a second local search runs over the schedule itself, using a 2-opt pass and a relocate pass that evaluate the full arrival schedule rather than just distance.

Checking the clock every 64 evaluations

performance.now() is not free, and calling it inside the innermost loop of a local search costs more than the search itself on small problems. The budget helper decrements a counter and only reads the clock every CLOCK_CHECK_INTERVAL = 64 evaluations.

The effect is that a time budget of 1500 ms is accurate to within 64 evaluations rather than exactly, which is entirely good enough, and the search does not spend a measurable share of its budget measuring its own budget.

Soft time windows

This is the part that most changes how the tool behaves in practice.

A hard time-window solver fails when no ordering satisfies every window. In a real conversation that failure is useless. The caller asked a question, and "infeasible" does not tell them whether they need another vehicle or a different depot time.

So time windows here are soft, and there are two objectives in a fixed order. The solver minimises total lateness first, then total distance. A slight distance increase is worth accepting if it removes a late arrival.

When windows truly cannot all be met, the tool reports exactly how bad it is rather than failing:

  • Each route carries a violations list.

  • Individual stops carry lateMin when they arrive after their due time.

  • The top-level feasible flag is false.

Arriving before a readyTimeMin produces a waitMin on the stop, because the vehicle waits. That wait is included in the schedule, so a downstream stop is not reported as early when the vehicle would still be sitting at the previous one.

Two rules are enforced rather than tolerated. A waypoint with readyTimeMin greater than its dueTimeMin is rejected outright, because that window can never be satisfied. And a dueTimeMin on the depot applies to the return leg of a closed route, which is the rule people forget, and the one that produces an "impossible" route that is actually just missing a constraint.

Splitting across vehicles

With vehicleCount greater than one, stops are assigned by a sweep around the depot. Each stop is sorted by its bearing from the depot, the wheel is cut at its widest gap so the route does not straddle an artificial seam, and the resulting sequence is divided across vehicles.

Two constraints are respected during assignment: the split is balanced so one vehicle does not receive everything, and capacity is checked as stops are placed. A stop whose demand exceeds a single vehicle's capacity cannot be routed at all, and it is returned in an unassigned list rather than silently dropped or crammed in. The same applies to any stop that will not fit once the balanced split is done: a second pass places the overflow, and anything still unplaceable ends up in unassigned.

unassigned being non-empty is one of the two conditions that sets feasible to false. The other is a time-window violation.

The antimeridian is not an edge case

Most geo code is written as though the world is a rectangle, which works until the data crosses longitude 180. Then a straight-line distance between two points a few kilometres apart comes back as most of the way around the planet, clusters split, and convex hulls wrap the wrong way.

Three parts of this server handle that explicitly.

shortestLngDelta normalises a longitude difference into the range -180 to 180, so the haversine calculation always measures the short way around. normalizeLng handles the awkward case of an input outside the valid range, including the sign flip at exactly -180.

Cluster centroids are computed as spherical means, converting each point to Cartesian coordinates, averaging, and converting back. A plain arithmetic mean of longitudes gives a centroid in the wrong hemisphere for a cluster spanning the line, which is a bug that only shows up on real data from Fiji or Alaska.

And bounding_box follows RFC 7946 properly: when points straddle the antimeridian, minLng is greater than maxLng and the result carries crossesAntimeridian: true. So a box around points at 179.8 and -179.8 comes back as minLng: 179.8, maxLng: -179.8, which is what the specification says and what any conforming GeoJSON consumer expects.

Being a decent citizen of public APIs

The defaults point at public infrastructure: the OSRM demo server for road distances and public Nominatim for geocoding. Those are shared resources with strict usage policies and no service guarantee, and it is entirely possible to build a tool that works beautifully for you and gets everyone else rate limited.

So politeness is enforced in the client rather than left to configuration discipline:

  • Requests to the public Nominatim and OSRM hosts are spaced at least PUBLIC_API_MIN_INTERVAL_MS apart, minimum 1000 ms, default 1000 ms.

  • Retry-After from a 429 is honoured.

  • GEO_USER_AGENT is sent on every outbound request, and the documentation asks you to include real contact details, which is what the Nominatim policy requires.

  • OSRM_MAX_TABLE_SIZE defaults to 100, the demo server's limit, and is checked before any call is made. A request for a 150 point matrix fails locally with a clear message instead of being sent and rejected upstream.

For anything real, the documentation says plainly: run your own OSRM instance and raise the limit. The public defaults exist so the tool works the moment you install it, not so they can carry production traffic.

Architecture

The layering is strict, and it is the reason the algorithms are testable:

Architecture of the route optimizer MCP server: MCP service layer over a side-effect-free domain layer over OSRM and Nominatim providers

Services never import the MCP SDK. Domain functions are side-effect free and take plain data in and return plain data out. That means the 2-opt pass, the sweep assignment, the spherical mean, and the antimeridian handling are all unit tested without a network, a mock, or a fixture server.

The server speaks MCP over stdio only. There is no HTTP port, which removes an entire class of deployment questions. Logs are JSON on stderr, stdout is reserved for the protocol, and the process is designed to sit there silently until a tool is called.

Errors and validation

Validation happens in two stages, and the split is deliberate.

The MCP SDK validates the schema first: types, ranges, required fields. Those failures come back as plain text, because the request never reached the tool.

Then the tool checks the rules the schema cannot express, which are the cross-field ones: the distance matrix cell cap, whether startIndex is in range, whether a time window is internally consistent, whether the cost matrix dimensions match the waypoint count. Those return a structured JSON body with a code and a message an agent can branch on: InvalidParams, ROUTE_FAIL, GeocodingFailed, CLUSTER_FAIL, GEOMETRY_FAIL, PROVIDER_CONFIG, TIMEOUT, UPSTREAM_ERROR, InternalError.

API keys are redacted from every log line and every error message. With NODE_ENV=production, which is the default, unexpected errors are reported to clients as Internal error and the detail stays on stderr, so a stack trace cannot leak through a tool result.

Current state and results

Six tools are implemented and covered by tests, with an 80 percent threshold enforced across statements, branches, functions and lines. The routing solver is exercised at both ends of its range: exact solutions for small problems, and time-budgeted local search with a verified best-so-far return for large ones.

Output precision, error codes, and the antimeridian behaviour for clusters, bounding boxes and convex hulls are all covered by unit tests, which is the only way to be confident about code that is correct everywhere except the one place it is not.

What is planned next

A persisted route history. A solved route is currently returned and forgotten. Storing solutions would let a caller ask what changed between two plans rather than re-solving from scratch.

Better local search for large time-window problems. The current second-phase search is 2-opt plus relocate over the full schedule. For problems in the hundreds of stops with tight windows, a construction heuristic that is window-aware from the start would likely outperform adding windows to a distance-first construction.

Provider-agnostic geocoding cache warm-up. The cache is an in-memory LRU with a 300 second TTL. For repeated planning work over the same set of addresses, a warm-up pass would remove the per-call geocoding latency entirely.

Takeaways for anyone building something similar

Give a solver a budget and make it return its best answer. The alternative, failing when the problem is hard, produces a tool that is technically correct and practically unusable inside a conversation where someone is waiting.

Treat constraints as soft unless they are genuinely impossible. A route with two late stops and an explanation is worth far more to a planner than an infeasibility error, and feasible: false with a violations list lets a caller decide whether the lateness matters.

And check whether the constraint can be satisfied at all before you spend time optimising it. Rejecting readyTimeMin greater than dueTimeMin upfront, and refusing a matrix larger than the upstream will accept before sending it, turns two confusing failures into two clear messages.

Share Case Study