v2.0.0
alpic-ai/skybridgev2.0.0Sep 4, 2026by harijoe
AI Summary
A major release migrating to the MCP 2026-07-28 SDK v2, completely rebuilding the server architecture from a singleton to a per-request instance, introducing a new `Skybridge` app class, and adding a new in-process testing framework.
Key Highlights
- Migrated to MCP SDK v2 (2026-07-28) with per-request server instances
- Introduced `Skybridge` app class replacing the `McpServer` singleton
- New `@skybridge/test` package for integration testing against real models
- Support for Standard Schema validators (not just Zod)
- New `setup` function for one-time initialization before requests
Breaking Changes
- McpServer singleton replaced by the new `Skybridge` app class
- Server entry point moved from `src/server.ts` to `src/index.ts`
- Package `skybridge/vite` moved to `@skybridge/vite-plugin`
- Removed `InvalidTokenError` (use `OAuthError` instead)
- Removed `useLayout` hook (split into `useUser` and `useViewport`)
- Reorganized `extra` object structure in tool handlers
- Removed `McpServer.connectStatelessTransport` method
- Removed `oauth.verify` option in favor of `oauthMetadata` and `verifier`
New Features
- In-process testing framework with `@skybridge/test`
- Support for Standard Schema validators in `inputSchema` and `outputSchema`
- Reorganized hooks surface (`theme` in `useUser`, viewport in `useViewport`)
- Deferred OAuth providers (no network calls during import)
- Custom Express error middleware support
Full Release Notes
No lift runs to the north face. You climb it, and you pick the line yourself because nobody has left tracks in it.
MCP shipped the `2026-07-28` revision this summer and no framework was on it yet. Getting there meant rebuilding the part of Skybridge nobody sees: how a request becomes a server. One `/mcp` endpoint now serves both the 2025-era hosts you already support and the new revision, and every request gets its own server instance instead of sharing one long-lived singleton.
The other unmarked line is testing. A tool that works is not the same as a tool the model decides to call, and until now there was no way to check the difference. `@skybridge/test` runs a real conversation against your app and asserts on the calls the model actually made.
Alongside those: schema validation that is no longer zod-only, and a hooks surface that groups things where you expect to find them.
**This is a major, so there are breaking changes.**
The full list, with before and after, is in the changelog below, and your coding agent can do most of it for you.
---
## MCP 2026-07-28, and why the server got rebuilt
The new protocol revision changes how results are serialized, and in SDK v2 the negotiated era is state on the server instance. Skybridge used to keep one `McpServer` for the process lifetime, which meant that instance never negotiated anything: it would have served 2026-era clients with the 2025 codec, and one client's `initialize` would have overwritten another's.
So registration moved into a handler the framework calls for each request. Your tools, resources, prompts and views are registered on the instance that serves the request, which is the one the SDK actually stamps with the negotiated version.
You see this as the new `Skybridge` app. The implementation info, the SDK options and Skybridge's own options merge into one config object, and the tool chain moves into its `handler` field:
```ts
// before
const server = new McpServer(
{ name: "my-app", version: "1.0.0" },
{ capabilities: {} },
{ oauth },
).registerTool({ name: "search", ... }, handler);
export default await server.run();
export type AppType = typeof server;
```
```ts
// src/server.ts, after
export const app = new Skybridge({
name: "my-app",
version: "1.0.0",
oauth: descopeProvider({ url: env.DESCOPE_URL }),
handler: (server) => server.registerTool({ name: "search", ... }, searchHandler),
});
export type AppType = typeof app;
```
```ts
// src/index.ts, after
import { app } from "./server.js";
export default await app.run();
```
`server.ts` stays the complete definition of your app, `index.ts` only runs it, and tests can import the app without triggering the run.
The handler must **return** the chained server, that is what carries your tool types into `typeof app`. Everything else is inferred from the config too: pass a provider to `oauth` and `extra.http.authInfo.extra` is typed with its claims in every tool handler, no annotation needed. Keep the handler inline for that to work; an extracted handler needs a hand-written `McpServer` type and loses the inference, which is why the `SkybridgeServer` alias from the v2 betas is gone.
There is no module-scope server object any more, so anything you used to do to one after startup, `registered.disable()` or a raw `setRequestHandler`, moves inside the handler. Outside it there is nothing to call them on, which the compiler will tell you.
`mcpMiddleware` follows the same rule: it is per-instance state now, so it chains inside the handler, and ordering within the chain is unchanged:
```ts
export const app = new Skybridge({
...config,
handler: (server) =>
server
.mcpMiddleware(intentMiddleware())
.registerTool(...),
});
```
### Keep the handler pure
This is the one part of the change that can bite you in production, and neither the compiler nor a manual test will catch it. The handler body runs on every request, so registration is the only work that belongs inside it:
```ts
// WRONG: one pool per request
export const app = new Skybridge({
...config,
handler: (server) => {
const pool = new pg.Pool();
return server.registerTool(...);
},
});
```
```ts
// RIGHT: setup runs once, the handler receives its result
export const app = new Skybridge({
...config,
setup: () => new pg.Pool(),
handler: (server, pool) => server.registerTool(...),
});
```
The same goes for file reads, config parsing and client construction. This matters most when migrating, because in v1 those statements sat at module scope in the same file and the tempting move is to wrap the lot in the handler. If a handler takes longer than 50ms, Skybridge warns once in the console.
`setup` runs once, at `run()` or on the first request and never at module import, and its result is passed to the handler as the second argument. The handler stays synchronous:
```ts
export const app = new Skybridge({
...config,
setup: async () => loadConfig(),
oauth: (cfg) => descopeProvider({ url: cfg.mcpServerUrl }),
handler: (server, cfg) => server.registerTool({ name: cfg.toolName, ... }, searchHandler),
});
```
`oauth` takes a provider, a raw config object, or a function of the `setup` result returning either. Providers are deferred: `descopeProvider(...)` validates its arguments but performs no network call until `run()` resolves it, so importing `server.ts` from tests and evals never reaches your IdP.
## Evals
A tool that works is not the same as a tool the model decides to call. `@skybridge/test` closes that gap: it runs a real conversation against your app, in process, and hands you the calls the model made.
`@skybridge/test` ships as a beta alongside this release: install it with `@skybridge/test@beta`, expect the matcher API to move in minors, and pin the exact version in CI if that matters to you.
```ts
import { anthropic } from "@ai-sdk/anthropic";
import { start } from "@skybridge/test";
import { expect, it } from "vitest";
import { app } from "../src/server.js";
it("reaches the capitals tool from a natural prompt", async () => {
const chat = await start({ app, model: anthropic("claude-sonnet-4-5") });
await chat.send("Tell me about the capital of France");
expect.chat(chat).toHaveCalledToolWith("explore-capitals", { name: "Paris" });
});
```
`toHaveCalledToolWith` and `toHaveCalledToolOnce` are typed against your own registry, so the tool name autocompletes and the argument object is checked against that tool's input schema. `toNeverHaveCalledTool`, `toHaveFailedToolCall` and `toHaveSaid` cover the negative and conversational cases. Add `evals: {}` to the Vite plugin options to wire the matchers into vitest, then `vitest run evals`.
No HTTP server, no port, no fixtures. The client dials your app through an in-process fetch, and each conversation gets its own MCP handler that closes with the test.
Add `@skybridge/test@beta` (the package is published on the `beta` dist-tag only while the API settles, so a bare `@skybridge/test` will not install it), `vitest`, `ai` and the provider package you want (`@ai-sdk/anthropic`, `@ai-sdk/openai`, …) as dev dependencies, name your scenarios `evals/*.eval.ts`, and run them with `vitest run evals`. Turning on `evals` also raises the per-scenario timeout to two minutes, since each turn is a live model call, and loads your `.env` so the provider key is picked up. Tune the timeout with `evals: { timeout }`.
For an app behind OAuth, claim an identity for the session:
```ts
const chat = await start({
app,
model: anthropic("claude-sonnet-4-5"),
authInfo: { token: "eval", clientId: "evals", scopes: ["read"] },
});
```
Only token verification is skipped. Per-tool schemes and scope checks run for real against those claims, and `extra` carries whatever your verifier would have produced. Omit `authInfo` to exercise the anonymous path, auth challenges included. The `auth-descope` example ships a scenario to start from.
## Standard Schema
`inputSchema` and `outputSchema` accept any [Standard Schema](https://standardschema.dev) validator now, not just zod. Nothing changes if you are happy with zod. If you would rather use valibot or arktype, you can, and the inferred handler argument types follow.
The schema has to advertise itself as JSON Schema too, since that is what `tools/list` sends to the host: the accepted type is the SDK's `StandardSchemaWithJSON`. Zod 4 and ArkType implement it out of the box; valibot does through `@valibot/to-json-schema`. A validator that cannot emit JSON Schema is a compile error, not a silent gap in your tool listing.
One consequence of users bringing their own schemas: zod is a peer dependency of `skybridge` now, not a bundled copy, and it has to be 4.2 or later, since 4.0 and 4.1 make the SDK fall back to a converter that drops `.describe()` descriptions from the schema the model sees.
## Hooks, regrouped
`useLayout` mixed two unrelated things: a user preference that rarely changes, and viewport geometry that changes on every resize. Views that only wanted `theme` re-rendered on every resize. They are split now: `theme` joins `useUser`, next to `locale` and `userAgent`, and `maxHeight` and `safeArea` move to the new `useViewport`.
---
## How to migrate?
1. Install [the latest version of the skill](https://docs.skybridge.tech/guides/migrate)
2. Run the prompt inside your favorite coding agent: `Migrate my app based on https://github.com/alpic-ai/skybridge/releases/tag/v2.0.0`
Then validate, because a green build is not enough:
1. Install dependencies and check for unmet peer warnings on `@skybridge/devtools` or `@skybridge/vite-plugin`.
2. `tsc --noEmit`. This catches the handler return, the `extra` reshape, and every removed export.
3. `skybridge build`, then `skybridge start`, not just `skybridge dev`: it is the only step that exercises the compiled `dist/index.js` entry.
4. `skybridge dev`, open the view in devtools and confirm it renders.
A missing `src/index.ts` passes `tsc` and `skybridge build`; `skybridge dev` and `skybridge start` both fail on it, unless a leftover `nodemon.json` still points `dev` at `src/server.ts`, in which case `dev` silently starts nothing. Delete that file, the CLI default already runs `src/index.ts`. An impure handler passes everything, devtools included, and only fails under sustained traffic.
Having issues? Join [our Discord](https://discord.gg/2jc92tZQdn) to get help from the maintainers.
---
## Breaking changes
Ordered by how likely a v1 app is to hit them. Each is enforced by the compiler unless noted.
### The Skybridge app replaces the McpServer singleton (PR #1008, #1070)
```ts
// before
const server = new McpServer(info, options, skybridgeOptions).registerTool(...);
```
```ts
// after
export const app = new Skybridge({ ...info, ...options, ...skybridgeOptions, handler: (server) => server.registerTool(...) });
```
One config object. The tool chain moves into `handler`, which must return it; one-time work goes in `setup`, whose result is the handler's second argument; `oauth` takes a provider, a config, or a function of the `setup` result. `mcpMiddleware` chains inside the handler too.
### The server entry moves to src/index.ts (PR #1070)
`src/server.ts` exports the app, a new `src/index.ts` runs it, and `skybridge start` looks for `dist/index.js`. Not caught by the compiler: a missing `index.ts` only fails at `skybridge start`.
```ts
// before, src/server.ts
export default await server.run();
```
```ts
// after, src/index.ts
import { app } from "./server.js";
export default await app.run();
```
### skybridge/vite moves to @skybridge/vite-plugin (PR #1008)
Install `@skybridge/vite-plugin` as a devDependency. The eval matchers live at `@skybridge/vite-plugin/evals`.
```ts
// before
import { skybridge } from "skybridge/vite";
```
```ts
// after
import { skybridge } from "@skybridge/vite-plugin";
```
### Dependencies: drop the SDK, add zod, bump devtools (PR #1008)
Delete `@modelcontextprotocol/sdk` from your dependencies and import from `skybridge/server`, which re-exports `ProtocolError`, `ProtocolErrorCode`, `OAuthError` and `OAuthErrorCode`. Add `zod` at `^4.2.0`, now a peer dependency (4.0 and 4.1 drop `.describe()` descriptions from the schema the model sees). Move `@skybridge/devtools` to v2 as well: `skybridge` v2 peer-depends on it, so a `^1.x` pin reports an unmet peer.
### Tool handler extra is now the SDK's ServerContext (PR #1008)
```ts
// before
async (args, extra) => {
const token = extra.authInfo?.token;
const meta = extra._meta;
extra.signal.throwIfAborted();
}
```
```ts
// after
async (args, extra) => {
const token = extra.http?.authInfo?.token;
const meta = extra.mcpReq._meta;
extra.mcpReq.signal.throwIfAborted();
}
```
`signal`, `id`, `notify` and `send` all move under `extra.mcpReq`. The typed ChatGPT client hints stay on `extra.mcpReq._meta`.
### The registerTool(name, config, handler) form is removed (PR #1008)
Not caught by the compiler in v1 either: this form was accepted at runtime only, never typed. If your app has it, it already failed `tsc`; v2 also drops the runtime tolerance.
```ts
// before
server.registerTool("search", { inputSchema }, handler);
```
```ts
// after
server.registerTool({ name: "search", inputSchema }, handler);
```
### useLayout is removed (PR #1008)
`LayoutState` becomes `ViewportState`.
```tsx
// before
const { theme, maxHeight, safeArea } = useLayout();
```
```tsx
// after
const { theme } = useUser();
const { maxHeight, safeArea } = useViewport();
```
### useHostInfo is renamed useHost (PR #1008)
Same return shape.
### useDownload returns the function bare (PR #1008)
```tsx
// before
const { download } = useDownload();
```
```tsx
// after
const download = useDownload();
```
### useToolInfo loses its idle state (PR #1008)
`status` starts at `"pending"` and never was `"idle"` at runtime. `ToolIdleState` and the `isIdle` field are gone from every state in the union.
```tsx
// before
const { isIdle, isPending } = useToolInfo("search");
```
```tsx
// after
const { isPending } = useToolInfo("search");
```
### InvalidTokenError is removed (PR #1008)
```ts
// before
throw new InvalidTokenError("expired");
```
```ts
// after
throw new OAuthError("invalid_token", "expired");
```
### Providers return a deferred OAuthProvider (PR #1008)
The branded providers and `customProvider` no longer return a promise. They return an `OAuthProvider`, an object whose `resolve()` runs discovery, and `oauth` accepts it directly. Drop the `await`; if you resolved a provider by hand to feed `requireBearerAuth`, call `resolve()`.
```ts
// before
oauth: await workosProvider({ domain, audience }),
const config = await customProvider({ issuer, audience });
```
```ts
// after
oauth: workosProvider({ domain, audience }),
const config = await customProvider({ issuer, audience }).resolve();
```
### The oauth verify option is removed (PR #1008)
`verifier` is the only path, and it types the claims your handlers receive.
```ts
// before
oauth: { verify: { jwksUri, issuer, audience } }
```
```ts
// after
oauth: { oauthMetadata, verifier: createJwksVerifier({ jwksUri, issuer, audience }) }
```
### KnownToolMeta.securitySchemes is removed (PR #1008)
Declare security schemes through the top-level tool config or the `auth` field instead of `_meta`. Not caught by the compiler: `ToolMeta` stays open (`Record<string, unknown>`), so `_meta: { securitySchemes }` still typechecks, is forwarded to the client untouched, and no longer enforces anything.
### ViewConfig.hosts and window.skybridge.hostType are removed (PR #1008)
Every view emits a single ext-apps resource, so host targeting no longer applies. `ViewHostType` is gone, and the served view page no longer declares `hostType` on `window.skybridge`; the runtime is detected at load time via `window.openai`. A view reading `window.skybridge.hostType` should drop the check.
### SDK v2 renames that reach tool handlers (PR #1008)
`McpError` is `ProtocolError` and `ErrorCode` is `ProtocolErrorCode`. `extra.http?.req` is a Web `Request`, so header reads use `.headers.get()`. `extra.mcpReq.send()` takes no result schema for spec methods. `extra.mcpReq.elicitInput()` exists but is deprecated by the SDK and throws on a 2026-07-28 request; on the new revision, return `inputRequired(...)` (re-exported from `skybridge/server`) from the handler instead, as the SDK's upgrade guide describes. `registerPrompt` and `registerResource` follow the SDK signatures: the variadic `.prompt()`/`.resource()` forms are gone, `argsSchema` is a schema object, and `registerResource` takes a metadata argument. The other OAuth error classes collapse into `OAuthError` + `OAuthErrorCode`. Not caught by the compiler: unknown-tool calls now reject with `ProtocolError` instead of resolving `isError: true`, and error messages lose the `MCP error <code>:` prefix. The SDK's [upgrade guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration/upgrade-to-v2.md) covers the rest.
### Registration and OAuth errors surface at first use (PR #1008)
`setup`, `oauth` and the first run of `handler` happen at `run()` or on the first request, not when `new Skybridge(...)` executes. A duplicate tool name or an invalid `oauth.baseUrl` therefore rejects the first `run()` or `createServerInstance()` call instead of throwing from the constructor. Custom Express middleware registered with `app.use()` before `run()` now runs before the bearer check on `/mcp`; the route itself stays protected.
### McpServer.connectStatelessTransport is removed (PR #1008)
Per-request instances make it meaningless: the framework connects a transport for every request. Apps that wired their own transport go through `app.run()` or `createServerInstance()` (below).
### app.fetchHandler is removed and createServerInstance is async (PR #1008)
Only relevant if you ran a v2 alpha or beta; neither existed in v1. The HTTP surface an app exposes is the Express listener (`app.run()` / `app.express`). Code that dialed `app.fetchHandler` builds its own handler with `createMcpHandler(() => app.createServerInstance())` from `@modelcontextprotocol/server`. `createServerInstance()` returns a promise now, since it resolves `setup` and `oauth` on first use.
### View tool inputSchema is zod-only (PR #1008)
Tools registered from a view via `useRegisterViewTool` take a zod shape (`ViewToolInputShape`); the bridge validates arguments with `z.object()` at runtime. Server-side schemas are unaffected and accept any Standard Schema validator.
### RawInputShape and InferSchemaOutput are removed (PR #1008)
Short-lived aliases from earlier v2 alphas. `skybridge/server` re-exports the SDK's `StandardSchemaWithJSON`: use it for a schema and `Record<string, StandardSchemaWithJSON>` for a shape. For what a schema produces, use your library's own inference helper (`z.infer`, `v.InferOutput`, `typeof schema.infer`).
### SkybridgeServerOptions is what McpServer reads (PR #1008)
Only relevant if you construct `McpServer` by hand: its third argument is now `{ oauth?: boolean; skills?: boolean }`. Everything else lives on the `Skybridge` config.
---
## Changes
* feat!: migrate to @modelcontextprotocol/sdk v2 (MCP 2026-07-28) by @harijoe in https://github.com/alpic-ai/skybridge/pull/1008
* feat(devtools): migrate the MCP client to sdk v2 and harden the browser OAuth flow by @harijoe in https://github.com/alpic-ai/skybridge/pull/1008
* feat!: replace the registrar with a user-defined factory via the Skybridge app class by @harijoe in https://github.com/alpic-ai/skybridge/pull/1070
* feat(test): evals by @harijoe in https://github.com/alpic-ai/skybridge/pull/1058
* feat: add mcpcn example by @Aniket-508 in https://github.com/alpic-ai/skybridge/pull/1056
* fix(docs): add missing mcpcn showcase preview image by @fredericbarthelet in https://github.com/alpic-ai/skybridge/pull/1068
* docs: restore page heroes with new illustrations by @valentinbeggi in https://github.com/alpic-ai/skybridge/pull/1060
* feat(create-skybridge): scaffold a repo example via --example <name> by @harijoe in https://github.com/alpic-ai/skybridge/pull/1072
* fix(core): always pass (args, extra) to schema-less tool handlers by @harijoe in https://github.com/alpic-ai/skybridge/pull/1061
* fix(devtools): QA feedbacks on the realistic UI preview by @harijoe in https://github.com/alpic-ai/skybridge/pull/1063
* fix(devtools): size the view iframe to the whole document so short widgets don't scroll by @harijoe in https://github.com/alpic-ai/skybridge/pull/1078
* fix(devtools): expose inspector in fullscreen by @MatLBS in https://github.com/alpic-ai/skybridge/pull/1073
* fix(ci): make npm publish idempotent per version by @harijoe in https://github.com/alpic-ai/skybridge/pull/1047