/

MCP Guides

Skybridge v2 ships the new MCP protocol and lets you test your app with Evals

The new version of the MCP protocol involved a few breaking changes in Skybridge. We took the opportunity to ship a bunch of features alongside it: one of them is Evals, a way to define and run tests that involve an LLM to make sure your app will behave correctly when published in the stores.

A new MCP protocol version

MCP shipped the 2026-07-28 revision in July. We wrote about what it changed at the time: the protocol went stateless, the initialize handshake and the session ID went away. App extensions, Skybridge’s raison d’être, are now part of the official spec.

Why the server had to be rebuilt

The new MCP revision changes how results are serialized. Before this revision, the SDK and thus Skybridge (which relies on the official SDK) used to keep one McpServer for the lifetime of the process. Now that the server needs to speak two different versions of the protocol, we switched to a factory pattern, where each request spins up its own McpServer.

So the server definition moved into a handler the framework calls for each request. Your tools, resources, prompts and views are now registered on the instance that serves that request, which is the one the SDK stamps with the negotiated revision. It's recreated on each request, but that is by design.

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

The entry point splits in two. server.ts stays the complete definition of your app, and a new src/index.ts only runs it, which is what lets tests and evals import the app without starting it.

Everything else is inferred from that one config object. Pass a provider to oauth and extra.http.authInfo.extra carries its claims, typed, in every tool handler.

Keep the handler pure

There is one change here 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. Anything you used to do once at module scope goes in setup:

// WRONG: one pool per request
export const app = new Skybridge({
  handler: (server) => {
    const pool = new pg.Pool();
    return server.registerTool(...);
  },
});
// WRONG: one pool per request
export const app = new Skybridge({
  handler: (server) => {
    const pool = new pg.Pool();
    return server.registerTool(...);
  },
});
// WRONG: one pool per request
export const app = new Skybridge({
  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(...),
});
// RIGHT: setup runs once, the handler receives its result
export const app = new Skybridge({
  ...config,
  setup: () => new pg.Pool(),
  handler: (server, pool) => 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 is worth watching for during a migration in particular, 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. setup runs once, at run() or on the first request and never at module import, and its result reaches the handler as its second argument. If a handler takes longer than 50ms, Skybridge warns once in the console.

Evals: ensure your tools are called before shipping to production

Every MCP app builder has hit this. The tool returns the right thing when you call it by hand in devtools, and then a user describes their problem in their own words and the model answers from its own knowledge, or reaches for the wrong tool, or asks a clarifying question you never wanted it to ask. Nothing is broken. The tool was simply not the obvious move to the model.

Until now there was no way to check for that short of opening ChatGPT and typing. With v2, Skybridge now ships @skybridge/test. It runs a real conversation against your app, in process, and hands you the calls the model made:

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" });
});
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" });
});
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" });
});

The matchers are typed against your own registry, so the tool name autocompletes and the argument object is checked against that tool's input schema. toNeverHaveCalledTool covers the tool you do not want reached, toHaveFailedToolCall the error path, and toHaveSaid the cases where what matters is the answer rather than the call. There is no HTTP server, no port and no fixtures: the client dials your app through an in-process fetch, and each conversation gets its own handler that closes with the test.

For an app behind sign-in, claim an identity for the session with authInfo. Only token verification is skipped. Per-tool schemes and scope checks run for real against those claims, so you can assert that an anonymous caller gets the auth challenge and a signed-in one gets the data.

@skybridge/test ships as a beta alongside this release. Install it from the beta dist-tag, expect the matcher API to move in minors, and pin the exact version in CI if that matters to you.

Bring your own validator

inputSchema and outputSchema now accept any Standard Schema validator. Nothing changes if you are happy with zod, and if you would rather use valibot or arktype, the inferred handler argument types follow.

The one requirement is that the validator can also emit JSON Schema, since that is what tools/list sends to the host. Zod 4 and ArkType do it out of the box, valibot does through @valibot/to-json-schema, and a validator that cannot is a compile error rather than a silent gap in your tool listing. Since apps now bring their own, zod is a peer dependency instead of a bundled copy, and it has to be 4.2 or later.

How to migrate

This is a major release, so there are breaking changes, and most of them are small mechanical edits: skybridge/vite moves to @skybridge/vite-plugin, the SDK comes out of your dependencies and its exports come from skybridge/server, extra.authInfo becomes extra.http.authInfo, useLayout splits into useUser for theme and useViewport for viewport geometry. The release notes list every one with a before and after, ordered by how likely a v1 app is to hit it, and the compiler catches all but a handful, which is why we wrote the migration as a skill rather than a checklist.

Install it, then point your coding agent at the release:

Then validate, because a green build is not enough here. Run tsc --noEmit first, which catches the handler return, the reshaped extra and every removed export. Then skybridge build followed by skybridge start, not just skybridge dev, since that is the only step that exercises the compiled entry point. Then open a view in devtools and confirm it renders. A missing src/index.ts passes both tsc and the build and only fails when you start the server, and an impure handler passes all of it and fails under sustained traffic.

To get started, npm create skybridge@latest scaffolds a v2 app, the migration guide covers the upgrade, and our Discord is where the maintainers answer questions.

Liked what you read here?

Get our newsletter!