@lovable.dev/mcp-js 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,189 @@
1
+ # @lovable.dev/mcp-js — internal notes
2
+
3
+ Architecture rationale, source layout, and dev scripts for contributors. The user-facing docs are in [`README.md`](README.md); release rules and build-system constraints are in [`AGENTS.md`](AGENTS.md). Nothing in this file ships to npm — only `dist`, `package.json`, `README.md`, `LICENSE`, and `CHANGELOG.md` end up in the tarball.
4
+
5
+ ## Folder meaning
6
+
7
+ - **`protocols/`** is the wire layer — one folder per externally observable HTTP surface. `protocols/mcp` is the public MCP-over-HTTP surface; `protocols/oauth-metadata` serves the RFC 9728 protected-resource metadata required to bootstrap protected MCP clients; `protocols/rest` is internal RPC for the upstream MCP proxy. Every backend wires both MCP and REST, plus the metadata endpoint when OAuth is configured.
8
+ - **`auth/`** (unpublished) is the cross-cutting OAuth middleware — bearer verification, issuer/JWKS discovery, the `auth.oauth.*` config namespace. It depends only on `core/`; both `protocols/mcp` and `protocols/rest` depend on it for the shared bearer gate, so it isn't a wire-format peer of `mcp`/`rest` and doesn't live under `protocols/`.
9
+ - **`stacks/`** is the framework integration: TanStack today; future entries (Supabase Edge Functions, classic Vite, …) live next to it under the same parent, forwarding to `protocols/{mcp,oauth-metadata,rest}` directly — protocol logic stays shared, only ctx unwrapping differs.
10
+
11
+ ## Design decisions
12
+
13
+ ### 1. Explicit `defineMcp({ tools })` over file-based discovery
14
+
15
+ `defineTool` is a typed identity function. The user imports tools explicitly into `defineMcp({ tools: [...] })`. The Vite plugin scans nothing — it emits routes that read the user's array at runtime.
16
+
17
+ Reasons:
18
+
19
+ - **Implicit registration drifts silently.** Tool name + file name can disagree; duplicates only surface at runtime in `registerTool`.
20
+ - **Refactoring is hostile.** Renaming a tool means renaming its file. Sub-directories require the plugin to recurse, and there's no clean convention for "implementation vs. helper" files.
21
+ - **PR review can't grep the surface.** A reader can mentally check `tools: [echoTool, addTool]`; they cannot mentally check a directory scan.
22
+
23
+ ### 2. Build-time route emission via a Vite plugin
24
+
25
+ TanStack's file-based router needs a route file on disk to register a URL. If we asked users to author the route file themselves, they'd own seven lines of plumbing that has to stay correct as the SDK's transport semantics evolve. Generating it lets us pin the wiring as a versioned package artifact — when the MCP SDK changes, the plugin emits a new template and every user picks it up on rebuild.
26
+
27
+ This is the same insight that drives shipping an SDK instead of having the agent author the MCP runtime: bug fixes ship to all apps on the next build, not per-app.
28
+
29
+ ### 3. The `[.mcp]` URL prefix uses bracket escaping
30
+
31
+ TanStack's file routing maps filenames to URLs and filters dot-prefixed entries as hidden — both directories (`src/routes/.mcp/...`) and flat files (`.mcp.list-tools.ts`). The escape is bracket-quoted literal segments: `[.mcp]/list-tools.ts` maps to `/.mcp/list-tools` and shows up in the generated route tree. The plugin emits at `src/routes/[.mcp]/list-tools.ts` and `src/routes/[.mcp]/invoke-tool/$tool.ts`.
32
+
33
+ ### 4. `invoke-tool/<tool>` instead of `<tool>` directly
34
+
35
+ The `invoke-tool/` segment scopes the user-defined tool namespace, so future endpoints under `/.mcp/` (`list-tools`, `health`, `audit-log`, anything else added) can't collide with a user-defined tool named the same thing. Cheap upfront, breaking to add later.
36
+
37
+ ### 5. Dynamic tool resolution at runtime
38
+
39
+ The dispatcher resolves the tool name against the live `mcp.tools` array on every request. From the caller's perspective `POST /.mcp/invoke-tool/echo` and `POST /.mcp/invoke-tool/add` look like separate endpoints; from the author's perspective the source of truth is one array. **Tool resolution is a per-request operation, not a per-build operation.**
40
+
41
+ This is architectural foundation, not convenience. Resolution stays inside app code so it can grow:
42
+
43
+ - **Per-user / per-scope tool visibility.** The lookup that maps `params.tool` to a handler can run *inside* app code, with access to the request's authenticated identity. An admin user might see `purge_workspace`; a regular user wouldn't see it in `list-tools` and would get a 404 from `invoke-tool`. Plan-tier gating, beta cohorts, feature flags, workspace roles — all want runtime context to decide what to expose.
44
+ - **The build doesn't have user context.** Emitting per-tool files at build time would lock the tool catalog to the deploy. Dynamic resolution leaves that decision to the request lifecycle.
45
+ - **The MCP and REST surfaces stay in sync automatically.** `tools/list` (over MCP) and `GET /.mcp/list-tools` (over REST) both read the same array. When per-user resolution lands, both surfaces filter through the same hook.
46
+
47
+ `list-tools` stays a separate handler (not auto-injected into the dispatcher) because it's naturally a `GET` — distinct HTTP semantics from `invoke-tool`'s `POST`. The per-user filter will hook into both, but the HTTP shape stays clean.
48
+
49
+ **Planned:** delegate the per-request tool-resolution step to app code via a hook (something like `resolveTools(ctx)` returning the subset of `mcp.tools` the caller can see). Until then, every authenticated caller sees the full array.
50
+
51
+ ### 6. Type-erased `AnyToolDefinition` at the array boundary
52
+
53
+ `ToolDefinition<TInput>` is generic — the handler's args are inferred from `inputSchema`:
54
+
55
+ ```ts
56
+ handler: TInput extends ZodRawShape
57
+ ? (args: ShapeOutput<TInput>) => ToolHandlerResult | Promise<...>
58
+ : () => ToolHandlerResult | Promise<...>;
59
+ ```
60
+
61
+ This works per-tool at the `defineTool` call site. It breaks at the `defineMcp({ tools: [...] })` boundary because of **function-parameter contravariance**: a heterogeneous array of `ToolDefinition<{a}>` and `ToolDefinition<{b, c}>` can't collapse to one generic without rejecting at least one entry.
62
+
63
+ `AnyToolDefinition` is the non-generic version used only at the array boundary, with `handler: (args: any) => ...`. `any` is bivariant in TypeScript, so any concrete handler assigns. Per-arg typing still happens inside `defineTool` — the only place it materially matters.
64
+
65
+ ### 7. Title, description, and instructions are required
66
+
67
+ The MCP spec promoted `title` to a top-level field on `Tool` and `Server` in 2025-06-18+; we require it. We also require `description` on tools and `instructions` on servers because:
68
+
69
+ - Both are read by LLMs as context for tool selection. A missing/templated description or instructions string produces agents that call the wrong tool or call none at all.
70
+ - Optional prose fields encourage drift over time. Required-at-the-type-level enforces "intentional surface area" at PR-review time.
71
+ - `instructions: ""` is the documented opt-out for servers that genuinely have nothing supplementary to say (single-purpose tools whose descriptions speak for themselves). Empty-string is intentional rather than accidental.
72
+
73
+ The MCP spec's legacy `annotations.title` is omitted from our `ToolAnnotations` type — use the top-level `title` instead. Two locations for the same field encourage drift.
74
+
75
+ ### 8. Decoupled type surface from `@modelcontextprotocol/sdk`
76
+
77
+ Two reasons:
78
+
79
+ 1. **`.d.ts` stability across SDK bumps.** The SDK's published type tree (`@modelcontextprotocol/sdk/types.js`) and its zod-compat shape generics have shipped breaking type-only changes between minor versions. Owning our own type re-declarations lets us bump the SDK's runtime without forcing every downstream consumer to re-type-check.
80
+ 2. **Optionality on the SDK itself.** If we ever swap or drop `@modelcontextprotocol/sdk` — different transport, in-house protocol implementation, a fork — consumers' code is the contract that matters, and it's typed against this package's surface, not the SDK's. The runtime hand-off can change without rippling through user code.
81
+
82
+ The published `.d.ts` files import only from `zod` and `vite`. Three SDK concerns were re-implemented (or re-typed) locally:
83
+
84
+ - **Content blocks and `ToolAnnotations`** — re-declared in `core/types.ts` to match the MCP wire format without depending on `@modelcontextprotocol/sdk/types.js`. The runtime still serializes via the SDK; only the type tree is owned.
85
+ - **`ZodRawShape`, `ZodType`, `infer`** — sourced directly from `zod` (a peer dep). zod v3 and v4 both export all three at the top level; `ZodType` is non-deprecated in both versions with safe generic defaults. We re-export them from `core/types.ts` as `ZodRawShape` / `ZodSchema` / `ShapeOutput` (`ZodSchema` is a no-generic alias for `ZodType`). No dependency on `@modelcontextprotocol/sdk/server/zod-compat.js` types.
86
+ - **`ContentBlock` union** — `TextContent | ImageContent | AudioContent | EmbeddedResource | ResourceLink`, structurally matching MCP's wire format. Consumers can type custom content (`return { content: [<ImageContent>, <TextContent>] }`) without referencing the SDK.
87
+
88
+ The **runtime** still hands off to the MCP SDK — we still `import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"` and call `registerTool`, and we still use `objectFromShape` + `safeParseAsync` + `toJsonSchemaCompat` from the SDK's zod-compat module for REST input validation and JSON-Schema serialization. That's a runtime dependency, not a type-surface dependency.
89
+
90
+ ### 9. Stale-route cleanup on every regen
91
+
92
+ The `GENERATED_BANNER` is the SDK's ownership token. Cleanup walks the whole `routesDir`, and any `.ts` file that carries the banner but is no longer in the current emit set gets unlinked. Catches two real cases:
93
+
94
+ - **Plugin version upgrades** that move or rename a route. Without cleanup, a stale file keeps importing a symbol the package no longer exports and breaks the build.
95
+ - **MCP entry removal.** Deleting `lib/mcp/index.ts` triggers the cleanup pass with an empty expected set; every banner-tagged file gets unlinked. Without this, the orphans still imported the deleted module and broke the build until removed by hand.
96
+
97
+ Two consequences for user-authored files:
98
+
99
+ - **Without the banner** (e.g., your own `src/routes/widgets.ts`): left alone. The banner is the only ownership signal, and it's 200+ idiosyncratic characters, so accidental collision is implausible.
100
+ - **With the banner, anywhere under `routesDir`**: treated as SDK-owned and reclaimed if it isn't in the current emit set. To take ownership of such a file, delete the banner line — the plugin then leaves it alone. Conversely, a hand-authored file *at* an emit path *without* the banner makes `writeIfChanged` refuse with a build-time error — `refusing to overwrite user-authored route at <path>. Delete it or move it first.`
101
+
102
+ ## Layout
103
+
104
+ ```
105
+ src/
106
+ index.ts # public entrypoint: defineTool/defineMcp, auth, ToolContext, public types
107
+ core/ # framework-agnostic kernel; not a published subpath
108
+ define.ts # defineTool, defineMcp
109
+ types.ts # types (zod-sourced shape types, MCP wire types)
110
+ http.ts # Web-Standard Response helpers
111
+ url.ts # URL parsing/validation
112
+ validation.ts # config assertions
113
+ promise.ts # cachedPromise (resolved-value cache)
114
+ auth/ # cross-cutting OAuth middleware (unpublished); depends only on core/
115
+ config.ts # auth namespace: auth.oauth.issuer
116
+ authorize.ts # request authorizer, bearer parsing, challenges
117
+ verifier.ts # JWT verification and auth context construction
118
+ context.ts # ToolContext (verified auth passed to tool handlers)
119
+ claims.ts # JWT claim accessors (scope/string helpers)
120
+ discovery.ts # OAuth/OIDC metadata and JWKS URI discovery
121
+ resource.ts # protected-resource URL resolution
122
+ metadata-path.ts # shared RFC 9728 metadata path constant
123
+ types.ts # auth config + context types
124
+ protocols/
125
+ oauth-metadata.ts # createOAuthProtectedResourceMetadataHandler (RFC 9728 wire surface)
126
+ mcp/
127
+ protocol.ts # createMcpProtocolHandler (Web-Standard)
128
+ index.ts # barrel
129
+ rest/
130
+ list-tools.ts # createListToolsHandler (Web-Standard, GET)
131
+ invoke-tool.ts # createInvokeToolHandler (Web-Standard, POST)
132
+ index.ts # barrel
133
+ stacks/
134
+ tanstack/
135
+ handlers.ts # TanStack route-ctx adapters
136
+ vite.ts # mcpPlugin (route emission + stale-file cleanup)
137
+ index.ts # barrel
138
+ tests/
139
+ core/{define,promise,url}.test.ts
140
+ auth/{claims,config,context,oauth}.test.ts # OAuth config + bearer verification
141
+ protocols/
142
+ mcp/protocol.test.ts
143
+ rest/{list-tools,invoke-tool}.test.ts
144
+ parity.test.ts # REST↔MCP equivalence contract
145
+ stacks/
146
+ tanstack/{handlers,vite}.test.ts
147
+ integration/ # boots an example app, hits live HTTP
148
+ global-setup.ts # spawns example/tanstack on :8080
149
+ tools.test.ts # non-OAuth example
150
+ oauth.test.ts # self-contained: mock issuer + OAuth example
151
+ ```
152
+
153
+ ## Scripts
154
+
155
+ ```bash
156
+ pnpm format # oxfmt --write src/ tests/
157
+ pnpm format:check # oxfmt --check src/ tests/
158
+ pnpm lint # format:check + oxlint (one command, both surfaces)
159
+ pnpm lint:fix # format + oxlint --fix
160
+ pnpm typecheck # tsgo --noEmit
161
+ pnpm test # vitest run --project unit (fast, hermetic)
162
+ pnpm test:watch # vitest --project unit
163
+ pnpm test:integration # build + install example + vitest run --project integration
164
+ pnpm test:integration:oauth # build + install OAuth example + vitest run --project integration-oauth
165
+ pnpm build # tsup → dist/{cjs,esm}/{index,protocols/*,stacks/*}
166
+ pnpm pack:local # build + npm pack into /tmp/lovable.dev-mcp-js-<version>.tgz
167
+ pnpm example:install # install example/tanstack deps (called by test:integration)
168
+ pnpm example:oauth:install # install example/tanstack-supabase-oauth deps
169
+ pnpm example:oauth:build # production-build the OAuth example
170
+ pnpm example:oauth:smoke # smoke a running OAuth example, optionally with MCP_ACCESS_TOKEN
171
+ ```
172
+
173
+ `lint` checks both formatting (oxfmt) and lint rules (oxlint) in one pass — the format check fails fast before the lint pass so you see the smaller diff first. `prepublishOnly` chains `clean → typecheck → test → build`; CI enforces `lint` separately.
174
+
175
+ **Integration tests** live under `tests/integration/` and run against a live HTTP server. `pnpm test:integration` builds the SDK, installs `example/tanstack`, boots its dev server on port 8080 via a Vitest `globalSetup`, runs the suite, and tears the server down. If the server is already running at `MCP_BASE_URL` (default `http://localhost:8080`), the setup reuses it — useful when iterating on the suite. See `CHANGELOG.md` for version history.
176
+
177
+ **OAuth integration test** (`tests/integration/oauth.test.ts`) is hermetic: `pnpm test:integration:oauth` builds + installs `example/tanstack-supabase-oauth`, then a self-contained `beforeAll` starts a mock RS256 issuer (JWKS + Supabase userinfo/PostgREST stubs), boots the example pointed at it on port 8081, signs a local test JWT, and asserts metadata, bearer challenges, and authorized REST + MCP calls. It runs in its own `integration-oauth` Vitest project (no shared `globalSetup`).
178
+
179
+ **OAuth local loop** lives under `example/tanstack-supabase-oauth`. `pnpm example:oauth:dev` uses that example's checked-in `.env`, which points at the shared Lovable/Supabase test project; `pnpm example:oauth:smoke` verifies metadata/challenge behavior against a running example and, with `MCP_ACCESS_TOKEN`, authenticated calls.
180
+
181
+ **Local-pack workflow** (for testing the SDK against a downstream consumer without publishing):
182
+
183
+ ```bash
184
+ pnpm pack:local # → /tmp/lovable.dev-mcp-js-<version>.tgz
185
+ cd path/to/your/app
186
+ bun add /tmp/lovable.dev-mcp-js-<version>.tgz # or `pnpm add`, `npm i`
187
+ ```
188
+
189
+ Note: bun caches tarballs by absolute path. If you re-run `pack:local` without bumping the version, bun will re-extract from cache rather than picking up the new bytes. Bump `version` in `package.json` between iterations.
package/README.md CHANGED
@@ -193,194 +193,4 @@ Use `auth.oauth.issuer(...)`, set `resource` or `acceptedAudiences` to anchor th
193
193
  | `@lovable.dev/mcp-js/stacks/tanstack` | TanStack-route-ctx adapters (`createTanStack*Handler`) |
