create-nwire 0.12.1 → 0.13.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.
Files changed (66) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +6 -2
  3. package/package.json +1 -1
  4. package/templates/enterprise/AGENTS.md +11 -11
  5. package/templates/enterprise/README.md +15 -10
  6. package/templates/enterprise/__tests__/auto-moderate.test.ts +22 -6
  7. package/templates/enterprise/__tests__/submit-flow.test.ts +22 -6
  8. package/templates/enterprise/app/api.ts +5 -1
  9. package/templates/enterprise/app/app.ts +23 -15
  10. package/templates/enterprise/app/main.ts +20 -45
  11. package/templates/enterprise/config/app.ts +18 -0
  12. package/templates/enterprise/config/env.ts +32 -0
  13. package/templates/enterprise/config/http.ts +15 -0
  14. package/templates/enterprise/modules/posts/actions/approve-post.ts +4 -3
  15. package/templates/enterprise/modules/posts/actions/reject-post.ts +3 -3
  16. package/templates/enterprise/modules/posts/actions/submit-post.ts +4 -3
  17. package/templates/enterprise/modules/posts/events/post-was-approved.ts +1 -1
  18. package/templates/enterprise/modules/posts/events/post-was-rejected.ts +2 -1
  19. package/templates/enterprise/modules/posts/events/post-was-submitted.ts +2 -1
  20. package/templates/enterprise/modules/posts/projections/queue-dashboard.ts +34 -27
  21. package/templates/enterprise/modules/posts/queries/posts-by-author.ts +7 -5
  22. package/templates/enterprise/modules/posts/routes/approve-post.ts +3 -3
  23. package/templates/enterprise/modules/posts/routes/get-post.ts +3 -3
  24. package/templates/enterprise/modules/posts/routes/list-queue.ts +4 -4
  25. package/templates/enterprise/modules/posts/routes/posts-by-author.ts +28 -0
  26. package/templates/enterprise/modules/posts/routes/reject-post.ts +3 -3
  27. package/templates/enterprise/modules/posts/routes/submit-post.ts +3 -3
  28. package/templates/enterprise/modules/posts/workflows/auto-moderate.ts +3 -3
  29. package/templates/enterprise/package.json +10 -11
  30. package/templates/enterprise/tsconfig.json +1 -1
  31. package/templates/mcp/AGENTS.md +73 -0
  32. package/templates/mcp/__tests__/mcp-server.test.ts +205 -0
  33. package/templates/mcp/_gitignore +5 -0
  34. package/templates/mcp/_npmrc +4 -0
  35. package/templates/mcp/_pnpm-workspace.yaml +12 -0
  36. package/templates/mcp/app/app.ts +24 -0
  37. package/templates/mcp/app/main.ts +197 -0
  38. package/templates/mcp/app/store/facts-store.ts +17 -0
  39. package/templates/mcp/app/tools/add-fact.ts +27 -0
  40. package/templates/mcp/app/tools/list-facts.ts +17 -0
  41. package/templates/mcp/app/tools/lookup-fact.ts +28 -0
  42. package/templates/mcp/app/tools.ts +19 -0
  43. package/templates/mcp/config/app.ts +16 -0
  44. package/templates/mcp/config/env.ts +27 -0
  45. package/templates/mcp/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  46. package/templates/mcp/package.json +25 -0
  47. package/templates/mcp/tsconfig.json +15 -0
  48. package/templates/mcp/vitest.config.ts +8 -0
  49. package/templates/minimal/AGENTS.md +16 -9
  50. package/templates/minimal/README.md +20 -10
  51. package/templates/minimal/__tests__/hello.test.ts +3 -3
  52. package/templates/minimal/app/app.ts +21 -0
  53. package/templates/minimal/app/main.ts +15 -24
  54. package/templates/minimal/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  55. package/templates/minimal/package.json +7 -8
  56. package/templates/service/AGENTS.md +13 -11
  57. package/templates/service/README.md +5 -3
  58. package/templates/service/__tests__/todo-api.test.ts +5 -5
  59. package/templates/service/app/app.ts +25 -0
  60. package/templates/service/app/main.ts +20 -30
  61. package/templates/service/app/store/todo-store.ts +6 -22
  62. package/templates/service/config/app.ts +18 -0
  63. package/templates/service/config/env.ts +32 -0
  64. package/templates/service/config/http.ts +15 -0
  65. package/templates/service/package.json +8 -9
  66. package/templates/service/tsconfig.json +1 -1
