@dockndevai/mcp-core 0.0.0-stage → 0.1.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/MIGRATION.md +247 -0
  3. package/README.md +158 -2
  4. package/bin/create-mcp-server.mjs +138 -0
  5. package/dist/annotations.d.ts +25 -0
  6. package/dist/annotations.d.ts.map +1 -0
  7. package/dist/annotations.js +23 -0
  8. package/dist/annotations.js.map +1 -0
  9. package/dist/elicit.d.ts +37 -0
  10. package/dist/elicit.d.ts.map +1 -0
  11. package/dist/elicit.js +44 -0
  12. package/dist/elicit.js.map +1 -0
  13. package/dist/env.d.ts +17 -0
  14. package/dist/env.d.ts.map +1 -0
  15. package/dist/env.js +33 -0
  16. package/dist/env.js.map +1 -0
  17. package/dist/index.d.ts +17 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +17 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/path.d.ts +29 -0
  22. package/dist/path.d.ts.map +1 -0
  23. package/dist/path.js +65 -0
  24. package/dist/path.js.map +1 -0
  25. package/dist/security.d.ts +114 -0
  26. package/dist/security.d.ts.map +1 -0
  27. package/dist/security.js +142 -0
  28. package/dist/security.js.map +1 -0
  29. package/dist/server.d.ts +29 -0
  30. package/dist/server.d.ts.map +1 -0
  31. package/dist/server.js +50 -0
  32. package/dist/server.js.map +1 -0
  33. package/dist/tools.d.ts +52 -0
  34. package/dist/tools.d.ts.map +1 -0
  35. package/dist/tools.js +13 -0
  36. package/dist/tools.js.map +1 -0
  37. package/package.json +65 -4
  38. package/scaffold/template/README.md +40 -0
  39. package/scaffold/template/env.example +19 -0
  40. package/scaffold/template/gitignore +9 -0
  41. package/scaffold/template/package.json +42 -0
  42. package/scaffold/template/src/client.ts +40 -0
  43. package/scaffold/template/src/context.ts +7 -0
  44. package/scaffold/template/src/index.ts +22 -0
  45. package/scaffold/template/src/security.ts +37 -0
  46. package/scaffold/template/src/server.ts +25 -0
  47. package/scaffold/template/src/tools/admin.ts +36 -0
  48. package/scaffold/template/src/tools/read.ts +22 -0
  49. package/scaffold/template/src/tools/write.ts +31 -0
  50. package/scaffold/template/test/security.test.ts +49 -0
  51. package/scaffold/template/tsconfig.json +20 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ankit Verma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/MIGRATION.md ADDED
