@mrfentmen/patch-mcp 0.0.0-stage → 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 +32 -2
- package/dist/api.d.ts +21 -0
- package/dist/api.js +263 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +12 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +43 -0
- package/package.json +41 -4
package/README.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Unified Patch
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Create, check and apply unified diffs offline - a real patch tool with no shell and no repo, keyless.
|
|
4
|
+
|
|
5
|
+
Keyless — no signup, no API key, no environment variables.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx -y @mrfentmen/patch-mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or add it to your MCP client:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"mcpServers": {
|
|
18
|
+
"patch-mcp": { "command": "npx", "args": ["-y", "@mrfentmen/patch-mcp"] }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Tools
|
|
24
|
+
|
|
25
|
+
| Tool | What it does |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `make_patch` | Produce a unified diff between two texts with configurable context lines. |
|
|
28
|
+
| `apply_patch` | Apply a unified diff to text; fails with a precise message when a hunk does not match. |
|
|
29
|
+
| `check_patch` | Dry-run a unified diff against text and report whether it applies cleanly. |
|
|
30
|
+
|
|
31
|
+
## Data source
|
|
32
|
+
|
|
33
|
+
Runs fully offline — all work happens inside the server with no API calls and no keys.
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Patch — keyless public API client.
|
|
3
|
+
*/
|
|
4
|
+
export declare class SourceError extends Error {
|
|
5
|
+
}
|
|
6
|
+
export declare function makePatch(a: {
|
|
7
|
+
before: string;
|
|
8
|
+
after: string;
|
|
9
|
+
context?: number;
|
|
10
|
+
oldPath?: string;
|
|
11
|
+
newPath?: string;
|
|
12
|
+
}): Promise<string>;
|
|
13
|
+
export declare function applyPatch(a: {
|
|
14
|
+
text: string;
|
|
15
|
+
patch: string;
|
|
16
|
+
reverse?: boolean;
|
|
17
|
+
}): Promise<string>;
|
|
18
|
+
export declare function checkPatch(a: {
|
|
19
|
+
text: string;
|
|
20
|
+
patch: string;
|
|
21
|
+
}): Promise<string>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Patch — keyless public API client.
|
|
3
|
+
*/
|
|
4
|
+
export class SourceError extends Error {
|
|
5
|
+
}
|
|
6
|
+
const MAX_LINES = 3000;
|
|
7
|
+
function linesOf(text) {
|
|
8
|
+
if (text === "")
|
|
9
|
+
return [];
|
|
10
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
11
|
+
// A trailing newline marks the end of the last line; it is not an extra
|
|
12
|
+
// empty line for diff-count purposes.
|
|
13
|
+
if (lines[lines.length - 1] === "")
|
|
14
|
+
lines.pop();
|
|
15
|
+
return lines;
|
|
16
|
+
}
|
|
17
|
+
function endsWithNewline(text) {
|
|
18
|
+
return /\n$/.test(text);
|
|
19
|
+
}
|
|
20
|
+
function parseHunkHeader(line) {
|
|
21
|
+
const m = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
22
|
+
if (!m)
|
|
23
|
+
throw new SourceError(`not a valid hunk header: "${line.slice(0, 60)}"`);
|
|
24
|
+
return {
|
|
25
|
+
oldStart: Number(m[1]),
|
|
26
|
+
oldCount: m[2] === undefined ? 1 : Number(m[2]),
|
|
27
|
+
newStart: Number(m[3]),
|
|
28
|
+
newCount: m[4] === undefined ? 1 : Number(m[4]),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function parsePatch(patch) {
|
|
32
|
+
const lines = linesOf(patch);
|
|
33
|
+
const files = [];
|
|
34
|
+
let current = null;
|
|
35
|
+
let hunk = null;
|
|
36
|
+
let ignoredMarker = false;
|
|
37
|
+
const finishHunk = () => {
|
|
38
|
+
if (hunk && current)
|
|
39
|
+
current.hunks.push(hunk);
|
|
40
|
+
hunk = null;
|
|
41
|
+
};
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
if (line.startsWith("--- ")) {
|
|
44
|
+
finishHunk();
|
|
45
|
+
current = { oldPath: line.slice(4).trim() || "a", newPath: "", hunks: [] };
|
|
46
|
+
files.push(current);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (line.startsWith("+++ ")) {
|
|
50
|
+
if (!current)
|
|
51
|
+
throw new SourceError("patch has a +++ line before any --- line");
|
|
52
|
+
current.newPath = line.slice(4).trim() || "b";
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (line.startsWith("@@")) {
|
|
56
|
+
if (!current)
|
|
57
|
+
throw new SourceError("patch has a hunk before any file header");
|
|
58
|
+
finishHunk();
|
|
59
|
+
const header = parseHunkHeader(line);
|
|
60
|
+
const tail = line.replace(/^@@ -[^@]*@@/, "");
|
|
61
|
+
hunk = { ...header, body: [] };
|
|
62
|
+
if (tail.trim())
|
|
63
|
+
throw new SourceError("hunk header has unexpected text after the ranges");
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (line.startsWith("\\")) {
|
|
67
|
+
ignoredMarker = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (hunk) {
|
|
71
|
+
const prefix = line.charAt(0);
|
|
72
|
+
if (prefix !== " " && prefix !== "-" && prefix !== "+") {
|
|
73
|
+
throw new SourceError(`hunk line must start with a space, - or +: "${line.slice(0, 60)}"`);
|
|
74
|
+
}
|
|
75
|
+
hunk.body.push(line);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (line.trim() !== "")
|
|
79
|
+
throw new SourceError("patch has text outside any hunk");
|
|
80
|
+
}
|
|
81
|
+
finishHunk();
|
|
82
|
+
if (!files.length)
|
|
83
|
+
throw new SourceError("no file header (--- / +++) found in the patch");
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
if (!file.hunks.length)
|
|
86
|
+
throw new SourceError(`file "${file.oldPath}" has no hunks`);
|
|
87
|
+
}
|
|
88
|
+
for (const file of files) {
|
|
89
|
+
for (const h of file.hunks) {
|
|
90
|
+
const oldCount = h.body.filter((l) => l[0] === " " || l[0] === "-").length;
|
|
91
|
+
const newCount = h.body.filter((l) => l[0] === " " || l[0] === "+").length;
|
|
92
|
+
if (oldCount !== h.oldCount || newCount !== h.newCount) {
|
|
93
|
+
throw new SourceError(`hunk at old line ${h.oldStart} declares -${h.oldCount}/+${h.newCount} but contains -${oldCount}/+${newCount}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { files, ignoredMarker };
|
|
98
|
+
}
|
|
99
|
+
function reverseHunks(hunks) {
|
|
100
|
+
return hunks.map((h) => ({
|
|
101
|
+
oldStart: h.newStart,
|
|
102
|
+
oldCount: h.newCount,
|
|
103
|
+
newStart: h.oldStart,
|
|
104
|
+
newCount: h.oldCount,
|
|
105
|
+
body: h.body.map((l) => (l[0] === "+" ? "-" + l.slice(1) : l[0] === "-" ? "+" + l.slice(1) : l)),
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
function applyFile(text, patch) {
|
|
109
|
+
const old = linesOf(text);
|
|
110
|
+
const out = [];
|
|
111
|
+
let cursor = 0;
|
|
112
|
+
let added = 0;
|
|
113
|
+
let removed = 0;
|
|
114
|
+
for (const hunk of patch.hunks) {
|
|
115
|
+
const target = hunk.oldStart - 1;
|
|
116
|
+
if (target < cursor)
|
|
117
|
+
throw new SourceError(`hunks overlap or run backwards at old line ${hunk.oldStart}`);
|
|
118
|
+
while (cursor < target)
|
|
119
|
+
out.push(old[cursor++]);
|
|
120
|
+
for (const body of hunk.body) {
|
|
121
|
+
const prefix = body[0];
|
|
122
|
+
const content = body.slice(1);
|
|
123
|
+
if (prefix === "+") {
|
|
124
|
+
out.push(content);
|
|
125
|
+
added++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const actual = old[cursor];
|
|
129
|
+
if (actual === undefined) {
|
|
130
|
+
throw new SourceError(`hunk expects line ${cursor + 1} but the text ends early`);
|
|
131
|
+
}
|
|
132
|
+
if (actual !== content) {
|
|
133
|
+
throw new SourceError(`hunk does not match at line ${cursor + 1}: expected "${content.slice(0, 60)}" but found "${actual.slice(0, 60)}"`);
|
|
134
|
+
}
|
|
135
|
+
if (prefix === " ")
|
|
136
|
+
out.push(actual);
|
|
137
|
+
else
|
|
138
|
+
removed++;
|
|
139
|
+
cursor++;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
while (cursor < old.length)
|
|
143
|
+
out.push(old[cursor++]);
|
|
144
|
+
return { text: out.join("\n") + (endsWithNewline(text) && out.length ? "\n" : ""), added, removed };
|
|
145
|
+
}
|
|
146
|
+
function diffPairs(a, b) {
|
|
147
|
+
const n = a.length;
|
|
148
|
+
const m = b.length;
|
|
149
|
+
if (n > MAX_LINES || m > MAX_LINES) {
|
|
150
|
+
throw new SourceError(`inputs are limited to ${MAX_LINES} lines each (got ${n} and ${m})`);
|
|
151
|
+
}
|
|
152
|
+
const width = m + 1;
|
|
153
|
+
const dp = new Uint32Array((n + 1) * width);
|
|
154
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
155
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
156
|
+
dp[i * width + j] =
|
|
157
|
+
a[i] === b[j]
|
|
158
|
+
? dp[(i + 1) * width + j + 1] + 1
|
|
159
|
+
: Math.max(dp[(i + 1) * width + j], dp[i * width + j + 1]);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const out = [];
|
|
163
|
+
let i = 0;
|
|
164
|
+
let j = 0;
|
|
165
|
+
while (i < n && j < m) {
|
|
166
|
+
if (a[i] === b[j]) {
|
|
167
|
+
out.push(" " + a[i++]);
|
|
168
|
+
j++;
|
|
169
|
+
}
|
|
170
|
+
else if (dp[(i + 1) * width + j] >= dp[i * width + j + 1]) {
|
|
171
|
+
out.push("-" + a[i++]);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
out.push("+" + b[j++]);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
while (i < n)
|
|
178
|
+
out.push("-" + a[i++]);
|
|
179
|
+
while (j < m)
|
|
180
|
+
out.push("+" + b[j++]);
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
export async function makePatch(a) {
|
|
184
|
+
const context = Math.floor(a.context ?? 3);
|
|
185
|
+
if (!Number.isInteger(context) || context < 0 || context > 20)
|
|
186
|
+
throw new SourceError("context must be between 0 and 20");
|
|
187
|
+
const before = linesOf(a.before);
|
|
188
|
+
const after = linesOf(a.after);
|
|
189
|
+
const pairs = diffPairs(before, after);
|
|
190
|
+
const changed = pairs.map((line, idx) => (line[0] === " " ? -1 : idx)).filter((idx) => idx >= 0);
|
|
191
|
+
if (!changed.length)
|
|
192
|
+
return "No differences between before and after.";
|
|
193
|
+
const ranges = [];
|
|
194
|
+
for (const idx of changed) {
|
|
195
|
+
const start = Math.max(0, idx - context);
|
|
196
|
+
const end = Math.min(pairs.length - 1, idx + context);
|
|
197
|
+
const last = ranges[ranges.length - 1];
|
|
198
|
+
if (last && start <= last.end + 1)
|
|
199
|
+
last.end = Math.max(last.end, end);
|
|
200
|
+
else
|
|
201
|
+
ranges.push({ start, end });
|
|
202
|
+
}
|
|
203
|
+
const oldPath = a.oldPath ?? "a";
|
|
204
|
+
const newPath = a.newPath ?? "b";
|
|
205
|
+
const header = [`--- ${oldPath}`, `+++ ${newPath}`];
|
|
206
|
+
for (const range of ranges) {
|
|
207
|
+
const body = pairs.slice(range.start, range.end + 1);
|
|
208
|
+
let oldStart = 1;
|
|
209
|
+
let newStart = 1;
|
|
210
|
+
for (let k = 0; k < range.start; k++) {
|
|
211
|
+
if (pairs[k][0] !== "+")
|
|
212
|
+
oldStart++;
|
|
213
|
+
if (pairs[k][0] !== "-")
|
|
214
|
+
newStart++;
|
|
215
|
+
}
|
|
216
|
+
const oldCount = body.filter((l) => l[0] !== "+").length;
|
|
217
|
+
const newCount = body.filter((l) => l[0] !== "-").length;
|
|
218
|
+
header.push(`@@ -${oldCount ? oldStart : oldStart - 1},${oldCount} +${newCount ? newStart : newStart - 1},${newCount} @@`);
|
|
219
|
+
header.push(...body);
|
|
220
|
+
}
|
|
221
|
+
return [
|
|
222
|
+
`Unified diff — ${changed.length} changed line(s), ${ranges.length} hunk(s):`,
|
|
223
|
+
"",
|
|
224
|
+
...header,
|
|
225
|
+
].join("\n");
|
|
226
|
+
}
|
|
227
|
+
export async function applyPatch(a) {
|
|
228
|
+
const parsed = parsePatch(a.patch);
|
|
229
|
+
if (parsed.ignoredMarker) {
|
|
230
|
+
// "" markers are dropped during parsing.
|
|
231
|
+
}
|
|
232
|
+
const files = parsed.files;
|
|
233
|
+
const target = files[0];
|
|
234
|
+
if (a.reverse)
|
|
235
|
+
target.hunks = reverseHunks(target.hunks);
|
|
236
|
+
const result = applyFile(a.text, target);
|
|
237
|
+
const beforeLines = linesOf(a.text).length;
|
|
238
|
+
const afterLines = linesOf(result.text).length;
|
|
239
|
+
return [
|
|
240
|
+
`Patch applied${a.reverse ? " in reverse" : ""}: +${result.added} line(s), -${result.removed} line(s).`,
|
|
241
|
+
`Lines: ${beforeLines} -> ${afterLines}`,
|
|
242
|
+
a.reverse ? `(reverse of ${files[0].hunks.length} hunk(s))` : `(${target.hunks.length} hunk(s))`,
|
|
243
|
+
"",
|
|
244
|
+
result.text,
|
|
245
|
+
].join("\n");
|
|
246
|
+
}
|
|
247
|
+
export async function checkPatch(a) {
|
|
248
|
+
const parsed = parsePatch(a.patch);
|
|
249
|
+
const target = parsed.files[0];
|
|
250
|
+
const added = target.hunks.reduce((sum, h) => sum + h.body.filter((l) => l[0] === "+").length, 0);
|
|
251
|
+
const removed = target.hunks.reduce((sum, h) => sum + h.body.filter((l) => l[0] === "-").length, 0);
|
|
252
|
+
try {
|
|
253
|
+
applyFile(a.text, target);
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
return `Patch does NOT apply: ${e.message}`;
|
|
257
|
+
}
|
|
258
|
+
return [
|
|
259
|
+
`Patch applies cleanly.`,
|
|
260
|
+
`File: ${target.oldPath} -> ${target.newPath}`,
|
|
261
|
+
`Hunks: ${target.hunks.length}, +${added} line(s), -${removed} line(s)`,
|
|
262
|
+
].join("\n");
|
|
263
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2
|
+
import { createServer } from "./server.js";
|
|
3
|
+
async function main() {
|
|
4
|
+
const server = createServer();
|
|
5
|
+
const transport = new StdioServerTransport();
|
|
6
|
+
await server.connect(transport);
|
|
7
|
+
console.error("patch-mcp running on stdio");
|
|
8
|
+
}
|
|
9
|
+
main().catch((err) => {
|
|
10
|
+
console.error("Fatal error:", err);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
});
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { applyPatch, checkPatch, makePatch, SourceError } from "./api.js";
|
|
4
|
+
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
5
|
+
function errorMessage(e) {
|
|
6
|
+
if (e instanceof SourceError || e instanceof Error)
|
|
7
|
+
return `Error: ${e.message}`;
|
|
8
|
+
return `Error: ${String(e)}`;
|
|
9
|
+
}
|
|
10
|
+
export function createServer() {
|
|
11
|
+
const server = new McpServer({ name: "patch-mcp", version: "1.0.0" });
|
|
12
|
+
server.tool("make_patch", "Produce a unified diff between two texts with configurable context lines.", {
|
|
13
|
+
before: z.string().describe('Original text'), after: z.string().describe('Changed text'), context: z.number().int().min(0).max(20).optional().describe('Context lines per hunk, default 3'), oldPath: z.string().optional().describe('Label for the old file, default a'), newPath: z.string().optional().describe('Label for the new file, default b')
|
|
14
|
+
}, async (a) => {
|
|
15
|
+
try {
|
|
16
|
+
return text(await makePatch(a));
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
return { content: [{ type: "text", text: errorMessage(e) }], isError: true };
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
server.tool("apply_patch", "Apply a unified diff to text; fails with a precise message when a hunk does not match.", {
|
|
23
|
+
text: z.string().describe('Text to patch'), patch: z.string().describe('Unified diff'), reverse: z.boolean().optional().describe('Apply the patch in reverse, undoing it')
|
|
24
|
+
}, async (a) => {
|
|
25
|
+
try {
|
|
26
|
+
return text(await applyPatch(a));
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
return { content: [{ type: "text", text: errorMessage(e) }], isError: true };
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
server.tool("check_patch", "Dry-run a unified diff against text and report whether it applies cleanly.", {
|
|
33
|
+
text: z.string().describe('Text to test against'), patch: z.string().describe('Unified diff')
|
|
34
|
+
}, async (a) => {
|
|
35
|
+
try {
|
|
36
|
+
return text(await checkPatch(a));
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
return { content: [{ type: "text", text: errorMessage(e) }], isError: true };
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
return server;
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrfentmen/patch-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Create, check and apply unified diffs offline - a real patch tool with no shell and no repo, keyless.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"mcpName": "io.github.mrfentmen/patch-mcp",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mrfentmen/awesome-servers.git"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"patch-mcp": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"start": "node dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"diff",
|
|
25
|
+
"patch",
|
|
26
|
+
"unified-diff",
|
|
27
|
+
"developer-tools",
|
|
28
|
+
"offline",
|
|
29
|
+
"keyless"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
34
|
+
"zod": "^3.23.8"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^22.0.0",
|
|
38
|
+
"typescript": "^5.6.0"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20"
|
|
42
|
+
}
|
|
43
|
+
}
|