# @lovable.dev/mcp-js

Author MCP servers for Lovable apps. Declare tools with `defineTool`, register them with `defineMcp`, and a Vite plugin emits the framework-specific routes at build time (TanStack today, Supabase Edge Functions next).

Vite is an optional peer dependency: deployed MCP handlers do not need it. Both `/stacks/*/vite` plugins and `lovable-mcp-extract-manifest` require an explicitly installed Vite version `>=5.0.0 <9.0.0`. Keep your project's existing compatible version, or install one with `npm install -D vite@^7`. Build and manifest-extraction jobs must include dev dependencies; runtime-only deployments can omit them.

```ts
// src/lib/mcp/tools/echo.ts
import { defineTool } from "@lovable.dev/mcp-js";
import { z } from "zod";

export default defineTool({
  name: "echo",
  title: "Echo",
  description: "Echo the input text back.",
  inputSchema: { text: z.string().min(1) },
  handler: ({ text }) => ({ content: [{ type: "text", text }] }),
});
```

```ts
// src/lib/mcp/index.ts
import { defineMcp } from "@lovable.dev/mcp-js";
import echoTool from "./tools/echo";

export default defineMcp({
  name: "my-app-mcp",
  title: "My App MCP",
  version: "0.1.0",
  instructions: "Tools for interacting with My App. Use `echo` to verify connectivity.",
  tools: [echoTool],
});
```

```ts
// vite.config.ts
import { defineConfig } from "vite";
import { mcpPlugin } from "@lovable.dev/mcp-js/stacks/tanstack/vite";

export default defineConfig({ plugins: [mcpPlugin()] });
```

That's the whole authoring surface. No imperative server construction, no route handlers, no JSON-RPC envelope, no transport instantiation — all wrapped.

Manifest extraction allows **512 tools** in the evaluated `defineMcp({ tools })` array, including conditional and programmatically generated tools. Above that limit, extraction writes the current manifest and exits with an error; reduce the array to 512 tools or fewer. This is an extraction limit, not a runtime cap: the SDK does not truncate `tools/list`.

`inputSchema` is closed: a `tools/call` carrying an argument key the schema does not declare fails in-band (`isError: true`, naming the key) instead of silently dropping it, and `tools/list` advertises `additionalProperties: false`. Set `strict: false` on a tool to accept and drop unknown keys instead.

## Task workflows as skills

