create-nwire 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) 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/__tests__/auto-moderate.test.ts +22 -6
  6. package/templates/enterprise/__tests__/submit-flow.test.ts +22 -6
  7. package/templates/enterprise/app/api.ts +3 -1
  8. package/templates/enterprise/app/app.ts +23 -15
  9. package/templates/enterprise/app/main.ts +20 -45
  10. package/templates/enterprise/config/app.ts +18 -0
  11. package/templates/enterprise/config/env.ts +32 -0
  12. package/templates/enterprise/config/http.ts +15 -0
  13. package/templates/enterprise/modules/posts/actions/approve-post.ts +4 -3
  14. package/templates/enterprise/modules/posts/actions/reject-post.ts +3 -3
  15. package/templates/enterprise/modules/posts/actions/submit-post.ts +4 -3
  16. package/templates/enterprise/modules/posts/events/post-was-approved.ts +1 -1
  17. package/templates/enterprise/modules/posts/events/post-was-rejected.ts +2 -1
  18. package/templates/enterprise/modules/posts/events/post-was-submitted.ts +2 -1
  19. package/templates/enterprise/modules/posts/projections/queue-dashboard.ts +27 -25
  20. package/templates/enterprise/modules/posts/queries/posts-by-author.ts +7 -5
  21. package/templates/enterprise/modules/posts/routes/approve-post.ts +3 -3
  22. package/templates/enterprise/modules/posts/routes/get-post.ts +3 -3
  23. package/templates/enterprise/modules/posts/routes/list-queue.ts +4 -4
  24. package/templates/enterprise/modules/posts/routes/posts-by-author.ts +28 -0
  25. package/templates/enterprise/modules/posts/routes/reject-post.ts +3 -3
  26. package/templates/enterprise/modules/posts/routes/submit-post.ts +3 -3
  27. package/templates/enterprise/modules/posts/workflows/auto-moderate.ts +3 -3
  28. package/templates/enterprise/package.json +10 -11
  29. package/templates/enterprise/tsconfig.json +1 -1
  30. package/templates/mcp/AGENTS.md +73 -0
  31. package/templates/mcp/__tests__/mcp-server.test.ts +205 -0
  32. package/templates/mcp/_gitignore +5 -0
  33. package/templates/mcp/_npmrc +4 -0
  34. package/templates/mcp/_pnpm-workspace.yaml +6 -0
  35. package/templates/mcp/app/app.ts +24 -0
  36. package/templates/mcp/app/main.ts +197 -0
  37. package/templates/mcp/app/store/facts-store.ts +17 -0
  38. package/templates/mcp/app/tools/add-fact.ts +27 -0
  39. package/templates/mcp/app/tools/list-facts.ts +17 -0
  40. package/templates/mcp/app/tools/lookup-fact.ts +28 -0
  41. package/templates/mcp/app/tools.ts +19 -0
  42. package/templates/mcp/config/app.ts +16 -0
  43. package/templates/mcp/config/env.ts +27 -0
  44. package/templates/mcp/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  45. package/templates/mcp/package.json +25 -0
  46. package/templates/mcp/tsconfig.json +15 -0
  47. package/templates/mcp/vitest.config.ts +8 -0
  48. package/templates/minimal/AGENTS.md +5 -5
  49. package/templates/minimal/__tests__/hello.test.ts +3 -3
  50. package/templates/minimal/app/app.ts +21 -0
  51. package/templates/minimal/app/main.ts +11 -23
  52. package/templates/minimal/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  53. package/templates/minimal/package.json +7 -8
  54. package/templates/service/AGENTS.md +12 -10
  55. package/templates/service/__tests__/todo-api.test.ts +5 -5
  56. package/templates/service/app/app.ts +25 -0
  57. package/templates/service/app/main.ts +20 -30
  58. package/templates/service/app/store/todo-store.ts +6 -22
  59. package/templates/service/config/app.ts +18 -0
  60. package/templates/service/config/env.ts +32 -0
  61. package/templates/service/config/http.ts +15 -0
  62. package/templates/service/package.json +8 -9
  63. package/templates/service/tsconfig.json +1 -1
@@ -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.0",
15
+ "@nwire/endpoint": "^0.13.0",
16
+ "@nwire/forge": "^0.13.0",
17
+ "@nwire/koa": "^0.13.0",
18
+ "@nwire/messages": "^0.13.0",
19
+ "@nwire/wires": "^0.13.0",
20
20
  "zod": "^4.0.0"
21
21
  },
22
22
  "devDependencies": {
23
- "@nwire/test-kit": "^0.12.1",
23
+ "@nwire/cli": "^0.13.0",
24
+ "@nwire/test-kit": "^0.13.0",
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,6 @@
1
+ # pnpm 11 reads build-allow + run settings here (not the package.json
2
+ # "pnpm" field). esbuild backs vite-node; allow its build, and skip the
3
+ # pre-run deps check so `pnpm dev` runs clean right after install.
4
+ onlyBuiltDependencies:
5
+ - esbuild
6
+ verifyDepsBeforeRun: false
@@ -0,0 +1,24 @@
1
+ /**
2
+ * App — the composition root.
3
+ *
4
+ * `createApp` constructs the container and wires all MCP tools in this
5
+ * module body. The exported `app` is COMPLETE on import — no caller needs
6
+ * to wire or start it. No ports are bound here; that is `main.ts`'s job.
7
+ *
8
+ * Tests import `{ app }` from this file and drive it with `mcpAdapter()`
9
+ * directly — never import `main.ts` from tests, it owns the stdio loop
10
+ * and will hang the runner.
11
+ *
12
+ * Graduate: add `@nwire/mongo` or `@nwire/drizzle` as a plugin here when
13
+ * the in-memory store outgrows its welcome. The tool handlers keep calling
14
+ * `ctx.resolve("db")`; nothing else changes.
15
+ */
16
+
17
+ import { createApp } from "@nwire/app";
18
+ import { tools } from "./tools";
19
+
20
+ export const app = createApp({ appName: "{{PROJECT_NAME}}" });
21
+
22
+ for (const { binding, handler } of tools) {
23
+ app.wire(binding, handler);
24
+ }