@supernova123/docker-mcp-server 0.3.3 → 0.3.5
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/.github/workflows/ci.yml +23 -0
- package/CHANGELOG.md +27 -1
- package/README.md +2 -0
- package/dist/server.js +3 -1
- package/dist/tools/compose.js +2 -2
- package/dist/tools/container.js +86 -1
- package/dist/tools/health.js +1 -1
- package/dist/tools/image.js +44 -2
- package/dist/tools/transfer.d.ts +4 -0
- package/dist/tools/transfer.js +175 -0
- package/dist/types.d.ts +56 -0
- package/dist/types.js +23 -0
- package/glama.json +28 -5
- package/package.json +9 -4
- package/src/cf-worker/README.md +73 -0
- package/src/cf-worker/index.ts +546 -0
- package/src/cf-worker/landing.html +390 -0
- package/src/cf-worker/mcp-agent.ts +362 -0
- package/src/cf-worker/types.ts +38 -0
- package/src/server.ts +4 -2
- package/src/tools/compose.ts +2 -2
- package/src/tools/container.ts +106 -0
- package/src/tools/health.ts +1 -1
- package/src/tools/image.ts +54 -1
- package/src/tools/transfer.ts +245 -0
- package/src/types.ts +29 -1
- package/tests/transfer.test.ts +176 -0
- package/tsconfig.cf.json +17 -0
- package/tsconfig.json +1 -1
- package/wrangler.jsonc +19 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import Dockerode from "dockerode";
|
|
2
|
+
import { Readable } from "stream";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { CopyFromContainerSchema, CopyToContainerSchema } from "../types.js";
|
|
5
|
+
import { formatError, withRetry } from "../docker.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read a file from a container using docker exec (cat).
|
|
9
|
+
* Much simpler and more reliable than parsing getArchive tar streams.
|
|
10
|
+
*/
|
|
11
|
+
async function readFileViaExec(
|
|
12
|
+
docker: Dockerode,
|
|
13
|
+
containerId: string,
|
|
14
|
+
filePath: string
|
|
15
|
+
): Promise<{ content: string; size: number }> {
|
|
16
|
+
const container = docker.getContainer(containerId);
|
|
17
|
+
|
|
18
|
+
// Create exec to cat the file
|
|
19
|
+
const exec = await container.exec({
|
|
20
|
+
Cmd: ["cat", filePath],
|
|
21
|
+
AttachStdout: true,
|
|
22
|
+
AttachStderr: true,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Start exec and collect output
|
|
26
|
+
const stream = await exec.start({ Detach: false });
|
|
27
|
+
|
|
28
|
+
const chunks: Buffer[] = [];
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
stream.on("data", (chunk: Buffer) => {
|
|
31
|
+
// Docker exec streams have 8-byte headers per frame
|
|
32
|
+
// Skip the header bytes (first 8 bytes of each frame)
|
|
33
|
+
if (chunk.length > 8) {
|
|
34
|
+
chunks.push(chunk.slice(8));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
stream.on("end", () => {
|
|
38
|
+
const content = Buffer.concat(chunks).toString("utf-8");
|
|
39
|
+
resolve({ content, size: content.length });
|
|
40
|
+
});
|
|
41
|
+
stream.on("error", reject);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get file metadata (size, permissions) via stat command.
|
|
47
|
+
*/
|
|
48
|
+
async function getFileStat(
|
|
49
|
+
docker: Dockerode,
|
|
50
|
+
containerId: string,
|
|
51
|
+
filePath: string
|
|
52
|
+
): Promise<{ size: number; mode: string; isFile: boolean }> {
|
|
53
|
+
const container = docker.getContainer(containerId);
|
|
54
|
+
|
|
55
|
+
const exec = await container.exec({
|
|
56
|
+
Cmd: ["stat", "-c", "%s %a %f", filePath],
|
|
57
|
+
AttachStdout: true,
|
|
58
|
+
AttachStderr: true,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const stream = await exec.start({ Detach: false });
|
|
62
|
+
const chunks: Buffer[] = [];
|
|
63
|
+
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
stream.on("data", (chunk: Buffer) => {
|
|
66
|
+
if (chunk.length > 8) chunks.push(chunk.slice(8));
|
|
67
|
+
});
|
|
68
|
+
stream.on("end", () => {
|
|
69
|
+
const output = Buffer.concat(chunks).toString("utf-8").trim();
|
|
70
|
+
const [sizeStr, modeStr, typeStr] = output.split(" ");
|
|
71
|
+
const size = parseInt(sizeStr, 10) || 0;
|
|
72
|
+
const mode = modeStr || "644";
|
|
73
|
+
const isFile = typeStr?.startsWith("81") ?? true;
|
|
74
|
+
resolve({ size, mode: `0${mode}`, isFile });
|
|
75
|
+
});
|
|
76
|
+
stream.on("error", reject);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create a minimal tar archive buffer containing a single file.
|
|
82
|
+
* Used for putArchive to inject files into containers.
|
|
83
|
+
*/
|
|
84
|
+
function createSingleFileTar(
|
|
85
|
+
filePath: string,
|
|
86
|
+
content: string,
|
|
87
|
+
mode: number
|
|
88
|
+
): Buffer {
|
|
89
|
+
const contentBuffer = Buffer.from(content, "utf-8");
|
|
90
|
+
const contentBlocks = Math.ceil(contentBuffer.length / 512);
|
|
91
|
+
const totalSize = 512 + contentBlocks * 512;
|
|
92
|
+
|
|
93
|
+
const tar = Buffer.alloc(totalSize, 0);
|
|
94
|
+
|
|
95
|
+
// File name (100 bytes, null-terminated)
|
|
96
|
+
const nameBytes = Buffer.from(filePath, "utf-8");
|
|
97
|
+
nameBytes.copy(tar, 0, 0, Math.min(nameBytes.length, 100));
|
|
98
|
+
|
|
99
|
+
// File mode (8 bytes, octal, null-padded)
|
|
100
|
+
const modeStr = mode.toString(8).padStart(7, "0") + "\0";
|
|
101
|
+
Buffer.from(modeStr).copy(tar, 100);
|
|
102
|
+
|
|
103
|
+
// Owner ID (8 bytes) - 0
|
|
104
|
+
Buffer.from("0000000\0").copy(tar, 108);
|
|
105
|
+
|
|
106
|
+
// Group ID (8 bytes) - 0
|
|
107
|
+
Buffer.from("0000000\0").copy(tar, 116);
|
|
108
|
+
|
|
109
|
+
// File size (12 bytes, octal)
|
|
110
|
+
const sizeStr = contentBuffer.length.toString(8).padStart(11, "0") + "\0";
|
|
111
|
+
Buffer.from(sizeStr).copy(tar, 124);
|
|
112
|
+
|
|
113
|
+
// Modification time (12 bytes, octal)
|
|
114
|
+
const mtime = Math.floor(Date.now() / 1000);
|
|
115
|
+
const mtimeStr = mtime.toString(8).padStart(11, "0") + "\0";
|
|
116
|
+
Buffer.from(mtimeStr).copy(tar, 136);
|
|
117
|
+
|
|
118
|
+
// Type flag (1 byte) - '0' = regular file
|
|
119
|
+
tar[156] = 0x30; // '0'
|
|
120
|
+
|
|
121
|
+
// Checksum placeholder (8 bytes)
|
|
122
|
+
tar.fill(" ", 148, 156);
|
|
123
|
+
|
|
124
|
+
// Compute checksum
|
|
125
|
+
let checksum = 0;
|
|
126
|
+
for (let i = 0; i < 512; i++) {
|
|
127
|
+
checksum += tar[i];
|
|
128
|
+
}
|
|
129
|
+
const chkStr = checksum.toString(8).padStart(7, "0") + "\0";
|
|
130
|
+
Buffer.from(chkStr).copy(tar, 148);
|
|
131
|
+
|
|
132
|
+
// Copy content
|
|
133
|
+
contentBuffer.copy(tar, 512);
|
|
134
|
+
|
|
135
|
+
return tar;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function registerTransferTools(
|
|
139
|
+
server: McpServer,
|
|
140
|
+
docker: Dockerode
|
|
141
|
+
): void {
|
|
142
|
+
server.tool(
|
|
143
|
+
"copy_from_container",
|
|
144
|
+
"Copy a file from a Docker container to read its contents. Returns the file content as text along with metadata (size, permissions). Useful for inspecting config files, logs, or application state inside running containers.",
|
|
145
|
+
CopyFromContainerSchema.shape,
|
|
146
|
+
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
147
|
+
async (params) => {
|
|
148
|
+
try {
|
|
149
|
+
const { content, size } = await withRetry(
|
|
150
|
+
() => readFileViaExec(docker, params.container_id, params.container_path),
|
|
151
|
+
{ label: "copy_from_container" }
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Get metadata (stat)
|
|
155
|
+
let mode = "0644";
|
|
156
|
+
try {
|
|
157
|
+
const stat = await getFileStat(
|
|
158
|
+
docker,
|
|
159
|
+
params.container_id,
|
|
160
|
+
params.container_path
|
|
161
|
+
);
|
|
162
|
+
mode = stat.mode;
|
|
163
|
+
} catch {
|
|
164
|
+
// stat might fail if file doesn't exist, exec already validated it
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const result = {
|
|
168
|
+
path: params.container_path,
|
|
169
|
+
content,
|
|
170
|
+
size,
|
|
171
|
+
mode,
|
|
172
|
+
truncated: content.length > 50000,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
177
|
+
};
|
|
178
|
+
} catch (error) {
|
|
179
|
+
return {
|
|
180
|
+
content: [{ type: "text", text: `Error: ${formatError(error)}` }],
|
|
181
|
+
isError: true,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
server.tool(
|
|
188
|
+
"copy_to_container",
|
|
189
|
+
"Write file content into a Docker container at the specified path. Overwrites existing files. Useful for injecting configuration files, scripts, or environment files into running or stopped containers.",
|
|
190
|
+
CopyToContainerSchema.shape,
|
|
191
|
+
{ readOnlyHint: false, idempotentHint: false, openWorldHint: false },
|
|
192
|
+
async (params) => {
|
|
193
|
+
try {
|
|
194
|
+
const container = docker.getContainer(params.container_id);
|
|
195
|
+
const mode = params.mode ?? 0o644;
|
|
196
|
+
|
|
197
|
+
// Create tar archive with the file
|
|
198
|
+
const tarBuffer = createSingleFileTar(
|
|
199
|
+
params.container_path,
|
|
200
|
+
params.content,
|
|
201
|
+
mode
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// putArchive expects the path to be the PARENT directory
|
|
205
|
+
const parts = params.container_path.split("/");
|
|
206
|
+
parts.pop(); // remove filename
|
|
207
|
+
const dirPath = parts.join("/") || "/";
|
|
208
|
+
|
|
209
|
+
const readable = Readable.from(tarBuffer);
|
|
210
|
+
|
|
211
|
+
// Use putArchive with promise API
|
|
212
|
+
await withRetry(
|
|
213
|
+
() =>
|
|
214
|
+
new Promise<void>((resolve, reject) => {
|
|
215
|
+
container
|
|
216
|
+
.putArchive(readable as any, { path: dirPath })
|
|
217
|
+
.then(() => resolve())
|
|
218
|
+
.catch(reject);
|
|
219
|
+
}),
|
|
220
|
+
{ label: "copy_to_container" }
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
content: [
|
|
225
|
+
{
|
|
226
|
+
type: "text",
|
|
227
|
+
text: JSON.stringify({
|
|
228
|
+
success: true,
|
|
229
|
+
path: params.container_path,
|
|
230
|
+
size: Buffer.byteLength(params.content, "utf-8"),
|
|
231
|
+
mode: `0${(mode & 0o777).toString(8)}`,
|
|
232
|
+
message: `File written to ${params.container_path} in container ${params.container_id}`,
|
|
233
|
+
}),
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
};
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return {
|
|
239
|
+
content: [{ type: "text", text: `Error: ${formatError(error)}` }],
|
|
240
|
+
isError: true,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
);
|
|
245
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -186,6 +186,21 @@ export const PruneVolumesSchema = z.object({
|
|
|
186
186
|
filter: z.string().optional().describe("Filter by label (e.g., 'label=key=value')"),
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
+
export const PruneContainersSchema = z.object({
|
|
190
|
+
filter: z.string().optional().describe("Filter by label (e.g., 'label=key=value')"),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
export const PruneImagesSchema = z.object({
|
|
194
|
+
filter: z.string().optional().describe('Docker filters JSON (e.g. "dangling=true")'),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
export const UpdateContainerSchema = z.object({
|
|
198
|
+
container_id: z.string().describe('Container ID or name'),
|
|
199
|
+
cpu_limit: z.number().optional().describe('CPU limit in cores (e.g. 1.5 for 1.5 CPUs)'),
|
|
200
|
+
memory_limit: z.string().optional().describe('Memory limit (e.g. "512m", "1g", "2048m")'),
|
|
201
|
+
cpu_shares: z.number().optional().describe('CPU shares (relative weight, 0-1024)'),
|
|
202
|
+
});
|
|
203
|
+
|
|
189
204
|
|
|
190
205
|
// Monitoring schemas (v0.2.0)
|
|
191
206
|
export const ContainerHealthStatusSchema = z.object({});
|
|
@@ -220,4 +235,17 @@ export const MonitorDashboardSchema = z.object({});
|
|
|
220
235
|
// System info schemas (v0.3.3)
|
|
221
236
|
export const DockerInfoSchema = z.object({});
|
|
222
237
|
|
|
223
|
-
export const DiskUsageSchema = z.object({});
|
|
238
|
+
export const DiskUsageSchema = z.object({});
|
|
239
|
+
|
|
240
|
+
// File transfer schemas (v0.3.4)
|
|
241
|
+
export const CopyFromContainerSchema = z.object({
|
|
242
|
+
container_id: z.string().describe("Container ID or name"),
|
|
243
|
+
container_path: z.string().describe("Path inside container to copy from (e.g., '/etc/nginx/nginx.conf')"),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
export const CopyToContainerSchema = z.object({
|
|
247
|
+
container_id: z.string().describe("Container ID or name"),
|
|
248
|
+
container_path: z.string().describe("Destination path inside container (e.g., '/app/config.json')"),
|
|
249
|
+
content: z.string().describe("File content to write (plain text)"),
|
|
250
|
+
mode: z.number().optional().describe("File permissions in octal (e.g., 0o644 = 420). Default: 0o644"),
|
|
251
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { PassThrough } from "stream";
|
|
3
|
+
|
|
4
|
+
const { mockExec, mockPutArchive } = vi.hoisted(() => ({
|
|
5
|
+
mockExec: vi.fn(),
|
|
6
|
+
mockPutArchive: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("dockerode", () => {
|
|
10
|
+
return {
|
|
11
|
+
default: vi.fn().mockImplementation(() => ({
|
|
12
|
+
getContainer: vi.fn().mockReturnValue({
|
|
13
|
+
exec: mockExec,
|
|
14
|
+
putArchive: mockPutArchive,
|
|
15
|
+
}),
|
|
16
|
+
})),
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
import { registerTransferTools } from "../src/tools/transfer.js";
|
|
21
|
+
import { createDockerClient } from "../src/docker.js";
|
|
22
|
+
|
|
23
|
+
function createMockServer() {
|
|
24
|
+
const tools: Record<string, { description: string; handler: Function }> = {};
|
|
25
|
+
return {
|
|
26
|
+
tool: (
|
|
27
|
+
name: string,
|
|
28
|
+
description: string,
|
|
29
|
+
_schemaOrAnnotations: unknown,
|
|
30
|
+
_annotationsOrHandler: unknown,
|
|
31
|
+
_maybeHandler?: Function
|
|
32
|
+
) => {
|
|
33
|
+
const handler =
|
|
34
|
+
typeof _annotationsOrHandler === "function"
|
|
35
|
+
? _annotationsOrHandler
|
|
36
|
+
: (_maybeHandler as Function);
|
|
37
|
+
tools[name] = { description, handler };
|
|
38
|
+
},
|
|
39
|
+
tools,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("Transfer Tools", () => {
|
|
44
|
+
let server: ReturnType<typeof createMockServer>;
|
|
45
|
+
let docker: ReturnType<typeof createDockerClient>;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
vi.clearAllMocks();
|
|
49
|
+
server = createMockServer();
|
|
50
|
+
docker = createDockerClient();
|
|
51
|
+
registerTransferTools(server as any, docker);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("copy_from_container", () => {
|
|
55
|
+
it("calls exec with correct command", async () => {
|
|
56
|
+
// Mock exec to reject (error path tests the call chain)
|
|
57
|
+
mockExec.mockRejectedValue(new Error("No such container"));
|
|
58
|
+
|
|
59
|
+
const result = await server.tools["copy_from_container"].handler({
|
|
60
|
+
container_id: "test-container",
|
|
61
|
+
container_path: "/etc/hosts",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Verify exec was called with the correct options
|
|
65
|
+
expect(mockExec).toHaveBeenCalledWith({
|
|
66
|
+
Cmd: ["cat", "/etc/hosts"],
|
|
67
|
+
AttachStdout: true,
|
|
68
|
+
AttachStderr: true,
|
|
69
|
+
});
|
|
70
|
+
expect(result.isError).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("handles exec errors gracefully", async () => {
|
|
74
|
+
mockExec.mockRejectedValue(new Error("No such container"));
|
|
75
|
+
|
|
76
|
+
const result = await server.tools["copy_from_container"].handler({
|
|
77
|
+
container_id: "nonexistent",
|
|
78
|
+
container_path: "/etc/hosts",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(result.isError).toBe(true);
|
|
82
|
+
expect(result.content[0].text).toContain("Error");
|
|
83
|
+
expect(result.content[0].text).toContain("No such container");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("tool is registered with correct metadata", () => {
|
|
87
|
+
const tool = server.tools["copy_from_container"];
|
|
88
|
+
expect(tool).toBeDefined();
|
|
89
|
+
expect(tool.description).toContain("Copy a file from a Docker container");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("copy_to_container", () => {
|
|
94
|
+
it("writes a file into container", async () => {
|
|
95
|
+
mockPutArchive.mockResolvedValue({});
|
|
96
|
+
|
|
97
|
+
const result = await server.tools["copy_to_container"].handler({
|
|
98
|
+
container_id: "abc123",
|
|
99
|
+
container_path: "/app/config.json",
|
|
100
|
+
content: '{"key": "value"}',
|
|
101
|
+
mode: 0o644,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const data = JSON.parse(result.content[0].text);
|
|
105
|
+
expect(data.success).toBe(true);
|
|
106
|
+
expect(data.path).toBe("/app/config.json");
|
|
107
|
+
expect(data.size).toBe(16); // '{"key": "value"}' = 16 bytes
|
|
108
|
+
expect(result.isError).toBeFalsy();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("uses default mode 0o644 when not specified", async () => {
|
|
112
|
+
mockPutArchive.mockResolvedValue({});
|
|
113
|
+
|
|
114
|
+
const result = await server.tools["copy_to_container"].handler({
|
|
115
|
+
container_id: "abc123",
|
|
116
|
+
container_path: "/app/script.sh",
|
|
117
|
+
content: "#!/bin/bash\necho hello",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const data = JSON.parse(result.content[0].text);
|
|
121
|
+
expect(data.mode).toBe("0644");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("calls putArchive with parent directory", async () => {
|
|
125
|
+
mockPutArchive.mockResolvedValue({});
|
|
126
|
+
|
|
127
|
+
await server.tools["copy_to_container"].handler({
|
|
128
|
+
container_id: "abc123",
|
|
129
|
+
container_path: "/app/config.json",
|
|
130
|
+
content: "data",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// putArchive should be called (stream, { path: "/app" })
|
|
134
|
+
expect(mockPutArchive).toHaveBeenCalled();
|
|
135
|
+
const callArgs = mockPutArchive.mock.calls[0];
|
|
136
|
+
expect(callArgs[1]).toEqual({ path: "/app" });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("handles nested paths correctly", async () => {
|
|
140
|
+
mockPutArchive.mockResolvedValue({});
|
|
141
|
+
|
|
142
|
+
const result = await server.tools["copy_to_container"].handler({
|
|
143
|
+
container_id: "abc123",
|
|
144
|
+
container_path: "/etc/nginx/nginx.conf",
|
|
145
|
+
content: "worker_processes 1;",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const data = JSON.parse(result.content[0].text);
|
|
149
|
+
expect(data.success).toBe(true);
|
|
150
|
+
expect(data.path).toBe("/etc/nginx/nginx.conf");
|
|
151
|
+
|
|
152
|
+
// Parent dir should be /etc/nginx
|
|
153
|
+
const callArgs = mockPutArchive.mock.calls[0];
|
|
154
|
+
expect(callArgs[1]).toEqual({ path: "/etc/nginx" });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("handles putArchive errors gracefully", async () => {
|
|
158
|
+
mockPutArchive.mockRejectedValue(new Error("Permission denied"));
|
|
159
|
+
|
|
160
|
+
const result = await server.tools["copy_to_container"].handler({
|
|
161
|
+
container_id: "abc123",
|
|
162
|
+
container_path: "/root/secret.txt",
|
|
163
|
+
content: "data",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(result.isError).toBe(true);
|
|
167
|
+
expect(result.content[0].text).toContain("Error");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("tool is registered with correct metadata", () => {
|
|
171
|
+
const tool = server.tools["copy_to_container"];
|
|
172
|
+
expect(tool).toBeDefined();
|
|
173
|
+
expect(tool.description).toContain("Write file content into a Docker container");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
package/tsconfig.cf.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ES2022",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"outDir": "dist-cf",
|
|
7
|
+
"rootDir": "src/cf-worker",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"types": ["@cloudflare/workers-types"]
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/cf-worker/**/*"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|
package/tsconfig.json
CHANGED
package/wrangler.jsonc
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "node_modules/wrangler/config-schema.json",
|
|
3
|
+
"name": "docker-mcp-worker",
|
|
4
|
+
"main": "src/cf-worker/index.ts",
|
|
5
|
+
"compatibility_date": "2026-01-28",
|
|
6
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
7
|
+
"observability": { "logs": { "enabled": true } },
|
|
8
|
+
"durable_objects": {
|
|
9
|
+
"bindings": [
|
|
10
|
+
{ "name": "MCP_AGENT", "class_name": "McpAgentDO" }
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
"migrations": [
|
|
14
|
+
{ "tag": "v1", "new_sqlite_classes": ["McpAgentDO"] }
|
|
15
|
+
],
|
|
16
|
+
"kv_namespaces": [
|
|
17
|
+
{ "binding": "API_KEYS", "id": "placeholder-api-keys" }
|
|
18
|
+
]
|
|
19
|
+
}
|