@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.
@@ -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, readdir?: (d: string) => Array<{ name: string, isDir: boolean }>, bunCacheDir?: string | null }} fsDeps
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
- if (!hits.length) return `No matches for "${query}" in the @webjsdev/* source.`;
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, join } from 'node:path';
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, readdir, bunCacheDir: resolveBunCacheDir(existsSync) }),
488
+ roots: resolveFrameworkRoots(cwd, { exists: existsSync }),
513
489
  readFile,
514
490
  readdir,
515
491
  realpath: realpathSync,