env-contract-mcp 1.0.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/README.md +43 -0
- package/dist/contract.d.ts +4 -0
- package/dist/contract.js +94 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +11 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +22 -0
- package/package.json +44 -0
- package/server.json +20 -0
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# env-contract-mcp
|
|
2
|
+
|
|
3
|
+
Env Contract is a local MCP tool for finding configuration drift before a project fails at runtime. It compares variable names declared in `.env.example` and dotenv files with likely environment references in source and config files.
|
|
4
|
+
|
|
5
|
+
## Tool
|
|
6
|
+
|
|
7
|
+
- `inspect_contract`: report declared names, referenced names, missing names, unused names, and the files where they occur.
|
|
8
|
+
|
|
9
|
+
## Safety
|
|
10
|
+
|
|
11
|
+
- Reads local project files only; it never fetches URLs.
|
|
12
|
+
- Never reads or emits environment values. Only names and bounded file paths are returned.
|
|
13
|
+
- Ignores `node_modules`, build output, coverage, and Git internals.
|
|
14
|
+
- Caps file count, file size, recursion depth, and output.
|
|
15
|
+
- Matching is heuristic and is not a replacement for a build, deployment check, or secret scanner.
|
|
16
|
+
|
|
17
|
+
Set `ENV_CONTRACT_ROOT` to bound analysis to a workspace root. It defaults to the parent of the MCP package when launched normally.
|
|
18
|
+
|
|
19
|
+
## Run
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install
|
|
23
|
+
npm run build
|
|
24
|
+
node dist/index.js
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install
|
|
31
|
+
npm run build
|
|
32
|
+
node dist/index.js
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The server uses stdio, so it can be connected to Claude Desktop, Cursor, VS Code, MCP Inspector, or another compatible MCP client.
|
|
36
|
+
|
|
37
|
+
## Tools at a glance
|
|
38
|
+
|
|
39
|
+
- `inspect_contract`: Inspect a local project for declared and referenced environment variable names without reading values.
|
|
40
|
+
|
|
41
|
+
## Try it
|
|
42
|
+
|
|
43
|
+
After building, connect the server through your MCP client. The repository root also contains `smoke-test.mjs` for projects covered by the shared harness. A typical tool call starts with `inspect_contract`.
|
package/dist/contract.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
4
|
+
const MAX_FILES = 100;
|
|
5
|
+
const MAX_NAMES = 500;
|
|
6
|
+
const MAX_OUTPUT = 14000;
|
|
7
|
+
const ALLOWED_FILES = /(^|\/)(\.env(?:\.[\w.-]+)?|docker-compose(?:\.[\w.-]+)?\.ya?ml|compose(?:\.[\w.-]+)?\.ya?ml|package\.json|.*\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|yml|yaml))$/i;
|
|
8
|
+
const NAME = /\b[A-Z][A-Z0-9_]{1,127}\b/g;
|
|
9
|
+
const RESERVED = new Set(["JSON", "HTTP", "HTTPS", "TRUE", "FALSE", "NULL", "NODE", "PATH", "HOME", "USER", "PORT"]);
|
|
10
|
+
export class ContractError extends Error {
|
|
11
|
+
}
|
|
12
|
+
async function safeRoot(input) {
|
|
13
|
+
const configured = process.env.ENV_CONTRACT_ROOT ?? path.join(process.cwd(), "..");
|
|
14
|
+
const root = await realpath(path.resolve(configured)).catch(() => path.resolve(configured));
|
|
15
|
+
const target = await realpath(path.resolve(input || ".")).catch(() => path.resolve(input || "."));
|
|
16
|
+
const relative = path.relative(root, target);
|
|
17
|
+
if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
18
|
+
throw new ContractError(`Project path must stay inside ${root}`);
|
|
19
|
+
return target;
|
|
20
|
+
}
|
|
21
|
+
function kind(file) { if (/\.env(?:\.|$)/i.test(file))
|
|
22
|
+
return /\.env\.example$/i.test(file) ? "env-example" : "dotenv"; return /\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb)$/i.test(file) ? "source" : "config"; }
|
|
23
|
+
function names(text) { return [...new Set((text.match(NAME) ?? []).filter((value) => !RESERVED.has(value) && !/^\d+$/.test(value)))].slice(0, MAX_NAMES); }
|
|
24
|
+
function declared(text) { return [...new Set(text.split(/\r?\n/).map((line) => line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]{1,127})\s*(?:=|:|$)/)?.[1]).filter((value) => Boolean(value)))].slice(0, MAX_NAMES); }
|
|
25
|
+
async function files(root) {
|
|
26
|
+
const result = [];
|
|
27
|
+
async function walk(directory, depth) {
|
|
28
|
+
if (depth > 5 || result.length >= MAX_FILES)
|
|
29
|
+
return;
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = await (await import("node:fs/promises")).readdir(directory, { withFileTypes: true });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
if (entry.name.startsWith(".") && !entry.name.startsWith(".env"))
|
|
39
|
+
continue;
|
|
40
|
+
if (["node_modules", "dist", ".git", "coverage", "build"].includes(entry.name))
|
|
41
|
+
continue;
|
|
42
|
+
const full = path.join(directory, entry.name);
|
|
43
|
+
const resolved = await realpath(full).catch(() => "");
|
|
44
|
+
if (!resolved)
|
|
45
|
+
continue;
|
|
46
|
+
const relativeResolved = path.relative(root, resolved);
|
|
47
|
+
if (relativeResolved.startsWith("..") || path.isAbsolute(relativeResolved))
|
|
48
|
+
continue;
|
|
49
|
+
if (entry.isDirectory())
|
|
50
|
+
await walk(resolved, depth + 1);
|
|
51
|
+
else if (ALLOWED_FILES.test(relativeResolved))
|
|
52
|
+
result.push(resolved);
|
|
53
|
+
if (result.length >= MAX_FILES)
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
await walk(root, 0);
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
export async function analyze(project) {
|
|
61
|
+
const root = await safeRoot(project);
|
|
62
|
+
const all = await files(root);
|
|
63
|
+
const declarations = new Map();
|
|
64
|
+
const references = new Map();
|
|
65
|
+
const fileKinds = {};
|
|
66
|
+
let read = 0;
|
|
67
|
+
for (const file of all) {
|
|
68
|
+
const relative = path.relative(root, file);
|
|
69
|
+
fileKinds[relative] = kind(relative);
|
|
70
|
+
let text;
|
|
71
|
+
try {
|
|
72
|
+
const buffer = await readFile(file);
|
|
73
|
+
if (buffer.byteLength > MAX_FILE_BYTES)
|
|
74
|
+
continue;
|
|
75
|
+
text = buffer.toString("utf8");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
read += 1;
|
|
81
|
+
const found = names(text);
|
|
82
|
+
const type = fileKinds[relative];
|
|
83
|
+
if (type === "env-example" || type === "dotenv")
|
|
84
|
+
declarations.set(relative, declared(text));
|
|
85
|
+
if (type === "source" || type === "config")
|
|
86
|
+
references.set(relative, found.filter((value) => /ENV|KEY|TOKEN|SECRET|URL|HOST|PORT|MODE|REGION|DATABASE|REDIS|AWS|GITHUB|NPM|API/i.test(value)));
|
|
87
|
+
}
|
|
88
|
+
const declaredNames = [...new Set([...declarations.values()].flat())].sort();
|
|
89
|
+
const referencedNames = [...new Set([...references.values()].flat())].sort();
|
|
90
|
+
const missing = referencedNames.filter((value) => !declaredNames.includes(value)).slice(0, MAX_NAMES);
|
|
91
|
+
const unused = declaredNames.filter((value) => !referencedNames.includes(value)).slice(0, MAX_NAMES);
|
|
92
|
+
return { root, filesScanned: read, declarations: Object.fromEntries(declarations), references: Object.fromEntries(references), declaredNames, referencedNames, missing, unused, valueFree: true, warning: "Only variable names and file paths are reported. Environment values are never read or emitted; matches are heuristic and not a complete build validation." };
|
|
93
|
+
}
|
|
94
|
+
export function format(value) { return JSON.stringify(value, null, 2).slice(0, MAX_OUTPUT); }
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2
|
+
import { createServer } from "./server.js";
|
|
3
|
+
async function main() {
|
|
4
|
+
const server = createServer();
|
|
5
|
+
await server.connect(new StdioServerTransport());
|
|
6
|
+
console.error("MCP server running on stdio");
|
|
7
|
+
}
|
|
8
|
+
main().catch((error) => {
|
|
9
|
+
console.error("Fatal error:", error);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
});
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { analyze, format } from "./contract.js";
|
|
4
|
+
const text = (value) => ({ content: [{ type: "text", text: value }] });
|
|
5
|
+
const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
6
|
+
const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
|
|
7
|
+
const errorText = (error) => text(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
8
|
+
export function createServer() {
|
|
9
|
+
const server = new McpServer({ name: "env-contract-mcp", version: "1.0.0" });
|
|
10
|
+
server.registerTool("inspect_contract", {
|
|
11
|
+
title: "Inspect contract",
|
|
12
|
+
description: "Inspect a local project for declared and referenced environment variable names without reading values.",
|
|
13
|
+
inputSchema: z.object({ project: z.string().min(1).max(1000).describe("Local project directory; no network URLs") }),
|
|
14
|
+
annotations: READ_ONLY,
|
|
15
|
+
}, async ({ project }) => { try {
|
|
16
|
+
return text(format(await analyze(project)));
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
return errorText(error);
|
|
20
|
+
} });
|
|
21
|
+
return server;
|
|
22
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "env-contract-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Local value-free environment contract and configuration drift inspection. Tools include inspect contract",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"mcpName": "io.github.mrfentmen/env-contract-mcp",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/mrfentmen/env-contract-mcp.git"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"env-contract-mcp": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"server.json",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.json",
|
|
22
|
+
"start": "node dist/index.js",
|
|
23
|
+
"dev": "npm run build && node dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"mcp",
|
|
27
|
+
"environment",
|
|
28
|
+
"configuration",
|
|
29
|
+
"dotenv",
|
|
30
|
+
"local-first"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
35
|
+
"zod": "^3.23.8"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^22.0.0",
|
|
39
|
+
"typescript": "^5.6.0"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=20"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.mrfentmen/env-contract-mcp",
|
|
4
|
+
"description": "Local value-free environment contract and configuration drift inspection. Tools include inspect...",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/mrfentmen/env-contract-mcp",
|
|
7
|
+
"source": "github"
|
|
8
|
+
},
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"packages": [
|
|
11
|
+
{
|
|
12
|
+
"registryType": "npm",
|
|
13
|
+
"identifier": "env-contract-mcp",
|
|
14
|
+
"version": "1.0.0",
|
|
15
|
+
"transport": {
|
|
16
|
+
"type": "stdio"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|