@webjsdev/mcp 0.1.5 → 0.1.7
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.md +1 -3
- package/package.json +1 -1
- package/resources/AGENTS.md +97 -16
- package/resources/agent-docs/advanced.md +97 -14
- package/resources/agent-docs/built-ins.md +17 -7
- package/resources/agent-docs/components-shadowing.md +87 -0
- package/resources/agent-docs/components.md +35 -13
- package/resources/agent-docs/configuration.md +1 -1
- package/resources/agent-docs/framework-dev.md +8 -0
- package/resources/agent-docs/orm.md +198 -0
- package/resources/agent-docs/recipes.md +136 -0
- package/resources/agent-docs/runtime.md +17 -43
- package/resources/agent-docs/service-worker.md +5 -1
- package/resources/agent-docs/testing.md +47 -6
- package/resources/agent-docs/types-and-mutations.md +171 -0
- package/src/mcp-source.js +12 -74
- package/src/mcp.js +2 -26
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Types and mutations: sharing server-derived shapes with the browser
|
|
2
|
+
|
|
3
|
+
An interactive component that ships to the browser almost always needs the
|
|
4
|
+
shape of the data it renders (`Todo`, `Post`, the `ActionResult<T>` envelope a
|
|
5
|
+
mutation returns). Those shapes are DERIVED from server-only modules (the
|
|
6
|
+
Drizzle schema, the action files), so the reflex is to import them straight from
|
|
7
|
+
`db/schema.server.ts` or a `*.server.ts` action. That reflex pins a server
|
|
8
|
+
module to a browser closure and breaks the build. This page codifies where the
|
|
9
|
+
types live so a shipping component consumes them safely.
|
|
10
|
+
|
|
11
|
+
The one rule. **A type crossing to the browser is a TYPE, never a runtime
|
|
12
|
+
value.** Keep the shape in a browser-safe `modules/<feature>/types.ts` with no
|
|
13
|
+
runtime import from a `.server.ts` file or from `db/`. A `import type { ... }`
|
|
14
|
+
is erased by the TypeScript stripper before the module reaches the browser, so
|
|
15
|
+
it is safe. A plain `import { ... }` (a value import) is NOT erased, so it pins
|
|
16
|
+
the server module and trips the `no-server-import-in-browser-module` check.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Why a value import breaks and a type import does not
|
|
21
|
+
|
|
22
|
+
webjs strips TypeScript at runtime with no bundler. The stripper erases every
|
|
23
|
+
`import type` and every type-only specifier, so those imports never exist in the
|
|
24
|
+
served JavaScript. A value import survives stripping and becomes a real browser
|
|
25
|
+
`import`. When the target is a `.server.ts` module, its browser stub throws at
|
|
26
|
+
module load (invariant 1), and the `no-server-import-in-browser-module` check
|
|
27
|
+
flags it statically for any module the framework determines actually ships.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// SAFE: type-only, erased before it reaches the browser.
|
|
31
|
+
import type { Post } from '#db/schema.server.ts';
|
|
32
|
+
|
|
33
|
+
// UNSAFE: a runtime value import of the server schema. Survives stripping,
|
|
34
|
+
// pins db/schema.server.ts into the browser closure, throws at load.
|
|
35
|
+
import { posts } from '#db/schema.server.ts';
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The subtle trap is deriving a type from a value with a value import. `typeof
|
|
39
|
+
posts.$inferSelect` needs the `posts` binding, and `import { posts }` is a value
|
|
40
|
+
import even though you only use it at the type level. Write `import type` (rc.3
|
|
41
|
+
schemas already `export type Post = typeof posts.$inferSelect`, so import the
|
|
42
|
+
ready-made type), or hand-write the DTO in `types.ts`.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## The wrong chain: a types module re-exporting a runtime value
|
|
47
|
+
|
|
48
|
+
The classic failure is a `types.ts` that looks type-only but carries a runtime
|
|
49
|
+
re-export. A browser-shipped page or component imports it, and the whole server
|
|
50
|
+
schema is dragged along.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// modules/posts/types.ts WRONG
|
|
54
|
+
// A star or value re-export carries the runtime `posts`, `users` table
|
|
55
|
+
// bindings, not just their types.
|
|
56
|
+
export * from '#db/schema.server.ts'; // re-exports VALUES
|
|
57
|
+
export { posts } from '#db/schema.server.ts'; // re-exports a VALUE
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// components/post-card.ts (an interactive component, ships to the browser)
|
|
62
|
+
import type { Post } from '#modules/posts/types.ts';
|
|
63
|
+
// Because types.ts value-re-exports the schema, the browser module graph now
|
|
64
|
+
// reaches db/schema.server.ts. no-server-import-in-browser-module fails, and
|
|
65
|
+
// at runtime the server stub throws the moment post-card.ts loads.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The check fires on the component (a shipping browser module), even though the
|
|
69
|
+
mistake is in `types.ts`. The re-export made a server value reachable from a
|
|
70
|
+
browser closure.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## The right chain: a browser-safe DTO in `types.ts`
|
|
75
|
+
|
|
76
|
+
Define the wire shape as a plain interface (or a `type`) in `types.ts` with no
|
|
77
|
+
runtime import. This is the DTO the query / action returns and the component
|
|
78
|
+
consumes. It has zero server dependency, so it crosses the boundary freely.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// modules/posts/types.ts RIGHT
|
|
82
|
+
// A hand-written DTO. No import from db/ or any *.server.ts. Pure types.
|
|
83
|
+
export type PostFormatted = {
|
|
84
|
+
id: number;
|
|
85
|
+
slug: string;
|
|
86
|
+
title: string;
|
|
87
|
+
body: string;
|
|
88
|
+
authorId: number;
|
|
89
|
+
authorName: string | null;
|
|
90
|
+
createdAt: string; // serialized for the wire
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export type CreatePostInput = { title: string; body: string };
|
|
94
|
+
|
|
95
|
+
// The mutation envelope every action returns. Browser-safe, no server import.
|
|
96
|
+
export type ActionResult<T> =
|
|
97
|
+
| { success: true; data?: T; redirect?: string }
|
|
98
|
+
| { success: false; error?: string; fieldErrors?: Record<string, string>;
|
|
99
|
+
values?: Record<string, string>; status?: number };
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// modules/posts/queries/list-posts.server.ts (server: may touch db)
|
|
104
|
+
import { db } from '#db/connection.server.ts';
|
|
105
|
+
import type { PostFormatted } from '#modules/posts/types.ts';
|
|
106
|
+
export async function listPosts(): Promise<PostFormatted[]> {
|
|
107
|
+
const rows = await db.query.posts.findMany({ orderBy: { createdAt: 'desc' } });
|
|
108
|
+
return rows.map(formatPost); // maps the row to the DTO
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
// components/post-card.ts (browser: consumes the DTO type only)
|
|
114
|
+
import type { PostFormatted } from '#modules/posts/types.ts';
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
If you want the DTO to track the schema automatically rather than restating it,
|
|
118
|
+
use a **type-only** derivation and keep it in `types.ts`.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// modules/posts/types.ts RIGHT (type-only derivation, still erased)
|
|
122
|
+
import type { Post } from '#db/schema.server.ts'; // type-only, erased
|
|
123
|
+
export type PostFormatted = Omit<Post, 'createdAt'> & { createdAt: string };
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Either form ships nothing server-side to the browser. The `import type` is the
|
|
127
|
+
seam. A value import is the leak.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Carrier choice: register an interactive component in `layout.ts`
|
|
132
|
+
|
|
133
|
+
An interactive component reaches the browser because SOME module imports it and
|
|
134
|
+
that module ships. A `page.ts` and a `layout.ts` never hydrate, but they DO load
|
|
135
|
+
in the browser to register the components they import. When a page's ONLY reason
|
|
136
|
+
to ship is registering a component, prefer registering that component in
|
|
137
|
+
`app/layout.ts` (or a section layout), not `app/page.ts`.
|
|
138
|
+
|
|
139
|
+
The reason is elision economics. A `page.ts` whose sole client job is
|
|
140
|
+
registering a component is dropped by the framework, and the browser fetches the
|
|
141
|
+
component leaf directly (the import-only page optimization). But when you have a
|
|
142
|
+
component shared across a section (a header search box, a theme toggle, a cart
|
|
143
|
+
button), the layout is the natural single carrier. Register it once there and
|
|
144
|
+
every page under it gets the upgraded element with no per-page import.
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
// app/layout.ts RIGHT: the section carrier registers shared interactive UI
|
|
148
|
+
import '#components/theme-toggle.ts'; // registers <theme-toggle> for every page
|
|
149
|
+
import '#components/ui/dialog.ts'; // registers the Tier-2 <ui-dialog> element
|
|
150
|
+
|
|
151
|
+
export default function RootLayout({ children }) {
|
|
152
|
+
return html`... <theme-toggle></theme-toggle> ${children} ...`;
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
// app/page.ts RIGHT: page stays a pure server function, no client import
|
|
158
|
+
export default function Home() {
|
|
159
|
+
return html`<h1>Home</h1><theme-toggle></theme-toggle>`; // tag already registered by the layout
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Register in `page.ts` only when the component is page-specific (used on that one
|
|
164
|
+
route). A shared element registered per page duplicates the import intent across
|
|
165
|
+
routes and muddies which module is the carrier. Keep pages pure server functions
|
|
166
|
+
where you can, and let the nearest layout carry the shared interactive tags.
|
|
167
|
+
|
|
168
|
+
Cross-references. The server-only boundary and the type-only-import exception
|
|
169
|
+
are invariant 1 in the root `AGENTS.md`. The Drizzle query surface that produces
|
|
170
|
+
these rows is in `agent-docs/orm.md`. The optimistic mutation flow that consumes
|
|
171
|
+
`ActionResult<T>` on the client is in `agent-docs/advanced.md`.
|
package/src/mcp-source.js
CHANGED
|
@@ -48,15 +48,8 @@ const MAX_FILES = 4000;
|
|
|
48
48
|
* reliable. The source dir is `src/`, or `lib/` for the cli. A package that is
|
|
49
49
|
* not installed is skipped (not every app depends on every `@webjsdev/*`).
|
|
50
50
|
*
|
|
51
|
-
* Under Bun ZERO-INSTALL (no `node_modules`, #675) the packages live only in
|
|
52
|
-
* Bun's global install cache (`<bunCacheDir>/@webjsdev/<pkg>@<version>@@@<n>/`,
|
|
53
|
-
* which holds the full source). When the `node_modules` walk finds nothing and a
|
|
54
|
-
* `bunCacheDir` + `readdir` are supplied, fall back to scanning that scope dir
|
|
55
|
-
* for the package's versioned entry (highest version wins, best-effort), so the
|
|
56
|
-
* tool works whether the package is installed OR resolved from the Bun cache.
|
|
57
|
-
*
|
|
58
51
|
* @param {string} cwd
|
|
59
|
-
* @param {{ exists: (p: string) => boolean
|
|
52
|
+
* @param {{ exists: (p: string) => boolean }} fsDeps
|
|
60
53
|
* @returns {Array<{ pkg: string, root: string, src: string }>}
|
|
61
54
|
*/
|
|
62
55
|
export function resolveFrameworkRoots(cwd, fsDeps) {
|
|
@@ -76,8 +69,6 @@ export function resolveFrameworkRoots(cwd, fsDeps) {
|
|
|
76
69
|
const cand = join(base, '@webjsdev', pkg);
|
|
77
70
|
if (fsDeps.exists(join(cand, 'package.json'))) { root = cand; break; }
|
|
78
71
|
}
|
|
79
|
-
// Zero-install fallback: no node_modules, so look in Bun's global cache.
|
|
80
|
-
if (!root) root = resolveFromBunCache(pkg, fsDeps);
|
|
81
72
|
if (!root) continue;
|
|
82
73
|
// Most packages keep source in `src/`; the cli keeps it in `lib/`. Use
|
|
83
74
|
// whichever exists so every framework package's source is reachable.
|
|
@@ -91,69 +82,6 @@ export function resolveFrameworkRoots(cwd, fsDeps) {
|
|
|
91
82
|
return out;
|
|
92
83
|
}
|
|
93
84
|
|
|
94
|
-
/**
|
|
95
|
-
* Locate `@webjsdev/<pkg>` in Bun's global install cache (the zero-install
|
|
96
|
-
* fallback). Bun caches a scoped package at `<bunCacheDir>/@webjsdev/<pkg>@<ver>@@@<n>/`
|
|
97
|
-
* (the versioned dir holds the full source; a sibling unversioned `<pkg>/` dir is
|
|
98
|
-
* metadata, skipped). Picks the highest cached version (semver-aware, so
|
|
99
|
-
* `0.10.0` beats `0.9.0`), and returns its path only if it carries a
|
|
100
|
-
* `package.json`. Returns '' when there is no cache dir, no `readdir`, or no
|
|
101
|
-
* matching entry.
|
|
102
|
-
*
|
|
103
|
-
* @param {string} pkg
|
|
104
|
-
* @param {{ exists: (p: string) => boolean, readdir?: (d: string) => Array<{ name: string, isDir: boolean }>, bunCacheDir?: string | null }} fsDeps
|
|
105
|
-
* @returns {string}
|
|
106
|
-
*/
|
|
107
|
-
function resolveFromBunCache(pkg, fsDeps) {
|
|
108
|
-
const { bunCacheDir, readdir, exists } = fsDeps;
|
|
109
|
-
if (!bunCacheDir || typeof readdir !== 'function') return '';
|
|
110
|
-
const scopeDir = join(bunCacheDir, '@webjsdev');
|
|
111
|
-
if (!exists(scopeDir)) return '';
|
|
112
|
-
let entries;
|
|
113
|
-
try { entries = readdir(scopeDir); } catch { return ''; }
|
|
114
|
-
const prefix = `${pkg}@`;
|
|
115
|
-
// A cache dir is `<pkg>@<version>@@@<n>`; extract <version> (between the name
|
|
116
|
-
// and the `@@@` cache-key suffix) and sort by it, highest first.
|
|
117
|
-
const versioned = entries
|
|
118
|
-
.filter((e) => e.isDir && e.name.startsWith(prefix) && e.name.includes('@@@'))
|
|
119
|
-
.map((e) => ({ name: e.name, version: e.name.slice(prefix.length).split('@@@')[0] }))
|
|
120
|
-
.sort((a, b) => compareVersions(b.version, a.version));
|
|
121
|
-
for (const { name } of versioned) {
|
|
122
|
-
const cand = join(scopeDir, name);
|
|
123
|
-
if (exists(join(cand, 'package.json'))) return cand;
|
|
124
|
-
}
|
|
125
|
-
return '';
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Compare two semver-ish version strings numerically (so `0.10.0` > `0.9.0`),
|
|
130
|
-
* with a release ranking ABOVE its prerelease (`1.0.0` > `1.0.0-rc.1`). Compares
|
|
131
|
-
* the release core (`major.minor.patch`) segment by segment numerically; on a
|
|
132
|
-
* tie, a version WITHOUT a prerelease tag sorts higher, else the prerelease tags
|
|
133
|
-
* compare lexically. The build suffix (`+...`) is ignored, per semver. Returns
|
|
134
|
-
* negative / 0 / positive like a comparator.
|
|
135
|
-
* @param {string} a
|
|
136
|
-
* @param {string} b
|
|
137
|
-
* @returns {number}
|
|
138
|
-
*/
|
|
139
|
-
function compareVersions(a, b) {
|
|
140
|
-
const parse = (v) => {
|
|
141
|
-
const [core, pre = ''] = v.split('+')[0].split('-');
|
|
142
|
-
return { nums: core.split('.').map(Number), pre };
|
|
143
|
-
};
|
|
144
|
-
const A = parse(a);
|
|
145
|
-
const B = parse(b);
|
|
146
|
-
for (let i = 0; i < Math.max(A.nums.length, B.nums.length); i++) {
|
|
147
|
-
const x = A.nums[i] || 0;
|
|
148
|
-
const y = B.nums[i] || 0;
|
|
149
|
-
if (x !== y) return x - y;
|
|
150
|
-
}
|
|
151
|
-
if (A.pre === B.pre) return 0;
|
|
152
|
-
if (!A.pre) return 1; // a release outranks any prerelease of the same core
|
|
153
|
-
if (!B.pre) return -1;
|
|
154
|
-
return A.pre < B.pre ? -1 : 1;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
85
|
/**
|
|
158
86
|
* Recursively list text-source files under `dir` (absolute paths), skipping
|
|
159
87
|
* `node_modules` / `dist` and bounded by {@link MAX_FILES}.
|
|
@@ -225,6 +153,13 @@ export function listSources(deps, pkgFilter) {
|
|
|
225
153
|
export async function grepSources(deps, query) {
|
|
226
154
|
const q = String(query).toLowerCase();
|
|
227
155
|
if (!q) return 'Provide a non-empty `query`.';
|
|
156
|
+
// No resolvable packages means NOTHING was searched. Returning "no matches"
|
|
157
|
+
// here would read as authoritative absence when the search never ran, which
|
|
158
|
+
// is the #837 dogfood failure: an odd/shimmed node_modules layout resolved
|
|
159
|
+
// zero roots, so a real symbol looked missing and the agent lost trust.
|
|
160
|
+
if (!deps.roots.length) {
|
|
161
|
+
return 'No @webjsdev/* source is resolvable here, so NOTHING was searched (run inside a webjs app or the monorepo). This is not the same as "not found".';
|
|
162
|
+
}
|
|
228
163
|
/** @type {string[]} */
|
|
229
164
|
const hits = [];
|
|
230
165
|
let capped = false;
|
|
@@ -242,7 +177,10 @@ export async function grepSources(deps, query) {
|
|
|
242
177
|
}
|
|
243
178
|
}
|
|
244
179
|
}
|
|
245
|
-
|
|
180
|
+
// Disclose the SEARCHED scope on a no-match, so "no matches" reads as a
|
|
181
|
+
// comprehensive-search result the agent can trust, not a silent gap (#837).
|
|
182
|
+
const searched = deps.roots.map((r) => r.pkg).join(', ');
|
|
183
|
+
if (!hits.length) return `No matches for "${query}" in the searched @webjsdev/* source (${searched}).`;
|
|
246
184
|
if (capped) hits.push(`... (truncated at ${MAX_HITS} matches; narrow the query or read a file with \`path\`)`);
|
|
247
185
|
return hits.join('\n');
|
|
248
186
|
}
|
package/src/mcp.js
CHANGED
|
@@ -21,8 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { createInterface } from 'node:readline';
|
|
24
|
-
import { relative
|
|
25
|
-
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { relative } from 'node:path';
|
|
26
25
|
|
|
27
26
|
import {
|
|
28
27
|
resolveDocsLocation,
|
|
@@ -37,29 +36,6 @@ import { resolveFrameworkRoots, runSourceTool } from './mcp-source.js';
|
|
|
37
36
|
|
|
38
37
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
39
38
|
|
|
40
|
-
/**
|
|
41
|
-
* Best-effort path to Bun's global install cache, for the zero-install `source`
|
|
42
|
-
* fallback (#687): under Bun zero-install an app has no `node_modules`, so
|
|
43
|
-
* `@webjsdev/*` lives only in this cache. Prefers `bun pm cache` (authoritative),
|
|
44
|
-
* then `$BUN_INSTALL/install/cache`, then `~/.bun/install/cache`. Returns null
|
|
45
|
-
* when none exists (e.g. Bun is not installed), which leaves the tool on the
|
|
46
|
-
* node_modules path with no behavior change.
|
|
47
|
-
* @param {(p: string) => boolean} exists
|
|
48
|
-
* @returns {string | null}
|
|
49
|
-
*/
|
|
50
|
-
function resolveBunCacheDir(exists) {
|
|
51
|
-
try {
|
|
52
|
-
const out = spawnSync('bun', ['pm', 'cache'], { encoding: 'utf8' });
|
|
53
|
-
const dir = out && out.status === 0 && out.stdout ? out.stdout.trim() : '';
|
|
54
|
-
if (dir && exists(dir)) return dir;
|
|
55
|
-
} catch { /* bun not on PATH */ }
|
|
56
|
-
const candidates = [];
|
|
57
|
-
if (process.env.BUN_INSTALL) candidates.push(join(process.env.BUN_INSTALL, 'install', 'cache'));
|
|
58
|
-
if (process.env.HOME) candidates.push(join(process.env.HOME, '.bun', 'install', 'cache'));
|
|
59
|
-
for (const c of candidates) { if (exists(c)) return c; }
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
39
|
// Mirrors packages/server/src/action-config.js. A drift test in
|
|
64
40
|
// packages/mcp/test/mcp.test.mjs asserts these stay in sync with the source.
|
|
65
41
|
/** HTTP verbs an action may declare (mirrors RPC_VERBS in action-config.js). */
|
|
@@ -509,7 +485,7 @@ export async function runMcpServer(opts) {
|
|
|
509
485
|
const { readdirSync, existsSync, realpathSync } = await import('node:fs');
|
|
510
486
|
const readdir = (d) => readdirSync(d, { withFileTypes: true }).map((e) => ({ name: e.name, isDir: e.isDirectory() }));
|
|
511
487
|
sourceDeps = {
|
|
512
|
-
roots: resolveFrameworkRoots(cwd, { exists: existsSync
|
|
488
|
+
roots: resolveFrameworkRoots(cwd, { exists: existsSync }),
|
|
513
489
|
readFile,
|
|
514
490
|
readdir,
|
|
515
491
|
realpath: realpathSync,
|