@juzser/blacksmith 0.1.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/.claude/agents/auditor.md +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- package/package.json +98 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
include: ['test/**/*.test.ts'],
|
|
6
|
+
coverage: {
|
|
7
|
+
provider: 'v8',
|
|
8
|
+
// `json-summary` is not decoration: the text reporter suppresses rows for
|
|
9
|
+
// files at 100% on every metric, so the file an acceptance criterion
|
|
10
|
+
// names vanishes from the transcript exactly when it is doing best. The
|
|
11
|
+
// gate reads coverage/coverage-summary.json instead (D-40/P9-25).
|
|
12
|
+
reporter: ['text', 'text-summary', 'json-summary'],
|
|
13
|
+
include: ['src/**/*.ts'],
|
|
14
|
+
// `src/main.ts` is the Vite mount entrypoint the `--ui` variant writes:
|
|
15
|
+
// three lines that call `createApp(App).mount('#app')`, with nothing a
|
|
16
|
+
// node-environment test could assert that a browser wouldn't. Counted, it
|
|
17
|
+
// drags a freshly scaffolded UI project to 50% lines and the floor below
|
|
18
|
+
// fails the project on its very first run — measured, not predicted.
|
|
19
|
+
// Ambient declarations have no statements to cover at all.
|
|
20
|
+
exclude: ['src/main.ts', 'src/**/*.d.ts'],
|
|
21
|
+
thresholds: {
|
|
22
|
+
lines: 80,
|
|
23
|
+
statements: 80,
|
|
24
|
+
functions: 80,
|
|
25
|
+
branches: 80,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__PROJECT_NAME__",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"protocolRevision": "2025-11-25",
|
|
5
|
+
"transport": {
|
|
6
|
+
"kind": "stdio"
|
|
7
|
+
},
|
|
8
|
+
"tools": [
|
|
9
|
+
{
|
|
10
|
+
"name": "project_health",
|
|
11
|
+
"title": "Project health",
|
|
12
|
+
"description": "Report this project's MCP surface: server name, version, pinned protocol revision, and — with verbose — the declared tool surface. Reads no project state and no credentials.",
|
|
13
|
+
"mutation": "read-only",
|
|
14
|
+
"annotations": {
|
|
15
|
+
"readOnlyHint": true,
|
|
16
|
+
"destructiveHint": false,
|
|
17
|
+
"idempotentHint": true,
|
|
18
|
+
"openWorldHint": false
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# The MCP surface
|
|
2
|
+
|
|
3
|
+
This is `__PROJECT_NAME__`'s agent-callable face: the whole of what an MCP
|
|
4
|
+
client can see, and the whole of what an agent can call.
|
|
5
|
+
|
|
6
|
+
## What is here
|
|
7
|
+
|
|
8
|
+
| File | Job |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| `../../mcp.manifest.json` | The declaration. Every tool, its mutation class, its annotations, and — for anything that is not read-only — the operator sign-off. |
|
|
11
|
+
| `guard.ts` | The runtime half of the policy. Refuses to register an undeclared, reclassified, or unsigned tool, and refuses to boot on a transport this surface has not implemented. |
|
|
12
|
+
| `redact.ts` | Output scrubbing. Two independent nets: by key name and by credential shape. |
|
|
13
|
+
| `server.ts` | Wiring. Loads the manifest, builds the server, connects stdio. |
|
|
14
|
+
| `tools/health.ts` | The one shipped tool, and the worked example every other tool copies. |
|
|
15
|
+
|
|
16
|
+
Tests live in `test/mcp/`. `surface.test.ts` connects a real client to a real
|
|
17
|
+
server over an in-memory transport, so the assertions are about what an agent
|
|
18
|
+
would actually see, not about what the manifest says.
|
|
19
|
+
|
|
20
|
+
## Adding a tool
|
|
21
|
+
|
|
22
|
+
1. **Declare it in `mcp.manifest.json` first.** Name it `snake_case`, give it a
|
|
23
|
+
`mutation` class (`read-only` | `write` | `destructive` | `outbound`), and
|
|
24
|
+
set the four annotations to match that class. A read-only tool with
|
|
25
|
+
`destructiveHint: true` is a contradiction and the check will say so.
|
|
26
|
+
2. **If it is not read-only, get it signed off.** Add
|
|
27
|
+
`"approval": { "operator": "...", "date": "YYYY-MM-DD", "milestone": "..." }`.
|
|
28
|
+
This is a person deciding, at the MCP milestone, that an agent may do this
|
|
29
|
+
thing unattended. It is not a formality and it is not yours to add for
|
|
30
|
+
yourself.
|
|
31
|
+
3. **Write the tool in `tools/`.** Start with `requireDeclaredTool(manifest,
|
|
32
|
+
NAME, <what this code actually does>)`. Pass the class the *implementation*
|
|
33
|
+
is, not the class you wish it were — that argument is what stops a tool that
|
|
34
|
+
quietly started writing from shipping under a read-only declaration.
|
|
35
|
+
4. **Give it a typed input schema.** Unvalidated arguments must never reach tool
|
|
36
|
+
code.
|
|
37
|
+
5. **Redact on the way out.** `redact()` the payload even when nothing in it is
|
|
38
|
+
secret today; that is what keeps it holding when the payload grows.
|
|
39
|
+
6. **Register it in `server.ts`** and add a case to `test/mcp/surface.test.ts`.
|
|
40
|
+
|
|
41
|
+
`pnpm test test/mcp` is the verdict. `surface.test.ts` advertises exactly what
|
|
42
|
+
the manifest declares and fails on anything else, so a tool registered without
|
|
43
|
+
a declaration is caught there rather than by a client.
|
|
44
|
+
|
|
45
|
+
## Why stdio
|
|
46
|
+
|
|
47
|
+
No port is opened, no listener is exposed, and there is no authorization surface
|
|
48
|
+
to get wrong. The MCP security guidance is explicit that servers meant to run
|
|
49
|
+
locally should use stdio to limit access to just the MCP client.
|
|
50
|
+
|
|
51
|
+
Serving over HTTP is a deliberate opt-in, not a config flag. It requires
|
|
52
|
+
Cloudflare Access in front, a canonical resource URI over `https`, RFC 8707
|
|
53
|
+
resource indicators, and — non-negotiably — no token passthrough: this server
|
|
54
|
+
must never accept a token that was not issued for it. Until that work is real,
|
|
55
|
+
`requireSupportedTransport` refuses to boot on anything but stdio.
|
|
56
|
+
|
|
57
|
+
## Two rules that are easy to break by accident
|
|
58
|
+
|
|
59
|
+
- **stdout belongs to the protocol.** A stray `console.log` corrupts the stream.
|
|
60
|
+
Diagnostics go to stderr, through `redactError` first.
|
|
61
|
+
- **Nothing is registered that the manifest does not declare.** The manifest is
|
|
62
|
+
the one place an operator reads to know what this server can do. If the code
|
|
63
|
+
can do more than the manifest admits, that document is a lie.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The startup guard for this project's MCP surface.
|
|
3
|
+
*
|
|
4
|
+
* The manifest is checked statically too, before any of this runs. This is
|
|
5
|
+
* the runtime half: no tool reaches a client unless the manifest declares it
|
|
6
|
+
* with the mutation class the code claims. The two halves are deliberately
|
|
7
|
+
* separate — a static check the server can ignore is a suggestion, and a
|
|
8
|
+
* runtime check nothing audits is unreviewable — and the shared contract
|
|
9
|
+
* between them is mcp.manifest.json itself.
|
|
10
|
+
*
|
|
11
|
+
* Fail closed: every failure here throws before `connect()`, so a
|
|
12
|
+
* misconfigured surface never starts rather than starting with a hole.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type MutationClass = 'read-only' | 'write' | 'destructive' | 'outbound';
|
|
16
|
+
|
|
17
|
+
export interface ToolAnnotations {
|
|
18
|
+
readOnlyHint: boolean;
|
|
19
|
+
destructiveHint: boolean;
|
|
20
|
+
idempotentHint: boolean;
|
|
21
|
+
openWorldHint: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ToolApproval {
|
|
25
|
+
operator: string;
|
|
26
|
+
date: string;
|
|
27
|
+
milestone: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DeclaredTool {
|
|
31
|
+
name: string;
|
|
32
|
+
title: string;
|
|
33
|
+
description: string;
|
|
34
|
+
mutation: MutationClass;
|
|
35
|
+
annotations: ToolAnnotations;
|
|
36
|
+
approval?: ToolApproval;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface McpManifest {
|
|
40
|
+
name: string;
|
|
41
|
+
version: string;
|
|
42
|
+
protocolRevision: string;
|
|
43
|
+
transport: { kind: string };
|
|
44
|
+
tools: DeclaredTool[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class SurfaceError extends Error {
|
|
48
|
+
readonly code: string;
|
|
49
|
+
|
|
50
|
+
constructor(code: string, message: string) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = 'SurfaceError';
|
|
53
|
+
this.code = code;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Look a tool up in the manifest and refuse anything undeclared, reclassified,
|
|
59
|
+
* or unsigned.
|
|
60
|
+
*
|
|
61
|
+
* `mutation` is what the implementation says it does. Passing it in is the
|
|
62
|
+
* whole point: when a tool that used to only read starts writing, the author
|
|
63
|
+
* changes this argument, the manifest no longer agrees, and the surface
|
|
64
|
+
* refuses to boot until an operator signs the new class off. Trusting the
|
|
65
|
+
* manifest alone would make that change silent.
|
|
66
|
+
*/
|
|
67
|
+
export function requireDeclaredTool(
|
|
68
|
+
manifest: McpManifest,
|
|
69
|
+
name: string,
|
|
70
|
+
mutation: MutationClass,
|
|
71
|
+
): DeclaredTool {
|
|
72
|
+
const declared = manifest.tools.find((tool) => tool.name === name);
|
|
73
|
+
if (!declared) {
|
|
74
|
+
throw new SurfaceError(
|
|
75
|
+
'mcp.undeclared-tool',
|
|
76
|
+
`Tool "${name}" is not declared in mcp.manifest.json. Declare it (docs/standards/mcp.md MCP-T5) before registering it.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (declared.mutation !== mutation) {
|
|
80
|
+
throw new SurfaceError(
|
|
81
|
+
'mcp.mutation-mismatch',
|
|
82
|
+
`Tool "${name}" is implemented as ${mutation} but declared as ${declared.mutation}. Update the manifest and get the new class signed off (MCP-T4).`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (mutation !== 'read-only' && !declared.approval) {
|
|
86
|
+
throw new SurfaceError(
|
|
87
|
+
'mcp.unapproved-tool',
|
|
88
|
+
`Tool "${name}" is ${mutation} and has no operator sign-off in the manifest (MCP-T4).`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return declared;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Refuse to start on a transport this template does not implement. Shipping
|
|
96
|
+
* `stdio` opens no port and presents no auth surface (MCP spec, "Local MCP
|
|
97
|
+
* Server Compromise": servers meant to run locally SHOULD use stdio to limit
|
|
98
|
+
* access to just the MCP client). Serving over HTTP is a deliberate opt-in that
|
|
99
|
+
* has to satisfy MCP-X2..X6 first — Cloudflare Access in front, a canonical
|
|
100
|
+
* resource URI, resource indicators, no token passthrough — so this refuses
|
|
101
|
+
* rather than quietly falling back to a listener nobody reviewed.
|
|
102
|
+
*/
|
|
103
|
+
export function requireSupportedTransport(manifest: McpManifest): void {
|
|
104
|
+
if (manifest.transport.kind !== 'stdio') {
|
|
105
|
+
throw new SurfaceError(
|
|
106
|
+
'mcp.unsupported-transport',
|
|
107
|
+
`This surface is stdio-only; the manifest declares "${manifest.transport.kind}". Implement the HTTP opt-in against docs/standards/mcp.md MCP-X2..X6 before declaring it.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output redaction for the MCP surface — docs/standards/mcp.md MCP-S1, and
|
|
3
|
+
* guardrails.md's "No secrets in outputs". Everything leaving a tool passes
|
|
4
|
+
* through here: results, error text, stderr diagnostics. The cheapest way to
|
|
5
|
+
* leak a token is to put a config object into an error message, so redaction
|
|
6
|
+
* lives on the way OUT rather than at each call site that might forget.
|
|
7
|
+
*
|
|
8
|
+
* Two independent nets, because either alone has a blind spot:
|
|
9
|
+
* - by key — anything under a secret-looking key is dropped regardless of
|
|
10
|
+
* its value, which catches short or unusual credentials.
|
|
11
|
+
* - by shape — recognisable token formats are scrubbed wherever they appear,
|
|
12
|
+
* including inside free text where no key exists.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const REDACTED = '[redacted]';
|
|
16
|
+
|
|
17
|
+
/** Object keys whose value is never safe to emit, whatever it looks like. */
|
|
18
|
+
const SECRET_KEY =
|
|
19
|
+
/(?:pass(?:word|phrase)?|secret|token|api[-_]?key|apikey|authorization|auth|cookie|credential|private[-_]?key|session[-_]?id)/i;
|
|
20
|
+
|
|
21
|
+
/** Credential shapes recognisable without a key to hang them on. */
|
|
22
|
+
const SECRET_SHAPED: RegExp[] = [
|
|
23
|
+
/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}/g,
|
|
24
|
+
/\bgh[pousr]_[A-Za-z0-9]{16,}/g,
|
|
25
|
+
/\bxox[abposr]-[A-Za-z0-9-]{10,}/g,
|
|
26
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+/g,
|
|
27
|
+
/\bAKIA[0-9A-Z]{16}/g,
|
|
28
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** Deep structures are truncated rather than walked forever. */
|
|
32
|
+
const MAX_DEPTH = 8;
|
|
33
|
+
|
|
34
|
+
export function redactText(text: string): string {
|
|
35
|
+
let out = text;
|
|
36
|
+
for (const pattern of SECRET_SHAPED) {
|
|
37
|
+
out = out.replace(pattern, REDACTED);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Recursively redact a value. Cycles are broken by a seen-set rather than by
|
|
44
|
+
* throwing: a tool that accidentally returns a cyclic object should degrade to
|
|
45
|
+
* a marker, not crash the surface mid-response.
|
|
46
|
+
*/
|
|
47
|
+
export function redact(value: unknown, depth = 0, seen = new WeakSet<object>()): unknown {
|
|
48
|
+
if (typeof value === 'string') return redactText(value);
|
|
49
|
+
if (value === null || typeof value !== 'object') return value;
|
|
50
|
+
if (depth >= MAX_DEPTH) return '[truncated]';
|
|
51
|
+
if (seen.has(value)) return '[circular]';
|
|
52
|
+
seen.add(value);
|
|
53
|
+
|
|
54
|
+
if (Array.isArray(value)) {
|
|
55
|
+
return value.map((entry) => redact(entry, depth + 1, seen));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const out: Record<string, unknown> = {};
|
|
59
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
60
|
+
out[key] = SECRET_KEY.test(key) ? REDACTED : redact(entry, depth + 1, seen);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Error text safe to hand back to a client or write to stderr. */
|
|
66
|
+
export function redactError(error: unknown): string {
|
|
67
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
68
|
+
return redactText(message);
|
|
69
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* __PROJECT_NAME__'s MCP surface — the agent-callable face of this project.
|
|
4
|
+
*
|
|
5
|
+
* stdio only, by default and on purpose: no port is opened, no listener is
|
|
6
|
+
* exposed, and there is no authorization surface to get wrong. Serving this
|
|
7
|
+
* over HTTP is an opt-in that has to satisfy docs/standards/mcp.md MCP-X2..X6
|
|
8
|
+
* first; `requireSupportedTransport` refuses to boot until that work is real.
|
|
9
|
+
*
|
|
10
|
+
* Two invariants this file exists to hold:
|
|
11
|
+
* - stdout belongs to the protocol. Every diagnostic goes to stderr, and
|
|
12
|
+
* goes through redaction first (MCP-S1) — a stray console.log corrupts the
|
|
13
|
+
* stream, and an un-redacted one leaks.
|
|
14
|
+
* - nothing is registered that the manifest does not declare. Registration
|
|
15
|
+
* runs through guard.ts, and a mismatch throws before connect().
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
21
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
22
|
+
import { type McpManifest, requireSupportedTransport } from './guard.js';
|
|
23
|
+
import { redactError } from './redact.js';
|
|
24
|
+
import { registerHealthTool } from './tools/health.js';
|
|
25
|
+
|
|
26
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
/** Resolves to the project root from both `src/mcp/` and the built `dist/mcp/`. */
|
|
28
|
+
const MANIFEST_PATH = path.resolve(HERE, '..', '..', 'mcp.manifest.json');
|
|
29
|
+
|
|
30
|
+
export function loadManifest(manifestPath: string = MANIFEST_PATH): McpManifest {
|
|
31
|
+
return JSON.parse(readFileSync(manifestPath, 'utf8')) as McpManifest;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildServer(manifest: McpManifest): McpServer {
|
|
35
|
+
requireSupportedTransport(manifest);
|
|
36
|
+
const server = new McpServer({ name: manifest.name, version: manifest.version });
|
|
37
|
+
registerHealthTool(server, manifest);
|
|
38
|
+
return server;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function main(): Promise<void> {
|
|
42
|
+
const manifest = loadManifest();
|
|
43
|
+
const server = buildServer(manifest);
|
|
44
|
+
await server.connect(new StdioServerTransport());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const invokedDirectly =
|
|
48
|
+
process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
49
|
+
|
|
50
|
+
if (invokedDirectly) {
|
|
51
|
+
main().catch((error: unknown) => {
|
|
52
|
+
process.stderr.write(`${redactError(error)}\n`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { type McpManifest, requireDeclaredTool } from '../guard.js';
|
|
4
|
+
import { redact } from '../redact.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The one tool the scaffold ships: enough to prove the surface is wired end to
|
|
8
|
+
* end, and a worked example of the shape every other tool copies —
|
|
9
|
+
*
|
|
10
|
+
* 1. a typed input schema (MCP-P4), so unknown arguments never reach code;
|
|
11
|
+
* 2. `requireDeclaredTool` with the class this implementation actually is,
|
|
12
|
+
* so a later change to what it does cannot outrun the manifest;
|
|
13
|
+
* 3. title/description/annotations taken FROM the manifest rather than
|
|
14
|
+
* restated here, so there is one place an operator has to read to know
|
|
15
|
+
* what this server can do.
|
|
16
|
+
*/
|
|
17
|
+
const TOOL_NAME = 'project_health';
|
|
18
|
+
|
|
19
|
+
const inputSchema = {
|
|
20
|
+
verbose: z
|
|
21
|
+
.boolean()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe('Also list the declared tool surface, not just the server identity.'),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function registerHealthTool(server: McpServer, manifest: McpManifest): void {
|
|
27
|
+
const declared = requireDeclaredTool(manifest, TOOL_NAME, 'read-only');
|
|
28
|
+
|
|
29
|
+
server.registerTool(
|
|
30
|
+
declared.name,
|
|
31
|
+
{
|
|
32
|
+
title: declared.title,
|
|
33
|
+
description: declared.description,
|
|
34
|
+
inputSchema,
|
|
35
|
+
annotations: declared.annotations,
|
|
36
|
+
},
|
|
37
|
+
async ({ verbose }) => {
|
|
38
|
+
const payload = {
|
|
39
|
+
name: manifest.name,
|
|
40
|
+
version: manifest.version,
|
|
41
|
+
protocolRevision: manifest.protocolRevision,
|
|
42
|
+
transport: manifest.transport.kind,
|
|
43
|
+
...(verbose
|
|
44
|
+
? {
|
|
45
|
+
tools: manifest.tools.map((tool) => ({
|
|
46
|
+
name: tool.name,
|
|
47
|
+
mutation: tool.mutation,
|
|
48
|
+
})),
|
|
49
|
+
}
|
|
50
|
+
: {}),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Redacted even though nothing here is secret today: the redaction runs
|
|
54
|
+
// on the way out so it keeps holding when this payload grows.
|
|
55
|
+
return {
|
|
56
|
+
content: [{ type: 'text' as const, text: JSON.stringify(redact(payload), null, 2) }],
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
type McpManifest,
|
|
4
|
+
requireDeclaredTool,
|
|
5
|
+
requireSupportedTransport,
|
|
6
|
+
SurfaceError,
|
|
7
|
+
} from '../../src/mcp/guard.js';
|
|
8
|
+
|
|
9
|
+
function manifest(overrides: Partial<McpManifest> = {}): McpManifest {
|
|
10
|
+
return {
|
|
11
|
+
name: 'demo',
|
|
12
|
+
version: '0.0.0',
|
|
13
|
+
protocolRevision: '2025-11-25',
|
|
14
|
+
transport: { kind: 'stdio' },
|
|
15
|
+
tools: [
|
|
16
|
+
{
|
|
17
|
+
name: 'project_health',
|
|
18
|
+
title: 'Project health',
|
|
19
|
+
description: 'Reports the surface.',
|
|
20
|
+
mutation: 'read-only',
|
|
21
|
+
annotations: {
|
|
22
|
+
readOnlyHint: true,
|
|
23
|
+
destructiveHint: false,
|
|
24
|
+
idempotentHint: true,
|
|
25
|
+
openWorldHint: false,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('requireDeclaredTool', () => {
|
|
34
|
+
it('returns the declaration for a tool the manifest declares', () => {
|
|
35
|
+
expect(requireDeclaredTool(manifest(), 'project_health', 'read-only').title).toBe(
|
|
36
|
+
'Project health',
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('refuses a tool the manifest does not declare', () => {
|
|
41
|
+
expect(() => requireDeclaredTool(manifest(), 'delete_everything', 'destructive')).toThrow(
|
|
42
|
+
SurfaceError,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('refuses when the implementation mutates more than the manifest admits', () => {
|
|
47
|
+
// The tool used to only read; someone made it write. The manifest still
|
|
48
|
+
// says read-only, so the surface must not start.
|
|
49
|
+
expect(() => requireDeclaredTool(manifest(), 'project_health', 'write')).toThrow(
|
|
50
|
+
/declared as read-only/,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('refuses a mutating tool with no operator sign-off', () => {
|
|
55
|
+
const unsigned = manifest({
|
|
56
|
+
tools: [
|
|
57
|
+
{
|
|
58
|
+
name: 'purge_cache',
|
|
59
|
+
title: 'Purge cache',
|
|
60
|
+
description: 'Drops every cached entry.',
|
|
61
|
+
mutation: 'destructive',
|
|
62
|
+
annotations: {
|
|
63
|
+
readOnlyHint: false,
|
|
64
|
+
destructiveHint: true,
|
|
65
|
+
idempotentHint: true,
|
|
66
|
+
openWorldHint: false,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
expect(() => requireDeclaredTool(unsigned, 'purge_cache', 'destructive')).toThrow(
|
|
72
|
+
/no operator sign-off/,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('accepts a mutating tool once it is signed off', () => {
|
|
77
|
+
const signed = manifest({
|
|
78
|
+
tools: [
|
|
79
|
+
{
|
|
80
|
+
name: 'purge_cache',
|
|
81
|
+
title: 'Purge cache',
|
|
82
|
+
description: 'Drops every cached entry.',
|
|
83
|
+
mutation: 'destructive',
|
|
84
|
+
annotations: {
|
|
85
|
+
readOnlyHint: false,
|
|
86
|
+
destructiveHint: true,
|
|
87
|
+
idempotentHint: true,
|
|
88
|
+
openWorldHint: false,
|
|
89
|
+
},
|
|
90
|
+
approval: { operator: 'ops', date: '2026-08-07', milestone: 'demo-mcp-surface' },
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
expect(requireDeclaredTool(signed, 'purge_cache', 'destructive').name).toBe('purge_cache');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('requireSupportedTransport', () => {
|
|
99
|
+
it('accepts stdio', () => {
|
|
100
|
+
expect(() => requireSupportedTransport(manifest())).not.toThrow();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('refuses to boot on a transport this surface has not implemented', () => {
|
|
104
|
+
expect(() => requireSupportedTransport(manifest({ transport: { kind: 'http' } }))).toThrow(
|
|
105
|
+
/stdio-only/,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { REDACTED, redact, redactError, redactText } from '../../src/mcp/redact.js';
|
|
3
|
+
|
|
4
|
+
describe('redactText', () => {
|
|
5
|
+
it('scrubs credential shapes out of free text', () => {
|
|
6
|
+
const text = 'failed with Bearer abcdefghijklmnop and ghp_0123456789abcdefghij';
|
|
7
|
+
expect(redactText(text)).not.toContain('ghp_0123456789abcdefghij');
|
|
8
|
+
expect(redactText(text)).not.toContain('abcdefghijklmnop');
|
|
9
|
+
expect(redactText(text)).toContain(REDACTED);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('leaves ordinary text alone', () => {
|
|
13
|
+
expect(redactText('no credentials here')).toBe('no credentials here');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('redact', () => {
|
|
18
|
+
it('drops values under secret-looking keys whatever they contain', () => {
|
|
19
|
+
const out = redact({ apiKey: 'x', password: 'y', session_id: 'z', name: 'keep' });
|
|
20
|
+
expect(out).toEqual({
|
|
21
|
+
apiKey: REDACTED,
|
|
22
|
+
password: REDACTED,
|
|
23
|
+
session_id: REDACTED,
|
|
24
|
+
name: 'keep',
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('recurses through arrays and nested objects', () => {
|
|
29
|
+
const out = redact({ items: [{ token: 'secret', label: 'ok' }] });
|
|
30
|
+
expect(out).toEqual({ items: [{ token: REDACTED, label: 'ok' }] });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('survives a cyclic value instead of throwing', () => {
|
|
34
|
+
const cyclic: Record<string, unknown> = { name: 'self' };
|
|
35
|
+
cyclic.self = cyclic;
|
|
36
|
+
expect(() => redact(cyclic)).not.toThrow();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('passes primitives through untouched', () => {
|
|
40
|
+
expect(redact(42)).toBe(42);
|
|
41
|
+
expect(redact(null)).toBe(null);
|
|
42
|
+
expect(redact(true)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('truncates rather than walking an arbitrarily deep structure', () => {
|
|
46
|
+
let deep: Record<string, unknown> = { end: true };
|
|
47
|
+
for (let i = 0; i < 20; i += 1) deep = { next: deep };
|
|
48
|
+
expect(JSON.stringify(redact(deep))).toContain('[truncated]');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('redactError', () => {
|
|
53
|
+
it('redacts the message of a thrown Error', () => {
|
|
54
|
+
const error = new Error('token ghp_0123456789abcdefghij rejected');
|
|
55
|
+
expect(redactError(error)).toBe(`token ${REDACTED} rejected`);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('handles a non-Error throw', () => {
|
|
59
|
+
expect(redactError('plain string')).toBe('plain string');
|
|
60
|
+
});
|
|
61
|
+
});
|