@@ -4,23 +4,23 @@
4
4
  * NOTE THE SYMMETRY:
5
5
  *
6
6
  * - The autoModerate WORKFLOW subscribes to events via
7
- * `on(PostWasSubmitted, (event) => ...)`. Its job is to produce SIDE
7
+ * `when(PostWasSubmitted, (event) => ...)`. Its job is to produce SIDE
8
8
  * EFFECTS — dispatch more actions.
9
9
  *
10
- * - This PROJECTION subscribes to events via the same `on` map shape.
10
+ * - This PROJECTION subscribes to events via the same `when` verb shape.
11
11
  * Its job is to fold events into a denormalized state that queries
12
12
  * read from. No side effects; pure state derivation.
13
13
  *
14
- * Same subscription primitive. Same `on(Event)` ergonomics. Different
14
+ * Same subscription primitive. Same `when(Event)` ergonomics. Different
15
15
  * intent. If you understand workflows, you understand projections —
16
16
  * just write to state instead of dispatching.
17
17
  *
18
18
  * ## When this lives in a database
19
19
  *
20
20
  * The in-memory state here is for example clarity. In production the
21
- * projection's `on` reducers would write to Postgres / Mongo / Redis,
21
+ * projection's `when` reducers would write to Postgres / Mongo / Redis,
22
22
  * and the query would read from there. The PRIMITIVE is the same —
23
- * `on(Event) → mutate state`. Where state lives is a deployment choice.
23
+ * `when(Event) → mutate state`. Where state lives is a deployment choice.
24
24
  *
25
25
  * Queries (`listPending`, `queueTotals`, `getPost`) live in this file
26
26
  * too because they're tightly coupled to the projection's state shape.
@@ -52,18 +52,12 @@ interface QueueState {
52
52
  totals: { pending: number; approved: number; rejected: number };
53
53
  }
54
54
 
55
- // ─── Projection — `on` map matches the workflow's shape ─────────
55
+ // ─── Projection — `when` verb matches the workflow's shape ──────
56
56
 
57
- export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
58
- description: "The moderation dashboard read model — pending queue + decision totals.",
59
- listens: [PostWasSubmitted, PostWasApproved, PostWasRejected],
60
- initial: () => ({
61
- byId: {},
62
- pendingIds: [],
63
- totals: { pending: 0, approved: 0, rejected: 0 },
64
- }),
65
- on: {
66
- [PostWasSubmitted.name]: (state, event) => ({
57
+ export const queueDashboard = defineProjection<QueueState>(
58
+ "queue-dashboard",
59
+ ({ when }) => {
60
+ when(PostWasSubmitted, (state, event) => ({
67
61
  byId: {
68
62
  ...state.byId,
69
63
  [event.postId]: {
@@ -76,11 +70,14 @@ export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
76
70
  },
77
71
  pendingIds: [...state.pendingIds, event.postId],
78
72
  totals: { ...state.totals, pending: state.totals.pending + 1 },
79
- }),
73
+ }));
80
74
 
81
- [PostWasApproved.name]: (state, event) => {
75
+ when(PostWasApproved, (state, event) => {
82
76
  const item = state.byId[event.postId];
83
- if (!item) return state;
77
+ // Only a pending post transitions. Re-applying a decision (a duplicate
78
+ // event, or an approve after a reject) must be a no-op — otherwise the
79
+ // pending counter is decremented twice and the totals drift negative.
80
+ if (!item || item.status !== "pending") return state;
84
81
  return {
85
82
  byId: {
86
83
  ...state.byId,
@@ -98,11 +95,13 @@ export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
98
95
  approved: state.totals.approved + 1,
99
96
  },
100
97
  };
101
- },
98
+ });
102
99
 
103
- [PostWasRejected.name]: (state, event) => {
100
+ when(PostWasRejected, (state, event) => {
104
101
  const item = state.byId[event.postId];
105
- if (!item) return state;
102
+ // Same idempotency guard as approve: a post that already left the queue
103
+ // must not decrement `pending` a second time.
104
+ if (!item || item.status !== "pending") return state;
106
105
  return {
107
106
  byId: {
108
107
  ...state.byId,
@@ -121,15 +120,23 @@ export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
121
120
  rejected: state.totals.rejected + 1,
122
121
  },
123
122
  };
124
- },
123
+ });
125
124
  },
126
- });
125
+ {
126
+ description: "The moderation dashboard read model — pending queue + decision totals.",
127
+ initial: () => ({
128
+ byId: {},
129
+ pendingIds: [],
130
+ totals: { pending: 0, approved: 0, rejected: 0 },
131
+ }),
132
+ },
133
+ );
127
134
 
