silicon-transfer-mcp-server 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/LICENSE +21 -0
- package/README.md +87 -0
- package/dist/index.js +45 -0
- package/dist/services/connection.js +175 -0
- package/dist/services/proof.js +46 -0
- package/dist/tools/register-dir.js +148 -0
- package/dist/tools/register.js +172 -0
- package/package.json +39 -0
- package/src/index.ts +55 -0
- package/src/services/connection.ts +180 -0
- package/src/services/proof.ts +64 -0
- package/src/tools/register-dir.ts +173 -0
- package/src/tools/register.ts +230 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { localSha256, judge } from "../services/proof.js";
|
|
3
|
+
function ok(output) {
|
|
4
|
+
return {
|
|
5
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
6
|
+
structuredContent: output,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
function fail(err, hint) {
|
|
10
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
11
|
+
const output = { error: msg, hint };
|
|
12
|
+
return {
|
|
13
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
14
|
+
structuredContent: output,
|
|
15
|
+
isError: true,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const proofSchema = z
|
|
19
|
+
.enum(["hash", "size", "none"])
|
|
20
|
+
.default("hash")
|
|
21
|
+
.describe("Beweisstufe: hash = SHA-256-Rueckvergleich (Standard), size = nur Groesse, none = ohne Urteil");
|
|
22
|
+
export function registerAll(server, cm) {
|
|
23
|
+
server.registerTool("silicon_connect", {
|
|
24
|
+
title: "Verbindung aufbauen",
|
|
25
|
+
description: "Verbindet zu einem FTP-, FTPS- oder SFTP-Server. SFTP unterstuetzt Passwort oder SSH-Key (privateKeyPath).",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
protocol: z.enum(["ftp", "ftps", "sftp"]).describe("Uebertragungsprotokoll"),
|
|
28
|
+
host: z.string().describe("Hostname oder IP"),
|
|
29
|
+
port: z.number().int().optional().describe("Port (Standard: 21 fuer ftp/ftps, 22 fuer sftp)"),
|
|
30
|
+
username: z.string().describe("Benutzername"),
|
|
31
|
+
password: z.string().optional().describe("Passwort (bei SSH-Key optional)"),
|
|
32
|
+
privateKeyPath: z.string().optional().describe("Pfad zu einem privaten SSH-Key (nur sftp)"),
|
|
33
|
+
passphrase: z.string().optional().describe("Passphrase des SSH-Keys, falls verschluesselt"),
|
|
34
|
+
},
|
|
35
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
36
|
+
}, async (a) => {
|
|
37
|
+
try {
|
|
38
|
+
await cm.connect(a);
|
|
39
|
+
return ok({ status: "verbunden", target: cm.target });
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
return fail(e, "Host, Port, Zugangsdaten und Protokoll pruefen.");
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
server.registerTool("silicon_disconnect", {
|
|
46
|
+
title: "Verbindung trennen",
|
|
47
|
+
description: "Trennt die aktive Verbindung sauber.",
|
|
48
|
+
inputSchema: {},
|
|
49
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
50
|
+
}, async () => {
|
|
51
|
+
const was = cm.target;
|
|
52
|
+
await cm.disconnect();
|
|
53
|
+
return ok({ status: "getrennt", war: was });
|
|
54
|
+
});
|
|
55
|
+
server.registerTool("silicon_status", {
|
|
56
|
+
title: "Verbindungsstatus",
|
|
57
|
+
description: "Zeigt, ob und wohin aktuell eine Verbindung besteht.",
|
|
58
|
+
inputSchema: {},
|
|
59
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
60
|
+
}, async () => ok({ verbunden: cm.connected, target: cm.target }));
|
|
61
|
+
server.registerTool("silicon_list_dir", {
|
|
62
|
+
title: "Verzeichnis auflisten",
|
|
63
|
+
description: "Listet ein Remote-Verzeichnis mit Typ, Groesse und Aenderungsdatum.",
|
|
64
|
+
inputSchema: { path: z.string().describe("Remote-Pfad, z.B. /home/user/www") },
|
|
65
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
66
|
+
}, async ({ path }) => {
|
|
67
|
+
try {
|
|
68
|
+
const entries = await cm.list(path);
|
|
69
|
+
return ok({ path, count: entries.length, entries });
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
return fail(e, "Pfad pruefen; fuehrender / noetig? Verbindung aktiv?");
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
server.registerTool("silicon_upload_file", {
|
|
76
|
+
title: "Datei hochladen (mit Beweis)",
|
|
77
|
+
description: "Laedt eine lokale Datei hoch und liefert das Transfer-Urteil mit: PROVEN nur, wenn der SHA-256 der Remote-Datei nach dem Upload dem lokalen entspricht.",
|
|
78
|
+
inputSchema: {
|
|
79
|
+
localPath: z.string().describe("Lokaler Quellpfad"),
|
|
80
|
+
remotePath: z.string().describe("Remote-Zielpfad inkl. Dateiname"),
|
|
81
|
+
proof: proofSchema,
|
|
82
|
+
},
|
|
83
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
84
|
+
}, async ({ localPath, remotePath, proof }) => {
|
|
85
|
+
try {
|
|
86
|
+
const t0 = Date.now();
|
|
87
|
+
const local = await localSha256(localPath);
|
|
88
|
+
await cm.upload(localPath, remotePath);
|
|
89
|
+
const remote = proof === "hash"
|
|
90
|
+
? await cm.remoteSha256(remotePath)
|
|
91
|
+
: proof === "size"
|
|
92
|
+
? { sha256: undefined, bytes: await cm.remoteSize(remotePath) }
|
|
93
|
+
: { sha256: undefined, bytes: undefined };
|
|
94
|
+
const urteil = judge(local, remote, proof);
|
|
95
|
+
return ok({ localPath, remotePath, duration_ms: Date.now() - t0, proof: urteil });
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
return fail(e, "Lokalen Pfad, Remote-Rechte und Verbindung pruefen.");
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
server.registerTool("silicon_download_file", {
|
|
102
|
+
title: "Datei herunterladen (mit Beweis)",
|
|
103
|
+
description: "Laedt eine Remote-Datei herunter und liefert das Transfer-Urteil mit: PROVEN nur bei identischem SHA-256 zwischen Remote-Quelle und lokaler Kopie.",
|
|
104
|
+
inputSchema: {
|
|
105
|
+
remotePath: z.string().describe("Remote-Quellpfad"),
|
|
106
|
+
localPath: z.string().describe("Lokaler Zielpfad inkl. Dateiname"),
|
|
107
|
+
proof: proofSchema,
|
|
108
|
+
},
|
|
109
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
110
|
+
}, async ({ remotePath, localPath, proof }) => {
|
|
111
|
+
try {
|
|
112
|
+
const t0 = Date.now();
|
|
113
|
+
await cm.download(remotePath, localPath);
|
|
114
|
+
const local = await localSha256(localPath);
|
|
115
|
+
const remote = proof === "hash"
|
|
116
|
+
? await cm.remoteSha256(remotePath)
|
|
117
|
+
: proof === "size"
|
|
118
|
+
? { sha256: undefined, bytes: await cm.remoteSize(remotePath) }
|
|
119
|
+
: { sha256: undefined, bytes: undefined };
|
|
120
|
+
const urteil = judge(local, remote, proof);
|
|
121
|
+
return ok({ remotePath, localPath, duration_ms: Date.now() - t0, proof: urteil });
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
return fail(e, "Remote-Pfad, lokale Schreibrechte und Verbindung pruefen.");
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
server.registerTool("silicon_delete_file", {
|
|
128
|
+
title: "Remote-Datei loeschen",
|
|
129
|
+
description: "Loescht eine Datei auf dem Server. Endgueltig - vorher mit silicon_list_dir pruefen.",
|
|
130
|
+
inputSchema: { remotePath: z.string().describe("Remote-Pfad der zu loeschenden Datei") },
|
|
131
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
132
|
+
}, async ({ remotePath }) => {
|
|
133
|
+
try {
|
|
134
|
+
await cm.remove(remotePath);
|
|
135
|
+
return ok({ status: "geloescht", remotePath });
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
return fail(e, "Existiert die Datei? Rechte vorhanden?");
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
server.registerTool("silicon_make_dir", {
|
|
142
|
+
title: "Remote-Verzeichnis anlegen",
|
|
143
|
+
description: "Legt ein Verzeichnis auf dem Server an (rekursiv moeglich).",
|
|
144
|
+
inputSchema: {
|
|
145
|
+
remotePath: z.string().describe("Anzulegender Remote-Pfad"),
|
|
146
|
+
recursive: z.boolean().default(true).describe("Elternverzeichnisse mit anlegen"),
|
|
147
|
+
},
|
|
148
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
149
|
+
}, async ({ remotePath, recursive }) => {
|
|
150
|
+
try {
|
|
151
|
+
await cm.mkdir(remotePath, recursive);
|
|
152
|
+
return ok({ status: "angelegt", remotePath });
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
return fail(e, "Rechte pruefen; existiert der Pfad bereits?");
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
server.registerTool("silicon_file_info", {
|
|
159
|
+
title: "Datei-Steckbrief",
|
|
160
|
+
description: "Groesse und SHA-256 einer Remote-Datei - der Fingerabdruck ohne Download auf Platte.",
|
|
161
|
+
inputSchema: { remotePath: z.string().describe("Remote-Pfad der Datei") },
|
|
162
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
163
|
+
}, async ({ remotePath }) => {
|
|
164
|
+
try {
|
|
165
|
+
const r = await cm.remoteSha256(remotePath);
|
|
166
|
+
return ok({ remotePath, bytes: r.bytes, sha256: r.sha256 });
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
return fail(e, "Existiert die Datei? Verbindung aktiv?");
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "silicon-transfer-mcp-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "FTP/SFTP/FTPS MCP server with built-in transfer proofs - every upload returns a checksum verdict. Built from scratch by Silicon Networks.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"silicon-transfer-mcp-server": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"dev": "tsc --watch"
|
|
14
|
+
},
|
|
15
|
+
"author": "Silicon Networks (https://aegis888.com)",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mcp",
|
|
19
|
+
"model-context-protocol",
|
|
20
|
+
"ftp",
|
|
21
|
+
"sftp",
|
|
22
|
+
"ftps",
|
|
23
|
+
"file-transfer",
|
|
24
|
+
"checksum",
|
|
25
|
+
"deploy",
|
|
26
|
+
"claude"
|
|
27
|
+
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
30
|
+
"basic-ftp": "^5.0.5",
|
|
31
|
+
"ssh2-sftp-client": "^11.0.0",
|
|
32
|
+
"zod": "^3.24.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^22.0.0",
|
|
36
|
+
"@types/ssh2-sftp-client": "^9.0.4",
|
|
37
|
+
"typescript": "^5.6.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Silicon Transfer MCP Server
|
|
4
|
+
* FTP/SFTP/FTPS fuer Claude - mit eingebauten Transfer-Beweisen.
|
|
5
|
+
* Grundsatz: erst beweisen, dann behaupten. Jeder Upload liefert seinen Hash-Beweis mit.
|
|
6
|
+
*
|
|
7
|
+
* Built from scratch by Silicon Networks (aegis888.com), 2026.
|
|
8
|
+
*/
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { ConnectionManager } from "./services/connection.js";
|
|
12
|
+
import { registerAll } from "./tools/register.js";
|
|
13
|
+
import { registerDirTools } from "./tools/register-dir.js";
|
|
14
|
+
|
|
15
|
+
const SERVER_NAME = "silicon-transfer-mcp-server";
|
|
16
|
+
const SERVER_VERSION = "0.1.0";
|
|
17
|
+
|
|
18
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
|
|
19
|
+
const connections = new ConnectionManager();
|
|
20
|
+
|
|
21
|
+
server.registerTool(
|
|
22
|
+
"silicon_server_info",
|
|
23
|
+
{
|
|
24
|
+
title: "Server Info",
|
|
25
|
+
description: "Name, Version und Philosophie dieses MCP-Servers. Lebenszeichen-Check.",
|
|
26
|
+
inputSchema: {},
|
|
27
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
28
|
+
},
|
|
29
|
+
async () => {
|
|
30
|
+
const output = {
|
|
31
|
+
name: SERVER_NAME,
|
|
32
|
+
version: SERVER_VERSION,
|
|
33
|
+
philosophy: "erst beweisen, dann behaupten - every transfer returns its checksum proof",
|
|
34
|
+
protocols: ["ftp", "ftps", "sftp"],
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
38
|
+
structuredContent: output,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
registerAll(server, connections);
|
|
44
|
+
registerDirTools(server, connections);
|
|
45
|
+
|
|
46
|
+
async function main(): Promise<void> {
|
|
47
|
+
const transport = new StdioServerTransport();
|
|
48
|
+
await server.connect(transport);
|
|
49
|
+
console.error(`${SERVER_NAME} ${SERVER_VERSION} bereit (stdio)`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
main().catch((err) => {
|
|
53
|
+
console.error("Serverstart fehlgeschlagen:", err);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verbindungs-Schicht: FTP / FTPS / SFTP hinter einer einheitlichen Fassade.
|
|
3
|
+
* Ein aktiver Kanal pro Server-Instanz - bewusst simpel, bewusst nachvollziehbar.
|
|
4
|
+
*/
|
|
5
|
+
import { Client as FtpClient } from "basic-ftp";
|
|
6
|
+
import SftpClient from "ssh2-sftp-client";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { Writable } from "node:stream";
|
|
10
|
+
|
|
11
|
+
export type Protocol = "ftp" | "ftps" | "sftp";
|
|
12
|
+
|
|
13
|
+
export interface ConnectOptions {
|
|
14
|
+
protocol: Protocol;
|
|
15
|
+
host: string;
|
|
16
|
+
port?: number;
|
|
17
|
+
username: string;
|
|
18
|
+
password?: string;
|
|
19
|
+
privateKeyPath?: string;
|
|
20
|
+
passphrase?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RemoteEntry {
|
|
24
|
+
name: string;
|
|
25
|
+
type: "file" | "dir" | "link";
|
|
26
|
+
size: number;
|
|
27
|
+
modifiedAt?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class ConnectionManager {
|
|
31
|
+
private ftp: FtpClient | null = null;
|
|
32
|
+
private sftp: SftpClient | null = null;
|
|
33
|
+
private opts: ConnectOptions | null = null;
|
|
34
|
+
|
|
35
|
+
get connected(): boolean {
|
|
36
|
+
return this.ftp !== null || this.sftp !== null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get target(): string {
|
|
40
|
+
if (!this.opts) return "nicht verbunden";
|
|
41
|
+
const p = this.opts.port ?? (this.opts.protocol === "sftp" ? 22 : 21);
|
|
42
|
+
return `${this.opts.protocol}://${this.opts.username}@${this.opts.host}:${p}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async connect(o: ConnectOptions): Promise<void> {
|
|
46
|
+
await this.disconnect();
|
|
47
|
+
if (o.protocol === "sftp") {
|
|
48
|
+
const c = new SftpClient();
|
|
49
|
+
await c.connect({
|
|
50
|
+
host: o.host,
|
|
51
|
+
port: o.port ?? 22,
|
|
52
|
+
username: o.username,
|
|
53
|
+
password: o.password,
|
|
54
|
+
privateKey: o.privateKeyPath ? readFileSync(o.privateKeyPath) : undefined,
|
|
55
|
+
passphrase: o.passphrase,
|
|
56
|
+
readyTimeout: 30000,
|
|
57
|
+
});
|
|
58
|
+
this.sftp = c;
|
|
59
|
+
} else {
|
|
60
|
+
const c = new FtpClient();
|
|
61
|
+
await c.access({
|
|
62
|
+
host: o.host,
|
|
63
|
+
port: o.port ?? 21,
|
|
64
|
+
user: o.username,
|
|
65
|
+
password: o.password,
|
|
66
|
+
secure: o.protocol === "ftps",
|
|
67
|
+
});
|
|
68
|
+
this.ftp = c;
|
|
69
|
+
}
|
|
70
|
+
this.opts = o;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async disconnect(): Promise<void> {
|
|
74
|
+
if (this.ftp) { this.ftp.close(); this.ftp = null; }
|
|
75
|
+
if (this.sftp) { await this.sftp.end().catch(() => undefined); this.sftp = null; }
|
|
76
|
+
this.opts = null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private ensure(): void {
|
|
80
|
+
if (!this.connected) {
|
|
81
|
+
throw new Error("Keine aktive Verbindung - zuerst silicon_connect aufrufen.");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async list(path: string): Promise<RemoteEntry[]> {
|
|
86
|
+
this.ensure();
|
|
87
|
+
if (this.sftp) {
|
|
88
|
+
const entries = await this.sftp.list(path);
|
|
89
|
+
return entries.map((e) => ({
|
|
90
|
+
name: e.name,
|
|
91
|
+
type: e.type === "d" ? "dir" : e.type === "l" ? "link" : "file",
|
|
92
|
+
size: e.size,
|
|
93
|
+
modifiedAt: new Date(e.modifyTime).toISOString(),
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
const entries = await this.ftp!.list(path);
|
|
97
|
+
return entries.map((e) => ({
|
|
98
|
+
name: e.name,
|
|
99
|
+
type: e.isDirectory ? "dir" : e.isSymbolicLink ? "link" : "file",
|
|
100
|
+
size: e.size,
|
|
101
|
+
modifiedAt: e.modifiedAt ? e.modifiedAt.toISOString() : undefined,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async upload(localPath: string, remotePath: string): Promise<void> {
|
|
106
|
+
this.ensure();
|
|
107
|
+
if (this.sftp) { await this.sftp.fastPut(localPath, remotePath); return; }
|
|
108
|
+
await this.ftp!.uploadFrom(localPath, remotePath);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async download(remotePath: string, localPath: string): Promise<void> {
|
|
112
|
+
this.ensure();
|
|
113
|
+
if (this.sftp) { await this.sftp.fastGet(remotePath, localPath); return; }
|
|
114
|
+
await this.ftp!.downloadTo(localPath, remotePath);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async remove(remotePath: string): Promise<void> {
|
|
118
|
+
this.ensure();
|
|
119
|
+
if (this.sftp) { await this.sftp.delete(remotePath); return; }
|
|
120
|
+
await this.ftp!.remove(remotePath);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async mkdir(remotePath: string, recursive: boolean): Promise<void> {
|
|
124
|
+
this.ensure();
|
|
125
|
+
if (this.sftp) { await this.sftp.mkdir(remotePath, recursive); return; }
|
|
126
|
+
await this.ftp!.ensureDir(remotePath);
|
|
127
|
+
await this.ftp!.cd("/");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async remoteSize(remotePath: string): Promise<number> {
|
|
131
|
+
this.ensure();
|
|
132
|
+
if (this.sftp) {
|
|
133
|
+
const s = await this.sftp.stat(remotePath);
|
|
134
|
+
return s.size;
|
|
135
|
+
}
|
|
136
|
+
return this.ftp!.size(remotePath);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Herzstueck der Beweis-DNA: liest die Remote-Datei als Strom zurueck
|
|
141
|
+
* und berechnet ihren SHA-256 - ohne sie auf Platte zu speichern.
|
|
142
|
+
*/
|
|
143
|
+
async remoteSha256(remotePath: string): Promise<{ sha256: string; bytes: number }> {
|
|
144
|
+
this.ensure();
|
|
145
|
+
const hash = createHash("sha256");
|
|
146
|
+
let bytes = 0;
|
|
147
|
+
const sink = new Writable({
|
|
148
|
+
write(chunk: Buffer, _enc, cb) {
|
|
149
|
+
hash.update(chunk);
|
|
150
|
+
bytes += chunk.length;
|
|
151
|
+
cb();
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
if (this.sftp) {
|
|
155
|
+
await this.sftp.get(remotePath, sink);
|
|
156
|
+
} else {
|
|
157
|
+
await this.ftp!.downloadTo(sink, remotePath);
|
|
158
|
+
}
|
|
159
|
+
return { sha256: hash.digest("hex"), bytes };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async rename(fromPath: string, toPath: string): Promise<void> {
|
|
163
|
+
this.ensure();
|
|
164
|
+
if (this.sftp) { await this.sftp.rename(fromPath, toPath); return; }
|
|
165
|
+
await this.ftp!.rename(fromPath, toPath);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async uploadDir(localDir: string, remoteDir: string): Promise<void> {
|
|
169
|
+
this.ensure();
|
|
170
|
+
if (this.sftp) { await this.sftp.uploadDir(localDir, remoteDir); return; }
|
|
171
|
+
await this.ftp!.uploadFromDir(localDir, remoteDir);
|
|
172
|
+
await this.ftp!.cd("/");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async downloadDir(remoteDir: string, localDir: string): Promise<void> {
|
|
176
|
+
this.ensure();
|
|
177
|
+
if (this.sftp) { await this.sftp.downloadDir(remoteDir, localDir); return; }
|
|
178
|
+
await this.ftp!.downloadToDir(localDir, remoteDir);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Beweis-Maschine: erst beweisen, dann behaupten.
|
|
3
|
+
* Jeder Transfer bekommt sein Urteil - PROVEN nur bei identischem SHA-256.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { createReadStream } from "node:fs";
|
|
7
|
+
|
|
8
|
+
export type ProofMode = "hash" | "size" | "none";
|
|
9
|
+
export type Verdict = "PROVEN" | "SIZE_MATCH" | "UNVERIFIED" | "MISMATCH";
|
|
10
|
+
|
|
11
|
+
export interface TransferProof {
|
|
12
|
+
verdict: Verdict;
|
|
13
|
+
detail: string;
|
|
14
|
+
local_sha256?: string;
|
|
15
|
+
remote_sha256?: string;
|
|
16
|
+
local_bytes?: number;
|
|
17
|
+
remote_bytes?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function localSha256(path: string): Promise<{ sha256: string; bytes: number }> {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const hash = createHash("sha256");
|
|
23
|
+
let bytes = 0;
|
|
24
|
+
const rs = createReadStream(path);
|
|
25
|
+
rs.on("data", (chunk) => {
|
|
26
|
+
hash.update(chunk);
|
|
27
|
+
bytes += chunk.length;
|
|
28
|
+
});
|
|
29
|
+
rs.on("end", () => resolve({ sha256: hash.digest("hex"), bytes }));
|
|
30
|
+
rs.on("error", reject);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function judge(
|
|
35
|
+
local: { sha256?: string; bytes?: number },
|
|
36
|
+
remote: { sha256?: string; bytes?: number },
|
|
37
|
+
mode: ProofMode
|
|
38
|
+
): TransferProof {
|
|
39
|
+
if (mode === "none") {
|
|
40
|
+
return { verdict: "UNVERIFIED", detail: "Beweis abgewaehlt (proof=none) - Transfer ohne Urteil." };
|
|
41
|
+
}
|
|
42
|
+
if (mode === "size") {
|
|
43
|
+
const ok = local.bytes !== undefined && local.bytes === remote.bytes;
|
|
44
|
+
return {
|
|
45
|
+
verdict: ok ? "SIZE_MATCH" : "MISMATCH",
|
|
46
|
+
detail: ok
|
|
47
|
+
? `Groessen identisch (${local.bytes} Bytes) - schwacher Beweis, fuer harte Faelle proof=hash nutzen.`
|
|
48
|
+
: `Groessen weichen ab: lokal ${local.bytes} vs remote ${remote.bytes} Bytes.`,
|
|
49
|
+
local_bytes: local.bytes,
|
|
50
|
+
remote_bytes: remote.bytes,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const ok = !!local.sha256 && local.sha256 === remote.sha256;
|
|
54
|
+
return {
|
|
55
|
+
verdict: ok ? "PROVEN" : "MISMATCH",
|
|
56
|
+
detail: ok
|
|
57
|
+
? "SHA-256 identisch - Transfer bewiesen."
|
|
58
|
+
: "SHA-256 weicht ab - Transfer NICHT vertrauen, erneut uebertragen.",
|
|
59
|
+
local_sha256: local.sha256,
|
|
60
|
+
remote_sha256: remote.sha256,
|
|
61
|
+
local_bytes: local.bytes,
|
|
62
|
+
remote_bytes: remote.bytes,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verzeichnis-Werkzeuge: rename, upload_dir, download_dir.
|
|
3
|
+
* Auch Verzeichnisse liefern Urteile - pro Datei, mit Mismatch-Liste.
|
|
4
|
+
*/
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { readdirSync, statSync } from "node:fs";
|
|
8
|
+
import { join, relative } from "node:path";
|
|
9
|
+
import { ConnectionManager } from "../services/connection.js";
|
|
10
|
+
import { localSha256 } from "../services/proof.js";
|
|
11
|
+
|
|
12
|
+
type ToolResult = {
|
|
13
|
+
content: Array<{ type: "text"; text: string }>;
|
|
14
|
+
structuredContent?: Record<string, unknown>;
|
|
15
|
+
isError?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function ok(output: Record<string, unknown>): ToolResult {
|
|
19
|
+
return {
|
|
20
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
21
|
+
structuredContent: output,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fail(err: unknown, hint: string): ToolResult {
|
|
26
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
27
|
+
const output = { error: msg, hint };
|
|
28
|
+
return {
|
|
29
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
30
|
+
structuredContent: output,
|
|
31
|
+
isError: true,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Alle Dateien unter dir, als relative Pfade mit / als Trenner. */
|
|
36
|
+
function walkLocal(dir: string, base: string = dir): string[] {
|
|
37
|
+
const out: string[] = [];
|
|
38
|
+
for (const name of readdirSync(dir)) {
|
|
39
|
+
const full = join(dir, name);
|
|
40
|
+
if (statSync(full).isDirectory()) out.push(...walkLocal(full, base));
|
|
41
|
+
else out.push(relative(base, full).split("\\").join("/"));
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function registerDirTools(server: McpServer, cm: ConnectionManager): void {
|
|
47
|
+
server.registerTool(
|
|
48
|
+
"silicon_rename",
|
|
49
|
+
{
|
|
50
|
+
title: "Umbenennen / Verschieben",
|
|
51
|
+
description: "Benennt eine Remote-Datei oder ein Verzeichnis um bzw. verschiebt es.",
|
|
52
|
+
inputSchema: {
|
|
53
|
+
fromPath: z.string().describe("Aktueller Remote-Pfad"),
|
|
54
|
+
toPath: z.string().describe("Neuer Remote-Pfad"),
|
|
55
|
+
},
|
|
56
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
57
|
+
},
|
|
58
|
+
async ({ fromPath, toPath }) => {
|
|
59
|
+
try {
|
|
60
|
+
await cm.rename(fromPath, toPath);
|
|
61
|
+
return ok({ status: "umbenannt", fromPath, toPath });
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return fail(e, "Quelle vorhanden? Ziel frei? Rechte pruefen.");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
server.registerTool(
|
|
69
|
+
"silicon_upload_dir",
|
|
70
|
+
{
|
|
71
|
+
title: "Verzeichnis hochladen (mit Beweis-Wanderung)",
|
|
72
|
+
description:
|
|
73
|
+
"Laedt ein lokales Verzeichnis rekursiv hoch. Danach wird jede Datei einzeln verglichen: proof=hash prueft SHA-256 pro Datei (gruendlich, langsamer), proof=size vergleicht Groessen (schnell), none ueberspringt.",
|
|
74
|
+
inputSchema: {
|
|
75
|
+
localDir: z.string().describe("Lokales Quellverzeichnis"),
|
|
76
|
+
remoteDir: z.string().describe("Remote-Zielverzeichnis"),
|
|
77
|
+
proof: z.enum(["hash", "size", "none"]).default("size").describe("Beweisstufe pro Datei (Standard: size)"),
|
|
78
|
+
},
|
|
79
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
80
|
+
},
|
|
81
|
+
async ({ localDir, remoteDir, proof }) => {
|
|
82
|
+
try {
|
|
83
|
+
const t0 = Date.now();
|
|
84
|
+
const files = walkLocal(localDir);
|
|
85
|
+
await cm.uploadDir(localDir, remoteDir);
|
|
86
|
+
const mismatches: Array<Record<string, unknown>> = [];
|
|
87
|
+
let checked = 0;
|
|
88
|
+
|
|
89
|
+
if (proof !== "none") {
|
|
90
|
+
for (const rel of files) {
|
|
91
|
+
const lp = join(localDir, rel);
|
|
92
|
+
const rp = `${remoteDir.replace(/\/$/, "")}/${rel}`;
|
|
93
|
+
if (proof === "hash") {
|
|
94
|
+
const [l, r] = await Promise.all([localSha256(lp), cm.remoteSha256(rp)]);
|
|
95
|
+
checked++;
|
|
96
|
+
if (l.sha256 !== r.sha256) mismatches.push({ file: rel, local_sha256: l.sha256, remote_sha256: r.sha256 });
|
|
97
|
+
} else {
|
|
98
|
+
const lb = statSync(lp).size;
|
|
99
|
+
const rb = await cm.remoteSize(rp);
|
|
100
|
+
checked++;
|
|
101
|
+
if (lb !== rb) mismatches.push({ file: rel, local_bytes: lb, remote_bytes: rb });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const verdict =
|
|
106
|
+
proof === "none" ? "UNVERIFIED" : mismatches.length === 0 ? (proof === "hash" ? "PROVEN" : "SIZE_MATCH") : "MISMATCH";
|
|
107
|
+
return ok({
|
|
108
|
+
localDir,
|
|
109
|
+
remoteDir,
|
|
110
|
+
files_total: files.length,
|
|
111
|
+
files_checked: checked,
|
|
112
|
+
duration_ms: Date.now() - t0,
|
|
113
|
+
proof: { verdict, mismatches },
|
|
114
|
+
});
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return fail(e, "Lokales Verzeichnis vorhanden? Remote-Rechte? Bei grossen Baeumen proof=size nutzen.");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
server.registerTool(
|
|
122
|
+
"silicon_download_dir",
|
|
123
|
+
{
|
|
124
|
+
title: "Verzeichnis herunterladen (mit Beweis-Wanderung)",
|
|
125
|
+
description:
|
|
126
|
+
"Laedt ein Remote-Verzeichnis rekursiv herunter. Danach wird jede lokale Datei gegen die Remote-Quelle verglichen (hash/size/none, Standard size).",
|
|
127
|
+
inputSchema: {
|
|
128
|
+
remoteDir: z.string().describe("Remote-Quellverzeichnis"),
|
|
129
|
+
localDir: z.string().describe("Lokales Zielverzeichnis"),
|
|
130
|
+
proof: z.enum(["hash", "size", "none"]).default("size").describe("Beweisstufe pro Datei (Standard: size)"),
|
|
131
|
+
},
|
|
132
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async ({ remoteDir, localDir, proof }) => {
|
|
136
|
+
try {
|
|
137
|
+
const t0 = Date.now();
|
|
138
|
+
await cm.downloadDir(remoteDir, localDir);
|
|
139
|
+
const files = walkLocal(localDir);
|
|
140
|
+
const mismatches: Array<Record<string, unknown>> = [];
|
|
141
|
+
let checked = 0;
|
|
142
|
+
if (proof !== "none") {
|
|
143
|
+
for (const rel of files) {
|
|
144
|
+
const lp = join(localDir, rel);
|
|
145
|
+
const rp = `${remoteDir.replace(/\/$/, "")}/${rel}`;
|
|
146
|
+
if (proof === "hash") {
|
|
147
|
+
const [l, r] = await Promise.all([localSha256(lp), cm.remoteSha256(rp)]);
|
|
148
|
+
checked++;
|
|
149
|
+
if (l.sha256 !== r.sha256) mismatches.push({ file: rel, local_sha256: l.sha256, remote_sha256: r.sha256 });
|
|
150
|
+
} else {
|
|
151
|
+
const lb = statSync(lp).size;
|
|
152
|
+
const rb = await cm.remoteSize(rp);
|
|
153
|
+
checked++;
|
|
154
|
+
if (lb !== rb) mismatches.push({ file: rel, local_bytes: lb, remote_bytes: rb });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const verdict =
|
|
159
|
+
proof === "none" ? "UNVERIFIED" : mismatches.length === 0 ? (proof === "hash" ? "PROVEN" : "SIZE_MATCH") : "MISMATCH";
|
|
160
|
+
return ok({
|
|
161
|
+
remoteDir,
|
|
162
|
+
localDir,
|
|
163
|
+
files_total: files.length,
|
|
164
|
+
files_checked: checked,
|
|
165
|
+
duration_ms: Date.now() - t0,
|
|
166
|
+
proof: { verdict, mismatches },
|
|
167
|
+
});
|
|
168
|
+
} catch (e) {
|
|
169
|
+
return fail(e, "Remote-Verzeichnis vorhanden? Lokale Schreibrechte? Bei grossen Baeumen proof=size nutzen.");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
);
|
|
173
|
+
}
|