**Experimental:** Skills support follows the developing [MCP Skills extension](https://modelcontextprotocol.io/extensions/skills). Client support varies, and the integration may change as the specification evolves.

Keep shared terminology and server-wide rules in `instructions`, individual tool contracts in tool descriptions, and task procedures in [Agent Skills](https://agentskills.io/specification). Import a dedicated skill directory and pass it in `defineMcp({ skills: [refunds] })`:

```ts
import refunds from "./skills/refunds?skill";
```

Add `/// <reference types="@lovable.dev/mcp-js/skill-imports" />` to an included `.d.ts` file. Imports use relative or absolute directory paths, not aliases. Both stack plugins and the manifest extractor compile these imports; the deployed server needs no source directory or filesystem access. Imported values have the opaque `Skill` type: pass them to `defineMcp`, rather than constructing or inspecting compiled manifests. Each directory needs `SKILL.md` with valid YAML `name` and `description`; `name` must match its directory. All JSON-compatible frontmatter fields are preserved. Supporting files must be UTF-8 text. Binary content, ASCII controls other than tab/CR/LF (including DEL), symlinks, cycles and metadata that cannot survive JSON serialization fail the build. Text scripts can be included, but the SDK neither installs nor executes them.

**Every ordinary file beneath an imported directory is published recursively.** Hidden entries (a dot-prefixed path segment) fail the build unless each file is explicitly allowlisted on that import:

```ts
import refunds from "./skills/refunds?allowHidden=templates/.prettierrc&allowHidden=.config/example.json&skill";
```

Repeat `allowHidden` for each exact, case-sensitive file path relative to the skill directory. Directory-wide grants, globs, traversal paths, duplicate entries and entries that do not name an existing file are rejected. A hidden directory is traversed only for listed descendants; every file beneath it needs its own entry. An empty hidden directory fails too. Allowed files still pass the text-only and symlink checks. Combine `allowHidden` with `path` in either order, keeping `skill` last; percent-encode reserved characters in values (for example `%26` for `&` and `%25` for `%`).

Use dedicated publication directories without credentials or private documents. The allowlist prevents accidental hidden-file publication, not secrets in ordinary or explicitly approved files. Files use the same server-level OAuth and origin policy as tools. Tool visibility does not control Skill visibility; use a conditional Skill registration when callers need different procedures.

By default, `./skills/refunds?skill` publishes `skill://refunds/SKILL.md` and its supporting files. Use root-relative file references such as `references/rules.md` inside a skill. Reference a different skill with its complete URI; the host resolves it through `skills/get` and its skill-loading path. Reading `SKILL.md` as an ordinary resource does not activate it.

An explicit path supports namespacing and nested skills:

```ts
import refunds from "./skills/refunds?path=acme/refunds&skill";
import escalation from "./skills/refunds/escalation?path=acme/refunds/escalation&skill";
```

Register both imports to list both skills. Keep a nested import's path consistent with its enclosing skill so shared files have one URI. Nested directories remain supporting content unless separately registered. The last path segment must match the skill's name; duplicate names at distinct URIs are allowed. URI collisions with different content fail. Paths are encoded per segment, so Unicode supporting filenames are preserved.

The server advertises the [Skills extension](https://modelcontextprotocol.io/extensions/skills), implements `skills/list` and `skills/get`, and serves complete manifests and byte-exact files through MCP resources. Client support is evolving; consult the official page for current implementations.

| Client declaration | Server instructions |
| --- | --- |
| Declares `io.modelcontextprotocol/skills` | Authored instructions plus names, descriptions and URIs; the host loads skills on demand. |
| No declaration | Authored instructions plus a deterministic, URI-labelled copy of every included text file. |

Fallback is reference text, not installation, execution or a permission grant. It preserves source text rather than summarizing it or rewriting links. A capable client that omits the declaration can receive both representations, even if its native skill loader is disabled. Host context limits still apply: the SDK does not truncate instructions or guarantee that a host will retain them. Build output lists published file sizes; the standard recommends at most 512 files and 16 MiB per skill, and fallback instructions should be substantially smaller for practical model context. Omit `skills` to retain existing tools-only behavior.

### Caller-specific skills

With SDK 3.3.0, mix unconditional imports with conditional registrations:

```ts
skills: [memberGuide, { skill: adminGuide, enabled: ({ appContext }) => appContext.isAdmin }],
```

Use `defineMcp<AppContext>` and an authenticated `createAppContext`, as in [caller-dependent tool catalogs](#caller-dependent-tool-catalogs). Derive permissions from verified `auth`, not client metadata. `enabled` receives only `{ appContext }` and must be a pure synchronous boolean predicate. The SDK creates context once per protocol request that builds a server, shared with tools, including discovery, resource reads and static tool calls when conditional Skills are configured. A context or predicate failure fails the request with a redacted 500; asynchronous and non-boolean predicates are rejected. Unconditional-only servers retain their existing context-loading behavior.

Disabled registrations are absent from `skills/list` and unavailable through `skills/get`. Resources and fallback contain exactly the union of enabled bundles. Each enabled registration grants its entire recursive directory: an enabled parent still publishes a disabled child's files, including its `SKILL.md`, without registering that child as a Skill. Disabling a registration does not revoke copies independently published by another registration, another URI, or authored `instructions`. Keep restricted content outside directories granted to other callers. A reference requiring `skills/get` for a disabled registration cannot activate it, even if its text remains readable through a parent.

This selects static authored content; it does not generate personalized Skill text. Keep handler/service/RLS authorization independent. ChatGPT currently imports submission-time snapshots: do not rely on these predicates for per-user ChatGPT delivery or submit admin-only content assuming it will be filtered for later users.

## What the plugin emits

| URL                              | File                                       | Purpose                                  |
| -------------------------------- | ------------------------------------------ | ---------------------------------------- |
| `POST /mcp`                      | `src/routes/mcp.ts`                        | Full MCP streamable-HTTP protocol        |
| `GET /.well-known/oauth-protected-resource` | `src/routes/[.well-known]/oauth-protected-resource.ts` | OAuth protected-resource metadata |

The table shows the default `path: "/mcp"`; `mcpPlugin({ path: "/api/public/mcp" })` moves the MCP route accordingly.

MCP (`POST <path>`, `/mcp` by default) is the wire format clients speak directly. All runtime routes import the same `defineMcp` result, so they stay in sync on which tools exist and which auth and browser-origin policies protect them. If `defineMcp({ auth: ... })` is omitted, the handlers stay unauthenticated; if OAuth auth is configured, clients must pass `Authorization: Bearer <token>`. Rate limiting belongs at the app host or edge.

The MCP endpoint supports both protocol eras on one URL. Finalized `2026-07-28` clients send the version, client capabilities, and mirrored HTTP headers on every request; the server returns typed results plus cache metadata on discovery and list responses without initialization or protocol sessions. Initialization-era clients continue through a stateless compatibility path: `initialize`, `tools/list`, and `tools/call` work as independent requests, and the endpoint never mints `Mcp-Session-Id`. `GET` and `DELETE` return `405` because there is no standalone stream or session to manage.

## Caller-dependent tool catalogs

Use a request context only when authenticated callers must see different tool
names. Bind its type once and export the resulting helper for tool files:

```ts
// src/lib/mcp/context.ts
import { defineTool } from "@lovable.dev/mcp-js";

export interface AppContext {
  readonly plan: "free" | "pro";
}

export const defineAppTool = defineTool.withAppContext<AppContext>();
```

Create the app context from verified auth when an operation needs it, then
register every tool in the build-time union:

```ts
import { auth, defineMcp } from "@lovable.dev/mcp-js";
import type { AppContext } from "./context";
import exportDataTool from "./tools/export-data";

export default defineMcp<AppContext>({
  name: "my-app-mcp",
  title: "My App MCP",
  version: "0.1.0",
  instructions: "Tools for interacting with My App.",
  auth: auth.oauth.issuer({
    issuer: "https://auth.example.com",
    resource: "https://my-app.example.com/mcp",
  }),
  createAppContext: async ({ auth, signal }) => ({
    // Unknown callers get the least-privilege catalog; only a failed lookup throws.
    plan: (await loadPlan(auth.getUserId(), signal)) ?? "free",
  }),
  tools: [exportDataTool],
});
```

Define conditional tools with the bound helper. The predicate is synchronous
and intentionally receives only `appContext`; handlers receive the same value
on `ctx.appContext`. Keep predicates pure: do not mutate `appContext` or depend
on tool evaluation order.

```ts
import { defineAppTool } from "../context";

export default defineAppTool({
  name: "export_data",
  title: "Export data",
  description: "Export the caller's data.",
  enabled: ({ appContext }) => appContext.plan === "pro",
  handler: async (_args, ctx) => {
    const token = ctx.getToken();
    if (!token) throw new Error("Authenticated caller required");
    await exportDataForCaller(token, ctx.signal);
    return { content: [{ type: "text", text: "Export ready." }] };
  },
});
```

The SDK runs `createAppContext` once per request: for `tools/list` when any tool
has `enabled`, and for a call to a conditional or app-context tool. Plain static
tool calls, discovery, and ping skip it. A throw from the factory or a predicate
fails the whole request with a redacted 500, including `tools/list`, so return a
least-privilege context for callers who lack access and throw only when the
lookup itself fails. Disabled tools are omitted from
`tools/list`; direct calls receive the standard unknown-tool error. `enabled`
gates discovery and dispatch, but the operation must still authorize current
permissions in its app service or database. Repeating the predicate against the
same `ctx.appContext` is not a fresh authorization check.

Modern `tools/list` and `server/discover` responses advertise `ttlMs: 0` and
`cacheScope: "private"` for all catalogs. Clients should fetch a fresh result
rather than reuse a cached response, including after a deployment or permission
change. These MCP hints do not purge existing client caches or change Lovable's
internal connector cache. `private` tells clients not to reuse results across
auth contexts; it does not enforce endpoint access. The publish manifest always contains the
complete tool union and marks predicate-bearing entries as `conditional`, so
Lovable can review and display the full API surface.

`outputSchema` accepts either the existing object-shape shorthand (`{ value: z.string() }`) or a JSON-Schema-representable Zod schema such as `z.array(z.string())`; `structuredContent` may be any JSON value. The server returns the schema's parsed output, so coercions are reflected in the result; transforms with no truthful JSON Schema are rejected when `defineMcp` runs. Modern clients receive the natural schema and value. The compatibility path wraps non-object schemas and values under `result` for initialization-era clients whose wire format requires an object root.

Browser-origin checks apply to the MCP and OAuth metadata routes before authentication. Omit `allowedOrigins` to allow requests with no `Origin` header (Go, desktop, CLI, and server clients) plus same-origin browser requests. Set `allowedOrigins: ["https://client.example.com"]` for exact additional origins, or `allowedOrigins: "any"` to intentionally restore wildcard CORS. Exact and same origins are reflected with `Vary: Origin`; other browser origins receive `403` without an OAuth challenge. Reverse-proxy adapters compare against the effective public origin after applying only their configured trusted forwarded headers.

The OAuth metadata route is emitted by default and returns `404` until OAuth auth is configured. Disable it with `mcpPlugin({ protectedResourceMetadataRoute: false })` only if the app owns `/.well-known/oauth-protected-resource` itself.

To serve the metadata from a different path — e.g. a workspace-private project where only `/api/public/*` is anonymously reachable and `/.well-known/...` is gated — pass `mcpPlugin({ metadataPath: "/api/public/.well-known/oauth-protected-resource" })`. The plugin emits the route there and injects the same path into every handler, so the 401 `WWW-Authenticate` challenge advertises exactly what it serves. `metadataPath` is mutually exclusive with `auth.protectedResourceMetadataUrl` (and with `protectedResourceMetadataRoute: false`).

### `.lovable/mcp/manifest.json`

`.lovable/mcp/manifest.json` is a snapshot the Lovable platform reads to register the MCP server. Envelope fields: `version` (manifest schema version), `sdk_version` (the `@lovable.dev/mcp-js` release that wrote the snapshot), `path`, and `auth`. `auth` is the server's auth configuration, lifted into the envelope (not into `mcp`): `{ "type": "none" }`, or `{ "type": "oauth", ... }` mirroring the `defineMcp({ auth })` config (snake_case — `issuer`, `accepted_audiences`, `required_scopes`, `resource`, …). The `mcp` field is the server listing — `server` plus the complete build-time tool catalog (`name`/`title`/`description`/`annotations`, JSON-Schema `inputSchema`/`outputSchema`, and `conditional: true` for tools with an `enabled` predicate). It's produced by loading the entry and reading the catalog off the `defineMcp` result, so it includes tools built programmatically (spreads, computed names, tools from npm) even when a live caller's `tools/list` narrows the catalog. **The `lovable-mcp-extract-manifest` CLI writes it** (loading the entry through Vite's SSR module loader, so it works under Node/Bun), and removes it when the entry is deleted. The Lovable platform runs the CLI in its commit pipeline; the Vite plugin only generates routes. Commit it: the platform reads the committed file.

Three caveats:

- **The entry must import cleanly.** Because extraction imports the entry, it must not throw at module load — read env vars and do I/O *inside* tool handlers, not at module top level. A top-level throw also breaks Worker cold-start, so this is correct authoring regardless. An entry that can't be imported (or doesn't `export default defineMcp(...)`) **makes the extract CLI exit non-zero with a clear error** rather than emitting a partial manifest.
- **Tool `title`/`description` and the `auth` config are serialized verbatim.** They land in a committed file. The `auth` config is whatever the entry resolves at build time — an issuer read from env resolves to its build-time value (e.g. a fallback like `https://supabase.invalid/auth/v1` when the env var is unset), so don't put secrets in it.
- **The `version` field is a wire-format version, not the package version.** The platform reader must accept a manifest `version` before this package starts emitting it, so bumping it is a coordinated deploy with the Lovable backend — not something an app author changes.

## OAuth resource-server auth

`@lovable.dev/mcp-js` can protect an app-hosted MCP server as an OAuth 2.1 resource server. The package does not implement `/authorize`, `/token`, client registration, refresh tokens, or consent UI; those stay with the authorization server (for today's Supabase-backed Lovable Cloud apps, Supabase Auth). The MCP runtime validates bearer JWTs, publishes RFC 9728 protected-resource metadata, returns `WWW-Authenticate: Bearer ... resource_metadata="..."` challenges, and passes verified claims to tools.

Point the issuer at your OAuth/OIDC authorization server and anchor the accepted audience with either `resource` or `acceptedAudiences`:

```ts
// src/lib/mcp/index.ts
import { auth, defineMcp } from "@lovable.dev/mcp-js";

export default defineMcp({
  name: "my-app-mcp",
  title: "My App MCP",
  version: "0.1.0",
  instructions: "Tools for interacting with My App.",
  auth: auth.oauth.issuer({
    issuer: "https://auth.example.com",
    resource: "https://my-app.example.com/mcp",
    requiredScopes: ["mcp:tools"],
  }),
  tools: [],
});
```

The `auth` namespace groups auth families; today it exposes `auth.oauth.issuer(...)`. It is exported from the package root (shown above), the canonical surface for app authors. `issuer` must be HTTPS (except localhost development URLs) and match the token `iss` exactly; the SDK discovers `jwks_uri` from the issuer's OAuth/OIDC metadata. The SDK reads no environment variables — pass values explicitly.

### Supabase recipe

For a Supabase-backed project, the issuer is the project's `<url>/auth/v1` and the accepted audience is Supabase's project-wide `authenticated` audience:

```ts
const supabaseUrl = process.env.SUPABASE_URL!.replace(/\/+$/, "");

auth: auth.oauth.issuer({
  issuer: `${supabaseUrl}/auth/v1`,
  acceptedAudiences: "authenticated",
}),
```

Supabase auth is **project-scoped**: Supabase access tokens use `aud: "authenticated"`, so the same delegated token verifies against every MCP server in that project. The binding is "an OAuth client of the project," not "this MCP server." Use `ctx.getClientId()`, `ctx.getUserId()`, and `ctx.getClaims()` for handler-level app/business checks — never authorize on the audience — and pass `ctx.getToken()` through to Supabase so RLS evaluates the token itself. Supabase tokens do not carry OAuth scopes today, so leave `requiredScopes` unset.

To skip metadata discovery entirely, pin `jwksUri` to the issuer's JWKS endpoint. Verification then makes zero discovery probes; key rotation still works because the JWKS is fetched (and refreshed) on demand:

```ts
auth: auth.oauth.issuer({
  issuer: `${supabaseUrl}/auth/v1`,
  acceptedAudiences: "authenticated",
  jwksUri: `${supabaseUrl}/auth/v1/.well-known/jwks.json`,
}),
```

For a direct Supabase project, the relevant URLs look like this:

| Supabase URL | Used by |
| --- | --- |
| `https://<project-ref>.supabase.co/auth/v1/.well-known/openid-configuration` | SDK: discovers `issuer` and `jwks_uri` |
| `https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json` | SDK: verifies bearer JWT signatures |
| `https://<project-ref>.supabase.co/auth/v1/oauth/authorize` | OAuth clients: start user authorization |
| `https://<project-ref>.supabase.co/auth/v1/oauth/token` | OAuth clients: exchange/refresh tokens |

The SDK only uses discovery + JWKS. It never calls the authorization or token endpoints.

Tool handlers receive their input args plus a `ToolContext` as the second
argument. The SDK constructs one per request from the verified token and passes
it to the handler. `ToolContext` extends `AuthContext`, which is also what
`createAppContext` receives as `auth`; helpers that need the auth should take an
`AuthContext` parameter so both call sites can share them.

```ts
import type { AuthContext } from "@lovable.dev/mcp-js";

// Usable from tool handlers (`ctx`) and from `createAppContext` (`auth`).
function requireUserId(auth: AuthContext): string {
  const userId = auth.getUserId(); // token `sub`, or undefined when unauthenticated
  if (!userId) throw new Error("Authenticated caller required");
  return userId;
}

handler: async ({ title }, ctx) => {
  const userId = requireUserId(ctx);
  // Apply app/business permissions here using ctx.getClaims() / ctx.getClientId().
  // For Supabase RLS, pass ctx.getToken() through to Supabase.
  return { content: [{ type: "text", text: `user=${userId}` }] };
};
```

| `AuthContext` method (inherited by `ToolContext`) | Returns |
| --- | --- |
| `isAuthenticated()` | `true` when the call carries a verified auth context. |
| `getUserId()` | The token `sub` (subject) — the user id. |
| `getUserEmail()` | Token `email` when present. |
| `getClientId()` | OAuth `client_id`, falling back to the OIDC `azp` (authorized party) — the client binding handle, useful for authorization. |
| `getScopes()` | The parsed OAuth `scope`s. |
| `getIssuer()` | The verified token issuer. |
| `getClaims()` | The full verified JWT claims — for app/business authorization on issuer-specific claims with no dedicated accessor. |
| `getToken()` | The raw bearer — pass to downstream APIs (e.g. Supabase RLS); never return or log it. |

Each returns `undefined` when the server is unauthenticated. `ToolContext` is a
plain object passed by value — there is no ambient store, no `AsyncLocalStorage`,
and no `node:async_hooks` dependency, so it works on any runtime and is safe
under concurrency by construction. A handler that hands `ctx` to deferred work (a
detached timer, an un-awaited promise) keeps full access to it.

Beyond auth, `ToolContext` carries the per-request runtime surface:

| Member | Purpose |
| --- | --- |
| `signal` | An `AbortSignal` that aborts when the client cancels the request or disconnects. Pass it to `fetch` and other cancelable APIs, or check `signal.aborted` between steps of long work. |
| `client` | What the calling client declared about itself (`McpClientInfo`): `protocol` (`"2026-07-28"` or `"legacy"`), and for modern requests the envelope's `info`, `capabilities`, and `extensions`. Unverified client input — use it to adapt output, never to authorize. |
| `progress(update)` | Sends a `notifications/progress` frame (`{ progress, total?, message? }`) for long-running tools. A no-op unless the client requested progress by sending `_meta.progressToken`; safe to call unconditionally. |

```ts
handler: async ({ url }, ctx) => {
  await ctx.progress({ progress: 0, total: 2, message: "fetching" });
  const res = await fetch(url, { signal: ctx.signal });
  await ctx.progress({ progress: 1, total: 2, message: "processing" });
  return { content: [{ type: "text", text: await res.text() }] };
};
```

**Security contract for the bearer token.** `getToken()` is the only way to read
the raw token. `ToolContext` holds the verified context in a private field, so the
credential can't be read off the instance via `JSON.stringify`, object spread, or
`structuredContent` — only the accessors above expose anything, and `getToken()` is
the lone path to the bearer. Pass `getToken()` only into outbound `fetch`/client
construction (e.g. calling Supabase on the user's behalf); never return it from a
tool or write it to logs. A leaked token stays valid at the authorization server
until it expires.

**Caller-visible errors.** Throwing `ToolError` from a handler returns an `isError` result carrying exactly its message — the same shape as returning `{ isError: true, content: [...] }`. Any other exception is redacted to a generic failure on the wire; its name and message appear only in the server's local `tool.invoked` log line (`errorText`), never in telemetry.

```ts
import { ToolError } from "@lovable.dev/mcp-js";

handler: async ({ id }, ctx) => {
  const row = await findBook(ctx, id);
  if (!row) throw new ToolError(`book ${id} not found`);
  return { content: [{ type: "text", text: JSON.stringify(row) }] };
};
```

Use an OAuth access token from the configured authorization server for MCP calls. Plain app-session JWTs, such as Supabase tokens from `signInWithPassword`, carry neither `client_id` nor `azp`; the SDK rejects them by default so copied browser sessions do not pass as delegated OAuth client tokens. Set `requireOAuthClientClaim: false` only when intentionally accepting those session tokens and relying on app checks plus downstream RLS via the forwarded bearer token.

`auth.oauth.issuer(...)` fields:

| Field | Meaning |
| --- | --- |
| `issuer` | OAuth/OIDC issuer URL. Must be HTTPS except localhost development URLs and must match the token `iss` exactly. Required. |
| `resource` | Canonical protected-resource URL published in metadata. It also anchors the accepted audience unless `acceptedAudiences` is set. When omitted, the published resource is derived from the request origin plus the stack adapter's `resourcePath` and you must set `acceptedAudiences`; the metadata handler throws at construction if neither `resource` nor `resourcePath` is set. |
| `acceptedAudiences` | Accepted JWT audience(s). Defaults to `resource` when that is set. One of `resource` or `acceptedAudiences` is required. A populated token `aud` must match one of these exactly; when `aud` is empty or absent, see `acceptResourceClaim`. |
| `acceptResourceClaim` | Defaults to `true`: a token whose `aud` is an empty array or absent is accepted when its `resource` claim matches an accepted audience (trailing slashes ignored). The fallback engages for **any** configured issuer minting such tokens, not only Lovable's. The `resource` claim is a Lovable OAuth convention derived from the RFC 8707 `resource` request parameter, not a registered JWT claim; Lovable's OAuth server validates the requested resource at authorization time before stamping it. A populated `aud` is always authoritative — the fallback never rescues a non-matching `aud`. Set `false` to require a matching `aud` (strict RFC 9068). |
| `jwksUri` | JWKS URL override. If omitted, the runtime discovers `jwks_uri` from OAuth/OIDC metadata; setting it skips discovery entirely. |
| `requiredScopes` | Required scopes. Advertised in metadata/challenges and enforced against the token `scope`. |
| `requireOAuthClientClaim` | Defaults to `true`; rejects tokens carrying neither `client_id` nor the OIDC `azp` (authorized party) so copied app-session JWTs do not pass as OAuth client tokens. Supabase OAuth 2.1 server tokens carry `client_id` and issuers like Google/Keycloak/Entra use `azp`, so keep the default; only legacy `signInWithPassword` session tokens lack both and need `false`. |
| `algorithms` | Accepted JWT signing algorithms. Defaults to asymmetric algorithms (`RS*`, `ES*`, `EdDSA`); set explicitly only for a narrower policy. |
| `clockToleranceSeconds` | Allowed JWT clock skew for `exp`/`nbf` checks, in seconds. Defaults to `30`; max `300`. |
| `resourceName`, `resourceDocumentation` | Optional metadata fields for MCP clients and humans. `resourceName` defaults to the MCP server `title`. |
| `protectedResourceMetadataUrl` | Absolute HTTPS URL of an externally hosted RFC 9728 protected-resource metadata document. When set, the SDK advertises this URL in the 401 `WWW-Authenticate: resource_metadata` challenge and stops generating its own metadata — the `/.well-known/oauth-protected-resource` handler returns 404. Use it when the resource server already publishes its own PRM; you'll typically also pass `protectedResourceMetadataRoute: false` to the Vite plugin so the now-unused route isn't emitted. Mutually exclusive with the plugin's `metadataPath` (which has the SDK serve its own document at a custom path); configuring both throws at handler construction. |

Protected-resource metadata is derived from the same config. When `resource` is omitted, the published resource is the incoming request origin plus the MCP route path the stack adapter supplies as `resourcePath` (for example, `https://my-app.lovable.app/mcp`); `authorization_servers` is the configured issuer, and `resource_name` is `defineMcp({ title })` unless `resourceName` overrides it. Because the metadata endpoint is served from `/.well-known/...`, it can't infer the resource from its own path — the handler throws at construction when neither `resource` nor `resourcePath` is set (the generated TanStack routes always pass `resourcePath`). For Supabase auth, this metadata still names the MCP resource even though Supabase JWTs use the project audience `"authenticated"`.

The request-derived `resource` default trusts the incoming `Host` to name this server's origin. Because a config always sets `acceptedAudiences` (Supabase: `"authenticated"`) or `resource`, this default only names the advertised protected-resource metadata, never the accepted JWT audience. A platform that terminates TLS and sets `Host` from the verified domain (Lovable Cloud) makes this safe. Deployments fronted by a proxy that forwards an attacker-controlled `Host` should pin `resource` to the canonical URL so a spoofed host cannot shift the advertised metadata URL.

Use `auth.oauth.issuer(...)`, set `resource` or `acceptedAudiences` to anchor the accepted audience, and optionally set `jwksUri` and `requiredScopes`. For Supabase project auth, set `acceptedAudiences: "authenticated"`, keep app/business checks in app code, and forward `ctx.getToken()` to Supabase for RLS-backed data access.

## Testing

Test an MCP server in three layers:

1. **Business and persistence logic.** Keep it in ordinary functions and services and test it with your usual runner. No MCP is involved.
2. **Tool results.** Assert a result directly with `assertToolResult` from `@lovable.dev/mcp-js/testing`.
3. **The endpoint.** Connect a real MCP client to an in-process handler or a URL with `@lovable.dev/mcp-js/testing/server` or the `lovable-mcp-check` CLI.

Browser and UI testing are not covered.

```ts
import { assertToolResult } from "@lovable.dev/mcp-js/testing";

await assertToolResult(result, { outputSchema }); // expects success by default
await assertToolResult(failed, { isError: true });
```

A failed assertion throws `McpCheckError` with a fixed, value-free diagnostic. This assertion accepts package handler conveniences such as omitted content; it is not a raw-wire validator.

```ts
import assert from "node:assert/strict";
import { checkMcpServer, withMcpClient } from "@lovable.dev/mcp-js/testing/server";

// In process: the callback receives the official MCP SDK Client.
await withMcpClient({ endpoint: "http://localhost/mcp", handler }, async (client) => {
  const result = await client.callTool({ name: "echo", arguments: { text: "hi" } });
  assert.deepEqual(result.content, [{ type: "text", text: "hi" }]);
});

// Discovery plus explicit calls; returns { checks, passed }.
const report = await checkMcpServer({
  endpoint: "https://my-app.lovable.app/mcp",
  headers: { authorization: `Bearer ${process.env.MCP_TOKEN}` },
  calls: [{ name: "echo", arguments: { text: "hi" } }, { name: "echo", arguments: {}, isError: true }],
});
```

```bash
pnpm exec lovable-mcp-check --url https://my-app.lovable.app/mcp --bearer-env MCP_TOKEN --calls calls.json
```

`calls.json` holds the same `calls` array. Both entry points accept `protocolVersion` (`--protocol`): `2026-07-28` (default) or `2025-11-25`. `timeoutMs` (`--timeout`) bounds HTTP requests and checker operations, not the whole run, and defaults to 10 seconds. Inside `withMcpClient`, use the SDK's request options to bound long-running streaming operations. A timed-out handler must honor `request.signal` to stop its own work.

What a check does and does not prove:

- Discovery never calls a tool. Only the listed `calls` run, in order. After a failed or incomplete call, or a tool whose schema can't be checked, the remaining calls are skipped.
- `passed` means no failure was observed. A successful connection does not prove that auth is enforced or that business behavior is correct; assert those separately.
- Reports contain only fixed, value-free diagnostics with indexed locations. Exceptions from `withMcpClient` and its callback are **not** redacted.
- Credential headers are never sent across a redirect or to a different endpoint, and require HTTPS to a non-loopback host unless an in-process `handler` is supplied. The kit does not mint tokens or run a deployed OAuth flow; supply your own bearer.

Notes:

- `/testing/server` depends on the official MCP client, which adds install weight. The root `@lovable.dev/mcp-js` import never loads it.
- Both testing subpaths support ESM and CommonJS on Node 20 or later.
- Keep these tests isolated from production data. Tool schemas are not sandboxed: run checks against servers you don't trust in a separate, externally time-bounded process.

See [`example/testing`](./example/testing) for a runnable setup; `pnpm test:package:testing` runs it against the packed package.

## Security posture

For anyone evaluating an app that exposes an MCP server with this package:

- **Verification runs in the app runtime.** When `defineMcp({ auth })` configures OAuth, the emitted TanStack route or Supabase Edge Function verifies the bearer before dispatching a tool. Clients call the app's published endpoint; the SDK does not relay MCP calls through a shared Lovable MCP protocol gateway, although a Lovable-hosted TanStack app still receives ordinary web traffic through Lovable's hosting proxy. The SDK may fetch issuer metadata and JWKS. When default usage telemetry is emitted, it sends the operational fields described below, including the verified token `sub`; it never sends the bearer, full claims, tool arguments, or tool results.
- **No protocol sessions.** Every HTTP request gets a fresh MCP server, auth context, and tool callback closure. The endpoint never mints a session ID or retains MCP protocol or auth session state across requests. Durable application state belongs in the app's own storage.
- **Audience validation is explicit.** A populated token `aud` must exactly match `resource` or `acceptedAudiences`. For issuer compatibility, the default also accepts a matching `resource` claim when `aud` is absent or an empty array; set `acceptResourceClaim: false` to require a matching `aud` (strict RFC 9068).
- **The deployment controls the SDK version.** TanStack resolves the app's declared dependency through its package manager and lockfile. The generated Supabase function exact-pins the SDK release that emitted it. Updating or regenerating the dependency and redeploying is how an app adopts a newer SDK.
- **Controls are divided between layers.** When OAuth is configured, the SDK owns bearer verification; it also owns origin policy (`allowedOrigins`) and redacts unexpected errors. `ToolError` and returned `isError` results are intentionally caller-visible. Per-tool authorization, data access (including RLS), rate limiting, and WAF belong to the app and its host.

## Debug logging

The runtime logs operational events (tool invocations, auth outcomes, warnings, errors) at `info` by default. Raise the level to `debug` to trace OAuth discovery, JWKS resolution, and token verification when a deployed MCP server returns `401`/`500` and you need to see why. Logs go to `console` (so they land in your platform's function logs) and **never include the bearer token or full claims**. Depending on the log level, operational fields include the JWT header `alg`/`kid`, `issuer`, `jwks_uri`, token scopes, and `jose` error codes. The default `info` level excludes end-user identifiers; the token `sub`/`client_id` appears only at `debug`, as the `oauth.verify.principal` event.

Enable it either way:

```ts
// Programmatic — works on every runtime, including Workers that pass env via bindings.
import { setLogLevel } from "@lovable.dev/mcp-js";
setLogLevel("debug"); // "silent" | "error" | "warn" | "info" | "debug"
```

```bash
# Or via env var. Node reads it at startup; on Deno and Cloudflare Workers the
# recorder applies it at the first request's telemetry emit, so logs before that
# point may not honor it — prefer `setLogLevel()` there.
LOVABLE_MCP_LOG_LEVEL=debug
```

A `500` on an OAuth-protected route is always one of three causes, logged at `error`: an `OAuthConfigurationError` from issuer-metadata discovery or a JWKS *fetch* failure (`oauth.discovery.config_error` / `oauth.jwks.fetch_failed` → `auth.config_error`), a throw from the app's own `createAppContext` or an `enabled` predicate (`mcp.app_context_error`, carrying that error's name and message), or a transport-level fault in the MCP handler (`mcp.transport_error`). Auth outcomes are logged at `info`: a granted request as `oauth.verify.ok`, a request with no parseable bearer as `auth.no_bearer_token`, and a rejected token as `auth.token_rejected` (a `jose` rejection such as an expired token carries the `jose` error name and code; the full reason appears at `debug` as `oauth.verify.rejected`) — so a request rejected for insufficient scope shows both `oauth.verify.ok` and the `auth.token_rejected` that follows it. A sustained flood of `auth.token_rejected` whose `code` is `ERR_JOSE_NOT_SUPPORTED` or `ERR_JOSE_ALG_NOT_ALLOWED` (rather than `ERR_JWT_EXPIRED`) points at server-side key or algorithm configuration, not client error.

## Usage metrics

Each MCP server records per-invocation telemetry — tool name, JSON-RPC method, outcome, latency, request and response sizes when available, and the verified token `sub` for authenticated calls — as **OTLP/HTTP JSON logs**, one awaited POST per record, bounded by a 3-second timeout; a failed or slow POST is logged (`metrics.failed`) and swallowed, never failing the request. Outcomes cover tool results (`ok`, `tool_error`, `handler_error`, `transport_error`), `app_context_error` for a request failed by a throwing `createAppContext` or `enabled`, the authorization gate — `auth_challenge` for an unauthenticated request answered with the `401` challenge (so probe traffic produces records), and the `auth_*` discovery/JWKS failure outcomes — and `origin_rejected` for a request refused `403` by the origin policy. Tool-call records also carry the protocol era (`mcp.protocol`: `2026-07-28` or `legacy`); records emitted before protocol classification (auth challenges, transport errors, origin rejections) have no protocol attribute. The bearer token, full claims, tool arguments, and response payloads are never captured. Emission is **on by default** and self-disables at runtime when `LOVABLE_API_KEY` is absent: records are dropped and one `metrics.disabled_no_api_key` warning is logged per process, so a local `vite dev` stays quiet. With `metrics: false` the recorder never runs — on Deno and Cloudflare Workers that also means `LOVABLE_MCP_LOG_LEVEL` is never applied (use `setLogLevel()` instead).

Starting in SDK 3.0.5, rejected bearer tokens also emit `auth_token_expired`, `auth_token_invalid`, or `auth_insufficient_scope`. Unexpected verifier failures emit `auth_unexpected_error`; they are not evidence of an expired or invalid token. Each rejection emits one `mcp.gate.rejection` record with `mcp.method=authorize`, no tool or end-user identity, and no claims or error text. Successful authorization adds no event; missing bearers remain `auth_challenge`. Rejected-token telemetry uses the runtime's background scheduler when available, otherwise delivery is awaited.

To query these failures, filter `method = 'authorize'` and the outcomes above; use `method = 'tools/call'` for invocation counts. Coverage depends on apps adopting SDK 3.0.5 or later, so older SDKs' missing events do not mean zero rejections. These events increase the warehouse's operational `event_count`, which already includes authorization faults. They do not increase `tool_call_count` or the active-app signals derived from executed calls.

Configure it on the MCP definition:

```ts
import { defineMcp } from "@lovable.dev/mcp-js";

export default defineMcp({
  name: "my-app-mcp",
  // ...
  tools: [],
  metrics: {
    endpoint: "https://your-collector.example.com/v1/logs",
    headers: { authorization: "Bearer your-token" },
  },
});
```

| Use case | Config |
| --- | --- |
| **Default (recommended)** | Omit `metrics`, or `metrics: true` — batches to Lovable, authenticated with `LOVABLE_API_KEY` |
| **Opt out entirely** | `metrics: false` |
| **Self-hosted collector** | `metrics: { endpoint, headers? }` — your own OTLP/HTTP (JSON) endpoint |

**Key scoping:** `LOVABLE_API_KEY` is read and sent (as the bearer) **only** for the default Lovable endpoint; a custom collector is reached with your own `headers` and never receives the workspace key. `headers` are baked into the build output, so source any token from your own build-time env. `content-type` is forced to `application/json` and can't be overridden.

## Supabase Edge Functions

Ship the same `defineMcp` server as a single Supabase Edge Function. Authoring is identical to the TanStack flow; only the build-time step differs — a different Vite plugin bundles your MCP entry and its local imports into one self-contained Deno function (with esbuild) instead of emitting a tree of file-router routes. Third-party dependencies are left as `npm:` specifiers that Deno resolves at runtime, so no import map / `deno.json` is needed.

```ts
// src/lib/mcp/index.ts — same as the TanStack flow
import { auth, defineMcp } from "@lovable.dev/mcp-js";
import echoTool from "./tools/echo";

export default defineMcp({
  name: "my-app-mcp",
  title: "My App MCP",
  version: "0.1.0",
  instructions: "Tools for interacting with My App.",
  auth: auth.oauth.issuer({
    issuer: `${process.env.SUPABASE_URL!.replace(/\/+$/, "")}/auth/v1`,
    acceptedAudiences: "authenticated",
  }),
  tools: [echoTool],
});
```

```ts
// vite.config.ts — Lovable Classic projects already run Vite for the
// frontend, so the plugin is the natural authoring surface (the emitted
// output is a Deno function, not a Vite-bundled file; Vite is just the
// trigger). The export is named `mcpPlugin` in every stack — the import
// path is what discriminates the TanStack and Supabase variants.
import { defineConfig } from "vite";
import { mcpPlugin } from "@lovable.dev/mcp-js/stacks/supabase/vite";

export default defineConfig({ plugins: [mcpPlugin()] });
```

The plugin bundles `src/lib/mcp/index.ts` (and the tool files it imports) into `supabase/functions/mcp/index.ts` on `configResolved`, re-emits on `buildStart`, and watches the bundled source files for changes during `vite dev`. The bundle supports TypeScript, JavaScript, and JSON project imports plus Vite aliases and npm dependencies. It rejects frontend image imports (`.avif`, `.gif`, `.jpg`, `.jpeg`, `.png`, `.svg`, `.webp`, including query suffixes) with the local import chain. Supabase MCP tools should read assets from runtime storage or use external URLs instead; the emitter does not turn images into data URLs or add URL loaders.

The emitted function calls `Deno.serve(createSupabaseHandler(mcp, { functionName: "mcp" }))`. The handler is one Web-Standard `Request → Response` that dispatches by URL suffix to the same protocol handlers the TanStack stack uses:

| URL suffix | Target |
| --- | --- |
| (root) | MCP streamable-HTTP protocol |
| `/.well-known/oauth-protected-resource` | RFC 9728 metadata |

In production the function is mounted at `https://<project-ref>.supabase.co/functions/v1/mcp` and the dispatcher binds the OAuth `resource` to that URL via `functionName`. Locally, run `supabase functions serve mcp --no-verify-jwt` so Supabase's edge-runtime doesn't 401 before the SDK's OAuth verifier sees the bearer.

Ownership: the emitted `index.ts` carries a `// AUTO-GENERATED by @lovable.dev/mcp-js` banner. The plugin rewrites the banner-tagged file in place and cleans up orphans (including a `deno.json` left by a pre-bundling SDK version); banner-less files (yours) are never touched. Delete the banner line to take ownership. Deleting the `defineMcp` entry and re-running removes the SDK-owned function file.

## Subpath exports

| Subpath                                 | Contents                                                                |
| --------------------------------------- | ----------------------------------------------------------------------- |
| `@lovable.dev/mcp-js`                       | `defineTool`, `defineMcp`, public types                                 |
| `@lovable.dev/mcp-js/skill-imports`          | Type-only declarations for skill directory imports                     |
| `@lovable.dev/mcp-js/protocols/mcp`            | `createMcpProtocolHandler` — Web-Standard MCP-over-HTTP                 |
| `@lovable.dev/mcp-js/protocols/oauth-metadata` | `createOAuthProtectedResourceMetadataHandler` (the `auth` namespace lives at the root) |
| `@lovable.dev/mcp-js/stacks/tanstack`          | TanStack-route-ctx adapters (`createTanStack*Handler`)                  |
| `@lovable.dev/mcp-js/stacks/tanstack/vite`     | The Vite plugin                                                         |
| `@lovable.dev/mcp-js/stacks/supabase`          | `createSupabaseHandler` — Web-Standard Supabase Edge Function dispatcher |
| `@lovable.dev/mcp-js/stacks/supabase/vite`     | `mcpPlugin` — the Vite plugin (Supabase variant; same name as TanStack's, picked by import path) |
| `@lovable.dev/mcp-js/testing`                 | Direct handler result assertion and redacted diagnostics |
| `@lovable.dev/mcp-js/testing/server`          | Official-client lifecycle helper and endpoint checker (Node only) |

End users only need the root import (`@lovable.dev/mcp-js`). Generated route files import from `@lovable.dev/mcp-js/stacks/tanstack` (TanStack) or `@lovable.dev/mcp-js/stacks/supabase` (Supabase). Keep the testing subpaths in tests; the protocol subpaths are escape hatches for hand-wiring custom stacks.
