@massapi/svn-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/.cnb.yml ADDED
@@ -0,0 +1,43 @@
1
+ # .cnb.yml
2
+ $:
3
+ tag_push:
4
+ - docker:
5
+ image: node:22-alpine
6
+ imports:
7
+ - https://cnb.cool/massapi/keys/-/blob/main/github.yml
8
+ stages:
9
+ - name: update version
10
+ # 修改 package.json 中的version为当前tag,但不会提交代码
11
+ script: npm version $CNB_BRANCH --no-git-tag-version
12
+ - name: build
13
+ image: node:20
14
+ script:
15
+ - npm ci
16
+ - npm run build
17
+ - name: npm publish
18
+ image: tencentcom/npm
19
+ settings:
20
+ # CNB_TOKEN_USER_NAME, CNB_TOKEN,CNB_COMMITTER_EMAIL 为流水线自带环境变量
21
+ username: $CNB_TOKEN_USER_NAME
22
+ token: $CNB_TOKEN
23
+ email: $CNB_COMMITTER_EMAIL
24
+ registry: https://npm.cnb.cool/massapi/npm-repo/-/packages/
25
+ folder: ./
26
+ fail_on_version_conflict: true
27
+
28
+ vscode:
29
+ - runner:
30
+ cpus: 4
31
+ docker:
32
+ image: docker.cnb.cool/massapi/default-dev-env
33
+ imports: https://cnb.cool/massapi/keys/-/blob/main/agent.yml
34
+ env:
35
+ CNB_WELCOME_CMD: echo "Welcome to CNB"
36
+ services:
37
+ - vscode
38
+ - docker
39
+ stages:
40
+ - name: set-agent-env
41
+ script: chmod +x /scripts/set-agent-env.sh; /scripts/set-agent-env.sh
42
+ - name: ls
43
+ script: ls -la
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # svn-mcp
2
+
3
+ Node.js-based SVN MCP server using stdio transport. Connects to svnserve via the native svn:// protocol — no SVN CLI installation required. Exposes 1 MCP tool:
4
+ * list_logs — Retrieves recent commit logs. Accepts 1 parameter `limit` (default: 10), representing the number of recent entries. Returns an array of objects, each containing author, date, message, revision, action, and file list.
5
+
6
+ All code comments and messages are in English.
7
+ Package name: @massapi/svn-mcp
8
+
9
+
10
+ ## Environment Variables
11
+
12
+ | Variable | Description |
13
+ |----------|-------------|
14
+ | SVN_URL | SVN repository URL, e.g. `svn://svn.example.com/repo/project/trunk` |
15
+ | SVN_USER | SVN username (optional for anonymous access) |
16
+ | SVN_PASSWORD | SVN password (optional for anonymous access) |
17
+
18
+ ## Run
19
+
20
+ ```bash
21
+ # Install dependencies
22
+ npm install
23
+
24
+ # Build
25
+ npm run build
26
+
27
+ # Start service
28
+ SVN_URL=svn://your-svn-host/repo/path SVN_USER=your-user SVN_PASSWORD=your-password npm start
29
+ ```
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ npm install
35
+ npm run build
36
+ ```
37
+
38
+ ### Publish to npm
39
+
40
+ ```bash
41
+ npm config set //registry.npmjs.org/:_authToken=npm_token
42
+ npm publish --access public --loglevel verbose
43
+ ```
44
+
45
+ ## Inspector Test
46
+
47
+ ```bash
48
+ SVN_URL=svn://your-svn-host/repo/path SVN_USER=your-user SVN_PASSWORD=your-password npm run inspect
49
+ ```
50
+
51
+ This opens the Inspector UI in your browser for interactive MCP tool testing.
52
+
53
+ ### Inspector CLI Test
54
+
55
+ ```bash
56
+ npm config set registry https://registry.npmjs.org/
57
+
58
+ npx -y -p @massapi/svn-mcp -p @modelcontextprotocol/inspector mcp-inspector --cli \
59
+ -e SVN_URL=svn://svn.example.com/repo/project/trunk \
60
+ -e SVN_USER=admin \
61
+ -e SVN_PASSWORD=your-password \
62
+ svn-mcp \
63
+ --method tools/call \
64
+ --tool-name list_logs
65
+
66
+ npx -y -p @massapi/svn-mcp -p @modelcontextprotocol/inspector mcp-inspector --cli \
67
+ -e SVN_URL=svn://svn.example.com/repo/project/trunk \
68
+ -e SVN_USER=admin \
69
+ -e SVN_PASSWORD=your-password \
70
+ svn-mcp \
71
+ --method tools/call \
72
+ --tool-name list_logs \
73
+ --tool-arg limit=5
74
+ ```
75
+
76
+ ## Configure in Claude Desktop
77
+
78
+ Add to your Claude Desktop config file:
79
+
80
+ ```json
81
+ {
82
+ "mcpServers": {
83
+ "svn": {
84
+ "command": "node",
85
+ "args": ["/path/to/svn-mcp/dist/index.js"],
86
+ "env": {
87
+ "SVN_URL": "svn://svn.example.com/repo/project/trunk",
88
+ "SVN_USER": "your-username",
89
+ "SVN_PASSWORD": "your-password"
90
+ }
91
+ }
92
+ }
93
+ }
94
+ ```
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { fetchSvnLogs } from "./svn-protocol.js";
6
+ // SVN server configuration via environment variables
7
+ // SVN_URL format: svn://host:port/repo/path
8
+ const SVN_URL = process.env.SVN_URL || "";
9
+ const SVN_USER = process.env.SVN_USER || "";
10
+ const SVN_PASSWORD = process.env.SVN_PASSWORD || "";
11
+ /** Parse svn:// URL into host, port, path components. */
12
+ function parseSvnUrl(url) {
13
+ const parsed = new URL(url);
14
+ return {
15
+ host: parsed.hostname,
16
+ port: parsed.port ? Number(parsed.port) : 3690,
17
+ path: parsed.pathname,
18
+ };
19
+ }
20
+ async function main() {
21
+ const server = new McpServer({
22
+ name: "svn-mcp",
23
+ version: "1.0.0",
24
+ });
25
+ server.tool("list_logs", "Get recent SVN commit logs via native svn:// protocol", { limit: z.number().default(10).describe("Number of recent commits to retrieve") }, async ({ limit }) => {
26
+ const { host, port, path } = parseSvnUrl(SVN_URL);
27
+ const logs = await fetchSvnLogs(host, port, SVN_USER, SVN_PASSWORD, path, limit);
28
+ return {
29
+ content: [{ type: "text", text: JSON.stringify(logs, null, 2) }],
30
+ };
31
+ });
32
+ const transport = new StdioServerTransport();
33
+ await server.connect(transport);
34
+ }
35
+ main().catch((err) => {
36
+ console.error("Failed to start svn-mcp:", err);
37
+ process.exit(1);
38
+ });
39
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,61 @@
1
+ import * as net from "node:net";
2
+ export type SvnItem = string | number | SvnItem[];
3
+ /**
4
+ * Streaming reader for the SVN ra_svn wire protocol.
5
+ */
6
+ export declare class SvnRaReader {
7
+ private buf;
8
+ private resolver;
9
+ constructor(socket: net.Socket);
10
+ private recv;
11
+ /** Ensure at least n chars in buffer. */
12
+ private ensure;
13
+ /** Skip whitespace. */
14
+ private skipWs;
15
+ /** Peek at next non-whitespace char. */
16
+ private peek;
17
+ /** Read one ra_svn item. */
18
+ readItem(): Promise<SvnItem>;
19
+ readWord(): Promise<string>;
20
+ readNumber(): Promise<number>;
21
+ readString(): Promise<string>;
22
+ readList(): Promise<SvnItem[]>;
23
+ /** Read a top-level response: either a list `( cmd ... )` or the word `done`. */
24
+ readResponse(): Promise<{
25
+ cmd: string;
26
+ params: SvnItem[];
27
+ }>;
28
+ }
29
+ export interface LogEntry {
30
+ revision: number;
31
+ author: string;
32
+ date: string;
33
+ message: string;
34
+ files: {
35
+ path: string;
36
+ action: string;
37
+ kind: string;
38
+ }[];
39
+ }
40
+ export declare class SvnRaClient {
41
+ private host;
42
+ private port;
43
+ private user;
44
+ private password;
45
+ private repoPath;
46
+ private socket;
47
+ private reader;
48
+ private connected;
49
+ constructor(host: string, port: number, user: string, password: string, repoPath: string);
50
+ connect(): Promise<void>;
51
+ private doCramMd5;
52
+ getLogs(limit: number): Promise<LogEntry[]>;
53
+ disconnect(): void;
54
+ private send;
55
+ private findMechs;
56
+ private findChallenge;
57
+ /** Extract revision number from ( success ( N ) ) response. */
58
+ private extractRev;
59
+ private parseLogEntry;
60
+ }
61
+ export declare function fetchSvnLogs(host: string, port: number, user: string, password: string, repoPath: string, limit: number): Promise<LogEntry[]>;
@@ -0,0 +1,366 @@
1
+ import * as net from "node:net";
2
+ import * as crypto from "node:crypto";
3
+ /**
4
+ * Streaming reader for the SVN ra_svn wire protocol.
5
+ */
6
+ export class SvnRaReader {
7
+ buf = "";
8
+ resolver = null;
9
+ constructor(socket) {
10
+ socket.on("data", (data) => {
11
+ const chunk = data.toString("utf-8");
12
+ if (this.resolver) {
13
+ const r = this.resolver;
14
+ this.resolver = null;
15
+ r(chunk);
16
+ }
17
+ else {
18
+ this.buf += chunk;
19
+ }
20
+ });
21
+ }
22
+ async recv() {
23
+ return new Promise((resolve) => { this.resolver = resolve; });
24
+ }
25
+ /** Ensure at least n chars in buffer. */
26
+ async ensure(n) {
27
+ while (this.buf.length < n) {
28
+ this.buf += await this.recv();
29
+ }
30
+ }
31
+ /** Skip whitespace. */
32
+ async skipWs() {
33
+ for (;;) {
34
+ await this.ensure(1);
35
+ if (this.buf[0] === " " || this.buf[0] === "\n" || this.buf[0] === "\r") {
36
+ this.buf = this.buf.slice(1);
37
+ }
38
+ else {
39
+ return;
40
+ }
41
+ }
42
+ }
43
+ /** Peek at next non-whitespace char. */
44
+ async peek() {
45
+ await this.skipWs();
46
+ await this.ensure(1);
47
+ return this.buf[0];
48
+ }
49
+ /** Read one ra_svn item. */
50
+ async readItem() {
51
+ const ch = await this.peek();
52
+ if (ch === "(")
53
+ return this.readList();
54
+ if (ch >= "0" && ch <= "9") {
55
+ // Look ahead to distinguish string (N:data) from number
56
+ await this.ensure(2);
57
+ for (let i = 0; i < this.buf.length; i++) {
58
+ const c = this.buf[i];
59
+ if (c === ":")
60
+ return this.readString();
61
+ if (c === " " || c === "\n" || c === "\r" || c === ")")
62
+ return this.readNumber();
63
+ if (c < "0" || c > "9")
64
+ return this.readNumber();
65
+ }
66
+ return this.readNumber();
67
+ }
68
+ return this.readWord();
69
+ }
70
+ async readWord() {
71
+ await this.skipWs();
72
+ let word = "";
73
+ for (;;) {
74
+ await this.ensure(1);
75
+ const c = this.buf[0];
76
+ if (/[A-Za-z0-9_-]/.test(c)) {
77
+ word += c;
78
+ this.buf = this.buf.slice(1);
79
+ }
80
+ else {
81
+ return word;
82
+ }
83
+ }
84
+ }
85
+ async readNumber() {
86
+ await this.skipWs();
87
+ let digits = "";
88
+ for (;;) {
89
+ await this.ensure(1);
90
+ const c = this.buf[0];
91
+ if (c >= "0" && c <= "9") {
92
+ digits += c;
93
+ this.buf = this.buf.slice(1);
94
+ }
95
+ else {
96
+ return Number(digits);
97
+ }
98
+ }
99
+ }
100
+ async readString() {
101
+ await this.skipWs();
102
+ let lenStr = "";
103
+ for (;;) {
104
+ await this.ensure(1);
105
+ if (this.buf[0] === ":") {
106
+ this.buf = this.buf.slice(1);
107
+ break;
108
+ }
109
+ lenStr += this.buf[0];
110
+ this.buf = this.buf.slice(1);
111
+ }
112
+ const len = Number(lenStr);
113
+ while (Buffer.byteLength(this.buf, "utf-8") < len) {
114
+ this.buf += await this.recv();
115
+ }
116
+ const raw = Buffer.from(this.buf, "utf-8");
117
+ const str = raw.toString("utf-8", 0, len);
118
+ this.buf = raw.toString("utf-8", len);
119
+ return str;
120
+ }
121
+ async readList() {
122
+ await this.skipWs();
123
+ await this.ensure(1);
124
+ if (this.buf[0] !== "(")
125
+ throw new Error(`Expected '(' got '${this.buf[0]}'`);
126
+ this.buf = this.buf.slice(1);
127
+ const items = [];
128
+ for (;;) {
129
+ const ch = await this.peek();
130
+ if (ch === ")") {
131
+ this.buf = this.buf.slice(1);
132
+ return items;
133
+ }
134
+ items.push(await this.readItem());
135
+ }
136
+ }
137
+ /** Read a top-level response: either a list `( cmd ... )` or the word `done`. */
138
+ async readResponse() {
139
+ const ch = await this.peek();
140
+ if (ch === "d") {
141
+ const word = await this.readWord();
142
+ return { cmd: word, params: [] };
143
+ }
144
+ const list = await this.readList();
145
+ if (list.length === 0)
146
+ return { cmd: "", params: [] };
147
+ if (typeof list[0] === "string") {
148
+ return { cmd: list[0], params: list.slice(1) };
149
+ }
150
+ return { cmd: "", params: list };
151
+ }
152
+ }
153
+ // ---------------------------------------------------------------------------
154
+ // Encoder helpers
155
+ // ---------------------------------------------------------------------------
156
+ function w(word) { return word + " "; }
157
+ function n(num) { return num + " "; }
158
+ function s(str) { const b = Buffer.from(str, "utf-8"); return b.length + ":" + str + " "; }
159
+ function b(val) { return val ? "true " : "false "; }
160
+ export class SvnRaClient {
161
+ host;
162
+ port;
163
+ user;
164
+ password;
165
+ repoPath;
166
+ socket;
167
+ reader;
168
+ connected = false;
169
+ constructor(host, port, user, password, repoPath) {
170
+ this.host = host;
171
+ this.port = port;
172
+ this.user = user;
173
+ this.password = password;
174
+ this.repoPath = repoPath;
175
+ }
176
+ async connect() {
177
+ this.socket = await new Promise((resolve, reject) => {
178
+ const sock = net.createConnection({ host: this.host, port: this.port }, () => resolve(sock));
179
+ sock.on("error", reject);
180
+ });
181
+ this.reader = new SvnRaReader(this.socket);
182
+ this.connected = true;
183
+ // 1. Read server greeting
184
+ // ( success ( min max ( mechs ) ( cap1 cap2 ... ) ) )
185
+ await this.reader.readResponse();
186
+ // 2. Send client response: ( version ( caps ) url client-str ( ) )
187
+ const url = `svn://${this.host}${this.repoPath}`;
188
+ this.send("( 2 ( edit-pipeline svndiff1 absent-entries depth mergeinfo log-revprops ) " +
189
+ s(url) +
190
+ s("SVN/1.14.0") +
191
+ "( ) ) ");
192
+ // 3. Read auth request: ( success ( ( CRAM-MD5 ) realm ) )
193
+ const authResp = await this.reader.readResponse();
194
+ const mechs = this.findMechs(authResp.params);
195
+ if (mechs.length === 0) {
196
+ // No auth – server sends ( success ( ( ) 0: ) )
197
+ }
198
+ else if (mechs.includes("CRAM-MD5")) {
199
+ await this.doCramMd5();
200
+ }
201
+ else {
202
+ throw new Error("Unsupported auth: " + mechs.join(", "));
203
+ }
204
+ // 4. Read repos-info: ( success ( uuid url ( caps ) ) )
205
+ await this.reader.readResponse();
206
+ }
207
+ async doCramMd5() {
208
+ // Send: ( CRAM-MD5 ( ) )
209
+ this.send("( CRAM-MD5 ( ) ) ");
210
+ // Read challenge: ( step ( challenge-string ) )
211
+ const step = await this.reader.readResponse();
212
+ const challenge = this.findChallenge(step.params);
213
+ // Compute HMAC-MD5
214
+ const hmac = crypto.createHmac("md5", this.password);
215
+ hmac.update(challenge);
216
+ const hex = hmac.digest("hex");
217
+ const reply = `${this.user} ${hex}`;
218
+ // Send response as a BARE string (not wrapped in a tuple!)
219
+ this.send(s(reply));
220
+ // Read auth result: ( success ( ) )
221
+ const authResult = await this.reader.readResponse();
222
+ if (authResult.cmd !== "success") {
223
+ throw new Error("Auth failed: " + JSON.stringify(authResult));
224
+ }
225
+ }
226
+ async getLogs(limit) {
227
+ // First get the latest revision
228
+ this.send("( get-latest-rev ( ) ) ");
229
+ // trivial auth + response
230
+ await this.reader.readResponse();
231
+ const revResp = await this.reader.readResponse();
232
+ // Response is ( success ( N ) ), so revResp.params[0] is [N]
233
+ const latestRev = this.extractRev(revResp.params);
234
+ // ( log ( ( path ) ( start-rev ) ( end-rev ) changed-paths strict-node limit include-merged revprops ( revprop-names ) ) )
235
+ this.send("( log ( ( 0: ) ( " + n(latestRev) + ") ( 0 ) " +
236
+ b(true) + b(false) +
237
+ n(limit) + b(false) +
238
+ w("revprops") + " ( " +
239
+ s("svn:author") + s("svn:date") + s("svn:log") +
240
+ ") ) ) ");
241
+ // trivial auth
242
+ await this.reader.readResponse();
243
+ // Read log entries until "done"
244
+ const entries = [];
245
+ for (;;) {
246
+ const resp = await this.reader.readResponse();
247
+ if (resp.cmd === "done")
248
+ break;
249
+ entries.push(this.parseLogEntry(resp.params));
250
+ }
251
+ // Final: ( success ( ) )
252
+ await this.reader.readResponse();
253
+ return entries;
254
+ }
255
+ disconnect() {
256
+ if (this.connected) {
257
+ this.socket.destroy();
258
+ this.connected = false;
259
+ }
260
+ }
261
+ send(data) {
262
+ this.socket.write(data, "utf-8");
263
+ }
264
+ findMechs(params) {
265
+ const result = [];
266
+ const walk = (items) => {
267
+ for (const item of items) {
268
+ if (typeof item === "string" && /^[A-Z][A-Z0-9-]/.test(item)) {
269
+ result.push(item);
270
+ }
271
+ else if (Array.isArray(item)) {
272
+ walk(item);
273
+ }
274
+ }
275
+ };
276
+ walk(params);
277
+ return result;
278
+ }
279
+ findChallenge(params) {
280
+ const walk = (items) => {
281
+ for (const item of items) {
282
+ if (typeof item === "string" && item.includes("@"))
283
+ return item;
284
+ if (Array.isArray(item)) {
285
+ const r = walk(item);
286
+ if (r)
287
+ return r;
288
+ }
289
+ }
290
+ return "";
291
+ };
292
+ return walk(params);
293
+ }
294
+ /** Extract revision number from ( success ( N ) ) response. */
295
+ extractRev(params) {
296
+ for (const p of params) {
297
+ if (typeof p === "number")
298
+ return p;
299
+ if (Array.isArray(p)) {
300
+ const r = this.extractRev(p);
301
+ if (r > 0)
302
+ return r;
303
+ }
304
+ }
305
+ return 0;
306
+ }
307
+ parseLogEntry(items) {
308
+ // Wire: ( ( changed-paths ) rev ( author ) ( date ) ( message ) has-children invalid-revnum revprop-count ( ) subtractive-merge )
309
+ let idx = 0;
310
+ // Changed paths
311
+ const files = [];
312
+ if (Array.isArray(items[idx])) {
313
+ const pathsList = items[idx];
314
+ for (const p of pathsList) {
315
+ if (Array.isArray(p)) {
316
+ const e = p;
317
+ // ( path action ( ) ( kind text-mods prop-mods ) )
318
+ let kind = "";
319
+ if (Array.isArray(e[3]) && typeof e[3][0] === "string") {
320
+ kind = e[3][0];
321
+ }
322
+ files.push({
323
+ path: typeof e[0] === "string" ? e[0] : "",
324
+ action: typeof e[1] === "string" ? e[1] : "",
325
+ kind,
326
+ });
327
+ }
328
+ }
329
+ idx++;
330
+ }
331
+ // Revision
332
+ const revision = typeof items[idx] === "number" ? items[idx] : 0;
333
+ idx++;
334
+ // ( author ) ( date ) ( message ) – each is a list with one string
335
+ const readOptStr = () => {
336
+ if (idx >= items.length)
337
+ return "";
338
+ const item = items[idx++];
339
+ if (typeof item === "string")
340
+ return item;
341
+ if (Array.isArray(item) && item.length === 1 && typeof item[0] === "string")
342
+ return item[0];
343
+ if (Array.isArray(item) && item.length === 0)
344
+ return "";
345
+ return "";
346
+ };
347
+ const author = readOptStr();
348
+ const date = readOptStr();
349
+ const message = readOptStr();
350
+ return { revision, author, date, message, files };
351
+ }
352
+ }
353
+ // ---------------------------------------------------------------------------
354
+ // Convenience
355
+ // ---------------------------------------------------------------------------
356
+ export async function fetchSvnLogs(host, port, user, password, repoPath, limit) {
357
+ const client = new SvnRaClient(host, port, user, password, repoPath);
358
+ try {
359
+ await client.connect();
360
+ return await client.getLogs(limit);
361
+ }
362
+ finally {
363
+ client.disconnect();
364
+ }
365
+ }
366
+ //# sourceMappingURL=svn-protocol.js.map
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@massapi/svn-mcp",
3
+ "version": "1.0.0",
4
+ "description": "SVN MCP server based on Node.js, using native svn:// protocol via stdio transport",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "svn-mcp": "dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "start": "node dist/index.js",
12
+ "inspect": "npx @modelcontextprotocol/inspector node dist/index.js"
13
+ },
14
+ "type": "module",
15
+ "keywords": ["svn", "mcp"],
16
+ "license": "MIT",
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "^1.12.1"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^22.15.0",
22
+ "typescript": "^5.7.0"
23
+ }
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod";
6
+ import { fetchSvnLogs } from "./svn-protocol.js";
7
+
8
+ // SVN server configuration via environment variables
9
+ // SVN_URL format: svn://host:port/repo/path
10
+ const SVN_URL = process.env.SVN_URL || "";
11
+ const SVN_USER = process.env.SVN_USER || "";
12
+ const SVN_PASSWORD = process.env.SVN_PASSWORD || "";
13
+
14
+ /** Parse svn:// URL into host, port, path components. */
15
+ function parseSvnUrl(url: string): { host: string; port: number; path: string } {
16
+ const parsed = new URL(url);
17
+ return {
18
+ host: parsed.hostname,
19
+ port: parsed.port ? Number(parsed.port) : 3690,
20
+ path: parsed.pathname,
21
+ };
22
+ }
23
+
24
+ async function main() {
25
+ const server = new McpServer({
26
+ name: "svn-mcp",
27
+ version: "1.0.0",
28
+ });
29
+
30
+ server.tool(
31
+ "list_logs",
32
+ "Get recent SVN commit logs via native svn:// protocol",
33
+ { limit: z.number().default(10).describe("Number of recent commits to retrieve") },
34
+ async ({ limit }) => {
35
+ const { host, port, path } = parseSvnUrl(SVN_URL);
36
+ const logs = await fetchSvnLogs(host, port, SVN_USER, SVN_PASSWORD, path, limit);
37
+ return {
38
+ content: [{ type: "text", text: JSON.stringify(logs, null, 2) }],
39
+ };
40
+ }
41
+ );
42
+
43
+ const transport = new StdioServerTransport();
44
+ await server.connect(transport);
45
+ }
46
+
47
+ main().catch((err) => {
48
+ console.error("Failed to start svn-mcp:", err);
49
+ process.exit(1);
50
+ });
@@ -0,0 +1,412 @@
1
+ import * as net from "node:net";
2
+ import * as crypto from "node:crypto";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // SVN RA (ra_svn) wire protocol – framing, auth, and log retrieval
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export type SvnItem = string | number | SvnItem[];
9
+
10
+ /**
11
+ * Streaming reader for the SVN ra_svn wire protocol.
12
+ */
13
+ export class SvnRaReader {
14
+ private buf = "";
15
+ private resolver: ((chunk: string) => void) | null = null;
16
+
17
+ constructor(socket: net.Socket) {
18
+ socket.on("data", (data: Buffer) => {
19
+ const chunk = data.toString("utf-8");
20
+ if (this.resolver) {
21
+ const r = this.resolver;
22
+ this.resolver = null;
23
+ r(chunk);
24
+ } else {
25
+ this.buf += chunk;
26
+ }
27
+ });
28
+ }
29
+
30
+ private async recv(): Promise<string> {
31
+ return new Promise<string>((resolve) => { this.resolver = resolve; });
32
+ }
33
+
34
+ /** Ensure at least n chars in buffer. */
35
+ private async ensure(n: number): Promise<void> {
36
+ while (this.buf.length < n) {
37
+ this.buf += await this.recv();
38
+ }
39
+ }
40
+
41
+ /** Skip whitespace. */
42
+ private async skipWs(): Promise<void> {
43
+ for (;;) {
44
+ await this.ensure(1);
45
+ if (this.buf[0] === " " || this.buf[0] === "\n" || this.buf[0] === "\r") {
46
+ this.buf = this.buf.slice(1);
47
+ } else {
48
+ return;
49
+ }
50
+ }
51
+ }
52
+
53
+ /** Peek at next non-whitespace char. */
54
+ private async peek(): Promise<string> {
55
+ await this.skipWs();
56
+ await this.ensure(1);
57
+ return this.buf[0];
58
+ }
59
+
60
+ /** Read one ra_svn item. */
61
+ async readItem(): Promise<SvnItem> {
62
+ const ch = await this.peek();
63
+ if (ch === "(") return this.readList();
64
+ if (ch >= "0" && ch <= "9") {
65
+ // Look ahead to distinguish string (N:data) from number
66
+ await this.ensure(2);
67
+ for (let i = 0; i < this.buf.length; i++) {
68
+ const c = this.buf[i];
69
+ if (c === ":") return this.readString();
70
+ if (c === " " || c === "\n" || c === "\r" || c === ")") return this.readNumber();
71
+ if (c < "0" || c > "9") return this.readNumber();
72
+ }
73
+ return this.readNumber();
74
+ }
75
+ return this.readWord();
76
+ }
77
+
78
+ async readWord(): Promise<string> {
79
+ await this.skipWs();
80
+ let word = "";
81
+ for (;;) {
82
+ await this.ensure(1);
83
+ const c = this.buf[0];
84
+ if (/[A-Za-z0-9_-]/.test(c)) {
85
+ word += c;
86
+ this.buf = this.buf.slice(1);
87
+ } else {
88
+ return word;
89
+ }
90
+ }
91
+ }
92
+
93
+ async readNumber(): Promise<number> {
94
+ await this.skipWs();
95
+ let digits = "";
96
+ for (;;) {
97
+ await this.ensure(1);
98
+ const c = this.buf[0];
99
+ if (c >= "0" && c <= "9") {
100
+ digits += c;
101
+ this.buf = this.buf.slice(1);
102
+ } else {
103
+ return Number(digits);
104
+ }
105
+ }
106
+ }
107
+
108
+ async readString(): Promise<string> {
109
+ await this.skipWs();
110
+ let lenStr = "";
111
+ for (;;) {
112
+ await this.ensure(1);
113
+ if (this.buf[0] === ":") {
114
+ this.buf = this.buf.slice(1);
115
+ break;
116
+ }
117
+ lenStr += this.buf[0];
118
+ this.buf = this.buf.slice(1);
119
+ }
120
+ const len = Number(lenStr);
121
+ while (Buffer.byteLength(this.buf, "utf-8") < len) {
122
+ this.buf += await this.recv();
123
+ }
124
+ const raw = Buffer.from(this.buf, "utf-8");
125
+ const str = raw.toString("utf-8", 0, len);
126
+ this.buf = raw.toString("utf-8", len);
127
+ return str;
128
+ }
129
+
130
+ async readList(): Promise<SvnItem[]> {
131
+ await this.skipWs();
132
+ await this.ensure(1);
133
+ if (this.buf[0] !== "(") throw new Error(`Expected '(' got '${this.buf[0]}'`);
134
+ this.buf = this.buf.slice(1);
135
+ const items: SvnItem[] = [];
136
+ for (;;) {
137
+ const ch = await this.peek();
138
+ if (ch === ")") {
139
+ this.buf = this.buf.slice(1);
140
+ return items;
141
+ }
142
+ items.push(await this.readItem());
143
+ }
144
+ }
145
+
146
+ /** Read a top-level response: either a list `( cmd ... )` or the word `done`. */
147
+ async readResponse(): Promise<{ cmd: string; params: SvnItem[] }> {
148
+ const ch = await this.peek();
149
+ if (ch === "d") {
150
+ const word = await this.readWord();
151
+ return { cmd: word, params: [] };
152
+ }
153
+ const list = await this.readList();
154
+ if (list.length === 0) return { cmd: "", params: [] };
155
+ if (typeof list[0] === "string") {
156
+ return { cmd: list[0] as string, params: list.slice(1) };
157
+ }
158
+ return { cmd: "", params: list };
159
+ }
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Encoder helpers
164
+ // ---------------------------------------------------------------------------
165
+
166
+ function w(word: string): string { return word + " "; }
167
+ function n(num: number): string { return num + " "; }
168
+ function s(str: string): string { const b = Buffer.from(str, "utf-8"); return b.length + ":" + str + " "; }
169
+ function b(val: boolean): string { return val ? "true " : "false "; }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Client
173
+ // ---------------------------------------------------------------------------
174
+
175
+ export interface LogEntry {
176
+ revision: number;
177
+ author: string;
178
+ date: string;
179
+ message: string;
180
+ files: { path: string; action: string; kind: string }[];
181
+ }
182
+
183
+ export class SvnRaClient {
184
+ private socket!: net.Socket;
185
+ private reader!: SvnRaReader;
186
+ private connected = false;
187
+
188
+ constructor(
189
+ private host: string,
190
+ private port: number,
191
+ private user: string,
192
+ private password: string,
193
+ private repoPath: string
194
+ ) {}
195
+
196
+ async connect(): Promise<void> {
197
+ this.socket = await new Promise<net.Socket>((resolve, reject) => {
198
+ const sock = net.createConnection({ host: this.host, port: this.port }, () => resolve(sock));
199
+ sock.on("error", reject);
200
+ });
201
+ this.reader = new SvnRaReader(this.socket);
202
+ this.connected = true;
203
+
204
+ // 1. Read server greeting
205
+ // ( success ( min max ( mechs ) ( cap1 cap2 ... ) ) )
206
+ await this.reader.readResponse();
207
+
208
+ // 2. Send client response: ( version ( caps ) url client-str ( ) )
209
+ const url = `svn://${this.host}${this.repoPath}`;
210
+ this.send(
211
+ "( 2 ( edit-pipeline svndiff1 absent-entries depth mergeinfo log-revprops ) " +
212
+ s(url) +
213
+ s("SVN/1.14.0") +
214
+ "( ) ) "
215
+ );
216
+
217
+ // 3. Read auth request: ( success ( ( CRAM-MD5 ) realm ) )
218
+ const authResp = await this.reader.readResponse();
219
+ const mechs = this.findMechs(authResp.params);
220
+
221
+ if (mechs.length === 0) {
222
+ // No auth – server sends ( success ( ( ) 0: ) )
223
+ } else if (mechs.includes("CRAM-MD5")) {
224
+ await this.doCramMd5();
225
+ } else {
226
+ throw new Error("Unsupported auth: " + mechs.join(", "));
227
+ }
228
+
229
+ // 4. Read repos-info: ( success ( uuid url ( caps ) ) )
230
+ await this.reader.readResponse();
231
+ }
232
+
233
+ private async doCramMd5(): Promise<void> {
234
+ // Send: ( CRAM-MD5 ( ) )
235
+ this.send("( CRAM-MD5 ( ) ) ");
236
+
237
+ // Read challenge: ( step ( challenge-string ) )
238
+ const step = await this.reader.readResponse();
239
+ const challenge = this.findChallenge(step.params);
240
+
241
+ // Compute HMAC-MD5
242
+ const hmac = crypto.createHmac("md5", this.password);
243
+ hmac.update(challenge);
244
+ const hex = hmac.digest("hex");
245
+ const reply = `${this.user} ${hex}`;
246
+
247
+ // Send response as a BARE string (not wrapped in a tuple!)
248
+ this.send(s(reply));
249
+
250
+ // Read auth result: ( success ( ) )
251
+ const authResult = await this.reader.readResponse();
252
+ if (authResult.cmd !== "success") {
253
+ throw new Error("Auth failed: " + JSON.stringify(authResult));
254
+ }
255
+ }
256
+
257
+ async getLogs(limit: number): Promise<LogEntry[]> {
258
+ // First get the latest revision
259
+ this.send("( get-latest-rev ( ) ) ");
260
+ // trivial auth + response
261
+ await this.reader.readResponse();
262
+ const revResp = await this.reader.readResponse();
263
+ // Response is ( success ( N ) ), so revResp.params[0] is [N]
264
+ const latestRev = this.extractRev(revResp.params);
265
+
266
+ // ( log ( ( path ) ( start-rev ) ( end-rev ) changed-paths strict-node limit include-merged revprops ( revprop-names ) ) )
267
+ this.send(
268
+ "( log ( ( 0: ) ( " + n(latestRev) + ") ( 0 ) " +
269
+ b(true) + b(false) +
270
+ n(limit) + b(false) +
271
+ w("revprops") + " ( " +
272
+ s("svn:author") + s("svn:date") + s("svn:log") +
273
+ ") ) ) "
274
+ );
275
+
276
+ // trivial auth
277
+ await this.reader.readResponse();
278
+
279
+ // Read log entries until "done"
280
+ const entries: LogEntry[] = [];
281
+ for (;;) {
282
+ const resp = await this.reader.readResponse();
283
+ if (resp.cmd === "done") break;
284
+ entries.push(this.parseLogEntry(resp.params));
285
+ }
286
+
287
+ // Final: ( success ( ) )
288
+ await this.reader.readResponse();
289
+
290
+ return entries;
291
+ }
292
+
293
+ disconnect(): void {
294
+ if (this.connected) {
295
+ this.socket.destroy();
296
+ this.connected = false;
297
+ }
298
+ }
299
+
300
+ private send(data: string): void {
301
+ this.socket.write(data, "utf-8");
302
+ }
303
+
304
+ private findMechs(params: SvnItem[]): string[] {
305
+ const result: string[] = [];
306
+ const walk = (items: SvnItem[]): void => {
307
+ for (const item of items) {
308
+ if (typeof item === "string" && /^[A-Z][A-Z0-9-]/.test(item)) {
309
+ result.push(item);
310
+ } else if (Array.isArray(item)) {
311
+ walk(item);
312
+ }
313
+ }
314
+ };
315
+ walk(params);
316
+ return result;
317
+ }
318
+
319
+ private findChallenge(params: SvnItem[]): string {
320
+ const walk = (items: SvnItem[]): string => {
321
+ for (const item of items) {
322
+ if (typeof item === "string" && item.includes("@")) return item;
323
+ if (Array.isArray(item)) {
324
+ const r = walk(item);
325
+ if (r) return r;
326
+ }
327
+ }
328
+ return "";
329
+ };
330
+ return walk(params);
331
+ }
332
+
333
+ /** Extract revision number from ( success ( N ) ) response. */
334
+ private extractRev(params: SvnItem[]): number {
335
+ for (const p of params) {
336
+ if (typeof p === "number") return p;
337
+ if (Array.isArray(p)) {
338
+ const r = this.extractRev(p);
339
+ if (r > 0) return r;
340
+ }
341
+ }
342
+ return 0;
343
+ }
344
+
345
+ private parseLogEntry(items: SvnItem[]): LogEntry {
346
+ // Wire: ( ( changed-paths ) rev ( author ) ( date ) ( message ) has-children invalid-revnum revprop-count ( ) subtractive-merge )
347
+ let idx = 0;
348
+
349
+ // Changed paths
350
+ const files: { path: string; action: string; kind: string }[] = [];
351
+ if (Array.isArray(items[idx])) {
352
+ const pathsList = items[idx] as SvnItem[];
353
+ for (const p of pathsList) {
354
+ if (Array.isArray(p)) {
355
+ const e = p as SvnItem[];
356
+ // ( path action ( ) ( kind text-mods prop-mods ) )
357
+ let kind = "";
358
+ if (Array.isArray(e[3]) && typeof (e[3] as SvnItem[])[0] === "string") {
359
+ kind = (e[3] as SvnItem[])[0] as string;
360
+ }
361
+ files.push({
362
+ path: typeof e[0] === "string" ? e[0] : "",
363
+ action: typeof e[1] === "string" ? e[1] : "",
364
+ kind,
365
+ });
366
+ }
367
+ }
368
+ idx++;
369
+ }
370
+
371
+ // Revision
372
+ const revision = typeof items[idx] === "number" ? (items[idx] as number) : 0;
373
+ idx++;
374
+
375
+ // ( author ) ( date ) ( message ) – each is a list with one string
376
+ const readOptStr = (): string => {
377
+ if (idx >= items.length) return "";
378
+ const item = items[idx++];
379
+ if (typeof item === "string") return item;
380
+ if (Array.isArray(item) && item.length === 1 && typeof item[0] === "string") return item[0];
381
+ if (Array.isArray(item) && item.length === 0) return "";
382
+ return "";
383
+ };
384
+
385
+ const author = readOptStr();
386
+ const date = readOptStr();
387
+ const message = readOptStr();
388
+
389
+ return { revision, author, date, message, files };
390
+ }
391
+ }
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // Convenience
395
+ // ---------------------------------------------------------------------------
396
+
397
+ export async function fetchSvnLogs(
398
+ host: string,
399
+ port: number,
400
+ user: string,
401
+ password: string,
402
+ repoPath: string,
403
+ limit: number
404
+ ): Promise<LogEntry[]> {
405
+ const client = new SvnRaClient(host, port, user, password, repoPath);
406
+ try {
407
+ await client.connect();
408
+ return await client.getLogs(limit);
409
+ } finally {
410
+ client.disconnect();
411
+ }
412
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "declaration": true,
11
+ "sourceMap": true,
12
+ "skipLibCheck": true
13
+ },
14
+ "include": ["src/**/*"]
15
+ }