@lovable.dev/mcp-js 0.5.0 → 0.5.1
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.
- package/README.internal.md +189 -0
- package/README.md +1 -191
- package/package.json +1 -1
|
@@ -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
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lovable.dev/mcp-js",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
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": {
|