@@ -0,0 +1,247 @@
1
+ # Migrating a suite server onto `@dockndevai/mcp-core`
2
+
3
+ This guide shows how an existing server replaces its local `security.ts`, `elicit.ts`, path
4
+ canonicalization, config helpers, tool types, annotations, and server-assembly with `mcp-core`
5
+ imports — **incrementally**, one server at a time, with no behaviour change.
6
+
7
+ The golden rule is unchanged: **safe by default, least privilege, never leak secrets.** `mcp-core`
8
+ owns the capability ranking, the guard flow, dry-run, elicitation, path safety, and the audit line.
9
+ Your server keeps everything genuinely domain-specific.
10
+
11
+ ## What moves to mcp-core vs. what stays
12
+
13
+ | Concern | Before (local file) | After |
14
+ | --- | --- | --- |
15
+ | Capability ranking + access-mode gating | `security.ts` | **mcp-core** `SecurityPolicy` |
16
+ | Resource allowlist / protected / opt-in flags / dry-run / audit | `security.ts` | **mcp-core** `SecurityPolicy` + `guard()` |
17
+ | Human-in-the-loop confirmation | `elicit.ts` | **mcp-core** `makeConfirmer` / `Confirmer` |
18
+ | Path canonicalization (symlink-bypass fix) | `config.ts` `resolveRealPath` / `canonicalize` | **mcp-core** `resolveRealPath` / `canonicalize` / `resolvePath` |
19
+ | Env parsing (`bool`/`csv`/`num`) | `config.ts` | **mcp-core** `envBool` / `envList` / `envNum` / `envEnum` |
20
+ | Tool types + result helpers | `tools/types.ts` | **mcp-core** `ToolDef` / `BaseToolContext` / `jsonResult` / `textResult` / `imageResult` |
21
+ | Annotations | `tools/annotations.ts` | **mcp-core** `annotationsFor` |
22
+ | Tool registration + error wrapping | `server.ts` | **mcp-core** `buildServer` / `defaultErrorMapper` |
23
+ | **Domain client** (SDK/REST wrapper) | `src/<domain>/client.ts` | **stays** — unchanged |
24
+ | **Tool definitions** (the tools themselves) | `tools/{read,write,admin}.ts` | **stays** — re-pointed imports only |
25
+ | **Env var names + defaults** (incl. full-control inversion, SAFE_PROTECTED) | `config.ts` | **stays** — `config.ts` now builds a `PolicyConfig` |
26
+ | **Extra context** (e.g. laya-guard `GuardClient`) | `guard.ts`, context type | **stays** — add to your own `ToolContext` |
27
+ | **Error subclasses** (e.g. `MacError`) + redaction | domain files | **stays** — pass a custom `mapError` |
28
+
29
+ Net effect: `security.ts`, `elicit.ts`, `tools/types.ts`, and `tools/annotations.ts` are **deleted**;
30
+ `config.ts` and `server.ts` shrink to domain wiring.
31
+
32
+ ## Step 1 — add the dependency
33
+
34
+ ```bash
35
+ npm install @dockndevai/mcp-core
36
+ ```
37
+
38
+ `@modelcontextprotocol/sdk` and `zod` are peer deps you already have.
39
+
40
+ ## Step 2 — delete `elicit.ts`, import from core
41
+
42
+ `mcp-core`'s `elicit.ts` is byte-for-byte the suite's implementation. Delete the local file and:
43
+
44
+ ```diff
45
+ - import { makeConfirmer, type Confirmer } from "./elicit.js";
46
+ + import { makeConfirmer, type Confirmer } from "@dockndevai/mcp-core";
47
+ ```
48
+
49
+ ## Step 3 — move path canonicalization to core
50
+
51
+ Delete the local `resolveRealPath` / `canonicalize` from `config.ts` and import them. `resolvePath`,
52
+ `resolveRealPath` (sync) and `canonicalize` (async) all come from core and behave identically.
53
+
54
+ ```diff
55
+ - import { resolveRealPath } from "../config.js";
56
+ + import { resolveRealPath } from "@dockndevai/mcp-core";
57
+ ```
58
+
59
+ ## Step 4 — rewrite `config.ts` to build a `PolicyConfig`
60
+
61
+ Keep your env-var names and defaults (including any full-control-by-default inversion and the
62
+ `SAFE_PROTECTED` list). Replace the local `bool`/`csv`/`num` with core's `envBool`/`envList`/`envNum`,
63
+ and emit a `PolicyConfig` instead of the old `SecurityConfig`.
64
+
65
+ **Path-scoped server (mac-control / macos):**
66
+
67
+ ```ts
68
+ import { type PolicyConfig, envBool, envEnum, envList, resolveRealPath } from "@dockndevai/mcp-core";
69
+
70
+ export function loadPolicyConfig(): PolicyConfig {
71
+ const safeMode = envBool("MACCTL_SAFE_MODE", false);
72
+ const protectedRaw = envList("MACCTL_PROTECTED_PATHS");
73
+ return {
74
+ mode: envEnum("MACCTL_MODE", ["read-only", "read-write", "admin"] as const, safeMode ? "read-only" : "admin"),
75
+ auditTag: "mac-control",
76
+ dryRun: envBool("MACCTL_DRY_RUN", false),
77
+ auditLog: envBool("MACCTL_AUDIT_LOG", true),
78
+ resources: {
79
+ path: {
80
+ label: "path",
81
+ match: "prefix",
82
+ allowlist: envList("MACCTL_PATH_ALLOWLIST").map(resolveRealPath),
83
+ protected: (protectedRaw.length ? protectedRaw : safeMode ? SAFE_PROTECTED : []).map(resolveRealPath),
84
+ allowlistEnv: "MACCTL_PATH_ALLOWLIST",
85
+ protectedEnv: "MACCTL_PROTECTED_PATHS",
86
+ },
87
+ command: {
88
+ label: "program",
89
+ match: "exact",
90
+ allowlist: envList("MACCTL_COMMAND_ALLOWLIST"),
91
+ allowlistEnv: "MACCTL_COMMAND_ALLOWLIST",
92
+ },
93
+ },
94
+ flags: {
95
+ exec: { enabled: envBool("MACCTL_ALLOW_EXEC", !safeMode), env: "MACCTL_ALLOW_EXEC", describe: "runs arbitrary code or kills a process" },
96
+ delete: { enabled: envBool("MACCTL_ALLOW_DELETE", !safeMode), env: "MACCTL_ALLOW_DELETE" },
97
+ input: { enabled: envBool("MACCTL_ALLOW_INPUT", !safeMode), env: "MACCTL_ALLOW_INPUT", describe: "injects GUI input" },
98
+ },
99
+ };
100
+ }
101
+ ```
102
+
103
+ **REST name-scoped server (debezium):**
104
+
105
+ ```ts
106
+ import { type PolicyConfig, envBool, envEnum, envList } from "@dockndevai/mcp-core";
107
+
108
+ export function loadPolicyConfig(): PolicyConfig {
109
+ return {
110
+ mode: envEnum("DEBEZIUM_MODE", ["read-only", "read-write", "admin"] as const, "read-only"),
111
+ auditTag: "debezium-mcp",
112
+ dryRun: envBool("DEBEZIUM_DRY_RUN"),
113
+ auditLog: envBool("DEBEZIUM_AUDIT_LOG", true),
114
+ resources: {
115
+ connector: {
116
+ label: "connector",
117
+ match: "exact",
118
+ allowlist: envList("DEBEZIUM_CONNECTOR_ALLOWLIST"),
119
+ protected: envList("DEBEZIUM_PROTECTED_CONNECTORS"),
120
+ allowlistEnv: "DEBEZIUM_CONNECTOR_ALLOWLIST",
121
+ protectedEnv: "DEBEZIUM_PROTECTED_CONNECTORS",
122
+ },
123
+ },
124
+ flags: { delete: { enabled: envBool("DEBEZIUM_ALLOW_DELETE"), env: "DEBEZIUM_ALLOW_DELETE" } },
125
+ destructiveFlag: "delete", // preserves the `destructive && !allowDelete` shape
126
+ };
127
+ }
128
+ ```
129
+
130
+ ## Step 5 — update `guard()` call sites in the tools
131
+
132
+ The guard context changes from flat fields to the `resources` / `requires` / `bypasses` shape. The
133
+ semantics are identical.
134
+
135
+ **Path-scoped:**
136
+
137
+ ```diff
138
+ - policy.guard({ tool: "read_file", capability: "read", path: abs });
139
+ + policy.guard({ tool: "read_file", capability: "read", resources: { path: { value: abs } } });
140
+
141
+ - policy.guard({ tool: "write_file", capability: "write", path: abs, pathMutation: true });
142
+ + policy.guard({ tool: "write_file", capability: "write", resources: { path: { value: abs, mutation: true } } });
143
+
144
+ - policy.guard({ tool: "run_command", capability: "admin", command: basename(cmd),
145
+ - shellEscape: isShellEscape(cmd, argv), requiresExec: true, destructive: true });
146
+ + policy.guard({ tool: "run_command", capability: "admin",
147
+ + resources: { command: { value: basename(cmd) } },
148
+ + bypasses: ["command"], // only matters when the command allowlist is set
149
+ + requires: ["exec"], destructive: true });
150
+
151
+ - policy.guard({ tool: "delete_path", capability: "admin", requiresDelete: true, destructive: true, path: abs, pathMutation: true });
152
+ + policy.guard({ tool: "delete_path", capability: "admin", requires: ["delete"], destructive: true,
153
+ + resources: { path: { value: abs, mutation: true } } });
154
+
155
+ - policy.guard({ tool: "type_text", capability: "admin", requiresInput: true });
156
+ + policy.guard({ tool: "type_text", capability: "admin", requires: ["input"] });
157
+ ```
158
+
159
+ `bypasses: ["command"]` replaces the `shellEscape` boolean: pass it when the op (a shell `-c`) could
160
+ run outside the named allowlist; core refuses it only while that rule's allowlist is non-empty.
161
+
162
+ **REST name-scoped:** the old code refused mutation of a protected connector when
163
+ `capability !== "read"`. Express that as `mutation`:
164
+
165
+ ```diff
166
+ - policy.guard({ tool: "delete_connector", capability: "admin", connector: name, destructive: true });
167
+ + policy.guard({ tool: "delete_connector", capability: "admin", destructive: true,
168
+ + resources: { connector: { value: name, mutation: true } } });
169
+
170
+ - policy.guard({ tool: "get_connector", capability: "read", connector: name });
171
+ + policy.guard({ tool: "get_connector", capability: "read", resources: { connector: { value: name } } });
172
+ ```
173
+
174
+ Also replace helper calls: `policy.isPathAllowed(p)` → `policy.isResourceAllowed("path", p)`,
175
+ `policy.isPathProtected(p)` → `policy.isResourceProtected("path", p)`,
176
+ `policy.isCommandAllowed(c)` → `policy.isResourceAllowed("command", c)`.
177
+
178
+ ## Step 6 — tool types, results, annotations
179
+
180
+ Delete `tools/types.ts` and `tools/annotations.ts`. In your tool files:
181
+
182
+ ```diff
183
+ - import { type ToolDef, jsonResult, textResult, imageResult } from "./types.js";
184
+ + import { type ToolDef, jsonResult, textResult, imageResult } from "@dockndevai/mcp-core";
185
+ ```
186
+
187
+ Define your own context by extending `BaseToolContext` (this is where extra context such as the
188
+ laya-guard `GuardClient` stays):
189
+
190
+ ```ts
191
+ import type { BaseToolContext } from "@dockndevai/mcp-core";
192
+ import type { MacClient } from "../mac/client.js";
193
+ import type { GuardClient } from "../guard.js";
194
+
195
+ export interface ToolContext extends BaseToolContext {
196
+ client: MacClient;
197
+ guard: GuardClient; // domain-specific — stays
198
+ }
199
+ ```
200
+
201
+ Tool definitions become `ToolDef<ToolContext>`. Keep the `capability` tag honest and set
202
+ `destructive` where it applied. Use `enabledWhen: (p) => p.isFlagEnabled("exec")` to keep the most
203
+ dangerous tools unregistered until their flag is on (replacing any manual filtering in `server.ts`).
204
+
205
+ ## Step 7 — rewrite `server.ts` with `buildServer`
206
+
207
+ ```ts
208
+ import { buildServer, makeConfirmer, defaultErrorMapper, type ToolResult } from "@dockndevai/mcp-core";
209
+ import { MacError } from "./mac/client.js";
210
+
211
+ export function build() {
212
+ const policy = new SecurityPolicy(loadPolicyConfig());
213
+ const client = new MacClient(loadLimits());
214
+ const guard = new GuardClient(loadGuardConfig());
215
+ const context = { policy, client, guard } as ToolContext; // confirm set below
216
+
217
+ const built = buildServer<ToolContext>({
218
+ name: "mac-control",
219
+ version: "0.4.0",
220
+ tools: [...readTools, ...writeTools, ...adminTools],
221
+ context,
222
+ mapError, // keep domain error handling (e.g. MacError) — see below
223
+ });
224
+ context.confirm = makeConfirmer(built.server);
225
+ return built;
226
+ }
227
+
228
+ // Keep domain-specific error mapping (and redaction) by wrapping the default.
229
+ function mapError(err: unknown): ToolResult {
230
+ if (err instanceof MacError) return { content: [{ type: "text", text: err.message }], isError: true };
231
+ return defaultErrorMapper(err);
232
+ }
233
+ ```
234
+
235
+ ## Step 8 — port the tests, verify
236
+
237
+ Point the server's `test/security.test.ts` at the new `PolicyConfig` shape (mcp-core already carries
238
+ the exhaustive guard/elicit/path-canonicalization tests, so the server's own suite can shrink to
239
+ domain wiring smoke tests — mirroring the generated scaffold's `test/security.test.ts`). Then:
240
+
241
+ ```bash
242
+ npm run typecheck && npm test && npm run build
243
+ npm audit # expect 0 vulnerabilities
244
+ ```
245
+
246
+ Nothing about the operator-facing env vars, defaults, or guarantees changes — only where the code
247
+ lives. Bump the server's version, keep `server.json` in sync, and cut a release as usual.
package/README.md CHANGED
@@ -1,3 +1,159 @@
1
- # Temporary Holding Version
1
+ # @dockndevai/mcp-core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The shared **governance library** for the [dockndevai](https://github.com/dockndevai) MCP server suite. It is the single audited implementation of the suite's safe-by-default security model, extracted so every server imports it instead of each carrying a near-identical copy.
4
+
5
+ > This is a **library**, not an MCP server. It has no `server.json`, no `mcpName`, and is not listed in any MCP registry. Node >= 22, ESM, TypeScript types included.
6
+
7
+ ## Why
8
+
9
+ Every server in the suite shares one philosophy — **safe by default, least privilege, never leak secrets** — and had near-identical `security.ts`, `elicit.ts`, path canonicalization, and config helpers. Divergence is a security risk: a fix to the symlink allowlist-bypass or the guard flow had to be applied N times. `mcp-core` makes it one audited, unit-tested implementation. Domains supply only what is genuinely domain-specific (the resource label, which gates apply, the client); core owns the capability ranking, the guard flow, dry-run, elicitation, path safety, and the JSON audit line.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @dockndevai/mcp-core
15
+ # peers (your server already depends on these):
16
+ npm install @modelcontextprotocol/sdk zod
17
+ ```
18
+
19
+ ## The model in one minute
20
+
21
+ - **Capabilities** — every tool is `read` | `write` | `admin`.
22
+ - **Access modes** — `read-only` (0) → `read-write` (1) → `admin` (2). A tool is registered only when its capability rank ≤ the mode rank, so over-privileged tools are never advertised.
23
+ - **Resources** — a named, scoped domain (a filesystem `path`, a Kafka `connector`, a `realm`, a `topic`, a `command`…). Each has an **allowlist** (empty = all) and a **protected** list (readable forever, never mutable), matched either by `prefix` (segment-aware, for hierarchical paths) or `exact` (for flat names).
24
+ - **Opt-in flags** — high-impact powers (exec, delete, input…) each need a second explicit flag on top of the mode.
25
+ - **Dry-run** — mutating ops can be validated + audited without executing.
26
+ - **Audit** — every decision emits one JSON line to **stderr** (never stdout — stdout belongs to the MCP transport).
27
+
28
+ ## API overview
29
+
30
+ ```ts
31
+ import {
32
+ // policy engine
33
+ SecurityPolicy, PolicyError,
34
+ type Capability, type AccessMode, type PolicyConfig, type ResourceRule, type FlagRule, type GuardContext,
35
+ isUnder,
36
+ // human-in-the-loop confirmation
37
+ makeConfirmer, type Confirmer, type ConfirmRequest, type ConfirmOutcome,
38
+ // path safety (the audited symlink-bypass fix)
39
+ resolvePath, resolveRealPath, canonicalize,
40
+ // env parsing
41
+ envBool, envList, envCsv, envNum, envEnum,
42
+ // tool types + result helpers
43
+ type ToolDef, type BaseToolContext, type ToolResult, type ContentBlock,
44
+ jsonResult, textResult, imageResult,
45
+ // annotations + server assembly
46
+ annotationsFor, type ToolAnnotations,
47
+ buildServer, defaultErrorMapper, type BuildServerOptions,
48
+ } from "@dockndevai/mcp-core";
49
+ ```
50
+
51
+ ### `SecurityPolicy`
52
+
53
+ ```ts
54
+ const policy = new SecurityPolicy({
55
+ mode: "read-only",
56
+ auditTag: "my-server", // the "audit" field of each JSON audit line
57
+ dryRun: false,
58
+ auditLog: true,
59
+ resources: {
60
+ path: { // key referenced from guard() contexts
61
+ label: "path",
62
+ match: "prefix", // "prefix" (isUnder) | "exact"
63
+ allowlist: ["/work"], // empty = all allowed
64
+ protected: ["/work/.secrets"],
65
+ allowlistEnv: "MYSRV_PATH_ALLOWLIST", // surfaced in denial messages
66
+ protectedEnv: "MYSRV_PROTECTED_PATHS",
67
+ },
68
+ },
69
+ flags: {
70
+ exec: { enabled: false, env: "MYSRV_ALLOW_EXEC", describe: "runs arbitrary code" },
71
+ delete: { enabled: false, env: "MYSRV_ALLOW_DELETE" },
72
+ },
73
+ destructiveFlag: "delete", // optional: a `destructive` op also requires this flag
74
+ });
75
+
76
+ policy.isCapabilityEnabled("write"); // mode gate (use when registering)
77
+ policy.isFlagEnabled("exec"); // flag state (use in enabledWhen)
78
+ policy.isResourceAllowed("path", "/work/a"); // allowlist check
79
+ policy.isResourceProtected("path", "/work/x"); // protected check
80
+ ```
81
+
82
+ ### `guard(ctx)` — call first in every handler
83
+
84
+ ```ts
85
+ const { dryRun } = policy.guard({
86
+ tool: "write_file",
87
+ capability: "write",
88
+ resources: { path: { value: abs, mutation: true } }, // mutation => protected refused
89
+ requires: ["exec"], // opt-in flags that must be enabled
90
+ bypasses: ["command"], // rule names whose allowlist this op could evade (e.g. shell -c)
91
+ destructive: false, // records in audit; gates destructiveFlag
92
+ });
93
+ if (dryRun) return textResult("[dry-run] Would …");
94
+ ```
95
+
96
+ `guard()` throws `PolicyError` on any denial (capability, flag, bypass, allowlist, protected) and emits the matching audit line. It returns `{ dryRun }` — `true` only for mutating ops when `dryRun` is configured, never for reads.
97
+
98
+ ### Path safety — `resolveRealPath` / `canonicalize`
99
+
100
+ The audited fix for the symlink allowlist-bypass: a symlink planted inside an allowlisted directory (or pointing at a protected one) must resolve to its **real** target before `guard()` sees it, because `fs` follows symlinks. Always guard on the canonical value and perform the op on it.
101
+
102
+ ```ts
103
+ const abs = resolveRealPath(args.path); // sync: ~-expand + realpath, parent-dir fallback for new leaves
104
+ policy.guard({ tool, capability: "read", resources: { path: { value: abs } } });
105
+ // or, async, when you resolvePath first:
106
+ const canon = await canonicalize(resolvePath(args.path));
107
+ ```
108
+
109
+ ### `makeConfirmer(server)` — human-in-the-loop
110
+
111
+ Returns a `Confirmer`. When the client advertises elicitation, destructive tools pause for the **human** to approve the exact action; when it can't (or elicitation fails), it falls back to `{ approved: true }` — the policy gate was the real control, so this only ever adds a checkpoint, never removes one.
112
+
113
+ ### `buildServer(opts)` — register the enabled tools
114
+
115
+ ```ts
116
+ const ctx: MyCtx = { policy, client, confirm: undefined as never };
117
+ const { server, enabled } = buildServer<MyCtx>({
118
+ name: "my-server",
119
+ version: "0.1.0",
120
+ tools: [...readTools, ...writeTools, ...adminTools],
121
+ context: ctx,
122
+ });
123
+ ctx.confirm = makeConfirmer(server); // confirmer needs the server
124
+ ```
125
+
126
+ A tool is registered only when `policy.isCapabilityEnabled(tool.capability)` **and** its optional `enabledWhen(policy)` passes (use it to keep the most dangerous tools hidden until an opt-in flag is on). Handlers are wrapped so thrown errors (including `PolicyError`) become a clean `isError` result via `defaultErrorMapper` (override with `mapError`).
127
+
128
+ ## Scaffold a new server — `create-mcp-server`
129
+
130
+ A generator ships with the package. It copies a minimal, working server built on `mcp-core` (config + security wiring + server + index + one read/write/admin tool each + a test) and substitutes the name/resource.
131
+
132
+ ```bash
133
+ npm create @dockndevai/mcp-server -- mcp-widget --resource widget
134
+ # or, once installed:
135
+ npx create-mcp-server mcp-widget --resource widget --dir ./mcp-widget
136
+ ```
137
+
138
+ | Arg | Meaning |
139
+ | --- | --- |
140
+ | `<name>` | Server name, e.g. `mcp-widget` (an `mcp-` prefix is added if missing). |
141
+ | `--resource <label>` | The resource the server scopes (singular), e.g. `widget`. Default `item`. |
142
+ | `--dir <path>` | Target directory. Default `./<name>`. |
143
+
144
+ It derives the package name (`@dockndevai/<name>`), the env prefix (`WIDGET_`), and the tool names (`get_widget`, `update_widget`, `delete_widget`). Then:
145
+
146
+ ```bash
147
+ cd mcp-widget && npm install
148
+ npm run typecheck && npm test && npm run build
149
+ ```
150
+
151
+ Replace the in-memory stub in `src/client.ts` with your backend, adjust the resource rule in `src/security.ts`, and flesh out the tools. The governance primitives stay in `mcp-core` — never reimplement them.
152
+
153
+ ## Migrating an existing server
154
+
155
+ See [MIGRATION.md](./MIGRATION.md) for the step-by-step: how a server replaces its local `security.ts` / `elicit.ts` / `canonicalize` / config helpers with `mcp-core` imports, and what stays domain-specific. Adoption is incremental — one server at a time, no big-bang.
156
+
157
+ ## License
158
+
159
+ MIT
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * create-mcp-server — scaffold a new dockndevai MCP server on top of @dockndevai/mcp-core.
4
+ *
5
+ * Usage:
6
+ * npx @dockndevai/create-mcp-server <name> [--resource <label>] [--dir <path>]
7
+ * npm create @dockndevai/mcp-server -- <name> --resource widget
8
+ *
9
+ * <name> Server name, e.g. "mcp-widget" (an "mcp-" prefix is added if missing).
10
+ * --resource The resource the server scopes (singular), e.g. "widget". Default: "item".
11
+ * --dir Target directory. Default: ./<name>.
12
+ *
13
+ * It copies scaffold/template, substitutes the placeholder tokens, and renames the dotfile
14
+ * templates (gitignore -> .gitignore, env.example -> .env.example). It does NOT run npm install.
15
+ */
16
+ import { cp, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
17
+ import { existsSync } from "node:fs";
18
+ import path from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
22
+ const TEMPLATE = path.join(HERE, "..", "scaffold", "template");
23
+
24
+ function parseArgs(argv) {
25
+ const args = { _: [] };
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const a = argv[i];
28
+ if (a === "--resource" || a === "-r") args.resource = argv[++i];
29
+ else if (a === "--dir" || a === "-d") args.dir = argv[++i];
30
+ else if (a === "--help" || a === "-h") args.help = true;
31
+ else args._.push(a);
32
+ }
33
+ return args;
34
+ }
35
+
36
+ function usage() {
37
+ console.log(
38
+ `create-mcp-server <name> [--resource <label>] [--dir <path>]\n\n` +
39
+ ` <name> Server name, e.g. "mcp-widget" (an "mcp-" prefix is added if missing).\n` +
40
+ ` --resource Resource the server scopes (singular), e.g. "widget". Default: "item".\n` +
41
+ ` --dir Target directory. Default: ./<name>.`,
42
+ );
43
+ }
44
+
45
+ function pascal(s) {
46
+ return s
47
+ .split(/[^a-zA-Z0-9]+/)
48
+ .filter(Boolean)
49
+ .map((w) => w[0].toUpperCase() + w.slice(1).toLowerCase())
50
+ .join("");
51
+ }
52
+
53
+ function sanitizeResource(r) {
54
+ // Keep it a safe JS identifier fragment (used in tool names like get_<resource>).
55
+ const clean = r.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
56
+ return clean || "item";
57
+ }
58
+
59
+ async function walk(dir) {
60
+ const out = [];
61
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
62
+ const full = path.join(dir, entry.name);
63
+ if (entry.isDirectory()) out.push(...(await walk(full)));
64
+ else out.push(full);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ async function main() {
70
+ const args = parseArgs(process.argv.slice(2));
71
+ if (args.help || args._.length === 0) {
72
+ usage();
73
+ process.exit(args.help ? 0 : 1);
74
+ }
75
+
76
+ let name = args._[0].trim();
77
+ if (!/^[a-z0-9-]+$/.test(name)) {
78
+ console.error(`Error: name "${name}" must be lowercase letters, digits and hyphens only.`);
79
+ process.exit(1);
80
+ }
81
+ if (!name.startsWith("mcp-")) name = `mcp-${name}`;
82
+
83
+ const resource = sanitizeResource(args.resource ?? "item");
84
+ const envBase = name.replace(/^mcp-/, "");
85
+ const tokens = {
86
+ __PKG_NAME__: `@dockndevai/${name}`,
87
+ __SERVER_NAME__: name,
88
+ __ENV_PREFIX__: envBase.toUpperCase().replace(/[^A-Z0-9]+/g, "_"),
89
+ __SERVER_PASCAL__: pascal(envBase),
90
+ __RESOURCE__: resource,
91
+ };
92
+
93
+ const target = path.resolve(args.dir ?? name);
94
+ if (existsSync(target) && (await readdir(target)).length > 0) {
95
+ console.error(`Error: target directory "${target}" exists and is not empty.`);
96
+ process.exit(1);
97
+ }
98
+
99
+ if (!existsSync(TEMPLATE)) {
100
+ console.error(`Error: template not found at ${TEMPLATE}. Is the package installed correctly?`);
101
+ process.exit(1);
102
+ }
103
+
104
+ // Copy the whole template, then substitute tokens in every file, then rename dotfiles.
105
+ await mkdir(target, { recursive: true });
106
+ await cp(TEMPLATE, target, { recursive: true });
107
+
108
+ const files = await walk(target);
109
+ for (const file of files) {
110
+ const raw = await readFile(file, "utf8");
111
+ let replaced = raw;
112
+ for (const [tok, val] of Object.entries(tokens)) replaced = replaced.split(tok).join(val);
113
+ if (replaced !== raw) await writeFile(file, replaced, "utf8");
114
+ }
115
+
116
+ // Template dotfiles are shipped without a leading dot so npm doesn't strip them on publish.
117
+ const renames = [
118
+ ["gitignore", ".gitignore"],
119
+ ["env.example", ".env.example"],
120
+ ];
121
+ for (const [from, to] of renames) {
122
+ const src = path.join(target, from);
123
+ if (existsSync(src)) await rename(src, path.join(target, to));
124
+ }
125
+
126
+ console.log(`\nScaffolded ${tokens.__PKG_NAME__} in ${target}\n`);
127
+ console.log(` resource: ${resource} env prefix: ${tokens.__ENV_PREFIX__}_\n`);
128
+ console.log(`Next steps:`);
129
+ console.log(` cd ${path.relative(process.cwd(), target) || "."}`);
130
+ console.log(` npm install`);
131
+ console.log(` npm run typecheck && npm test && npm run build`);
132
+ console.log(`\nThen wire src/client.ts to your backend and flesh out the tools. See the README.\n`);
133
+ }
134
+
135
+ main().catch((err) => {
136
+ console.error(err instanceof Error ? err.stack : String(err));
137
+ process.exit(1);
138
+ });
@@ -0,0 +1,25 @@
1
+ import type { BaseToolContext, ToolDef } from "./tools.js";
2
+ /**
3
+ * MCP tool annotations — machine-readable behaviour hints the host reads to decide what to
4
+ * auto-approve versus prompt on. See https://modelcontextprotocol.io/docs/concepts/tools#tool-annotations
5
+ */
6
+ export interface ToolAnnotations {
7
+ title?: string;
8
+ readOnlyHint?: boolean;
9
+ destructiveHint?: boolean;
10
+ idempotentHint?: boolean;
11
+ openWorldHint?: boolean;
12
+ }
13
+ /**
14
+ * Derive annotations from a tool's declared capability, so the safe-by-default access model is
15
+ * exposed to the host rather than hidden in the description.
16
+ *
17
+ * - `read` ⇒ read-only, non-destructive, idempotent.
18
+ * - `write` ⇒ mutating; non-destructive unless the tool sets `destructive`.
19
+ * - `admin` ⇒ mutating and destructive (data loss possible) unless the tool overrides it.
20
+ *
21
+ * `openWorldHint` defaults to true (these servers act on live external systems); pass `false` for a
22
+ * server whose effects are fully local and closed.
23
+ */
24
+ export declare function annotationsFor(tool: ToolDef<BaseToolContext>, openWorld?: boolean): ToolAnnotations;
25
+ //# sourceMappingURL=annotations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"annotations.d.ts","sourceRoot":"","sources":["../src/annotations.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAE3D;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,EAAE,SAAS,UAAO,GAAG,eAAe,CAUhG"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Derive annotations from a tool's declared capability, so the safe-by-default access model is
3
+ * exposed to the host rather than hidden in the description.
4
+ *
5
+ * - `read` ⇒ read-only, non-destructive, idempotent.
6
+ * - `write` ⇒ mutating; non-destructive unless the tool sets `destructive`.
7
+ * - `admin` ⇒ mutating and destructive (data loss possible) unless the tool overrides it.
8
+ *
9
+ * `openWorldHint` defaults to true (these servers act on live external systems); pass `false` for a
10
+ * server whose effects are fully local and closed.
11
+ */
12
+ export function annotationsFor(tool, openWorld = true) {
13
+ const readOnly = tool.capability === "read";
14
+ const destructive = tool.destructive ?? tool.capability === "admin";
15
+ return {
16
+ title: tool.config.title,
17
+ readOnlyHint: readOnly,
18
+ destructiveHint: readOnly ? false : destructive,
19
+ idempotentHint: tool.idempotent ?? readOnly,
20
+ openWorldHint: openWorld,
21
+ };
22
+ }
23
+ //# sourceMappingURL=annotations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"annotations.js","sourceRoot":"","sources":["../src/annotations.ts"],"names":[],"mappings":"AAcA;;;;;;;;;;GAUG;AACH,MAAM,UAAU,cAAc,CAAC,IAA8B,EAAE,SAAS,GAAG,IAAI;IAC7E,MAAM,QAAQ,GAAG,IAAI,CAAC,UAAU,KAAK,MAAM,CAAC;IAC5C,MAAM,WAAW,GAAG,IAAI,CAAC,WAAW,IAAI,IAAI,CAAC,UAAU,KAAK,OAAO,CAAC;IACpE,OAAO;QACL,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,KAAK;QACxB,YAAY,EAAE,QAAQ;QACtB,eAAe,EAAE,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,WAAW;QAC/C,cAAc,EAAE,IAAI,CAAC,UAAU,IAAI,QAAQ;QAC3C,aAAa,EAAE,SAAS;KACzB,CAAC;AACJ,CAAC"}
@@ -0,0 +1,37 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /**
3
+ * Human-in-the-loop confirmation for destructive operations, via MCP elicitation.
4
+ *
5
+ * When the connected client advertises the elicitation capability, a destructive tool
6
+ * pauses and asks the **human** to approve the exact action — the model cannot approve
7
+ * on the user's behalf, which is the whole point (a model can trivially echo a
8
+ * confirmation string; it cannot check a box a human is shown).
9
+ *
10
+ * When the client does NOT support elicitation, or the elicitation round-trip fails,
11
+ * the call falls back to the policy gate that already permitted it (admin mode + the
12
+ * `*_ALLOW_DELETE` flag). So enabling this is never *more* permissive than before — it
13
+ * only adds a human checkpoint where one is possible.
14
+ */
15
+ export interface ConfirmRequest {
16
+ /** Short verb phrase naming the action, e.g. "delete dashboard". */
17
+ action: string;
18
+ /** The exact resource affected (uid, name, id …) — shown to the human. */
19
+ target: string;
20
+ /** Extra context lines shown under the prompt. */
21
+ details?: Record<string, string | number | boolean | undefined>;
22
+ }
23
+ export type ConfirmOutcome = {
24
+ approved: true;
25
+ interactive: boolean;
26
+ } | {
27
+ approved: false;
28
+ reason: string;
29
+ };
30
+ export interface Confirmer {
31
+ /** Whether the connected client can prompt the human at all. */
32
+ available(): boolean;
33
+ /** Ask the human to approve a destructive action. */
34
+ confirm(req: ConfirmRequest): Promise<ConfirmOutcome>;
35
+ }
36
+ export declare function makeConfirmer(server: McpServer): Confirmer;
37
+ //# sourceMappingURL=elicit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"elicit.d.ts","sourceRoot":"","sources":["../src/elicit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,cAAc;IAC7B,oEAAoE;IACpE,MAAM,EAAE,MAAM,CAAC;IACf,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;IACf,kDAAkD;IAClD,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC,CAAC;CACjE;AAED,MAAM,MAAM,cAAc,GACtB;IAAE,QAAQ,EAAE,IAAI,CAAC;IAAC,WAAW,EAAE,OAAO,CAAA;CAAE,GACxC;IAAE,QAAQ,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAExC,MAAM,WAAW,SAAS;IACxB,gEAAgE;IAChE,SAAS,IAAI,OAAO,CAAC;IACrB,qDAAqD;IACrD,OAAO,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;CACvD;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,GAAG,SAAS,CA4C1D"}