v2.0.0 - Dual-era: serve 2025 and 2026-07-28 MCP clients from one endpoint
v2 is a ground-up rewrite of how MCP-Nest plugs into NestJS, and it delivers
the two things people kept asking for: serve the newest MCP clients without
thinking about it, and build MCP servers the way you already build NestJS apps —
guards, interceptors, pipes, filters, and DI, on every tool.
Upgrading an existing v1 server? It's a breaking change — jump to
Upgrading from v1.
Highlights
🕰️ Modern-era MCP clients work out of the box
MCP shipped a revision — 2026-07-28 — that deletes the initialize
handshake and protocol sessions entirely. It's a clean break with no deprecation
period: old clients and new clients literally cannot talk to the same
single-era server.
MCP-Nest serves both eras concurrently, on one endpoint, by default — and
your tool code doesn't change at all. A 2024 client and a 2026 client can hit
the same /mcp in the same process, and each gets served correctly.
new StreamableHttpTransport(); // dual-era — the default
new StreamableHttpTransport({ protocol: 'modern-only' }); // 2026-07-28 only
new StreamableHttpTransport({ protocol: 'legacy-only' }); // pre-2026 behaviourThere's nothing to configure. Era is a per-request fact, decided by whoever
called you.
🧩 Your tools are first-class NestJS handlers
This is the big one. Every @Tool, @Resource, @ResourceTemplate, and
@Prompt is now a real @MessagePattern handler on an @McpController(), so
the entire NestJS pipeline applies natively — no bespoke machinery bolted on
top:
@McpController()
@UseGuards(ApiKeyGuard) // gate the whole capability class
@UseInterceptors(MetricsInterceptor) // time and log every tool call
export class OrdersController {
constructor(private readonly orders: OrdersService) {} // real DI, real services
@Tool({
name: 'refund-order',
description: 'Refunds an order',
parameters: z.object({ id: z.string() }),
})
@ToolScopes(['orders:write']) // per-tool authorization
@UseFilters(DomainErrorFilter) // your own error mapping
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async refund(@Payload() { id }: { id: string }, @Ctx() ctx: McpContext) {
await ctx.reportProgress({ progress: 1, total: 2 });
const receipt = await this.orders.refund(id);
return { content: [{ type: 'text', text: `Refunded ${receipt.id}` }] };
}
}class-validator DTOs, custom pipes, request-scoped providers, @UseFilters,
RpcException — all of it just works, because these are ordinary Nest RPC
handlers now.
🔓 Bring your own schema library
parameters and outputSchema are no longer Zod-only. Any Standard Schema
validator that emits JSON Schema works — Zod, ArkType, Valibot — or hand it a
raw JSON Schema object:
@Tool({ name: 'add', description: 'Adds two numbers', parameters: type({ a: 'number', b: 'number' }) }) // ArkType
@Tool({ name: 'echo', description: 'Echoes', parameters: { type: 'object', properties: { text: { type: 'string' } } } }) // raw JSON SchemaExisting Zod code is untouched — this is a widening, not a migration.
🛣️ Own the MCP route like any other controller
McpHttpControllerFor(transport) turns a transport into a real Nest controller
you own, with the full HTTP pipeline — guards, interceptors, filters,
versioning, middleware:
@Controller('mcp')
@UseGuards(McpAuthJwtGuard)
@UseInterceptors(HttpTimingInterceptor)
export class McpHttpController extends McpHttpControllerFor(mcpTransport) {}That's also where authentication belongs now — a normal Nest guard, not a
module option.
🏢 Several MCP servers in one app, and tools registered at runtime
Named servers give each endpoint its own isolated tool set
(@McpController({ server: 'admin' }) + McpStrategy({ server: 'admin' })),
and the strategy can register and remove tools, resources, and prompts at
runtime — now emitting proper listChanged notifications so connected clients
refresh.
🔐 A hardened built-in OAuth server
@rekog/mcp-nest-auth (now its own package, so core stays free of typeorm,
passport, and @nestjs/jwt) gained real security work: token
audience/issuer/type validation, PKCE S256 enforcement, scope narrowing at
/authorize, an opt-in consent screen, Client ID Metadata Documents with an
SSRF-guarded fetcher, and RFC 9207 iss on authorization responses.
v1 → v2 at a glance
v1 (1.9.x) |
v2 (2.0.0) |
|
|---|---|---|
| How MCP plugs into Nest | McpModule.forRoot() generating HTTP controllers |
a NestJS microservice CustomTransportStrategy (McpStrategy) |
| Where tools live | @Injectable() classes in providers |
@McpController() classes in controllers |
| Guards / pipes / interceptors / filters on tools | bespoke, partial | native NestJS — tools are real @MessagePattern handlers |
| Transports | HTTP+SSE, Streamable HTTP, STDIO | Streamable HTTP, STDIO (HTTP+SSE removed) |
| Protocol revisions | 2025-era only | dual-era: 2025-era and the stateless 2026-07-28 revision on one endpoint |
| OAuth authorization server | bundled in core | separate package @rekog/mcp-nest-auth |
| MCP SDK | @modelcontextprotocol/sdk v1 |
@modelcontextprotocol/{core,node,server} v2 |
Verified on main at the time of writing: 851 unit/integration tests pass, 0
fail (56 files, both protocol eras). The example-driven e2e/ suite — which
boots every examples/<project> as a real subprocess and drives it with a
pinned old client (@modelcontextprotocol/sdk@1.10.0) and a modern
2026-07-28 client — last ran 316 pass / 0 fail across 16 files. The old
client passing unmodified is the backward-compatibility proof for 2025-era
consumers.
Installation
npm install @rekog/mcp-nest \
@modelcontextprotocol/server \
@modelcontextprotocol/core \
@modelcontextprotocol/node \
@nestjs/microservices \
zod@^4- The three MCP SDK packages are peer dependencies.
2.0.0-beta.5is the
minimum — it is the first SDK release matching the final2026-07-28
wire format.beta.4ships the pre-final shape and is interop-broken against
conforming peers. - The declared range
^2.0.0-beta.5accepts later2.0.0-beta.x,2.0.0-rc.x
and the eventual stable2.0.0/2.x. It does not accept prereleases of a
later line (e.g.2.1.0-beta.1) — that would need a range edit in a future
MCP-Nest release. @nestjs/microservicesis a new required peer dependency — it is how the
strategy attaches to your app.- Optional:
npm install @rekog/mcp-nest-authfor the built-in OAuth
authorization server, plus@nestjs/typeorm typeormif you use its TypeORM
store.
Upgrading from v1
v2 is not backward compatible. Every v1 server needs code changes — there is no
compatibility shim. The fastest path is to hand the migration to your coding
agent, then test against a real MCP client before shipping.
Upgrading with an agent
The migration is mechanical but touches every capability class and your
bootstrap. It is well suited to an agent, and the repo ships the guide the agent
needs.
1. Point the agent at the migration guide. It is the authoritative old→new
API map:
2. Give it live docs via Context7. MCP-Nest is indexed at
context7.com/rekog-labs/mcp-nest —
connect the Context7 MCP server
so the agent can pull current v2 documentation instead of recalling v1 APIs from
training data.
3. A prompt that works well — copy this verbatim:
Upgrade this NestJS app from @rekog/mcp-nest v1 to v2.
Read https://github.com/rekog-labs/MCP-Nest/blob/main/docs/migration-to-v2.md
first, and use Context7 (rekog-labs/mcp-nest) for anything the guide does not
cover. Do not rely on your memory of this library — v1's McpModule.forRoot()
API does not exist in v2 and will not compile.
Specifically:
- update dependencies as described in the guide's section 0: drop
@modelcontextprotocol/sdk, add @modelcontextprotocol/{core,node,server} and
@nestjs/microservices, and add @rekog/mcp-nest-auth (+ cookie-parser) if the
app uses the built-in OAuth server;
- replace McpModule.forRoot(...) with a new McpStrategy({ ... }) built in
main.ts, wired with setHttpAdapter -> connectMicroservice ->
startAllMicroservices() BEFORE listen();
- move every @Tool/@Resource/@ResourceTemplate/@Prompt class from providers to
controllers and decorate it with @McpController();
- convert positional (args, context, request) signatures to
@Payload() / @Ctx() ctx: McpContext / ctx.getRawRequest();
- move OAuth imports to @rekog/mcp-nest-auth and mount app.use(cookieParser())
if McpAuthModule is used;
- replace the module-level guards option with McpHttpControllerFor(transport)
+ @UseGuards().
Then build, run the test suite, and list anything you could not migrate
mechanically.
Test before you go to production
Please do not upgrade a production server without exercising it first. v2
rewrote the request path end to end — the transport layer, the handler dispatch,
the error masking, the auth surface, and the protocol negotiation. The suites
above are extensive, but they cannot cover your combination of guards,
interceptors, custom controllers, IdP, and client.
A reasonable pre-production checklist:
- Connect a real MCP client (Claude Code, MCP Inspector, your own) and
confirmtools/list,tools/call,resources/*, andprompts/*all behave
as they did on v1. - Exercise every guard and auth path. Authentication moved to guards on an
HTTP controller you own; per-tool authorization runs inside the RPC pipeline.
Check both an authorized and an unauthorized caller, and check that
tools/listis filtered the way you expect. - Check your error messages. Unknown errors are now masked to a generic
"Internal server error" by NestJS's RPC exception handler. If your clients
relied on seeing the real message, registerMcpExceptionFilteror throw
RpcException(see Behavioral changes). - Check statefulness. Streamable HTTP is now stateless by default —
if you relied on sessions, progress, or server-initiated notifications, you
need{ statefulMode: true }on the legacy era. - Test both protocol eras if your clients are mixed. A plain SDK v2 client
still negotiates the legacy era by default; pin2026-07-28to actually
exercise the modern leg (see
docs/protocol-revisions.md). - If you use
serverMutator, elicitation, or sampling — verify against the
era you actually serve. Push-style server→client requests throw on
2026-07-28(see Known gaps). - Run your own integration tests against a built artifact, not just a
type-check. Several classes of bug in this rewrite were only visible on the
wire.
If something is wrong, please open an issue — this is a large change and
unintended side effects are possible.
Everything new, in detail
Core architecture
- NestJS microservice transport strategy. MCP runs as a real
CustomTransportStrategy. Every tool, resource, and prompt is a
@MessagePatternhandler on an@McpController()class. - The full NestJS pipeline applies to MCP calls —
@UseGuards,
@UsePipes,@UseInterceptors,@UseFilters, request-scoped DI, and
class-validatorDTOs all work natively on tools, resources, and prompts. McpExceptionFilter(exported) un-masks handler errors to the agent when
you want that, andRpcExceptionpasses through unmasked so you can
surface a clean, client-facing message.McpHttpControllerFor(transport)— a mixin that turns a transport into a
real, ownable Nest controller with the full HTTP pipeline (guards,
interceptors, filters, versioning, middleware). Replaces the old
McpStreamableHttpServicehand-wiring, and is where HTTP-layer authentication
now belongs.- Named servers replace
forFeature:@McpController({ server: 'name' })+
McpStrategy({ server: 'name' })for several isolated MCP servers in one app. - Dynamic capabilities on the strategy —
registerTool/registerResource/registerPromptand matching
removeTool/removeResource/removePrompt, now emittinglistChanged
notifications. @McpRawRequest()param decorator (core) and@McpUser()(auth
package).- Resource templates: query parameters (
{?a,b}) are supported and the
catch-all wildcard ({path*}) — broken in v1 — now works.
Schema flexibility
@Tool'sparametersandoutputSchemaaccept any Standard Schema
validator that can emit JSON Schema (Zod 4.2+, ArkType 2.1+, Valibot via
@valibot/to-json-schema) or a raw JSON Schema object. Zod is no longer a
hard requirement of the core package. Existing Zod code is unchanged.- Tool schemas are now advertised in the JSON Schema 2020-12 dialect (was
draft-07). This matters: SDK v2 clients compileoutputSchemaas 2020-12 and
reject any other declared$schemaclient-side, so a draft-07 tool with an
output schema was uncallable by a conforming modern client. outputSchemais no longer force-cast totype: 'object'— array and scalar
output schemas survive intact (but see thestructuredContentcaveat in
Known gaps).
Protocol revision 2026-07-28 (dual-era serving)
One /mcp endpoint answers both the 2025-era protocol (initialize + sessions)
and the new stateless 2026-07-28 revision, concurrently, with no change to
your tool code. Era is a per-request fact, chosen by the client.
new StreamableHttpTransport(); // dual-era (default)
new StreamableHttpTransport({ protocol: 'modern-only' }); // 2026-07-28 only
new StreamableHttpTransport({ protocol: 'legacy-only' }); // pre-2026 behaviour
new StdioTransport({ legacy: 'reject' }); // stdio, modern openings onlyNew surface that comes with it:
ctx.getProtocolVersion()/getClientCapabilities()/getClientInfo()
— per-request client identity (modern era;undefinedon legacy).ctx.getSession().era—'legacy' | 'modern'.ctx.getTraceContext()— W3Ctraceparent/tracestate/baggagefrom
_meta; works on both eras.- Progress and logging are no longer a session privilege.
ctx.reportProgress()andctx.log.*work on every modern request (they ride
the request's own response stream, which auto-upgrades to SSE). On the modern
era, logging is opt-in per request viaio.modelcontextprotocol/logLevel—
a tool'sctx.log.info()produces no client output unless the caller asked
for it. That is spec-mandated, not a dropped message. responseMode: 'auto' | 'sse' | 'json'— the modern-era counterpart of
enableJsonResponse.cacheHintsonMcpStrategy— setttlMs/cacheScopeper cacheable
method. The SDK's default{ ttlMs: 0, cacheScope: 'private' }means no
client ever caches your tool list. Note thatcacheScope: 'public'is a
data-sharing decision; MCP-Nest warns at startup if you set it on a
tools/listthat is filtered per caller.security: { allowedOrigins, allowedHosts }—Origin/Hostvalidation
with a403+ JSON-RPC body. Off unless you configure an allowlist (the
library cannot know which hostnames your deployment answers on).stepUpAuthorization(opt-in) — HTTP403+
WWW-Authenticate: error="insufficient_scope"for per-tool scope failures,
instead of an in-band JSON-RPC error.- Spec error-code fixes: unknown tool / prompt / resource now return
-32602 InvalidParams, not-32601(which is reserved for "method not
implemented" and is load-bearing for client era detection).
Full detail: docs/protocol-revisions.md.
@rekog/mcp-nest-auth (the OAuth server, now its own package)
Moving it out keeps core free of typeorm, passport, and @nestjs/jwt. Its
v1 behaviour carries over, and v2 adds substantial security hardening:
- Token
audience/issuer/typevalidation. Previously the only
check was the HS256 signature — a token minted by the same authorization
server for a sibling resource, a refresh token, or a browser-cookie token all
authenticated as bearer credentials.⚠️ Breaking for deployments whose
configuredresourcedoesn't match theaudthey mint, or whose tokens
predate ajwtIssuer/serverUrlchange: those now401. - PKCE required, S256 only (
requirePkce: falseis the escape hatch;
plainis not re-advertised). - Requested scopes are narrowed at
/authorizeagainst the union of your
configured scope lists and every@ToolScopes()declared scope.
scopeValidation: 'passthrough'opts out. - Canonical issuer —
jwtIssuer(defaulting toserverUrl) is the single
issuer used everywhere. AjwtIssuer ≠ serverUrlconfig now throws at
bootstrap. isson authorization responses (RFC 9207) + the paired metadata flag.- Consent screen — opt-in (
consent: { enabled, render, rememberForMs }),
CSRF-protected, HTML-escaped, showing the client name, the user, the
redirect-URI hostname, and the narrowed scopes. - Client ID Metadata Documents (CIMD) — opt-in, SSRF-guarded (DNS pinned to
a vetted public address, no redirects, size/time caps), bounded LRU cache,
snapshot-at-consent semantics. Forces the consent screen on, because the
"MUST display the redirect URI hostname" requirement cannot otherwise be met. disableEndpoints.registerfor CIMD-only deployments; the
registration_endpointis then omitted from metadata rather than advertised
and 404ing.offline_accessdropped from protected-resourcescopes_supported(new
SHOULD NOT), andscopes_supportedis omitted rather than sent as[].mcpVersionsSupportednow defaults to['2026-07-28', '2025-06-18'],
matching the default dual-era posture.- Missing
cookie-parseris now a boot failure, not a confusing
400 Missing OAuth sessionat the last leg of the handshake. Mount
app.use(cookieParser())in your bootstrap;skipCookieParserCheck: true
opts out if you use an equivalent parser the check can't recognise.
Breaking changes
API map
| v1 | v2 |
|---|---|
McpModule.forRoot({...}) |
new McpStrategy({ name, version, transports: [...] }) |
McpModule.forRootAsync({ useFactory }) |
build the strategy in your own async bootstrap() before connectMicroservice |
McpModule.forFeature([...], 'name') |
@McpController({ server: 'name' }) + McpStrategy({ server: 'name' }) |
@Injectable() tool class in providers |
@McpController() class in controllers |
(args, context, request) positional params |
@Payload(), @Ctx() ctx: McpContext, ctx.getRawRequest() / @McpRawRequest() |
transport: McpTransportType[] |
transports: McpTransport[] instances |
new StreamableHttpTransport({ statelessMode: true }) |
new StreamableHttpTransport() (stateless is the default) |
new StreamableHttpTransport({ statelessMode: false }) |
new StreamableHttpTransport({ statefulMode: true }) |
McpRegistryService.registerTool() |
strategy.registerTool() (inject via MCP_STRATEGY) |
McpStreamableHttpService + hand-written controller |
class X extends McpHttpControllerFor(transport) |
McpModule.forRoot({ guards }) |
@UseGuards() on the McpHttpControllerFor controller |
@ToolGuards() |
native @UseGuards() |
McpAuthModule, McpAuthJwtGuard, McpUser, providers, stores from @rekog/mcp-nest |
same symbols from @rekog/mcp-nest-auth |
Removed
McpModule (forRoot/forRootAsync/forFeature), McpRegistryService,
McpRegistryDiscoveryService, the createStreamableHttpController /
createSseController factories, StdioService, the SseTransport
(HTTP+SSE) transport, the guards option on the strategy, the
@ToolGuards() decorator, and the module options transport, apiPrefix,
sseEndpoint, messagesEndpoint, mcpEndpoint, and streamableHttp.
There is no apiPrefix/global-prefix mechanism for MCP anymore — set the
transport's endpoint directly (a deeper path like /api/service/mcp works the
same way).
Behavioral changes to watch for
- Streamable HTTP is stateless by default, and
enableJsonResponsenow
defaults to the session mode (JSON in stateless, SSE in stateful) instead of
alwaysfalse. - Unknown errors are masked. A tool throwing a plain
Error(or an
McpError) returns{ isError: true }with a generic "Internal server error",
because NestJS's RPC exception handler masks unknown errors before the
strategy sees them. Input problems are not masked — schema validation
returns a clearInvalid parameters: …. To surface your own message, throw
RpcExceptionor registerMcpExceptionFilter. - Request scoping:
@Inject(REQUEST)in a request-scoped tool resolves to
the RPC request context, not the raw HTTP request. Usectx.getRawRequest(). sessionIdGenerator,enableJsonResponse,statefulMode,GET/DELETE /mcpare all legacy-era only. The2026-07-28revision removed sessions
outright.- Push-style server→client requests are legacy-only.
elicitInput(...),createMessage(...)(sampling),listRoots(...)and
ping(...)throw on a2026-07-28request. They keep working for 2025-era
clients on a dual-era server. - NestJS versioning is unaffected — the MCP endpoint stays
VERSION_NEUTRALalongside versioned REST routes. - STDIO servers must disable logging (
logging: false+{ logger: false })
— stdout carries the protocol. Create them with
NestFactory.createMicroservice(AppModule, { strategy }), and remember that
capability classes must be@McpController()incontrollers(v1-style
providersare not discovered, sotools/listcomes back empty).
Modern MCP spec (`2026-07-28`) — what's supported
Supported
| Area | Status |
|---|---|
| Dual-era serving on one endpoint | ✅ default (protocol: 'dual') |
server/discover (replaces initialize) |
✅ incl. supportedVersions, capabilities, _meta serverInfo |
Per-request _meta envelope (protocol version, client capabilities, client info) |
✅ + exposed on McpContext |
| Stateless request model | ✅ |
| Tools / Resources / Resource templates / Prompts on the modern era | ✅ same handlers, both eras |
| Progress + logging on sessionless requests | ✅ via the request's own response stream |
io.modelcontextprotocol/logLevel per-request logging opt-in |
✅ (spec MUST NOT enforced) |
subscriptions/listen |
✅ served by the SDK entry (in-process event bus) |
listChanged notifications for dynamic tools/resources/prompts |
✅ |
Mcp-Method / Mcp-Name headers + header↔body cross-check (-32020) |
✅ |
ttlMs / cacheScope cache hints |
✅ configurable via McpStrategy({ cacheHints }) |
resultType on results |
✅ (SDK-stamped) |
| JSON Schema 2020-12 dialect | ✅ |
Spec error codes (-32602 for unknown tool/prompt/resource, -32022, -32020) |
✅ |
| Never an empty error body (400/403/404/405 all carry JSON-RPC errors) | ✅ |
Origin / Host validation → 403 |
✅ opt-in allowlist |
W3C trace context (traceparent/tracestate/baggage) _meta keys |
✅ both eras |
Deterministic tools/list order |
✅ |
X-Accel-Buffering: no on SSE, listen-stream keep-alives |
✅ (SDK) |
extensions on client/server capabilities |
✅ passthrough |
| Pagination | ✅ single page, no nextCursor (valid per spec) |
stdio dual-era (probe → modern, initialize → legacy) |
✅ |
| Fastify and Express adapters on both eras | ✅ |
Authorization: RFC 8707 resource, PKCE S256, token audience validation, WWW-Authenticate + resource_metadata, exact redirect-URI matching, RFC 9207 iss, CIMD, consent, step-up 403 |
✅ (@rekog/mcp-nest-auth) |
Not supported / not implemented
| Gap | Detail |
|---|---|
| Multi Round-Trip Requests (MRTR) | The modern-era replacement for elicitation/sampling. Not wrapped. elicitInput/createMessage/listRoots/ping throw MethodNotSupportedByProtocolVersion on a 2026-07-28 request; they still work on the legacy leg. Gate on ctx.getSession().era === 'legacy' or serve that endpoint legacy-only. |
Tasks extension (io.modelcontextprotocol/tasks) |
Not implemented. |
Full SEP-2243 x-mcp-header support |
Header/body mirroring is refused at registration (a tool declaring it throws at startup / on registerTool) rather than implemented. Base64 sentinel decoding, static-reachability rules and numeric comparison are not done. |
subscriptions/listen across processes |
The default event bus is in-process only. A user-suppliable ServerEventBus transport option is not yet exposed, so multi-replica deployments only notify listeners on the replica that made the change. |
Non-object structuredContent round-trip |
The server side is correct (SEP-2106 honoured, array/scalar outputSchema advertised properly), but @modelcontextprotocol/client@2.0.0-beta.5 still types structuredContent as a record and rejects non-object values client-side. Blocked on the SDK client. |
private_key_jwt / mTLS client authentication |
CIMD documents declaring them are refused at /authorize with a clear message. No JWKS fetching or JWT-assertion verification. |
| CIMD cache & remembered consents across processes | Per-replica, in memory. Consequence is extra document fetches and extra consent prompts — not a correctness or safety issue. |
| CIMD trust policies (the spec's MAY) | No domain allowlist, reputation, domain-age or certificate checks. Any routable https origin is accepted. |
| Consent on STDIO | Not wired (no browser). No "revoke my approvals" route either (ConsentService.clearConsents() exists but is unexposed). |
logo_uri rendering on the consent page |
Parsed but deliberately never rendered — it would be an attacker-controlled outbound request from the consent page. |
code_challenge format validation (RFC 7636's 43–128 chars) |
Not validated; a malformed S256 challenge simply never verifies. |
Deprecated upstream, still working
Roots, Sampling, and Logging are deprecated in 2026-07-28 with a ≥12-month
window, and Dynamic Client Registration is deprecated in favour of CIMD with
removal not eligible before 2027-07-28. All remain functional in MCP-Nest —
plan the migration, don't rush it.
One thing that surprises people
A stock SDK v2 client — MCP Inspector included — reports a dual-era server as
legacy 2025-11-25. The client's default versionNegotiation is 'legacy':
no probe, no new headers. The era is chosen by the client, per request; the
server never prefers one. To actually exercise the modern leg you must pin
2026-07-28 or opt into mode: 'auto'.
v1 status
v1 lives on the v1 branch and is in security-patch-only maintenance:
community-contributed security fixes are accepted, but no new features or
general bug fixes from maintainers. Existing v1 users can stay on 1.9.x as
long as they need. New projects should start on v2.
Credits
The v2 rewrite was started by @Manuel-Antunes.
Please file issues for anything that breaks, feels wrong, or is missing from the
migration guide.