create-nwire 0.12.0 → 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.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/package.json +1 -1
- package/templates/enterprise/AGENTS.md +43 -0
- package/templates/enterprise/__tests__/auto-moderate.test.ts +22 -6
- package/templates/enterprise/__tests__/submit-flow.test.ts +22 -6
- package/templates/enterprise/_npmrc +4 -0
- package/templates/enterprise/_pnpm-workspace.yaml +6 -0
- package/templates/enterprise/app/api.ts +3 -1
- package/templates/enterprise/app/app.ts +23 -15
- package/templates/enterprise/app/main.ts +20 -47
- package/templates/enterprise/config/app.ts +18 -0
- package/templates/enterprise/config/env.ts +32 -0
- package/templates/enterprise/config/http.ts +15 -0
- package/templates/enterprise/modules/posts/actions/approve-post.ts +4 -3
- package/templates/enterprise/modules/posts/actions/reject-post.ts +3 -3
- package/templates/enterprise/modules/posts/actions/submit-post.ts +4 -3
- package/templates/enterprise/modules/posts/events/post-was-approved.ts +1 -1
- package/templates/enterprise/modules/posts/events/post-was-rejected.ts +2 -1
- package/templates/enterprise/modules/posts/events/post-was-submitted.ts +2 -1
- package/templates/enterprise/modules/posts/projections/queue-dashboard.ts +27 -25
- package/templates/enterprise/modules/posts/queries/posts-by-author.ts +7 -5
- package/templates/enterprise/modules/posts/routes/approve-post.ts +3 -3
- package/templates/enterprise/modules/posts/routes/get-post.ts +3 -3
- package/templates/enterprise/modules/posts/routes/list-queue.ts +4 -4
- package/templates/enterprise/modules/posts/routes/posts-by-author.ts +28 -0
- package/templates/enterprise/modules/posts/routes/reject-post.ts +3 -3
- package/templates/enterprise/modules/posts/routes/submit-post.ts +3 -3
- package/templates/enterprise/modules/posts/workflows/auto-moderate.ts +3 -3
- package/templates/enterprise/package.json +13 -10
- package/templates/enterprise/tsconfig.json +1 -1
- package/templates/mcp/AGENTS.md +73 -0
- package/templates/mcp/__tests__/mcp-server.test.ts +205 -0
- package/templates/mcp/_gitignore +5 -0
- package/templates/mcp/_npmrc +4 -0
- package/templates/mcp/_pnpm-workspace.yaml +6 -0
- package/templates/mcp/app/app.ts +24 -0
- package/templates/mcp/app/main.ts +197 -0
- package/templates/mcp/app/store/facts-store.ts +17 -0
- package/templates/mcp/app/tools/add-fact.ts +27 -0
- package/templates/mcp/app/tools/list-facts.ts +17 -0
- package/templates/mcp/app/tools/lookup-fact.ts +28 -0
- package/templates/mcp/app/tools.ts +19 -0
- package/templates/mcp/config/app.ts +16 -0
- package/templates/mcp/config/env.ts +27 -0
- package/templates/mcp/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/templates/mcp/package.json +25 -0
- package/templates/mcp/tsconfig.json +15 -0
- package/templates/mcp/vitest.config.ts +8 -0
- package/templates/minimal/AGENTS.md +37 -0
- package/templates/minimal/__tests__/hello.test.ts +3 -3
- package/templates/minimal/_npmrc +4 -0
- package/templates/minimal/_pnpm-workspace.yaml +6 -0
- package/templates/minimal/app/app.ts +21 -0
- package/templates/minimal/app/main.ts +12 -26
- package/templates/minimal/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/templates/minimal/package.json +10 -7
- package/templates/service/AGENTS.md +64 -0
- package/templates/service/__tests__/todo-api.test.ts +5 -5
- package/templates/service/_npmrc +4 -0
- package/templates/service/_pnpm-workspace.yaml +6 -0
- package/templates/service/app/app.ts +25 -0
- package/templates/service/app/errors/todo-errors.ts +2 -2
- package/templates/service/app/main.ts +22 -34
- package/templates/service/app/resources/todo.ts +1 -1
- package/templates/service/app/store/todo-store.ts +9 -25
- package/templates/service/config/app.ts +18 -0
- package/templates/service/config/env.ts +32 -0
- package/templates/service/config/http.ts +15 -0
- package/templates/service/package.json +11 -8
- package/templates/service/tsconfig.json +1 -1
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* bindings (a `db` connection, an http client, anything the runtime
|
|
14
14
|
* registered).
|
|
15
15
|
*
|
|
16
|
-
* defineQuery({ name,
|
|
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:
|
|
37
|
-
|
|
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 (
|
|
42
|
+
handler: async (ctx): Promise<AuthorPost[]> => {
|
|
43
|
+
const { authorId, limit } = ctx.input;
|
|
42
44
|
// Production form:
|
|
43
|
-
// const 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
|
|
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
|
|
25
|
-
await
|
|
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
|
|
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
|
|
21
|
-
const post = await
|
|
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
|
|
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
|
|
23
|
+
const queries = ctx.resolve<QueryRunner>(FORGE_QUERY_RUNNER_BINDING);
|
|
24
24
|
return {
|
|
25
|
-
items: await
|
|
26
|
-
totals: await
|
|
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
|
|
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
|
|
24
|
-
await
|
|
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
|
|
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
|
|
26
|
-
await
|
|
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 `
|
|
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", ({
|
|
51
|
-
|
|
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,23 +4,26 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"dev": "
|
|
8
|
-
"test": "vitest run"
|
|
7
|
+
"dev": "nwire dev",
|
|
8
|
+
"test": "vitest run",
|
|
9
|
+
"doctor": "nwire doctor",
|
|
10
|
+
"studio": "nwire studio",
|
|
11
|
+
"cache": "nwire cache"
|
|
9
12
|
},
|
|
10
13
|
"dependencies": {
|
|
11
|
-
"@nwire/app": "^0.
|
|
12
|
-
"@nwire/endpoint": "^0.
|
|
13
|
-
"@nwire/forge": "^0.
|
|
14
|
-
"@nwire/koa": "^0.
|
|
15
|
-
"@nwire/messages": "^0.
|
|
16
|
-
"@nwire/wires": "^0.
|
|
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",
|
|
17
20
|
"zod": "^4.0.0"
|
|
18
21
|
},
|
|
19
22
|
"devDependencies": {
|
|
20
|
-
"@nwire/
|
|
23
|
+
"@nwire/cli": "^0.13.0",
|
|
24
|
+
"@nwire/test-kit": "^0.13.0",
|
|
21
25
|
"@types/node": "^22.19.9",
|
|
22
26
|
"typescript": "^5.9.0",
|
|
23
|
-
"vite-node": "^3.2.4",
|
|
24
27
|
"vitest": "^4.0.18"
|
|
25
28
|
}
|
|
26
29
|
}
|
|
@@ -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,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
|
+
}
|