Skip to content

Commit 0dfdb3e

Browse files
committed
Support devcontainer_workspace_folders
1 parent cf581e7 commit 0dfdb3e

File tree

5 files changed

+180
-37
lines changed

5 files changed

+180
-37
lines changed

README.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ npx -y mcp-devcontainers
3030
- Run ESLint: `npm run lint` - Executes ESLint for code validation
3131
- Fix ESLint issues: `npm run lint:fix` - Automatically fixes ESLint errors
3232

33-
## MCP Transport
33+
## MCP Transport
3434

3535
### Option 1 - Start STDIO server
3636

@@ -128,6 +128,58 @@ Initializes and starts a devcontainer environment in the specified workspace fol
128128

129129
Text content with the current devcontainer Docker process status
130130

131+
### `devcontainer_workspace_folders`
132+
133+
Runs find command to get all workspace folders with devcontainer config.
134+
135+
- #### Input Parameters
136+
| Name | Required | Type | Description |
137+
| -------- | -------- | -------- | -------- |
138+
| rootPath || string | A path used to search its subdirectories for all workspace folders containing a devcontainer configuration. |
139+
140+
- #### Returns
141+
142+
Text content with all workspace folders under the specified root path.
143+
144+
145+
## 🧑‍💻 Quick Experience / Trial
146+
147+
For developers who want to quickly try this project without a local Docker setup, we recommend using GitHub Codespaces:
148+
149+
150+
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/AI-QL/mcp-devcontainers?quickstart=1)
151+
152+
Then follow these steps to set up a trial environment:
153+
154+
- Wait for the environment to initialize in your browser
155+
156+
- Install dependencies: `npm install`
157+
158+
- Launch the service: `npm start http`
159+
160+
> The codespace will automatically provide a forwarded port (e.g., https://ominous-halibut-7vvq7v56vgq6hr5p9-3001.app.github.dev/)
161+
162+
- Make the `forwarded port` publicly accessible (located on the right side of the VSCode `Terminal` tab)
163+
164+
- Connect using [mcp-inspector](https://github.com/modelcontextprotocol/inspector) via Streamable HTTP
165+
166+
```bash
167+
npx -y @modelcontextprotocol/inspector
168+
```
169+
> For a streamable HTTP connection, remember to append `/mcp` to the URL
170+
171+
For MCP Clients that don't support remote URLs, use this alternative configuration:
172+
173+
```json
174+
{
175+
"mcpServers": {
176+
"Devcontainer": {
177+
"command": "npx",
178+
"args": ["mcp-remote", "https://ominous-halibut-7vvq7v56vgq6hr5p9-3001.app.github.dev/mcp"]
179+
}
180+
}
181+
}
182+
```
131183

132184
## 🤝 Contributing
133185

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mcp-devcontainers",
3-
"version": "0.1.3",
3+
"version": "1.0.0",
44
"description": "MCP Server for devcontainers management",
55
"private": false,
66
"license": "MIT",

src/docker.ts

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { spawn, SpawnOptions, exec } from "child_process";
2+
import { promises } from "fs";
23
import fs from "fs";
4+
import path from "path";
35
import { z } from "zod";
4-
import { promisify } from 'util';
6+
import { promisify } from "util";
57

68
const execAsync = promisify(exec);
79

@@ -11,14 +13,23 @@ const COMMAND = "docker";
1113

1214
type CommandResult = Promise<string>;
1315

14-
const PS_PATTERN = "\"{psID: {{.ID}}, psName: {{.Names}}, workspaceFolder: {{.Label \"devcontainer.local_folder\"}}, container: {{.Label \"dev.containers.id\"}}}\""
16+
const PS_PATTERN =
17+
'{psID: {{.ID}}, psName: {{.Names}}, workspaceFolder: {{.Label "devcontainer.local_folder"}}, container: {{.Label "dev.containers.id"}}}';
18+
19+
const WS_FOLDER_DESC =
20+
"A path used to search its subdirectories for all workspace folders containing a devcontainer configuration.";
1521

1622
export const DevCleanupSchema = z.object({});
1723

1824
export const DevListSchema = z.object({});
1925

26+
export const DevWsFolderSchema = z.object({
27+
rootPath: z.string().describe(WS_FOLDER_DESC).optional(),
28+
});
29+
2030
type DevCleanupArgs = z.infer<typeof DevCleanupSchema>;
2131
type DevListArgs = z.infer<typeof DevListSchema>;
32+
type DevWsFolderArgs = z.infer<typeof DevWsFolderSchema>;
2233

2334
function createOutputStream(
2435
stdioFilePath: string = NULL_DEVICE
@@ -99,11 +110,15 @@ export async function devCleanup(options: DevCleanupArgs): CommandResult {
99110
let ids: string[];
100111

101112
try {
102-
const { stdout } = await execAsync("docker ps -aq -f label=dev.containers.id")
113+
const { stdout } = await execAsync(
114+
"docker ps -aq -f label=dev.containers.id"
115+
);
103116
const raw = stdout.toString().trim();
104117
ids = raw ? raw.split("\n") : [];
105118
} catch (error) {
106-
return Promise.reject(`Cannot list all docker ps: ${(error as Error).message}`);
119+
return Promise.reject(
120+
`Cannot list all docker ps: ${(error as Error).message}`
121+
);
107122
}
108123

109124
if (ids.length === 0) {
@@ -124,3 +139,67 @@ export async function devList(options: DevListArgs): CommandResult {
124139
stream
125140
);
126141
}
142+
143+
// Optionally skip well-known heavy directories to improve performance
144+
function shouldSkipDirectory(name: string): boolean {
145+
const skipDirs = ["node_modules", ".git", "dist", "build", ".next"];
146+
return skipDirs.includes(name);
147+
}
148+
149+
export async function devWsFolder(
150+
options: DevWsFolderArgs
151+
): Promise<CommandResult> {
152+
const rootPath = options.rootPath;
153+
const searchRoot = rootPath ? path.resolve(rootPath) : process.cwd();
154+
const results: string[] = [];
155+
156+
// Use concurrency to speed up directory traversal
157+
async function scanDirectory(dir: string): Promise<void> {
158+
let entries: fs.Dirent[];
159+
160+
try {
161+
entries = await promises.readdir(dir, { withFileTypes: true });
162+
} catch {
163+
// Skip directories that cannot be read (permissions, transient I/O errors, etc.)
164+
return;
165+
}
166+
167+
// Collect subdirectories and schedule checks separately to maximize parallelism
168+
const subDirs: string[] = [];
169+
const checkTasks: Promise<void>[] = [];
170+
171+
for (const entry of entries) {
172+
const fullPath = path.join(dir, entry.name);
173+
174+
if (entry.isDirectory()) {
175+
if (entry.name === ".devcontainer") {
176+
checkTasks.push(
177+
promises
178+
.access(path.join(fullPath, "devcontainer.json"))
179+
.then(() => void results.push(fullPath))
180+
.catch(() => {
181+
/* file not found; ignore */
182+
})
183+
);
184+
}
185+
else if (!shouldSkipDirectory(entry.name)) {
186+
subDirs.push(fullPath);
187+
}
188+
}
189+
}
190+
191+
// Wait for all devcontainer.json checks to complete
192+
await Promise.all(checkTasks);
193+
194+
// Recursively scan subdirectories in parallel
195+
await Promise.all(subDirs.map(scanDirectory));
196+
}
197+
198+
await scanDirectory(searchRoot);
199+
200+
if (results.length === 0) {
201+
throw new Error(`No Workspace Folders found in ${searchRoot}`);
202+
}
203+
204+
return "\n" + results.join("\n");
205+
}

src/server.ts

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@ import { devUp, devRunUserCommands, devExec } from "./devcontainer.js";
1010

1111
import { DevUpSchema, DevRunSchema, DevExecSchema } from "./devcontainer.js";
1212

13-
import { devCleanup, devList } from "./docker.js";
14-
import { DevCleanupSchema, DevListSchema } from "./docker.js";
13+
import { devCleanup, devList, devWsFolder } from "./docker.js";
14+
import {
15+
DevCleanupSchema,
16+
DevListSchema,
17+
DevWsFolderSchema,
18+
} from "./docker.js";
1519

1620
type ToolInput = Tool["inputSchema"];
1721
type ToolName = keyof typeof ToolMap;
@@ -55,14 +59,22 @@ const ToolMap = {
5559
label: "Devcontainer Cleanup",
5660
},
5761
devcontainer_list: {
58-
description:
59-
"Runs docker command to list all devcontainer environments.",
62+
description: "Runs docker command to list all devcontainer environments.",
6063
schema: DevListSchema,
6164
execute: async (args: ToolArgs) => {
6265
return devList(DevListSchema.parse(args));
6366
},
6467
label: "Devcontainer List",
6568
},
69+
devcontainer_workspace_folders: {
70+
description:
71+
"Runs find command to get all workspace folders with devcontainer config.",
72+
schema: DevWsFolderSchema,
73+
execute: async (args: ToolArgs) => {
74+
return devWsFolder(DevWsFolderSchema.parse(args));
75+
},
76+
label: "Devcontainer Workspace Folders",
77+
},
6678
};
6779

6880
export const createServer = () => {
@@ -103,34 +115,34 @@ export const createServer = () => {
103115
server.setRequestHandler(CallToolRequestSchema, async (request) => {
104116
const { name, arguments: args } = request.params;
105117
const Tool = ToolMap[name as ToolName];
106-
try {
107-
switch (name) {
108-
case "devcontainer_up":
109-
case "devcontainer_run_user_commands":
110-
case "devcontainer_exec":
111-
case "devcontainer_cleanup":
112-
case "devcontainer_list":
113-
{
114-
const result = await Tool.execute(args);
115-
return {
116-
content: [
117-
{ type: "text", text: `${Tool.label} result: ${result}` },
118-
],
119-
};
120-
}
121-
default:
118+
119+
switch (name) {
120+
case "devcontainer_up":
121+
case "devcontainer_run_user_commands":
122+
case "devcontainer_exec":
123+
case "devcontainer_cleanup":
124+
case "devcontainer_list":
125+
case "devcontainer_workspace_folders":
126+
{
127+
try {
128+
const result = await Tool.execute(args);
129+
return {
130+
content: [
131+
{ type: "text", text: `${Tool.label} result: ${result}` },
132+
],
133+
};
134+
} catch (error) {
135+
const message = error instanceof Error ? error.message : String(error)
122136
return {
123-
error: {
124-
code: -32602,
125-
message: `Undefined tool: ${name}`,
126-
},
137+
content: [
138+
{ type: "text", text: `${Tool.label} failure: ${message}` },
139+
],
140+
isError: true,
127141
};
142+
}
128143
}
129-
} catch (error) {
130-
return {
131-
content: [{ type: "text", text: `${Tool.label} failure: ${error}` }],
132-
isError: true,
133-
};
144+
default:
145+
throw new Error(`Undefined tool: ${name}`);
134146
}
135147
});
136148

0 commit comments

Comments
 (0)