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.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -2
- package/package.json +1 -1
- package/templates/enterprise/AGENTS.md +11 -11
- package/templates/enterprise/README.md +15 -10
- package/templates/enterprise/__tests__/auto-moderate.test.ts +22 -6
- package/templates/enterprise/__tests__/submit-flow.test.ts +22 -6
- package/templates/enterprise/app/api.ts +5 -1
- package/templates/enterprise/app/app.ts +23 -15
- package/templates/enterprise/app/main.ts +20 -45
- 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 +34 -27
- 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 +10 -11
- 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 +12 -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 +16 -9
- package/templates/minimal/README.md +20 -10
- package/templates/minimal/__tests__/hello.test.ts +3 -3
- package/templates/minimal/app/app.ts +21 -0
- package/templates/minimal/app/main.ts +15 -24
- package/templates/minimal/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/templates/minimal/package.json +7 -8
- package/templates/service/AGENTS.md +13 -11
- package/templates/service/README.md +5 -3
- package/templates/service/__tests__/todo-api.test.ts +5 -5
- package/templates/service/app/app.ts +25 -0
- package/templates/service/app/main.ts +20 -30
- package/templates/service/app/store/todo-store.ts +6 -22
- 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 +8 -9
- 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
|
-
* `
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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
|
-
* `
|
|
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 — `
|
|
55
|
+
// ─── Projection — `when` verb matches the workflow's shape ──────
|
|
56
56
|
|
|
57
|
-
export const queueDashboard = defineProjection<QueueState>(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
75
|
+
when(PostWasApproved, (state, event) => {
|
|
82
76
|
const item = state.byId[event.postId];
|
|
83
|
-
|
|
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
|
-
|
|
100
|
+
when(PostWasRejected, (state, event) => {
|
|
104
101
|
const item = state.byId[event.postId];
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,27 +4,26 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"dev": "
|
|
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.
|
|
15
|
-
"@nwire/endpoint": "^0.
|
|
16
|
-
"@nwire/forge": "^0.
|
|
17
|
-
"@nwire/koa": "^0.
|
|
18
|
-
"@nwire/messages": "^0.
|
|
19
|
-
"@nwire/wires": "^0.
|
|
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/
|
|
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
|
-
"
|
|
27
|
-
"vitest": "^4.0.18",
|
|
28
|
-
"@nwire/cli": "^0.12.1"
|
|
27
|
+
"vitest": "^4.0.18"
|
|
29
28
|
}
|
|
30
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,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
|