128
135
  // ─── Queries — read the projection's state ──────────────────────
129
136
 
130
137
  export const listPending = defineQuery(queueDashboard, {
131
138
  name: "posts.list-pending",
132
- schema: z.object({ limit: z.coerce.number().int().min(1).max(100).default(20) }),
139
+ input: z.object({ limit: z.coerce.number().int().min(1).max(100).default(20) }),
133
140
  execute: (state, { limit }) =>
134
141
  state.pendingIds
135
142
  .slice(0, limit)
@@ -139,12 +146,12 @@ export const listPending = defineQuery(queueDashboard, {
139
146
 
140
147
  export const queueTotals = defineQuery(queueDashboard, {
141
148
  name: "posts.queue-totals",
142
- schema: z.object({}),
149
+ input: z.object({}),
143
150
  execute: (state) => state.totals,
144
151
  });
145
152
 
146
153
  export const getPost = defineQuery(queueDashboard, {
147
154
  name: "posts.get-post",
148
- schema: z.object({ postId: z.string() }),
155
+ input: z.object({ postId: z.string() }),
149
156
  execute: (state, { postId }) => state.byId[postId] ?? null,
150
157
  });
@@ -13,7 +13,7 @@
13
13
  * bindings (a `db` connection, an http client, anything the runtime
14
14
  * registered).
15
15
  *
16
- * defineQuery({ name, schema, handler })
16
+ * defineQuery({ name, input, handler })
17
17
  *
18
18
  * The handler below returns a small mock list so the template boots
19
19
  * out of the box. The shape of a real implementation is in the comment
@@ -33,14 +33,16 @@ interface AuthorPost {
33
33
 
34
34
  export const postsByAuthor = defineQuery({
35
35
  name: "posts.by-author",
36
- description: "All posts an author has submitted, across statuses.",
37
- schema: z.object({
36
+ description:
37
+ "Alice checks her submission history — all posts she's ever sent, whatever their status.",
38
+ input: z.object({
38
39
  authorId: z.string(),
39
40
  limit: z.coerce.number().int().min(1).max(100).default(20),
40
41
  }),
41
- handler: async ({ authorId, limit }, _ctx): Promise<AuthorPost[]> => {
42
+ handler: async (ctx): Promise<AuthorPost[]> => {
43
+ const { authorId, limit } = ctx.input;
42
44
  // Production form:
43
- // const db = _ctx.resolve<DrizzleDb>("db");
45
+ // const db = ctx.resolve<DrizzleDb>("db");
44
46
  // return db.select().from(posts).where(eq(posts.authorId, authorId)).limit(limit);
45
47
  //
46
48
  // Template form — small in-memory mock so this boots without a DB:
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { post } from "@nwire/wires/http";
10
- import type { ForgeDispatcher } from "@nwire/forge";
10
+ import { FORGE_ACTION_RUNNER_BINDING, type ActionRunner } from "@nwire/forge";
11
11
  import { z } from "zod";
12
12
  import { approvePost } from "../actions/approve-post";
13
13
 
@@ -21,8 +21,8 @@ export const approvePostHandler = async (
21
21
  input: Input,
22
22
  ctx: { resolve: <T>(name: string) => T },
23
23
  ) => {
24
- const dispatcher = ctx.resolve<ForgeDispatcher>("forge.dispatcher");
25
- await dispatcher.dispatch(approvePost, {
24
+ const actions = ctx.resolve<ActionRunner>(FORGE_ACTION_RUNNER_BINDING);
25
+ await actions.dispatch(approvePost, {
26
26
  postId: input.postId,
27
27
  approvedBy: input.moderatorId,
28
28
  });
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { get } from "@nwire/wires/http";
9
- import type { ForgeDispatcher } from "@nwire/forge";
9
+ import { FORGE_QUERY_RUNNER_BINDING, type QueryRunner } from "@nwire/forge";
10
10
  import { z } from "zod";
11
11
 
12
12
  const Params = z.object({ postId: z.string() });
@@ -17,8 +17,8 @@ export const getPostHandler = async (
17
17
  input: z.infer<typeof Params>,
18
18
  ctx: { resolve: <T>(name: string) => T },
19
19
  ) => {
20
- const dispatcher = ctx.resolve<ForgeDispatcher>("forge.dispatcher");
21
- const post = await dispatcher.query("posts.get-post", input);
20
+ const queries = ctx.resolve<QueryRunner>(FORGE_QUERY_RUNNER_BINDING);
21
+ const post = await queries.run("posts.get-post", input);
22
22
  if (!post) return { $status: 404, body: { error: { code: "POST_NOT_FOUND" } } };
23
23
  return post;
24
24
  };
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { get } from "@nwire/wires/http";
10
- import type { ForgeDispatcher } from "@nwire/forge";
10
+ import { FORGE_QUERY_RUNNER_BINDING, type QueryRunner } from "@nwire/forge";
11
11
  import { z } from "zod";
12
12
 
13
13
  const Query = z.object({
@@ -20,9 +20,9 @@ export const listQueueHandler = async (
20
20
  input: z.infer<typeof Query>,
21
21
  ctx: { resolve: <T>(name: string) => T },
22
22
  ) => {
23
- const dispatcher = ctx.resolve<ForgeDispatcher>("forge.dispatcher");
23
+ const queries = ctx.resolve<QueryRunner>(FORGE_QUERY_RUNNER_BINDING);
24
24
  return {
25
- items: await dispatcher.query("posts.list-pending", input),
26
- totals: await dispatcher.query("posts.queue-totals", {}),
25
+ items: await queries.run("posts.list-pending", input),
26
+ totals: await queries.run("posts.queue-totals", {}),
27
27
  };
28
28
  };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * GET /api/posts/by-author — all posts an author has submitted, across statuses.
3
+ *
4
+ * Demonstrates the handler-form query: `postsByAuthor` has no projection
5
+ * backing — it reads directly from a DB (or a mock) via `QueryContext`.
6
+ * Swap the mock in `queries/posts-by-author.ts` for a real DB call when
7
+ * you connect a store.
8
+ */
9
+
10
+ import { get } from "@nwire/wires/http";
11
+ import { FORGE_QUERY_RUNNER_BINDING, type QueryRunner } from "@nwire/forge";
12
+ import { z } from "zod";
13
+
14
+ const Query = z.object({
15
+ authorId: z.string(),
16
+ limit: z.coerce.number().int().min(1).max(100).default(20),
17
+ });
18
+
19
+ export const postsByAuthorRoute = get("/posts/by-author", { query: Query });
20
+
21
+ export const postsByAuthorHandler = async (
22
+ input: z.infer<typeof Query>,
23
+ ctx: { resolve: <T>(name: string) => T },
24
+ ) => {
25
+ const queries = ctx.resolve<QueryRunner>(FORGE_QUERY_RUNNER_BINDING);
26
+ const posts = await queries.run("posts.by-author", input);
27
+ return { items: posts, total: (posts as unknown[]).length };
28
+ };
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { post } from "@nwire/wires/http";
9
- import type { ForgeDispatcher } from "@nwire/forge";
9
+ import { FORGE_ACTION_RUNNER_BINDING, type ActionRunner } from "@nwire/forge";
10
10
  import { z } from "zod";
11
11
  import { rejectPost } from "../actions/reject-post";
12
12
 
@@ -20,8 +20,8 @@ type Input = z.infer<typeof Params> & z.infer<typeof Body>;
20
20
  export const rejectPostRoute = post("/posts/:postId/reject", { params: Params, body: Body });
21
21
 
22
22
  export const rejectPostHandler = async (input: Input, ctx: { resolve: <T>(name: string) => T }) => {
23
- const dispatcher = ctx.resolve<ForgeDispatcher>("forge.dispatcher");
24
- await dispatcher.dispatch(rejectPost, {
23
+ const actions = ctx.resolve<ActionRunner>(FORGE_ACTION_RUNNER_BINDING);
24
+ await actions.dispatch(rejectPost, {
25
25
  postId: input.postId,
26
26
  rejectedBy: input.moderatorId,
27
27
  reason: input.reason,
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { post } from "@nwire/wires/http";
10
- import type { ForgeDispatcher } from "@nwire/forge";
10
+ import { FORGE_ACTION_RUNNER_BINDING, type ActionRunner } from "@nwire/forge";
11
11
  import { z } from "zod";
12
12
  import { submitPost } from "../actions/submit-post";
13
13
 
@@ -22,7 +22,7 @@ export const submitPostHandler = async (
22
22
  input: z.infer<typeof Body>,
23
23
  ctx: { resolve: <T>(name: string) => T },
24
24
  ) => {
25
- const dispatcher = ctx.resolve<ForgeDispatcher>("forge.dispatcher");
26
- await dispatcher.dispatch(submitPost, input);
25
+ const actions = ctx.resolve<ActionRunner>(FORGE_ACTION_RUNNER_BINDING);
26
+ await actions.dispatch(submitPost, input);
27
27
  return { $status: 202, body: { accepted: true } };
28
28
  };
@@ -3,7 +3,7 @@
3
3
  * submitted post and decides whether to bypass the human queue.
4
4
  *
5
5
  * Pattern: workflows are subscribers — they react to events with the
6
- * same `on(Event, handler)` shape projections use. The difference is
6
+ * same `when(Event, handler)` shape projections use. The difference is
7
7
  * what they DO with the event:
8
8
  *
9
9
  * - Workflows produce SIDE EFFECTS — dispatch more actions, send
@@ -47,8 +47,8 @@ function autoCheck(body: string): "approve" | { reject: string } | "human" {
47
47
  return "approve";
48
48
  }
49
49
 
50
- export const autoModerate = defineWorkflow("auto-moderate", ({ on, send }) => {
51
- on(PostWasSubmitted, async (event) => {
50
+ export const autoModerate = defineWorkflow("auto-moderate", ({ when, send }) => {
51
+ when(PostWasSubmitted, async (event) => {
52
52
  const decision = autoCheck(event.body);
53
53
 
54
54
  if (decision === "approve") {
@@ -4,27 +4,26 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "vite-node app/main.ts",
7
+ "dev": "nwire dev",
8
8
  "test": "vitest run",
9
9
  "doctor": "nwire doctor",
10
10
  "studio": "nwire studio",
11
11
  "cache": "nwire cache"
12
12
  },
13
13
  "dependencies": {
14
- "@nwire/app": "^0.12.1",
15
- "@nwire/endpoint": "^0.12.1",
16
- "@nwire/forge": "^0.12.1",
17
- "@nwire/koa": "^0.12.1",
18
- "@nwire/messages": "^0.12.1",
19
- "@nwire/wires": "^0.12.1",
14
+ "@nwire/app": "^0.13.1",
15
+ "@nwire/endpoint": "^0.13.1",
16
+ "@nwire/forge": "^0.13.1",
17
+ "@nwire/koa": "^0.13.1",
18
+ "@nwire/messages": "^0.13.1",
19
+ "@nwire/wires": "^0.13.1",
20
20
  "zod": "^4.0.0"
21
21
  },
22
22
  "devDependencies": {
23
- "@nwire/test-kit": "^0.12.1",
23
+ "@nwire/cli": "^0.13.1",
24
+ "@nwire/test-kit": "^0.13.1",
24
25
  "@types/node": "^22.19.9",
25
26
  "typescript": "^5.9.0",
26
- "vite-node": "^3.2.4",
27
- "vitest": "^4.0.18",
28
- "@nwire/cli": "^0.12.1"
27
+ "vitest": "^4.0.18"
29
28
  }
30
29
  }
@@ -11,5 +11,5 @@
11
11
  "noEmit": true,
12
12
  "types": ["node"]
13
13
  },
14
- "include": ["app/**/*", "modules/**/*", "__tests__/**/*"]
14
+ "include": ["app/**/*", "config/**/*", "modules/**/*", "__tests__/**/*"]
15
15
  }
@@ -0,0 +1,73 @@
1
+ # AGENTS.md — working in this nwire MCP server
2
+
3
+ This is a **nwire** backend wired as an MCP (Model Context Protocol) server.
4
+ AI clients (Claude, Cursor, IDE plugins) connect over stdio and call the
5
+ tools you define here. The server speaks JSON-RPC 2.0.
6
+
7
+ ## The shape
8
+
9
+ ```ts
10
+ import { tool } from "@nwire/wires/mcp";
11
+ import { z } from "zod";
12
+
13
+ // A binding declares the tool name + input schema.
14
+ const myTool = tool("my-tool", {
15
+ description: "What this tool does in plain language.",
16
+ input: z.object({ value: z.string() }),
17
+ });
18
+
19
+ // A handler is a plain async function.
20
+ const myHandler = async (input: { value: string }) => ({ result: input.value });
21
+
22
+ // Wire them in app/tools.ts and app.ts picks them up.
23
+ ```
24
+
25
+ `app/main.ts` boots the server (stdio loop + nwire endpoint).
26
+ Tools live in `app/tools/`, the store in `app/store/`.
27
+
28
+ ## stdout is the JSON-RPC channel
29
+
30
+ **Never write to stdout from tool handlers or any module they import.**
31
+ Any non-JSON byte on stdout corrupts the MCP framing and breaks the client.
32
+
33
+ - Log with: `console.error(...)` or `process.stderr.write(...)`
34
+ - Never use: `console.log(...)` inside tools
35
+
36
+ ## Import map — DO NOT GUESS THESE
37
+
38
+ | Primitive | Package |
39
+ | ------------------------------------------------ | ------------------ |
40
+ | `createApp`, `definePlugin` | `@nwire/app` |
41
+ | `endpoint` | `@nwire/endpoint` |
42
+ | `tool` | `@nwire/wires/mcp` |
43
+ | `mcpAdapter` | `@nwire/mcp` |
44
+ | `defineHandler`, `defineResource`, `defineError` | `@nwire/handler` |
45
+
46
+ ## Adding a tool
47
+
48
+ 1. Create `app/tools/<verb>-<noun>.ts` — export a `{name}Tool` binding and
49
+ `{name}Handler` function.
50
+ 2. Add it to `app/tools.ts`.
51
+ 3. Wire it in `app/app.ts` (already done via the `tools` array).
52
+
53
+ ## Commands
54
+
55
+ - `pnpm start` — run the MCP server on stdio.
56
+ - `pnpm dev` — same, with file-watching restart.
57
+ - `pnpm test` — run the test suite (in-process + stdio integration).
58
+ - `pnpm typecheck` — TypeScript check.
59
+
60
+ ## Connecting to Claude Desktop
61
+
62
+ Add this to your `claude_desktop_config.json`:
63
+
64
+ ```json
65
+ {
66
+ "mcpServers": {
67
+ "{{PROJECT_NAME}}": {
68
+ "command": "node",
69
+ "args": ["--import", "tsx/esm", "/absolute/path/to/app/main.ts"]
70
+ }
71
+ }
72
+ }
73
+ ```
@@ -0,0 +1,205 @@
1
+ /**
2
+ * MCP server stdio test — drives the server the same way Claude / Cursor
3
+ * will: spawn the entry process, feed newline-framed JSON-RPC over stdin,
4
+ * assert responses on stdout.
5
+ *
6
+ * Protocol order:
7
+ * initialize → (no response to notifications/initialized) → tools/list
8
+ * → tools/call
9
+ *
10
+ * The test also exercises the adapter in-process (without spawning) to keep
11
+ * the unit path fast and to make assertion failures easy to debug.
12
+ */
13
+
14
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
15
+ import { spawn, type ChildProcess } from "node:child_process";
16
+ import { endpoint } from "@nwire/endpoint";
17
+ import { mcpAdapter } from "@nwire/mcp";
18
+ import { app } from "../app/app";
19
+
20
+ // ─── In-process adapter tests ────────────────────────────────────────
21
+
22
+ let running: Awaited<ReturnType<ReturnType<typeof endpoint>["run"]>>;
23
+ let mcp: ReturnType<typeof mcpAdapter>;
24
+
25
+ beforeAll(async () => {
26
+ mcp = mcpAdapter();
27
+ running = await endpoint("{{PROJECT_NAME}}-test", {
28
+ exitOnShutdown: false,
29
+ banner: false,
30
+ probes: { enabled: false },
31
+ })
32
+ .use(mcp)
33
+ .mount(app)
34
+ .run();
35
+ });
36
+
37
+ afterAll(async () => {
38
+ await running.shutdown("test");
39
+ await app.stop();
40
+ });
41
+
42
+ describe("MCP adapter — in-process", () => {
43
+ it("lists the three registered tools", () => {
44
+ const names = mcp
45
+ .list()
46
+ .map((t) => t.name)
47
+ .sort();
48
+ expect(names).toEqual(["add-fact", "list-facts", "lookup-fact"]);
49
+ });
50
+
51
+ it("list-facts returns the seed entries", async () => {
52
+ const result = (await mcp.call("list-facts", {})) as { count: number; facts: unknown[] };
53
+ expect(result.count).toBeGreaterThanOrEqual(2);
54
+ });
55
+
56
+ it("add-fact stores a new entry", async () => {
57
+ const result = (await mcp.call("add-fact", {
58
+ topic: "test-topic",
59
+ body: "written by the test suite",
60
+ })) as { stored: { topic: string }; total: number };
61
+ expect(result.stored.topic).toBe("test-topic");
62
+ expect(result.total).toBeGreaterThan(2);
63
+ });
64
+
65
+ it("lookup-fact finds stored entries by keyword", async () => {
66
+ const result = (await mcp.call("lookup-fact", { query: "nwire" })) as {
67
+ count: number;
68
+ results: unknown[];
69
+ };
70
+ expect(result.count).toBeGreaterThanOrEqual(1);
71
+ });
72
+
73
+ it("lookup-fact returns empty results for an unknown term", async () => {
74
+ const result = (await mcp.call("lookup-fact", { query: "xyzzy-not-a-thing" })) as {
75
+ count: number;
76
+ };
77
+ expect(result.count).toBe(0);
78
+ });
79
+ });
80
+
81
+ // ─── Stdio integration — spawn the real entry ────────────────────────
82
+
83
+ /**
84
+ * A minimal client harness that speaks newline-delimited JSON-RPC over
85
+ * a spawned child process's stdio. Mirrors the shape used in
86
+ * `packages/nwire-mcp/src/__tests__/mcp-io.test.ts`.
87
+ */
88
+ class StdioClient {
89
+ private readonly child: ChildProcess;
90
+ private buffer = "";
91
+ private pending = new Map<number, (value: unknown) => void>();
92
+ private id = 0;
93
+
94
+ constructor(entryPath: string) {
95
+ this.child = spawn("node", ["--import", "tsx/esm", entryPath], {
96
+ stdio: ["pipe", "pipe", "pipe"],
97
+ env: { ...process.env, NODE_ENV: "test" },
98
+ });
99
+
100
+ this.child.stdout!.setEncoding("utf8");
101
+ this.child.stdout!.on("data", (chunk: string) => {
102
+ this.buffer += chunk;
103
+ const lines = this.buffer.split("\n");
104
+ this.buffer = lines.pop() ?? "";
105
+ for (const line of lines) {
106
+ if (!line.trim()) continue;
107
+ try {
108
+ const msg = JSON.parse(line) as { id?: number };
109
+ if (msg.id != null && this.pending.has(msg.id)) {
110
+ this.pending.get(msg.id)!(msg);
111
+ this.pending.delete(msg.id);
112
+ }
113
+ } catch {
114
+ // Non-JSON on stdout is a bug — surface it for debugging.
115
+ process.stderr.write(`[test] non-JSON on stdout: ${line}\n`);
116
+ }
117
+ }
118
+ });
119
+ }
120
+
121
+ request(method: string, params?: Record<string, unknown>): Promise<unknown> {
122
+ const id = ++this.id;
123
+ const promise = new Promise<unknown>((resolve) => {
124
+ this.pending.set(id, resolve);
125
+ });
126
+ this.child.stdin!.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
127
+ return promise;
128
+ }
129
+
130
+ notify(method: string, params?: Record<string, unknown>): void {
131
+ this.child.stdin!.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
132
+ }
133
+
134
+ close(): void {
135
+ this.child.stdin!.end();
136
+ }
137
+ }
138
+
139
+ describe("MCP server — stdio JSON-RPC", () => {
140
+ let client: StdioClient;
141
+
142
+ beforeAll(() => {
143
+ // Resolve relative to this test file at runtime.
144
+ const entry = new URL("../app/main.ts", import.meta.url).pathname;
145
+ client = new StdioClient(entry);
146
+ });
147
+
148
+ afterAll(() => {
149
+ client.close();
150
+ });
151
+
152
+ it("initialize returns the protocol version and server info", async () => {
153
+ const res = (await client.request("initialize", {
154
+ protocolVersion: "2024-11-05",
155
+ capabilities: {},
156
+ })) as { result: { protocolVersion: string; serverInfo: { name: string } } };
157
+ expect(res.result.protocolVersion).toBe("2024-11-05");
158
+ expect(res.result.serverInfo.name).toBe("{{PROJECT_NAME}}");
159
+ // Confirm: the MCP lifecycle notification must not block future requests.
160
+ client.notify("notifications/initialized");
161
+ });
162
+
163
+ it("tools/list returns all registered tools", async () => {
164
+ const res = (await client.request("tools/list")) as {
165
+ result: { tools: Array<{ name: string }> };
166
+ };
167
+ const names = res.result.tools.map((t) => t.name).sort();
168
+ expect(names).toContain("lookup-fact");
169
+ expect(names).toContain("add-fact");
170
+ expect(names).toContain("list-facts");
171
+ });
172
+
173
+ it("tools/call list-facts returns the seeded knowledge base", async () => {
174
+ const res = (await client.request("tools/call", {
175
+ name: "list-facts",
176
+ arguments: {},
177
+ })) as { result: { content: Array<{ type: string; text: string }>; isError: boolean } };
178
+ expect(res.result.isError).toBe(false);
179
+ const body = JSON.parse(res.result.content[0].text) as { count: number };
180
+ expect(body.count).toBeGreaterThanOrEqual(2);
181
+ });
182
+
183
+ it("tools/call lookup-fact finds a seeded entry", async () => {
184
+ const res = (await client.request("tools/call", {
185
+ name: "lookup-fact",
186
+ arguments: { query: "mcp" },
187
+ })) as { result: { content: Array<{ type: string; text: string }>; isError: boolean } };
188
+ expect(res.result.isError).toBe(false);
189
+ const body = JSON.parse(res.result.content[0].text) as { count: number };
190
+ expect(body.count).toBeGreaterThanOrEqual(1);
191
+ });
192
+
193
+ it("tools/call for an unknown tool responds with isError", async () => {
194
+ const res = (await client.request("tools/call", {
195
+ name: "nonexistent-tool",
196
+ arguments: {},
197
+ })) as {
198
+ result?: { isError: boolean; content: Array<{ text: string }> };
199
+ error?: { code: number };
200
+ };
201
+ // The server surfaces unknown-tool as an isError content response (not a JSON-RPC error).
202
+ const isErrorResult = res.result?.isError === true || res.error != null;
203
+ expect(isErrorResult).toBe(true);
204
+ });
205
+ });
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ dist/
3
+ .nwire/
4
+ *.log
5
+ .DS_Store
@@ -0,0 +1,4 @@
1
+ # nwire dev runs via vite-node (esbuild). pnpm 11 defers esbuild's build
2
+ # script and then fails the pre-run deps check; skip that check so
3
+ # `pnpm dev` runs clean right after install.
4
+ verify-deps-before-run=false
@@ -0,0 +1,12 @@
1
+ # pnpm reads build-allow + run settings here (not the package.json "pnpm"
2
+ # field). This server runs TypeScript via tsx, which pulls esbuild — a
3
+ # dependency with a build script. pnpm gates build scripts: without an
4
+ # explicit decision it writes an `allowBuilds` stub and exits non-zero on
5
+ # the very first `pnpm install`. Pre-approve esbuild here so install is
6
+ # clean out of the box. `onlyBuiltDependencies` covers older pnpm; skip the
7
+ # pre-run deps check so `pnpm dev` runs clean right after install.
8
+ allowBuilds:
9
+ esbuild: true
10
+ onlyBuiltDependencies:
11
+ - esbuild
12
+ verifyDepsBeforeRun: false