3.4 — Positional Dependency Injection¶
Breaking change in two runtimes
Java @MeshRoute / @MeshA2A and TypeScript mesh.route / mesh.a2a.mount changed how declared dependencies reach handler parameters. Python is unchanged, and so is binding at Java @MeshTool and TypeScript addTool — both were already positional. One caveat at @MeshTool: @MeshInject is now honoured on its parameters, where it was previously ignored, so a @MeshTool parameter whose @MeshInject value disagrees with its position now fails at boot.
As of 3.4.0 mcp-mesh has one dependency-injection rule, at every injection site in every runtime:
The Nth declared dependency binds to the Nth injectable parameter, in signature order. Parameter names are never consulted.
Before 3.4, four of the seven injection sites followed that rule and three did not. Java @MeshRoute and @MeshA2A matched by name — the @MeshInject value, falling back to the Java parameter name. TypeScript mesh.route and mesh.a2a.mount handed the handler an object keyed by capability. The two rules lived inside the same annotation family, with the same McpMeshTool parameter type at every slot, so the mental model carried from one to the other silently misbound and neither the compiler nor an IDE could catch it.
| Runtime | tool | route | a2a |
|---|---|---|---|
| Python | positional | positional | positional |
| TypeScript | positional | was capability-keyed | was capability-keyed |
| Java | positional | was by name | was by name |
What breaks¶
Java¶
- A handler whose declaration order disagrees with its
@MeshInjectvalues fails at boot.@MeshInjectno longer selects a dependency — it asserts the one position already assigns. A false assertion is fatal unconditionally, not gated onMCP_MESH_STRICT_DI. - A handler whose parameter names match the declared capabilities in a different order is diagnosed, not silently rebound. WARN by default, a startup failure under
MCP_MESH_STRICT_DI=true. - Return-type enrichment follows position too. The
McpMeshTool<T>generic that drives response deserialization and the schema-match payload is taken from the parameter's slot ordinal, not its name. This had to move with the resolver: leaving it by-name would have made a reordered handler deserialize into the wrong type. @MeshInjectis now honoured on@MeshToolparameters, where it was previously ignored — under the same assertion semantics.
TypeScript¶
deps.capabilitythrows. The dependency argument is an array; reading a declared capability off it by name raises aTypeErrornaming the index and printing the corrected signature.mount<{ ... }>androute<{ ... }>fail to compile.RouteDependenciesandA2ADependenciesnow alias a newly-exportedPositionalDependencies(Array<McpMeshTool | null>), so an object type argument no longer satisfies the constraint (TS2344).- Duplicate capabilities each own their index. The old per-capability dedupe on the required perimeter was correct only because injection collapsed duplicates into one key. Each declaration now has its own slot, so each is checked independently.
What does not break¶
Correctly-ordered code is unaffected. That is measured, not assumed — every handler in the repository was compared under both rules, on both sides of each conversion:
| Runtime | Handlers | Bound slots | Binding differences |
|---|---|---|---|
| Java | 21 (14 route, 7 a2a) | 14 | 0 |
| TypeScript | 35 (27 route, 8 a2a) | 30 | 0 |
Also unchanged:
- Slot preservation. An unresolved dependency holds its own index as
null(TypeScript) or an unavailable proxy /null(Java) and never shifts a later dependency up. - Everything about resolution. Tags, version constraints,
expectedType/expectedSchema,required, the 503 route perimeter, the settle window, auto-rewiring, and the wire format are all untouched. This is a binding change inside the consumer, not a protocol change. @MeshDependsOnbeans. They bind by Spring bean name via@Qualifier— a Spring wiring surface, not a mesh parameter-pairing one — and are deliberately out of scope.- Python. Every Python site was already positional.
Find your exposure before upgrading¶
A handler declaring two or more dependencies can rebind whenever its declaration order disagrees with the names it was written against. But do not use the dependency count as a filter — a one-dependency handler is exempt only under conditions worth stating, and they differ by runtime:
- TypeScript — no exemption. What changed is the callback shape, not the pairing. A sole-dependency handler still written
async (req, res, { cap }) => …, or readingdeps["cap"]inside amount, throws through the new guard exactly as a five-dependency one does. Every keyed handler becomes[cap], whatever it declares. - Java — exempt only when its
@MeshInjectalready names the declared capability, or carries none. With one slot there is nothing for position to reorder, so an unannotated single-dependency handler cannot change meaning. A sole@MeshInjectwhose value does not name the declared capability did change, though: on 3.3 it selected nothing and the parameter was injectednullwith a warning; on 3.4 it is a false assertion and the boot fails. Same for@MeshInjecton a@MeshToolparameter, where it used to be ignored outright.
Java — the detector ships in 3.3.x and warns¶
Upgrade to the latest 3.3.x first. It carries the legacy-shape detector, which changes no binding and only logs. Start each Spring Boot application and read the startup log:
A handler written against the old rule looks like this. Note that it prints both orderings, so you can see exactly what would move:
@MeshRoute com.example.api.ApiController.report(McpMeshTool, McpMeshTool): parameter
names disagree with declaration order, so these parameters do NOT bind the way their
names suggest.
@MeshRoute binds mesh dependencies BY POSITION (issue #1401): the Nth declared
dependency binds to the Nth injectable parameter, and parameter names are never
consulted. @MeshTool, and every Python and TypeScript site, work the same way.
@MeshInject no longer SELECTS a dependency — it ASSERTS the one positional pairing
assigns.
2 declared dependencies: [0] 'get_employee', [1] 'employee_count'
slot 0 = parameter 0 (McpMeshTool employee_count)
parameter name 'employee_count' used to bind: dependency[1] 'employee_count'
binds (by position): dependency[0] 'get_employee'
slot 1 = parameter 1 (McpMeshTool get_employee)
parameter name 'get_employee' used to bind: dependency[0] 'get_employee'
binds (by position): dependency[1] 'employee_count'
If this handler was written for mcp-mesh 3.3 or earlier (when @MeshRoute and @MeshA2A
bound by name), fix it — pick one:
• reorder dependencies = {...} to: [0] 'employee_count', [1] 'get_employee'
(move each whole @MeshDependency — keep its tags, version, required and schema
attributes with its capability)
• or reorder the parameters to match the declaration order
• or, if the current bindings are already what you want, add @MeshInject("<capability>")
to each parameter to assert it — that pins the pairing and silences this warning
Set MCP_MESH_STRICT_DI=true to fail startup on this instead of logging it.
The two reorder fixes it prescribes are behaviour-preserving on 3.3 and correct on 3.4 — moving whole @MeshDependency entries, or moving the parameters, is name-neutral under the old rule — so you can land that sweep before you upgrade the SDK. Its third suggestion, adding an @MeshInject where the parameter carried none, preserves 3.3 behaviour only where the parameter name already selected that same dependency; where the name matched no declared capability, 3.3 injected null there and the new annotation starts injecting a real proxy. Set MCP_MESH_STRICT_DI=true in CI to make the sweep enforceable.
The same pass also reports arity mismatches — a method declaring more dependencies than it has injectable parameters (the surplus is resolved and advertised but injected nowhere), or fewer (the surplus parameters receive null). Java had no such check before 3.3.x.
Compiled without -parameters?
The blind spot is narrower than it sounds — it covers unannotated parameters only.
A parameter carrying @MeshInject("cap") needs no reflected name: the annotation supplies the key. Such handlers worked fine on 3.3 without -parameters, and they are not in the blind spot — the detector compares the annotation value against the parameter's position, so it reads them exactly as it would in a -parameters build and a value that disagrees with its slot still fails the boot. They still have to be validated against the 3.4 positional order.
Only an unannotated, name-bound parameter is invisible: with no name there is nothing to compare, and the detector cannot speak. That case contains no working code — without -parameters the pre-3.4 route path already logged an error and injected null, and the A2A path matched arg0 against nothing — so nobody can regress from working to misbinding there.
TypeScript — no pre-upgrade signal, and none needed¶
Misbinding is structurally impossible: string keys and numeric indices are disjoint, so a stale deps.capability on the new array can only be undefined, never another dependency's proxy. Rather than let that surface far from the cause as Cannot read properties of undefined (reading 'call'), the array is wrapped so an un-migrated access throws immediately (see below).
To find candidates ahead of time, grep your handlers for the object-destructuring form:
How to fix each shape¶
Java — @MeshInject handler¶
The annotation values were the binding. Move the @MeshDependency entries so declaration order matches the parameters (or move the parameters — either end works):
// BEFORE (3.3) — bound by @MeshInject value; declaration order was irrelevant.
@MeshRoute(dependencies = {
@MeshDependency(capability = "get_employee"),
@MeshDependency(capability = "employee_count")
})
@GetMapping("/report")
public ResponseEntity<Report> report(
@RequestParam int id,
@MeshInject("employee_count") McpMeshTool<Integer> stats,
@MeshInject("get_employee") McpMeshTool<Employee> lookup) { ... }
// AFTER (3.4) — declaration order IS the binding.
@MeshRoute(dependencies = {
@MeshDependency(capability = "employee_count"),
@MeshDependency(capability = "get_employee")
})
@GetMapping("/report")
public ResponseEntity<Report> report(
@RequestParam int id,
@MeshInject("employee_count") McpMeshTool<Integer> stats,
@MeshInject("get_employee") McpMeshTool<Employee> lookup) { ... }
Keeping the annotations is optional and behaviour-neutral. A correct @MeshInject is a safety net: it pins the pairing, so a later reorder of one end without the other fails the boot instead of quietly rebinding. Dropping them entirely is equally valid once you are on 3.4.
Do not fold that into a pre-upgrade sweep. On 3.3 @MeshInject still selects the dependency, so removing it there falls back to parameter-name matching — which can silently rebind a running application, or inject null if no capability matches the name. Dropping is safe on 3.3 only where the parameter names already produce the same binding the annotations do.
If you leave them wrong, the boot fails with both orderings printed:
@MeshRoute com.example.api.ApiController.report(int, McpMeshTool, McpMeshTool): a
@MeshInject value contradicts the dependency its parameter's POSITION binds to.
2 declared dependencies: [0] 'get_employee', [1] 'employee_count'
slot 0 = parameter 1 (McpMeshTool stats)
@MeshInject("employee_count") asserts: dependency[1] 'employee_count'
binds (by position): dependency[0] 'get_employee'
slot 1 = parameter 2 (McpMeshTool lookup)
@MeshInject("get_employee") asserts: dependency[0] 'get_employee'
binds (by position): dependency[1] 'employee_count'
Fix — pick one:
• reorder dependencies = {...} to: [0] 'employee_count', [1] 'get_employee'
(move each whole @MeshDependency — keep its tags, version, required and schema
attributes with its capability)
• or reorder the parameters to match the declaration order
• or correct the @MeshInject value on each parameter to name the dependency at that
parameter's position (@MeshInject is an assertion — dropping it entirely is also
valid, and binding is unchanged either way)
When you reorder, move the whole @MeshDependency — tags, version, required, expectedType and schemaMode belong to the capability, not to the slot.
@MeshInject still accepts the @MeshDependency(name = ...) alias as well as the capability, at the paired index only — so a capability = "base-cap" declared with the framework-generated baseCap alias can be asserted either way.
Java — unannotated handler¶
Identical fix, with nothing to correct at the parameter end. The names carried the binding; now they carry nothing:
// BEFORE (3.3) — parameter names were the match keys.
@MeshRoute(dependencies = {
@MeshDependency(capability = "get_employee"),
@MeshDependency(capability = "employee_count")
})
public ResponseEntity<Report> report(
McpMeshTool<Integer> employee_count,
McpMeshTool<Employee> get_employee) { ... }
// AFTER (3.4) — reorder the declarations; rename the parameters to whatever reads best.
@MeshRoute(dependencies = {
@MeshDependency(capability = "employee_count"),
@MeshDependency(capability = "get_employee")
})
public ResponseEntity<Report> report(
McpMeshTool<Integer> headcount,
McpMeshTool<Employee> lookup) { ... }
@MeshA2A handlers convert the same way. Their dependency slots are McpMeshTool parameters (and any @MeshInject-annotated parameter, since the dispatcher owns the whole argument array); the inbound message parameter and an injected MeshJobSubmitter are not dependency slots and take no index.
TypeScript — mesh.route¶
// BEFORE (3.3)
app.post(
"/lucky",
mesh.route(["add", "greet_lucky"], async (req, res, { add, greet_lucky }) => {
if (!add || !greet_lucky) {
res.status(503).json({ error: "unavailable" });
return;
}
res.json({ result: await greet_lucky({ n: await add({ a: 1, b: 2 }) }) });
}),
);
// AFTER (3.4)
app.post(
"/lucky",
mesh.route(["add", "greet_lucky"], async (req, res, [add, greetLucky]) => {
if (!add || !greetLucky) {
res.status(503).json({ error: "unavailable" });
return;
}
res.json({ result: await greetLucky({ n: await add({ a: 1, b: 2 }) }) });
}),
);
The binding names in the array pattern are yours — greetLucky above renames greet_lucky. The keyed form could rename too, but only by spelling the alias out ({ greet_lucky: greetLucky }). What it could not bind plainly at all was a capability whose name is not a valid identifier: greet-lucky, with a hyphen, needed a quoted alias ({ "greet-lucky": greetLucky }) or a deps["greet-lucky"] bracket access.
If you miss one, the guard names the index and prints the rewrite:
mesh.route dependencies are positional as of 3.4.0.
You accessed `deps.add`; "add" is declared dependency [0].
Rewrite the handler as: async (req, res, [add, greet_lucky]) => { ... }
The guard fires only on declared capabilities, checked before anything else. Everything else passes straight through to the array — deps[0], destructuring, spread, for...of, deps.length, array methods, Array.isArray, JSON.stringify, console.log, util.inspect, and test-framework matchers all behave exactly as they would on a bare array. A capability literally named map or length still throws rather than silently returning the array method. An undeclared key returns undefined, as it did before — that is a typo, not a migration signal, and there is no rewrite to prescribe for it.
TypeScript — mesh.a2a.mount¶
// BEFORE (3.3)
mesh.a2a.mount(
app,
{
path: "/agents/date",
skillId: "get-date",
dependencies: ["date_service"],
},
async (deps, payload) => {
const dateService = deps["date_service"] as McpMeshTool | null;
if (!dateService) return { error: "date_service unresolved" };
return { date: await dateService({}) };
},
);
// AFTER (3.4)
mesh.a2a.mount(
app,
{
path: "/agents/date",
skillId: "get-date",
dependencies: ["date_service"],
},
async ([dateService], payload) => {
if (!dateService) return { error: "date_service unresolved" };
return { date: await dateService({}) };
},
);
The cast in the "before" form is worth calling out: it defeated the compiler entirely, which is why the runtime guard exists rather than relying on type errors alone.
A long-running handler still takes the MeshJobSubmitter as its third argument — async ([dep], payload, jobSubmitter) => { ... } — and that argument is not a dependency slot.
TypeScript — type arguments¶
// BEFORE (3.3) — object shape
mesh.a2a.mount<{ date_service: McpMeshTool }>(app, config, handler);
// AFTER (3.4) — per-slot tuple, and more useful than the object form was
mesh.a2a.mount<[McpMeshTool | null]>(app, config, handler);
mesh.a2a.mount cannot guarantee a resolved slot, so per-slot type arguments stay nullable. mesh.route is generic in the same way. Leaving the type argument off is fine — it defaults to PositionalDependencies.
After upgrading¶
- Java: keep
MCP_MESH_STRICT_DI=truein CI. The arity check and the legacy-order check both become startup failures under it, so a future reorder cannot land silently. - TypeScript:
tsc --noEmitcatches the type-argument shape at build time; the runtime guard catches the handler bodies on first request. - Neither runtime needs a coordinated rollout. Nothing on the wire changed, so a 3.4 consumer and a 3.3 provider (or the reverse) interoperate normally. This is a source-level migration inside each consumer.
See also¶
- Dependency Injection (Java)
- Dependency Injection (TypeScript)
- DDDI: Distributed Dynamic Dependency Injection
meshctl man upgrading— the same guidance offline- Issue #1401