---
title: A shared library across two languages needs a contract, not parity
description: Running the same MCP service shape in TypeScript and Rust only works if you decide, in writing, which parts are a contract and let everything else diverge.
date: 2026-09-22
area: infrastructure
tags:
  - architecture
  - patterns
  - agents
draft: false
---

I maintain a fleet of internal MCP servers at my current employer, most of them TypeScript, one in Rust because
its workload (a high-throughput sync loader) needed Rust's performance characteristics. Both
languages needed the same cross-cutting infrastructure: auth middleware, structured logging that
correlates with distributed traces, health checks, OAuth discovery endpoints, OpenTelemetry wiring.
Writing that twice, by hand, per service, was costing roughly 300 lines of boilerplate every time a
new server came online. So I built a shared library, once per language, and the interesting design
work wasn't the code inside either one. It was deciding what the two implementations were required
to agree on and what they were free to do differently.

## Naming the contract explicitly

If you don't draw that line on purpose, you end up drawing it by accident, and by accident means
inconsistently. I named four things as the actual contract between the Node and Rust libraries:
module boundaries (auth, middleware, logger, tracing, and the request proxy are separate concerns
in both, not collapsed differently per language), configuration shape (the same environment
variables mean the same thing in both), auth semantics (a token that's valid in one language's
implementation is valid in the other's, checked the same way), and a documented version-parity
note so nobody has to guess whether the two are still in step.

Everything else, internal structure, idiomatic patterns, how each language's ecosystem prefers to
handle a given concern, is explicitly not part of the contract and is free to diverge. A Rust
service and a Node service calling the shared library look nothing alike internally. They behave
identically from the outside, which is the property that actually matters to a caller.

## Why logger and tracer live in the same package

The one decision I'd point to as the most important is a small one: the structured logger and the
distributed tracer are not separate libraries a service wires together itself. They're the same
package, deliberately coupled, specifically so every log line a service emits automatically carries
the current trace id.

The alternative, two independent libraries with a README telling engineers to wire them together
correctly, sounds reasonable and fails silently. A service that forgets the wiring still logs fine
and still traces fine. What it loses is the ability to jump from a trace to the logs that happened
inside it, and nothing tells you that's missing until you're mid-incident trying to make that jump
and it isn't there. Correlation is a property you get by structural coupling, not by good
intentions in a README. The failure mode of skipping it is silent, so it has to be prevented
structurally, not documented as a best practice.

## The blind spot I chose to accept

One thing this design doesn't protect against, and I decided not to try to: two different
libraries, or two different versions of the same library, both independently configuring
OpenTelemetry inside one process. The shared library includes a double-registration guard for its
own instance, but that guard has no way to see a second, unrelated OTEL setup someone else wired in.
Closing that gap completely would mean either owning every telemetry decision a consuming service
makes, which defeats the point of keeping the languages free to diverge everywhere else, or
building a much heavier coordination mechanism for a failure mode that, in practice, only shows up
when someone's actively fighting the library rather than using it as intended. I named the gap in
the library's own docs instead of pretending it isn't there.

## Where the two libraries will diverge next

The next time a Rust service and a Node service both need a capability neither library has today,
the same question comes up again: does this belong in the four-item contract, or is it free to
diverge. Nothing about the current split guarantees the answer stays the same shape twice in a row.
The OTel double-registration gap already shows what happens when the honest answer is "neither,
yet": it goes in the docs, not the code, until it's the thing that's actually breaking rather than
the thing that might.
