Skip to content

v2.0.0: North Face

Latest

Choose a tag to compare

@harijoe harijoe released this 04 Sep 08:43
41df9c7

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:

// 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;
// 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;
// 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:

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:

// WRONG: one pool per request
export const app = new Skybridge({
  ...config,
  handler: (server) => {
    const pool = new pg.Pool();
    return server.registerTool(...);
  },
});
// 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:

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.

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:

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 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
  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 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)

// before
const server = new McpServer(info, options, skybridgeOptions).registerTool(...);
// 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.

// before, src/server.ts
export default await server.run();
// 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.

// before
import { skybridge } from "skybridge/vite";
// 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)

// before
async (args, extra) => {
  const token = extra.authInfo?.token;
  const meta = extra._meta;
  extra.signal.throwIfAborted();
}
// 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.

// before
server.registerTool("search", { inputSchema }, handler);
// after
server.registerTool({ name: "search", inputSchema }, handler);

useLayout is removed (PR #1008)

LayoutState becomes ViewportState.

// before
const { theme, maxHeight, safeArea } = useLayout();
// after
const { theme } = useUser();
const { maxHeight, safeArea } = useViewport();

useHostInfo is renamed useHost (PR #1008)

Same return shape.

useDownload returns the function bare (PR #1008)

// before
const { download } = useDownload();
// 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.

// before
const { isIdle, isPending } = useToolInfo("search");
// after
const { isPending } = useToolInfo("search");

InvalidTokenError is removed (PR #1008)

// before
throw new InvalidTokenError("expired");
// 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().

// before
oauth: await workosProvider({ domain, audience }),
const config = await customProvider({ issuer, audience });
// 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.

// before
oauth: { verify: { jwksUri, issuer, audience } }
// 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 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 #1008
  • feat(devtools): migrate the MCP client to sdk v2 and harden the browser OAuth flow by @harijoe in #1008
  • feat!: replace the registrar with a user-defined factory via the Skybridge app class by @harijoe in #1070
  • feat(test): evals by @harijoe in #1058
  • feat: add mcpcn example by @Aniket-508 in #1056
  • fix(docs): add missing mcpcn showcase preview image by @fredericbarthelet in #1068
  • docs: restore page heroes with new illustrations by @valentinbeggi in #1060
  • feat(create-skybridge): scaffold a repo example via --example by @harijoe in #1072
  • fix(core): always pass (args, extra) to schema-less tool handlers by @harijoe in #1061
  • fix(devtools): QA feedbacks on the realistic UI preview by @harijoe in #1063
  • fix(devtools): size the view iframe to the whole document so short widgets don't scroll by @harijoe in #1078
  • fix(devtools): expose inspector in fullscreen by @MatLBS in #1073
  • fix(ci): make npm publish idempotent per version by @harijoe in #1047