194
194
  | `@lovable.dev/mcp-js/stacks/tanstack/vite` | The Vite plugin |
195
195
 
196
- The folders carry distinct meaning:
197
-
198
- - **`protocols/`** is the wire layer — one folder per externally observable HTTP surface. `protocols/mcp` is the public MCP-over-HTTP surface; `protocols/oauth-metadata` serves the RFC 9728 protected-resource metadata required to bootstrap protected MCP clients; `protocols/rest` is internal RPC for the upstream MCP proxy (see "What the plugin emits"). Every backend wires both MCP and REST, plus the metadata endpoint when OAuth is configured.
199
- - **`auth/`** (unpublished) is the cross-cutting OAuth middleware — bearer verification, issuer/JWKS discovery, the `auth.oauth.*` config namespace. It depends only on `core/`; both `protocols/mcp` and `protocols/rest` depend on it for the shared bearer gate, so it isn't a wire-format peer of `mcp`/`rest` and doesn't live under `protocols/`.
200
- - **`stacks/`** is the framework integration: TanStack today; future entries (Supabase Edge Functions, classic Vite, …) live next to it under the same parent.
201
-
202
- Generated route files import from `@lovable.dev/mcp-js/stacks/tanstack`. End users only need the root import. Future stacks (Supabase Edge, classic Vite, …) land under `stacks/` as siblings, forwarding to `protocols/{mcp,oauth-metadata,rest}` directly — protocol logic stays shared, only ctx unwrapping differs.
203
-
204
- ---
205
-
206
- ## Design decisions
207
-
208
- ### 1. Explicit `defineMcp({ tools })` over file-based discovery
209
-
210
- `defineTool` is a typed identity function. The user imports tools explicitly into `defineMcp({ tools: [...] })`. The Vite plugin scans nothing — it emits routes that read the user's array at runtime.
211
-
212
- Reasons:
213
-
214
- - **Implicit registration drifts silently.** Tool name + file name can disagree; duplicates only surface at runtime in `registerTool`.
215
- - **Refactoring is hostile.** Renaming a tool means renaming its file. Sub-directories require the plugin to recurse, and there's no clean convention for "implementation vs. helper" files.
216
- - **PR review can't grep the surface.** A reader can mentally check `tools: [echoTool, addTool]`; they cannot mentally check a directory scan.
217
-
218
- ### 2. Build-time route emission via a Vite plugin
219
-
220
- TanStack's file-based router needs a route file on disk to register a URL. If we asked users to author the route file themselves, they'd own seven lines of plumbing that has to stay correct as the SDK's transport semantics evolve. Generating it lets us pin the wiring as a versioned package artifact — when the MCP SDK changes, the plugin emits a new template and every user picks it up on rebuild.
221
-
222
- This is the same insight that drives shipping an SDK instead of having the agent author the MCP runtime: bug fixes ship to all apps on the next build, not per-app.
223
-
224
- ### 3. The `[.mcp]` URL prefix uses bracket escaping
225
-
226
- TanStack's file routing maps filenames to URLs and filters dot-prefixed entries as hidden — both directories (`src/routes/.mcp/...`) and flat files (`.mcp.list-tools.ts`). The escape is bracket-quoted literal segments: `[.mcp]/list-tools.ts` maps to `/.mcp/list-tools` and shows up in the generated route tree. The plugin emits at `src/routes/[.mcp]/list-tools.ts` and `src/routes/[.mcp]/invoke-tool/$tool.ts`.
227
-
228
- ### 4. `invoke-tool/<tool>` instead of `<tool>` directly
229
-
230
- The `invoke-tool/` segment scopes the user-defined tool namespace, so future endpoints under `/.mcp/` (`list-tools`, `health`, `audit-log`, anything else added) can't collide with a user-defined tool named the same thing. Cheap upfront, breaking to add later.
231
-
232
- ### 5. Dynamic tool resolution at runtime
233
-
234
- The dispatcher resolves the tool name against the live `mcp.tools` array on every request. From the caller's perspective `POST /.mcp/invoke-tool/echo` and `POST /.mcp/invoke-tool/add` look like separate endpoints; from the author's perspective the source of truth is one array. **Tool resolution is a per-request operation, not a per-build operation.**
235
-
236
- This is architectural foundation, not convenience. Resolution stays inside app code so it can grow:
237
-
238
- - **Per-user / per-scope tool visibility.** The lookup that maps `params.tool` to a handler can run *inside* app code, with access to the request's authenticated identity. An admin user might see `purge_workspace`; a regular user wouldn't see it in `list-tools` and would get a 404 from `invoke-tool`. Plan-tier gating, beta cohorts, feature flags, workspace roles — all want runtime context to decide what to expose.
239
- - **The build doesn't have user context.** Emitting per-tool files at build time would lock the tool catalog to the deploy. Dynamic resolution leaves that decision to the request lifecycle.
240
- - **The MCP and REST surfaces stay in sync automatically.** `tools/list` (over MCP) and `GET /.mcp/list-tools` (over REST) both read the same array. When per-user resolution lands, both surfaces filter through the same hook.
241
-
242
- `list-tools` stays a separate handler (not auto-injected into the dispatcher) because it's naturally a `GET` — distinct HTTP semantics from `invoke-tool`'s `POST`. The per-user filter will hook into both, but the HTTP shape stays clean.
243
-
244
- **Planned:** delegate the per-request tool-resolution step to app code via a hook (something like `resolveTools(ctx)` returning the subset of `mcp.tools` the caller can see). Until then, every authenticated caller sees the full array.
245
-
246
- ### 6. Type-erased `AnyToolDefinition` at the array boundary
247
-
248
- `ToolDefinition<TInput>` is generic — the handler's args are inferred from `inputSchema`:
249
-
250
- ```ts
251
- handler: TInput extends ZodRawShape
252
- ? (args: ShapeOutput<TInput>) => ToolHandlerResult | Promise<...>
253
- : () => ToolHandlerResult | Promise<...>;
254
- ```
255
-
256
- This works per-tool at the `defineTool` call site. It breaks at the `defineMcp({ tools: [...] })` boundary because of **function-parameter contravariance**: a heterogeneous array of `ToolDefinition<{a}>` and `ToolDefinition<{b, c}>` can't collapse to one generic without rejecting at least one entry.
257
-
258
- `AnyToolDefinition` is the non-generic version used only at the array boundary, with `handler: (args: any) => ...`. `any` is bivariant in TypeScript, so any concrete handler assigns. Per-arg typing still happens inside `defineTool` — the only place it materially matters.
259
-
260
- ### 7. Title, description, and instructions are required
261
-
262
- The MCP spec promoted `title` to a top-level field on `Tool` and `Server` in 2025-06-18+; we require it. We also require `description` on tools and `instructions` on servers because:
263
-
264
- - Both are read by LLMs as context for tool selection. A missing/templated description or instructions string produces agents that call the wrong tool or call none at all.
265
- - Optional prose fields encourage drift over time. Required-at-the-type-level enforces "intentional surface area" at PR-review time.
266
- - `instructions: ""` is the documented opt-out for servers that genuinely have nothing supplementary to say (single-purpose tools whose descriptions speak for themselves). Empty-string is intentional rather than accidental.
267
-
268
- The MCP spec's legacy `annotations.title` is omitted from our `ToolAnnotations` type — use the top-level `title` instead. Two locations for the same field encourage drift.
269
-
270
- ### 8. Decoupled type surface from `@modelcontextprotocol/sdk`
271
-
272
- Two reasons:
273
-
274
- 1. **`.d.ts` stability across SDK bumps.** The SDK's published type tree (`@modelcontextprotocol/sdk/types.js`) and its zod-compat shape generics have shipped breaking type-only changes between minor versions. Owning our own type re-declarations lets us bump the SDK's runtime without forcing every downstream consumer to re-type-check.
275
- 2. **Optionality on the SDK itself.** If we ever swap or drop `@modelcontextprotocol/sdk` — different transport, in-house protocol implementation, a fork — consumers' code is the contract that matters, and it's typed against this package's surface, not the SDK's. The runtime hand-off can change without rippling through user code.
276
-
277
- The published `.d.ts` files import only from `zod` and `vite`. Three SDK concerns were re-implemented (or re-typed) locally:
278
-
279
- - **Content blocks and `ToolAnnotations`** — re-declared in `core/types.ts` to match the MCP wire format without depending on `@modelcontextprotocol/sdk/types.js`. The runtime still serializes via the SDK; only the type tree is owned.
280
- - **`ZodRawShape`, `ZodType`, `infer`** — sourced directly from `zod` (a peer dep). zod v3 and v4 both export all three at the top level; `ZodType` is non-deprecated in both versions with safe generic defaults. We re-export them from `core/types.ts` as `ZodRawShape` / `ZodSchema` / `ShapeOutput` (`ZodSchema` is a no-generic alias for `ZodType`). No dependency on `@modelcontextprotocol/sdk/server/zod-compat.js` types.
281
- - **`ContentBlock` union** — `TextContent | ImageContent | AudioContent | EmbeddedResource | ResourceLink`, structurally matching MCP's wire format. Consumers can type custom content (`return { content: [<ImageContent>, <TextContent>] }`) without referencing the SDK.
282
-
283
- The **runtime** still hands off to the MCP SDK — we still `import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"` and call `registerTool`, and we still use `objectFromShape` + `safeParseAsync` + `toJsonSchemaCompat` from the SDK's zod-compat module for REST input validation and JSON-Schema serialization. That's a runtime dependency, not a type-surface dependency.
284
-
285
- ### 9. Stale-route cleanup on every regen
286
-
287
- The `GENERATED_BANNER` is the SDK's ownership token. Cleanup walks the whole `routesDir`, and any `.ts` file that carries the banner but is no longer in the current emit set gets unlinked. Catches two real cases:
288
-
289
- - **Plugin version upgrades** that move or rename a route. Without cleanup, a stale file keeps importing a symbol the package no longer exports and breaks the build.
290
- - **MCP entry removal.** Deleting `lib/mcp/index.ts` triggers the cleanup pass with an empty expected set; every banner-tagged file gets unlinked. Without this, the orphans still imported the deleted module and broke the build until removed by hand.
291
-
292
- Two consequences for user-authored files:
293
-
294
- - **Without the banner** (e.g., your own `src/routes/widgets.ts`): left alone. The banner is the only ownership signal, and it's 200+ idiosyncratic characters, so accidental collision is implausible.
295
- - **With the banner, anywhere under `routesDir`**: treated as SDK-owned and reclaimed if it isn't in the current emit set. To take ownership of such a file, delete the banner line — the plugin then leaves it alone. Conversely, a hand-authored file *at* an emit path *without* the banner makes `writeIfChanged` refuse with a build-time error — `refusing to overwrite user-authored route at <path>. Delete it or move it first.`
296
-
297
- ---
298
-
299
- ## Layout
300
-
301
- ```
302
- src/
303
- index.ts # public entrypoint: defineTool/defineMcp, auth, ToolContext, public types
304
- core/ # framework-agnostic kernel; not a published subpath
305
- define.ts # defineTool, defineMcp
306
- types.ts # types (zod-sourced shape types, MCP wire types)
307
- http.ts # Web-Standard Response helpers
308
- url.ts # URL parsing/validation
309
- validation.ts # config assertions
310
- promise.ts # cachedPromise (resolved-value cache)
311
- auth/ # cross-cutting OAuth middleware (unpublished); depends only on core/
312
- config.ts # auth namespace: auth.oauth.issuer
313
- authorize.ts # request authorizer, bearer parsing, challenges
314
- verifier.ts # JWT verification and auth context construction
315
- context.ts # ToolContext (verified auth passed to tool handlers)
316
- claims.ts # JWT claim accessors (scope/string helpers)
317
- discovery.ts # OAuth/OIDC metadata and JWKS URI discovery
318
- resource.ts # protected-resource URL resolution
319
- metadata-path.ts # shared RFC 9728 metadata path constant
320
- types.ts # auth config + context types
321
- protocols/
322
- oauth-metadata.ts # createOAuthProtectedResourceMetadataHandler (RFC 9728 wire surface)
323
- mcp/
324
- protocol.ts # createMcpProtocolHandler (Web-Standard)
325
- index.ts # barrel
326
- rest/
327
- list-tools.ts # createListToolsHandler (Web-Standard, GET)
328
- invoke-tool.ts # createInvokeToolHandler (Web-Standard, POST)
329
- index.ts # barrel
330
- stacks/
331
- tanstack/
332
- handlers.ts # TanStack route-ctx adapters
333
- vite.ts # mcpPlugin (route emission + stale-file cleanup)
334
- index.ts # barrel
335
- tests/
336
- core/{define,promise,url}.test.ts
337
- auth/{claims,config,context,oauth}.test.ts # OAuth config + bearer verification
338
- protocols/
339
- mcp/protocol.test.ts
340
- rest/{list-tools,invoke-tool}.test.ts
341
- parity.test.ts # REST↔MCP equivalence contract
342
- stacks/
343
- tanstack/{handlers,vite}.test.ts
344
- integration/ # boots an example app, hits live HTTP
345
- global-setup.ts # spawns example/tanstack on :8080
346
- tools.test.ts # non-OAuth example
347
- oauth.test.ts # self-contained: mock issuer + OAuth example
348
- ```
349
-
350
- ## Scripts
351
-
352
- ```bash
353
- pnpm format # oxfmt --write src/ tests/
354
- pnpm format:check # oxfmt --check src/ tests/
355
- pnpm lint # format:check + oxlint (one command, both surfaces)
356
- pnpm lint:fix # format + oxlint --fix
357
- pnpm typecheck # tsgo --noEmit
358
- pnpm test # vitest run --project unit (fast, hermetic)
359
- pnpm test:watch # vitest --project unit
360
- pnpm test:integration # build + install example + vitest run --project integration
361
- pnpm test:integration:oauth # build + install OAuth example + vitest run --project integration-oauth
362
- pnpm build # tsup → dist/{cjs,esm}/{index,protocols/*,stacks/*}
363
- pnpm pack:local # build + npm pack into /tmp/lovable.dev-mcp-js-<version>.tgz
364
- pnpm example:install # install example/tanstack deps (called by test:integration)
365
- pnpm example:oauth:install # install example/tanstack-supabase-oauth deps
366
- pnpm example:oauth:build # production-build the OAuth example
367
- pnpm example:oauth:smoke # smoke a running OAuth example, optionally with MCP_ACCESS_TOKEN
368
- ```
369
-
370
- `lint` checks both formatting (oxfmt) and lint rules (oxlint) in one pass — the format check fails fast before the lint pass so you see the smaller diff first. `prepublishOnly` chains `clean → typecheck → test → build`; CI enforces `lint` separately.
371
-
372
- **Integration tests** live under `tests/integration/` and run against a live HTTP server. `pnpm test:integration` builds the SDK, installs `example/tanstack`, boots its dev server on port 8080 via a Vitest `globalSetup`, runs the suite, and tears the server down. If the server is already running at `MCP_BASE_URL` (default `http://localhost:8080`), the setup reuses it — useful when iterating on the suite. See `CHANGELOG.md` for version history.
373
-
374
- **OAuth integration test** (`tests/integration/oauth.test.ts`) is hermetic: `pnpm test:integration:oauth` builds + installs `example/tanstack-supabase-oauth`, then a self-contained `beforeAll` starts a mock RS256 issuer (JWKS + Supabase userinfo/PostgREST stubs), boots the example pointed at it on port 8081, signs a local test JWT, and asserts metadata, bearer challenges, and authorized REST + MCP calls. It runs in its own `integration-oauth` Vitest project (no shared `globalSetup`).
375
-
376
- **OAuth local loop** lives under `example/tanstack-supabase-oauth`. `pnpm example:oauth:dev` uses that example's checked-in `.env`, which points at the shared Lovable/Supabase test project; `pnpm example:oauth:smoke` verifies metadata/challenge behavior against a running example and, with `MCP_ACCESS_TOKEN`, authenticated calls.
377
-
378
- **Local-pack workflow** (for testing the SDK against a downstream consumer without publishing):
379
-
380
- ```bash
381
- pnpm pack:local # → /tmp/lovable.dev-mcp-js-<version>.tgz
382
- cd path/to/your/app
383
- bun add /tmp/lovable.dev-mcp-js-<version>.tgz # or `pnpm add`, `npm i`
384
- ```
385
-
386
- Note: bun caches tarballs by absolute path. If you re-run `pack:local` without bumping the version, bun will re-extract from cache rather than picking up the new bytes. Bump `version` in `package.json` between iterations.
196
+ End users only need the root import (`@lovable.dev/mcp-js`). Generated route files import from `@lovable.dev/mcp-js/stacks/tanstack`. The other subpaths are escape hatches for hand-wiring custom stacks against the protocol layer directly.
@@ -1,25 +1,24 @@
1
1
  import {
2
2
  JSON_HEADERS,
3
+ corsPreflightResponse,
3
4
  getOAuthRuntime,
4
5
  headResponse,
5
6
  methodNotAllowed,
6
7
  oauthConfigurationErrorResponse,
7
- resolveProtectedResource
8
- } from "./chunk-XEDRJFAR.js";
8
+ resolveProtectedResource,
9
+ withCors
10
+ } from "./chunk-ZKKLOL2C.js";
9
11
 
