Custom Middleware
Practical examples for writing your own middleware. For the interface definition and execution model, see Middleware.
onRegister: Initial Visibility
Hide tools until a condition is met:
import type { ToolMiddleware } from "@lynq/lynq";
function adminOnly(): ToolMiddleware {
return {
name: "admin",
onRegister() {
return false; // hidden until c.session.authorize("admin")
},
onCall(c, next) {
if (!c.session.get("isAdmin")) {
return c.error("Admin only.");
}
return next();
},
};
}
server.tool("reset-db", adminOnly(), { description: "Reset database" }, handler);onResult: Transform Responses
Modify the handler's return value:
function truncate(maxLength: number): ToolMiddleware {
return {
name: "truncate",
onResult(result) {
return {
...result,
content: result.content.map((c) =>
c.type === "text" && c.text.length > maxLength
? { ...c, text: c.text.slice(0, maxLength) + "..." }
: c,
),
};
},
};
}
server.tool("search", truncate(500), { description: "Search" }, handler);onCall: Pre/Post Processing
Code before next() runs on the way in; code after runs on the way out.
const timer: ToolMiddleware = {
name: "timer",
async onCall(c, next) {
const start = performance.now();
const result = await next();
const ms = (performance.now() - start).toFixed(0);
console.log(`[${c.toolName}] ${ms}ms`);
return result;
},
};Short-Circuiting
If onCall doesn't call next(), the handler and all onResult hooks are skipped:
function maintenanceMode(): ToolMiddleware {
return {
name: "maintenance",
onCall(c) {
return c.error("Service is under maintenance.");
},
};
}
server.use(maintenanceMode());Under the hood
The middleware chain is assembled once at server.tool() registration time. Global middlewares (from server.use()) are prepended to per-tool middlewares. The onCall chain follows the Koa pattern: each middleware calls next() to proceed, and can inspect or modify the result after next() resolves. onResult hooks run in reverse order after the handler completes, allowing outer middleware to see the final transformed result.
Recipes
Copy-paste middleware for common patterns.
cache -- time-based result cache
import type { ToolMiddleware } from "@lynq/lynq";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
function cache(ttlMs: number = 60_000): ToolMiddleware {
const store = new Map<string, { result: CallToolResult; expires: number }>();
return {
name: "cache",
async onCall(c, next) {
const key = c.toolName + ":" + c.sessionId;
const cached = store.get(key);
if (cached && Date.now() < cached.expires) {
return cached.result;
}
const result = await next();
store.set(key, { result, expires: Date.now() + ttlMs });
return result;
},
};
}
server.tool("weather", cache(30_000), config, handler);requireSession -- guard specific session keys
import type { ToolMiddleware } from "@lynq/lynq";
function requireSession(key: string, message?: string): ToolMiddleware {
return {
name: "requireSession",
onCall(c, next) {
if (!c.session.get(key)) {
return c.error(message ?? `Missing required session key: ${key}`);
}
return next();
},
};
}
server.tool("deploy", requireSession("env", "Set environment first."), config, handler);Under the hood
These recipes are pure functions returning plain objects. No class inheritance, no framework coupling. The cache recipe uses a closure-scoped Map -- this works because in stateful mode each session's middleware chain shares the same middleware instances. In sessionless mode, middleware instances are recreated per request, so cache would not persist across calls.