@lovable.dev/mcp-js
v3.4.0
Published
Author MCP servers for Lovable apps. Declare tools with defineTool, register them in defineMcp, and a framework adapter (TanStack or Supabase Edge Functions) emits the route(s) at build time.
Readme
@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 for TanStack or Supabase Edge Functions.
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.
// 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 }] }),
});// 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],
});// 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.
MCP POST bodies default to 1 MiB and tool arguments to 10,000 combined object members and array elements. Larger bodies receive HTTP 413; arguments over the element limit return a tool error before the handler runs. These defaults apply to both protocol eras, including Skills and app-context requests.
For tools that need larger inputs, set limits in defineMcp, for example limits: { maxRequestBodySize: 4 * 1024 * 1024, maxToolInputElements: 50_000 }. maxRequestBodySize is measured in bytes. Each override must be a positive safe integer; zero, fractional values, NaN and Infinity are rejected when the server is declared. Omitted fields keep their defaults. Hosting-provider limits still apply.
Task workflows as skills
Experimental: Skills support follows the developing MCP Skills extension. 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. Import a dedicated skill directory and pass it in defineMcp({ skills: [refunds] }):
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:
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:
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, 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:
skills: [memberGuide, { skill: adminGuide, enabled: ({ appContext }) => appContext.isAdmin }],Use defineMcp<AppContext> and an authenticated createAppContext, as in 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:
// 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:
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.
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. Conditional
MCP App links also need context for discovery, resource listing and ui://
reads. Plain static tool calls and ping skip it unless conditional Skills need
context. Reading an unconditional view does not load handler-only context.
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/descriptionand theauthconfig are serialized verbatim. They land in a committed file. Theauthconfig is whatever the entry resolves at build time — an issuer read from env resolves to its build-time value (e.g. a fallback likehttps://supabase.invalid/auth/v1when the env var is unset), so don't put secrets in it. - The
versionfield is a wire-format version, not the package version. The platform reader must accept a manifestversionbefore 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:
// 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:
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:
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.
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 scopes. |
| 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. |
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.
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 four layers:
- Business and persistence logic. Keep it in ordinary functions and services and test it with your usual runner. No MCP is involved.
- Tool results. Assert a result directly with
assertToolResultfrom@lovable.dev/mcp-js/testing. - The endpoint. Connect a real MCP client to an in-process handler or a URL with
@lovable.dev/mcp-js/testing/serveror thelovable-mcp-checkCLI. - MCP App views. Use
createAppFixturefrom@lovable.dev/mcp-js/testing/browserwith Playwright and the official ext-apps bridge for view interactions and host messages.
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.
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 }],
});pnpm exec lovable-mcp-check --url https://my-app.lovable.app/mcp --bearer-env MCP_TOKEN --calls calls.jsoncalls.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
callsrun, in order. After a failed or incomplete call, or a tool whose schema can't be checked, the remaining calls are skipped. - Discovery reads linked MCP App resources with the configured caller's credentials. An unreadable or mismatched HTML resource prevents selected calls from running.
passedmeans 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
withMcpClientand 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
handleris supplied. The kit does not mint tokens or run a deployed OAuth flow; supply your own bearer.
Notes:
/testing/serverdepends on the official MCP client, which adds install weight. The root@lovable.dev/mcp-jsimport never loads it.- All three testing subpaths support ESM and CommonJS on Node 20 or later. The browser fixture requires explicit Playwright and compatible ext-apps dependencies.
- 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 for endpoint checks and the
authenticated Apps example for layered tests
with test JWTs and a compiled view. The check contracts
name supported revisions, diagnostics and limitations. Live OAuth and external
services need separately authorized E2E checks. Package-contributor commands
live in README.internal.md.
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 tokensub; 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
audmust exactly matchresourceoracceptedAudiences. For issuer compatibility, the default also accepts a matchingresourceclaim whenaudis absent or an empty array; setacceptResourceClaim: falseto require a matchingaud(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.ToolErrorand returnedisErrorresults 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:
// 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"# 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=debugA 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:
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.
// 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],
});// 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.
MCP Apps (interactive tool UI)
MCP Apps (SEP-1865, extension io.modelcontextprotocol/ui) let a tool ship an interactive view that a supporting host renders in a sandboxed iframe next to the tool result.
MCP Apps beta policy
MCP Apps support is beta: host compatibility and authoring behavior are still being validated. It uses the package's normal public API names and release versions, not an experimental namespace or a permanent RC channel. Public API changes follow the package's normal semantic-versioning policy; breaking changes require a major version and migration guidance. This maturity label applies to MCP Apps, not the generic testing kit shipped in 3.1.0.
The view-build pipeline supports TanStack/Vite only, not the Supabase Edge Functions stack. On 2026-10-29, three synthetic TanStack apps deployed on Lovable Cloud using 3.4.0-gitee959cd were tested with MCP Inspector 2.13.0 in Chrome on macOS: a public calculator's rendered view called its tool; OAuth-backed tasks persisted across reconnects and rejected a cross-user mutation; an OAuth-backed ticket app restricted views by role and persisted an operator's resolution. Both authenticated apps completed real OAuth sign-in, consent and token exchange. These were assisted builds with explicit integration guidance, not an unguided authoring benchmark.
Real-host interoperability with ChatGPT and Claude remains unverified. These checks and the local protocol/browser fixtures do not establish compatibility with every host or validate every app's authorization; test your app with its intended host and real authentication before release. Hosts without MCP Apps support receive the tool's text fallback.
Builder feature flags can control whether MCP Apps authoring is offered or taught; they do not change installed SDK behavior or revoke installed apps. Adopting an SDK fix requires updating the dependency and rebuilding and redeploying the app.
Define a view
A view is a folder with an index.html entry, imported with the ?mcp-app suffix and wrapped in defineApp:
src/lib/mcp/
├── index.ts
├── tools/sales-summary.ts
└── apps/sales/
├── index.html
├── main.tsx
└── styles.css// src/lib/mcp/apps/sales.ts
import { defineApp } from "@lovable.dev/mcp-js";
import html from "./sales/index.html?mcp-app";
export const salesApp = defineApp({ name: "sales", title: "Sales dashboard", html });// src/lib/mcp/tools/sales-summary.ts
import { defineTool } from "@lovable.dev/mcp-js";
import { z } from "zod";
import { salesApp } from "../apps/sales";
export default defineTool({
name: "sales_summary",
title: "Sales summary",
description: "Monthly sales totals.",
inputSchema: { months: z.number().int().min(1).max(24) },
outputSchema: { rows: z.array(z.object({ month: z.string(), total: z.number() })) },
ui: { app: salesApp },
handler: async ({ months }) => {
const rows = await loadSales(months);
return { content: [{ type: "text", text: `${rows.length} months` }], structuredContent: { rows } };
},
});Use the official @modelcontextprotocol/ext-apps@^1.7.3 browser SDK. The browser testing fixture checks its resolved bridge before starting the host and supports stable >=1.7.3 <2. Preserve compatible project pins; explain incompatible pins and agree a dependency change rather than automatically replacing them. The executable React view shows pre-connect handlers, delivered results, user-initiated calls, cancellation, teardown, host styles and optional model-context/message support. Its built HTML runs in the browser contract suite.
defineMcp is unchanged — list the tool as usual. Apps are derived from tool.ui.app; several tools may share one app.
App-only helper tools can declare ui: { visibility: ["app"] } without attaching a view. This emits visibility metadata but no resource link or extra resource. Omit visibility for both model and app access; ["model"] asks hosts to reject calls from views. Visibility is a host routing rule, never server authorization. The package keeps its meaningful-content requirement for visibility-only tools too.
Portable view behavior
These authoring practices complement the protocol requirements; they are not additional wire requirements enforced by the compiler.
| Concern | Authoring contract |
| --- | --- |
| Lifecycle | Register input/result/cancel handlers in onAppCreated, before connection. Render delivered results; mounting or restoring a view must not replay a mutation. Partial inputs are preview-only. Clean up subscriptions, timers and outstanding requests on teardown/unmount; ignore stale completions. |
| Host adaptation | Apply initial and changing context with official SDK helpers. Consume host style variables/fonts with local fallbacks, merge partial context, respect safe areas and use the actual granted display mode. Check capabilities and handle rejected operations. Keep loading, empty, error, retry and keyboard paths usable at narrow widths. Pause expensive offscreen work when present. |
| Model context | Send concise selections or staged edits with updateModelContext; clear obsolete selection context. This does not request a conversational reply. Use sendMessage only for an explicit follow-up action, and only where supported. |
| State | Keep transient state local. Browser storage may be denied or evicted; optional recovery can use a server-provided view ID, with error handling. Save valuable work through authorized server tools. A view ID is not an identity or permission. |
| Security | The iframe is not your app's origin: do not depend on its cookies or relative API URLs. Prefer host-mediated tool calls. Keep CSP/permissions minimal; CSP does not grant CORS access, and permissions may be refused. Neither HTML nor result _meta is a place for credentials. |
Build. mcpPlugin() handles ?mcp-app by compiling the view into one HTML string containing its JavaScript, CSS and imported assets. The string lives in the server-side MCP entry; client code cannot value-import that entry or its views. The nested build does not load the project's Vite/PostCSS configuration, tsconfig or dotenv files.
Apps compilation requires stable Vite >=5.0.0 <9.0.0 and rejects unsupported versions when compiling a view, not when importing the plugin. Plain HTML views do not require ext-apps; its version check belongs to the browser testing fixture. Ordinary MCP paths do not run either Apps compatibility check.
Install lightningcss@^1.33.0 as a development dependency for Apps builds (bun add -D lightningcss@^1.33.0); preserve an existing compatible pin. It is an optional SDK peer loaded only when compiling a view. Ordinary MCP tools and runtime-only consumers do not need it. The generated HTML contains compiled CSS, not the compiler.
Browser sources. A view can read its own src/lib/mcp/apps/<name>/ folder, shared src/browser/ files and trusted npm dependencies. This applies transitively to modules, stylesheets and local assets, after resolving symlinks. Other project sources, Node builtins, server APIs and *.server.* files fail the build. Treat both browser folders as publishable; moving a file there does not remove secrets from it. Installed dependency code and build tooling remain trusted: this is not an OS sandbox.
Reuse. Move the components, browser helpers, assets and theme needed by both experiences into src/browser/, then update the normal app's imports. Keep data loading and routing in each experience's entrypoint. Small components taking data and callbacks as props make reuse straightforward; browser-safe hooks and local state are supported. Reuse is optional: a view may own its UI and styles entirely.
Styling contract. Target plain CSS or the current TanStack template's CSS-first Tailwind v4. Share theme CSS, not the normal app's scanning entrypoint:
| File | Contents |
| --- | --- |
| src/browser/theme.css | Shared @theme, variables, dark-mode rules, @utility definitions and ordinary CSS; no filesystem source scanning |
| src/styles.css | Normal app's Tailwind import and source scanning; imports ./browser/theme.css |
| src/lib/mcp/apps/<name>/styles.css | View's Tailwind import and shared theme import; compiler scans this view and src/browser/ |
The view's style entry is:
@import "tailwindcss" source(none);
@import "@/browser/theme.css";Install @tailwindcss/vite and tailwindcss in the project for Tailwind compilation. Filesystem @source/source(path) overrides are unsupported; @source inline(...) safelists remain available. Project-local JavaScript @config/@plugin, CSS modules and preprocessors are unsupported; express themes/utilities in CSS and use trusted installed npm plugins. Existing apps can migrate the reused subset, keep view-local CSS, or omit MCP Apps. See the shared theme, normal entry, and view entry.
Keep local image-set() assets in ordinary CSS declarations such as background, not custom properties. Relative URLs in custom properties are ambiguous during bundling; use already-resolved data or external URLs there.
Assets. Import local images/fonts from JavaScript or CSS. Static new URL("./asset.svg", import.meta.url) is supported; computed asset paths, workers and computed dynamic imports are not. Keep HTML to the module-script entry and stylesheet links: local HTML asset URLs, srcset, inline CSS, classic scripts and <base> fail with an authoring diagnostic. public/ is not served with a ui:// resource. Runtime-generated URLs are not validated; /logo.svg would resolve against the host, not your app. Explicit external URLs remain external and need the corresponding CSP declaration and host permission.
Wire format. With at least one allowed app, the server advertises capabilities.extensions["io.modelcontextprotocol/ui"], lists each allowed app as a ui://apps/<name> resource with MIME type text/html;profile=mcp-app and _meta.ui (csp, permissions, domain, prefersBorder when set), and puts _meta.ui.resourceUri (plus the pre-spec ui/resourceUri alias current hosts read) on the tool in tools/list. Servers with declared apps retain resource methods even when all views are hidden. Both protocol eras carry the same UI metadata. A UI tool's success and error results must include meaningful content for hosts without app support; empty content is replaced with an output-error diagnostic. ui.visibility: ["app"] asks supporting hosts to expose the tool only to the view; it does not prevent direct tool calls or replace handler authorization.
Handlers may return top-level _meta for additional view data without adding it to model-facing content. Use JSON-serializable values and do not include secrets: the MCP client receives this metadata. Accepted success and error results preserve it; validation-generated replacement errors discard the rejected result's data and metadata.
View access. After the endpoint's authentication and CORS checks, a view is listed and readable only if at least one tool linking it is enabled for the caller. An unconditional link grants access; all links share the request's context and each relevant predicate runs once. Hidden views return the same unknown-resource error as nonexistent URIs. Context or predicate failures fail the request with a redacted 500, even when another link grants access. Modern resource responses use ttlMs: 0 and cacheScope: "private"; HTTP responses use no-store.
Data boundary. View HTML is static code, not per-user data. Hiding a view cannot revoke copies already downloaded. Keep secrets and user data out of the HTML; fetch protected data through tools with handler, service, or RLS authorization. The publish manifest remains the complete build-time catalog.
Render untrusted tool data as JSX children or textContent, never innerHTML; injected scripts in a view can call tools as the user.
Development. Views are rebuilt when their files change during vite dev. To try a view, connect a host with MCP Apps support to the deployed /mcp URL; the ext-apps basic host works locally.
Supabase stack. Not supported: the emit step fails with an actionable error on any ?mcp-app import.
Subpath exports
| Subpath | Contents |
| --------------------------------------- | ----------------------------------------------------------------------- |
| @lovable.dev/mcp-js | defineTool, defineApp, 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 | assertToolResult, value-redacted diagnostics |
| @lovable.dev/mcp-js/testing/server | withMcpClient, checkMcpServer, readUiResource |
| @lovable.dev/mcp-js/testing/browser | createAppFixture (install Playwright and @modelcontextprotocol/ext-apps@^1.7.3 explicitly) |
App runtime code uses 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). Testing entrypoints are optional and are not imported by those runtime routes.