10
12
  // src/protocols/oauth-metadata.ts
11
- var CORS_ORIGIN = { "Access-Control-Allow-Origin": "*" };
12
- function withCors(response) {
13
- response.headers.set("Access-Control-Allow-Origin", "*");
14
- return response;
15
- }
16
13
  function notFound() {
17
- return new Response(JSON.stringify({ error: "not found" }), {
18
- status: 404,
19
- // `no-store` so a 404 (OAuth unconfigured) isn't heuristically cached and
20
- // then served past a later deploy that enables OAuth and starts returning 200.
21
- headers: { ...JSON_HEADERS, ...CORS_ORIGIN, "Cache-Control": "no-store" }
22
- });
14
+ return withCors(
15
+ new Response(JSON.stringify({ error: "not found" }), {
16
+ status: 404,
17
+ // `no-store` so a 404 (OAuth unconfigured) isn't heuristically cached and
18
+ // then served past a later deploy that enables OAuth and starts returning 200.
19
+ headers: { ...JSON_HEADERS, "Cache-Control": "no-store" }
20
+ })
21
+ );
23
22
  }
24
23
  async function buildProtectedResourceMetadata(mcp, auth, request, options, discovery) {
25
24
  const issuer = await discovery.resolveIssuer();
@@ -44,17 +43,12 @@ function createOAuthProtectedResourceMetadataHandler(mcp, options = {}) {
44
43
  return async (request) => {
45
44
  if (runtime.kind !== "configured" || runtime.auth.protectedResourceMetadataUrl !== void 0)
46
45
  return notFound();
47
- if (request.method === "OPTIONS") {
48
- return new Response(null, {
49
- status: 204,
50
- headers: { ...CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS" }
51
- });
52
- }
46
+ if (request.method === "OPTIONS")
47
+ return corsPreflightResponse("GET, HEAD, OPTIONS");
53
48
  if (request.method !== "GET" && request.method !== "HEAD")
54
49
  return withCors(methodNotAllowed("GET, HEAD, OPTIONS"));
55
50
  const headers = {
56
51
  ...JSON_HEADERS,
57
- ...CORS_ORIGIN,
58
52
  "Cache-Control": "public, max-age=300",
59
53
  Vary: "Host"
60
54
  };
@@ -66,7 +60,7 @@ function createOAuthProtectedResourceMetadataHandler(mcp, options = {}) {
66
60
  runtime.options,
67
61
  runtime.discovery
68
62
  );
69
- const response = Response.json(metadata, { headers });
63
+ const response = withCors(Response.json(metadata, { headers }));
70
64
  return request.method === "HEAD" ? headResponse(response) : response;
71
65
  } catch {
72
66
  const response = withCors(oauthConfigurationErrorResponse());
@@ -2,8 +2,10 @@ import {
2
2
  ToolContext
3
3
  } from "./chunk-MA5H6PSF.js";
4
4
  import {
5
- createRequestAuthorizer
6
- } from "./chunk-XEDRJFAR.js";
5
+ corsPreflightResponse,
6
+ createRequestAuthorizer,
7
+ withCors
8
+ } from "./chunk-ZKKLOL2C.js";
7
9
 
8
10
  // src/protocols/mcp/protocol.ts
9
11
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -25,7 +27,7 @@ function adaptToolToSdkCallback(tool, auth) {
25
27
  }
26
28
  function createMcpProtocolHandler(mcp, options = {}) {
27
29
  const authorizer = createRequestAuthorizer(mcp, options);
28
- return async (request) => {
30
+ const handle = async (request) => {
29
31
  const authResult = await authorizer.authorize(request);
30
32
  if (!authResult.ok)
31
33
  return authResult.response;
@@ -59,6 +61,11 @@ function createMcpProtocolHandler(mcp, options = {}) {
59
61
  );
60
62
  }
61
63
  };
64
+ return async (request) => {
65
+ if (request.method === "OPTIONS")
66
+ return corsPreflightResponse("GET, POST, DELETE, OPTIONS");
67
+ return withCors(await handle(request));
68
+ };
62
69
  }
63
70
 
64
71
  export {
@@ -4,10 +4,12 @@ import {
4
4
  import {
5
5
  JSON_HEADERS,
6
6
  assertRestResourceBinding,
7
+ corsPreflightResponse,
7
8
  createRequestAuthorizer,
8
9
  headResponse,
9
- methodNotAllowed
10
- } from "./chunk-XEDRJFAR.js";
10
+ methodNotAllowed,
11
+ withCors
12
+ } from "./chunk-ZKKLOL2C.js";
11
13
 
12
14
  // src/protocols/rest/list-tools.ts
13
15
  import { objectFromShape } from "@modelcontextprotocol/sdk/server/zod-compat.js";
@@ -24,12 +26,12 @@ function shapeToJsonSchema(shape) {
24
26
  function createListToolsHandler(mcp, options = {}) {
25
27
  assertRestResourceBinding(mcp, options);
26
28
  const authorizer = createRequestAuthorizer(mcp, options);
27
- return async (request) => {
29
+ const handle = async (request) => {
28
30
  const authResult = await authorizer.authorize(request);
29
31
  if (!authResult.ok)
30
32
  return authResult.response;
31
33
  if (request.method !== "GET" && request.method !== "HEAD")
32
- return methodNotAllowed("GET, HEAD");
34
+ return methodNotAllowed("GET, HEAD, OPTIONS");
33
35
  const body = {
34
36
  server: { name: mcp.name, version: mcp.version, title: mcp.title },
35
37
  tools: mcp.tools.map((tool) => ({
@@ -44,6 +46,11 @@ function createListToolsHandler(mcp, options = {}) {
44
46
  const response = Response.json(body);
45
47
  return request.method === "HEAD" ? headResponse(response) : response;
46
48
  };
49
+ return async (request) => {
50
+ if (request.method === "OPTIONS")
51
+ return corsPreflightResponse("GET, HEAD, OPTIONS");
52
+ return withCors(await handle(request));
53
+ };
47
54
  }
48
55
 
49
56
  // src/protocols/rest/invoke-tool.ts
@@ -63,12 +70,12 @@ function isEmptyArgs(value) {
63
70
  function createInvokeToolHandler(mcp, options = {}) {
64
71
  assertRestResourceBinding(mcp, options);
65
72
  const authorizer = createRequestAuthorizer(mcp, options);
66
- return async (request, toolName) => {
73
+ const handle = async (request, toolName) => {
67
74
  const authResult = await authorizer.authorize(request);
68
75
  if (!authResult.ok)
69
76
  return authResult.response;
70
77
  if (request.method !== "POST")
71
- return methodNotAllowed("POST");
78
+ return methodNotAllowed("POST, OPTIONS");
72
79
  const tool = mcp.tools.find((t) => t.name === toolName);
73
80
  if (!tool) {
74
81
  return new Response(JSON.stringify({ error: `unknown tool: ${safeReflectName(toolName)}` }), {
@@ -136,6 +143,11 @@ function createInvokeToolHandler(mcp, options = {}) {
136
143
  isError: result.isError
137
144
  });
138
145
  };
146
+ return async (request, toolName) => {
147
+ if (request.method === "OPTIONS")
148
+ return corsPreflightResponse("POST, OPTIONS");
149
+ return withCors(await handle(request, toolName));
150
+ };
139
151
  }
140
152
 
141
153
  export {
@@ -359,6 +359,26 @@ function createRequestAuthorizer(mcp, options = {}) {
359
359
  };
360
360
  }
361
361
 
362
+ // src/core/cors.ts
363
+ var EXPOSE_HEADERS = "WWW-Authenticate, Mcp-Session-Id, Mcp-Protocol-Version";
364
+ var ALLOW_HEADERS = "Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID";
365
+ function withCors(response) {
366
+ response.headers.set("Access-Control-Allow-Origin", "*");
367
+ response.headers.set("Access-Control-Expose-Headers", EXPOSE_HEADERS);
368
+ return response;
369
+ }
370
+ function corsPreflightResponse(allowMethods) {
371
+ return new Response(null, {
372
+ status: 204,
373
+ headers: {
374
+ "Access-Control-Allow-Origin": "*",
375
+ "Access-Control-Allow-Methods": allowMethods,
376
+ "Access-Control-Allow-Headers": ALLOW_HEADERS,
377
+ "Access-Control-Max-Age": "86400"
378
+ }
379
+ });
380
+ }
381
+
362
382
  export {
363
383
  JSON_HEADERS,
364
384
  headResponse,
@@ -367,5 +387,7 @@ export {
367
387
  oauthConfigurationErrorResponse,
368
388
  getOAuthRuntime,
369
389
  assertRestResourceBinding,
370
- createRequestAuthorizer
390
+ createRequestAuthorizer,
391
+ withCors,
392
+ corsPreflightResponse
371
393
  };
@@ -446,6 +446,26 @@ var ToolContext = class {
446
446
  }
447
447
  };
448
448
 
449
+ // src/core/cors.ts
450
+ var EXPOSE_HEADERS = "WWW-Authenticate, Mcp-Session-Id, Mcp-Protocol-Version";
451
+ var ALLOW_HEADERS = "Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID";
452
+ function withCors(response) {
453
+ response.headers.set("Access-Control-Allow-Origin", "*");
454
+ response.headers.set("Access-Control-Expose-Headers", EXPOSE_HEADERS);
455
+ return response;
456
+ }
457
+ function corsPreflightResponse(allowMethods) {
458
+ return new Response(null, {
459
+ status: 204,
460
+ headers: {
461
+ "Access-Control-Allow-Origin": "*",
462
+ "Access-Control-Allow-Methods": allowMethods,
463
+ "Access-Control-Allow-Headers": ALLOW_HEADERS,
464
+ "Access-Control-Max-Age": "86400"
465
+ }
466
+ });
467
+ }
468
+
449
469
  // src/protocols/mcp/protocol.ts
450
470
  function adaptToolToSdkCallback(tool, auth) {
451
471
  return async (first) => {
@@ -464,7 +484,7 @@ function adaptToolToSdkCallback(tool, auth) {
464
484
  }
465
485
  function createMcpProtocolHandler(mcp, options = {}) {
466
486
  const authorizer = createRequestAuthorizer(mcp, options);
467
- return async (request) => {
487
+ const handle = async (request) => {
468
488
  const authResult = await authorizer.authorize(request);
469
489
  if (!authResult.ok)
470
490
  return authResult.response;
@@ -498,6 +518,11 @@ function createMcpProtocolHandler(mcp, options = {}) {
498
518
  );
499
519
  }
500
520
  };
521
+ return async (request) => {
522
+ if (request.method === "OPTIONS")
523
+ return corsPreflightResponse("GET, POST, DELETE, OPTIONS");
524
+ return withCors(await handle(request));
525
+ };
501
526
  }
502
527
  // Annotate the CommonJS export names for ESM import in node:
503
528
  0 && (module.exports = {
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  createMcpProtocolHandler
3
- } from "../../chunk-GLG5RZGE.js";
3
+ } from "../../chunk-NJ5WBRYI.js";
4
4
  import "../../chunk-MA5H6PSF.js";
5
- import "../../chunk-XEDRJFAR.js";
5
+ import "../../chunk-ZKKLOL2C.js";
6
6
  import "../../chunk-QA3FWDUV.js";
7
7
  import "../../chunk-6DXGZZA4.js";
8
8
  export {
@@ -317,20 +317,37 @@ function getOAuthRuntime(mcp, options = {}) {
317
317
  };
318
318
  }
319
319
 
320
- // src/protocols/oauth-metadata.ts
321
- var CORS_ORIGIN = { "Access-Control-Allow-Origin": "*" };
320
+ // src/core/cors.ts
321
+ var EXPOSE_HEADERS = "WWW-Authenticate, Mcp-Session-Id, Mcp-Protocol-Version";
322
+ var ALLOW_HEADERS = "Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID";
322
323
  function withCors(response) {
323
324
  response.headers.set("Access-Control-Allow-Origin", "*");
325
+ response.headers.set("Access-Control-Expose-Headers", EXPOSE_HEADERS);
324
326
  return response;
325
327
  }
326
- function notFound() {
327
- return new Response(JSON.stringify({ error: "not found" }), {
328
- status: 404,
329
- // `no-store` so a 404 (OAuth unconfigured) isn't heuristically cached and
330
- // then served past a later deploy that enables OAuth and starts returning 200.
331
- headers: { ...JSON_HEADERS, ...CORS_ORIGIN, "Cache-Control": "no-store" }
328
+ function corsPreflightResponse(allowMethods) {
329
+ return new Response(null, {
330
+ status: 204,
331
+ headers: {
332
+ "Access-Control-Allow-Origin": "*",
333
+ "Access-Control-Allow-Methods": allowMethods,
334
+ "Access-Control-Allow-Headers": ALLOW_HEADERS,
335
+ "Access-Control-Max-Age": "86400"
336
+ }
332
337
  });
333
338
  }
339
+
340
+ // src/protocols/oauth-metadata.ts
341
+ function notFound() {
342
+ return withCors(
343
+ new Response(JSON.stringify({ error: "not found" }), {
344
+ status: 404,
345
+ // `no-store` so a 404 (OAuth unconfigured) isn't heuristically cached and
346
+ // then served past a later deploy that enables OAuth and starts returning 200.
347
+ headers: { ...JSON_HEADERS, "Cache-Control": "no-store" }
348
+ })
349
+ );
350
+ }
334
351
  async function buildProtectedResourceMetadata(mcp, auth, request, options, discovery) {
335
352
  const issuer = await discovery.resolveIssuer();
336
353
  const body = {
@@ -354,17 +371,12 @@ function createOAuthProtectedResourceMetadataHandler(mcp, options = {}) {
354
371
  return async (request) => {
355
372
  if (runtime.kind !== "configured" || runtime.auth.protectedResourceMetadataUrl !== void 0)
356
373
  return notFound();
357
- if (request.method === "OPTIONS") {
358
- return new Response(null, {
359
- status: 204,
360
- headers: { ...CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS" }
361
- });
362
- }
374
+ if (request.method === "OPTIONS")
375
+ return corsPreflightResponse("GET, HEAD, OPTIONS");
363
376
  if (request.method !== "GET" && request.method !== "HEAD")
364
377
  return withCors(methodNotAllowed("GET, HEAD, OPTIONS"));
365
378
  const headers = {
366
379
  ...JSON_HEADERS,
367
- ...CORS_ORIGIN,
368
380
  "Cache-Control": "public, max-age=300",
369
381
  Vary: "Host"
370
382
  };
@@ -376,7 +388,7 @@ function createOAuthProtectedResourceMetadataHandler(mcp, options = {}) {
376
388
  runtime.options,
377
389
  runtime.discovery
378
390
  );
379
- const response = Response.json(metadata, { headers });
391
+ const response = withCors(Response.json(metadata, { headers }));
380
392
  return request.method === "HEAD" ? headResponse(response) : response;
381
393
  } catch {
382
394
  const response = withCors(oauthConfigurationErrorResponse());
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createOAuthProtectedResourceMetadataHandler
3
- } from "../chunk-DDF63QWG.js";
4
- import "../chunk-XEDRJFAR.js";
3
+ } from "../chunk-K2T4WTKX.js";
4
+ import "../chunk-ZKKLOL2C.js";
5
5
  import "../chunk-QA3FWDUV.js";
6
6
  import "../chunk-6DXGZZA4.js";
7
7
  export {
@@ -421,6 +421,26 @@ function createRequestAuthorizer(mcp, options = {}) {
421
421
  };
422
422
  }
423
423
 
424
+ // src/core/cors.ts
425
+ var EXPOSE_HEADERS = "WWW-Authenticate, Mcp-Session-Id, Mcp-Protocol-Version";
426
+ var ALLOW_HEADERS = "Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID";
427
+ function withCors(response) {
428
+ response.headers.set("Access-Control-Allow-Origin", "*");
429
+ response.headers.set("Access-Control-Expose-Headers", EXPOSE_HEADERS);
430
+ return response;
431
+ }
432
+ function corsPreflightResponse(allowMethods) {
433
+ return new Response(null, {
434
+ status: 204,
435
+ headers: {
436
+ "Access-Control-Allow-Origin": "*",
437
+ "Access-Control-Allow-Methods": allowMethods,
438
+ "Access-Control-Allow-Headers": ALLOW_HEADERS,
439
+ "Access-Control-Max-Age": "86400"
440
+ }
441
+ });
442
+ }
443
+
424
444
  // src/protocols/rest/list-tools.ts
425
445
  function shapeToJsonSchema(shape) {
426
446
  if (!shape)
@@ -434,12 +454,12 @@ function shapeToJsonSchema(shape) {
434
454
  function createListToolsHandler(mcp, options = {}) {
435
455
  assertRestResourceBinding(mcp, options);
436
456
  const authorizer = createRequestAuthorizer(mcp, options);
437
- return async (request) => {
457
+ const handle = async (request) => {
438
458
  const authResult = await authorizer.authorize(request);
439
459
  if (!authResult.ok)
440
460
  return authResult.response;
441
461
  if (request.method !== "GET" && request.method !== "HEAD")
442
- return methodNotAllowed("GET, HEAD");
462
+ return methodNotAllowed("GET, HEAD, OPTIONS");
443
463
  const body = {
444
464
  server: { name: mcp.name, version: mcp.version, title: mcp.title },
445
465
  tools: mcp.tools.map((tool) => ({
@@ -454,6 +474,11 @@ function createListToolsHandler(mcp, options = {}) {
454
474
  const response = Response.json(body);
455
475
  return request.method === "HEAD" ? headResponse(response) : response;
456
476
  };
477
+ return async (request) => {
478
+ if (request.method === "OPTIONS")
479
+ return corsPreflightResponse("GET, HEAD, OPTIONS");
480
+ return withCors(await handle(request));
481
+ };
457
482
  }
458
483
 
459
484
  // src/protocols/rest/invoke-tool.ts
@@ -518,12 +543,12 @@ function isEmptyArgs(value) {
518
543
  function createInvokeToolHandler(mcp, options = {}) {
519
544
  assertRestResourceBinding(mcp, options);
520
545
  const authorizer = createRequestAuthorizer(mcp, options);
521
- return async (request, toolName) => {
546
+ const handle = async (request, toolName) => {
522
547
  const authResult = await authorizer.authorize(request);
523
548
  if (!authResult.ok)
524
549
  return authResult.response;
525
550
  if (request.method !== "POST")
526
- return methodNotAllowed("POST");
551
+ return methodNotAllowed("POST, OPTIONS");
527
552
  const tool = mcp.tools.find((t) => t.name === toolName);
528
553
  if (!tool) {
529
554
  return new Response(JSON.stringify({ error: `unknown tool: ${safeReflectName(toolName)}` }), {
@@ -591,6 +616,11 @@ function createInvokeToolHandler(mcp, options = {}) {
591
616
  isError: result.isError
592
617
  });
593
618
  };
619
+ return async (request, toolName) => {
620
+ if (request.method === "OPTIONS")
621
+ return corsPreflightResponse("POST, OPTIONS");
622
+ return withCors(await handle(request, toolName));
623
+ };
594
624
  }
595
625
  // Annotate the CommonJS export names for ESM import in node:
596
626
  0 && (module.exports = {
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  createInvokeToolHandler,
3
3
  createListToolsHandler
4
- } from "../../chunk-VD6CS7Y6.js";
4
+ } from "../../chunk-SYI32IRK.js";
5
5
  import "../../chunk-MA5H6PSF.js";
6
- import "../../chunk-XEDRJFAR.js";
6
+ import "../../chunk-ZKKLOL2C.js";
7
7
  import "../../chunk-QA3FWDUV.js";
8
8
  import "../../chunk-6DXGZZA4.js";
9
9
  export {
@@ -466,6 +466,26 @@ var ToolContext = class {
466
466
  }
467
467
  };
468
468
 
469
+ // src/core/cors.ts
470
+ var EXPOSE_HEADERS = "WWW-Authenticate, Mcp-Session-Id, Mcp-Protocol-Version";
471
+ var ALLOW_HEADERS = "Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID";
472
+ function withCors(response) {
473
+ response.headers.set("Access-Control-Allow-Origin", "*");
474
+ response.headers.set("Access-Control-Expose-Headers", EXPOSE_HEADERS);
475
+ return response;
476
+ }
477
+ function corsPreflightResponse(allowMethods) {
478
+ return new Response(null, {
479
+ status: 204,
480
+ headers: {
481
+ "Access-Control-Allow-Origin": "*",
482
+ "Access-Control-Allow-Methods": allowMethods,
483
+ "Access-Control-Allow-Headers": ALLOW_HEADERS,
484
+ "Access-Control-Max-Age": "86400"
485
+ }
486
+ });
487
+ }
488
+
469
489
  // src/protocols/mcp/protocol.ts
470
490
  function adaptToolToSdkCallback(tool, auth) {
471
491
  return async (first) => {
@@ -484,7 +504,7 @@ function adaptToolToSdkCallback(tool, auth) {
484
504
  }
485
505
  function createMcpProtocolHandler(mcp, options = {}) {
486
506
  const authorizer = createRequestAuthorizer(mcp, options);
487
- return async (request) => {
507
+ const handle = async (request) => {
488
508
  const authResult = await authorizer.authorize(request);
489
509
  if (!authResult.ok)
490
510
  return authResult.response;
@@ -518,21 +538,23 @@ function createMcpProtocolHandler(mcp, options = {}) {
518
538
  );
519
539
  }
520
540
  };
541
+ return async (request) => {
542
+ if (request.method === "OPTIONS")
543
+ return corsPreflightResponse("GET, POST, DELETE, OPTIONS");
544
+ return withCors(await handle(request));
545
+ };
521
546
  }
522
547
 
523
548
  // src/protocols/oauth-metadata.ts
524
- var CORS_ORIGIN = { "Access-Control-Allow-Origin": "*" };
525
- function withCors(response) {
526
- response.headers.set("Access-Control-Allow-Origin", "*");
527
- return response;
528
- }
529
549
  function notFound() {
530
- return new Response(JSON.stringify({ error: "not found" }), {
531
- status: 404,
532
- // `no-store` so a 404 (OAuth unconfigured) isn't heuristically cached and
533
- // then served past a later deploy that enables OAuth and starts returning 200.
534
- headers: { ...JSON_HEADERS, ...CORS_ORIGIN, "Cache-Control": "no-store" }
535
- });
550
+ return withCors(
551
+ new Response(JSON.stringify({ error: "not found" }), {
552
+ status: 404,
553
+ // `no-store` so a 404 (OAuth unconfigured) isn't heuristically cached and
554
+ // then served past a later deploy that enables OAuth and starts returning 200.
555
+ headers: { ...JSON_HEADERS, "Cache-Control": "no-store" }
556
+ })
557
+ );
536
558
  }
537
559
  async function buildProtectedResourceMetadata(mcp, auth, request, options, discovery) {
538
560
  const issuer = await discovery.resolveIssuer();
@@ -557,17 +579,12 @@ function createOAuthProtectedResourceMetadataHandler(mcp, options = {}) {
557
579
  return async (request) => {
558
580
  if (runtime.kind !== "configured" || runtime.auth.protectedResourceMetadataUrl !== void 0)
559
581
  return notFound();
560
- if (request.method === "OPTIONS") {
561
- return new Response(null, {
562
- status: 204,
563
- headers: { ...CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS" }
564
- });
565
- }
582
+ if (request.method === "OPTIONS")
583
+ return corsPreflightResponse("GET, HEAD, OPTIONS");
566
584
  if (request.method !== "GET" && request.method !== "HEAD")
567
585
  return withCors(methodNotAllowed("GET, HEAD, OPTIONS"));
568
586
  const headers = {
569
587
  ...JSON_HEADERS,
570
- ...CORS_ORIGIN,
571
588
  "Cache-Control": "public, max-age=300",
572
589
  Vary: "Host"
573
590
  };
@@ -579,7 +596,7 @@ function createOAuthProtectedResourceMetadataHandler(mcp, options = {}) {
579
596
  runtime.options,
580
597
  runtime.discovery
581
598
  );
582
- const response = Response.json(metadata, { headers });
599
+ const response = withCors(Response.json(metadata, { headers }));
583
600
  return request.method === "HEAD" ? headResponse(response) : response;
584
601
  } catch {
585
602
  const response = withCors(oauthConfigurationErrorResponse());
@@ -603,12 +620,12 @@ function shapeToJsonSchema(shape) {
603
620
  function createListToolsHandler(mcp, options = {}) {
604
621
  assertRestResourceBinding(mcp, options);
605
622
  const authorizer = createRequestAuthorizer(mcp, options);
606
- return async (request) => {
623
+ const handle = async (request) => {
607
624
  const authResult = await authorizer.authorize(request);
608
625
  if (!authResult.ok)
609
626
  return authResult.response;
610
627
  if (request.method !== "GET" && request.method !== "HEAD")
611
- return methodNotAllowed("GET, HEAD");
628
+ return methodNotAllowed("GET, HEAD, OPTIONS");
612
629
  const body = {
613
630
  server: { name: mcp.name, version: mcp.version, title: mcp.title },
614
631
  tools: mcp.tools.map((tool) => ({
@@ -623,6 +640,11 @@ function createListToolsHandler(mcp, options = {}) {
623
640
  const response = Response.json(body);
624
641
  return request.method === "HEAD" ? headResponse(response) : response;
625
642
  };
643
+ return async (request) => {
644
+ if (request.method === "OPTIONS")
645
+ return corsPreflightResponse("GET, HEAD, OPTIONS");
646
+ return withCors(await handle(request));
647
+ };
626
648
  }
627
649
 
628
650
  // src/protocols/rest/invoke-tool.ts
@@ -642,12 +664,12 @@ function isEmptyArgs(value) {
642
664
  function createInvokeToolHandler(mcp, options = {}) {
643
665
  assertRestResourceBinding(mcp, options);
644
666
  const authorizer = createRequestAuthorizer(mcp, options);
645
- return async (request, toolName) => {
667
+ const handle = async (request, toolName) => {
646
668
  const authResult = await authorizer.authorize(request);
647
669
  if (!authResult.ok)
648
670
  return authResult.response;
649
671
  if (request.method !== "POST")
650
- return methodNotAllowed("POST");
672
+ return methodNotAllowed("POST, OPTIONS");
651
673
  const tool = mcp.tools.find((t) => t.name === toolName);
652
674
  if (!tool) {
653
675
  return new Response(JSON.stringify({ error: `unknown tool: ${safeReflectName(toolName)}` }), {
@@ -715,6 +737,11 @@ function createInvokeToolHandler(mcp, options = {}) {
715
737
  isError: result.isError
716
738
  });
717
739
  };
740
+ return async (request, toolName) => {
741
+ if (request.method === "OPTIONS")
742
+ return corsPreflightResponse("POST, OPTIONS");
743
+ return withCors(await handle(request, toolName));
744
+ };
718
745
  }
719
746
 
720
747
  // src/stacks/tanstack/handlers.ts
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  createMcpProtocolHandler
3
- } from "../../chunk-GLG5RZGE.js";
3
+ } from "../../chunk-NJ5WBRYI.js";
4
4
  import {
5
5
  createOAuthProtectedResourceMetadataHandler
6
- } from "../../chunk-DDF63QWG.js";
6
+ } from "../../chunk-K2T4WTKX.js";
7
7
  import {
8
8
  createInvokeToolHandler,
9
9
  createListToolsHandler
10
- } from "../../chunk-VD6CS7Y6.js";
10
+ } from "../../chunk-SYI32IRK.js";
11
11
  import "../../chunk-MA5H6PSF.js";
12
- import "../../chunk-XEDRJFAR.js";
12
+ import "../../chunk-ZKKLOL2C.js";
13
13
  import "../../chunk-QA3FWDUV.js";
14
14
  import "../../chunk-6DXGZZA4.js";
15
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovable.dev/mcp-js",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Author MCP servers for Lovable apps. Declare tools with defineTool, register them in defineMcp, and the framework adapter (TanStack today, Supabase Edge Functions next) emits the route(s) at build time.",
5
5
  "type": "module",
6
6
  "repository": {