Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscodeignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ node_modules/**

# Exclude test files
tests/**
test/**

# Exclude build scripts
scripts/**
Expand Down
1 change: 0 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,6 @@ FastEdge-vscode/
│ ├── FastEdgeDebugAdapterDescriptorFactory.ts
│ ├── compiler/ # Rust/JS compilation
│ ├── commands/ # VS Code commands
│ ├── dotenv/ # Dotenv handling
│ └── autorun/ # File watching
├── fastedge-cli/ # Bundled FastEdge-run binary
├── package.json # Extension manifest
Expand Down
8 changes: 6 additions & 2 deletions context/CONTEXT_INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ Use this tree to find relevant documentation for your task:
→ Read: `features/MCP_INTEGRATION.md`
→ Read: `features/COMMANDS.md` (mcpJson command)

**Task: Bump the pinned MCP server Docker image version**
→ Edit: `mcp-server.version` (one line — the only file to change)
→ Read: `features/MCP_INTEGRATION.md` (explains the build-time injection)

**Task: Add new configuration option**
→ Read: `architecture/CONFIGURATION_SYSTEM.md`
→ Read: `BUNDLED_DEBUGGER.md` (fastedge-config.test.json section)
Expand Down Expand Up @@ -134,7 +138,7 @@ Use this tree to find relevant documentation for your task:
| **DOTENV_SYSTEM.md** | Dotenv file handling | Dotenv loading issues |
| **CROSS_PLATFORM.md** | Linux/macOS/Windows support, CI matrix, spawn rules | Any platform-specific work or new process spawning |
| **LAUNCH_CONFIG.md** | Launch.json generation | Launch config changes |
| **MCP_INTEGRATION.md** | MCP server configuration | MCP feature work |
| **MCP_INTEGRATION.md** | MCP server config, image version pinning, how to bump | MCP feature work or bumping the server version |
| **AUTORUN_SYSTEM.md** | File watching, auto-trigger | Auto-run functionality |
| **CODESPACE_SECRETS.md** | GitHub Codespaces integration | Codespaces features |

Expand Down Expand Up @@ -234,7 +238,7 @@ See `SEARCH_GUIDE.md` for more patterns.
- Run debugger (current file or package entry)
- Setup Codespace secrets

5. **Configuration** (`src/dotenv/`, `src/utils/resolveAppRoot.ts`)
5. **Configuration** (`src/utils/resolveAppRoot.ts`)
- Dotenv file auto-discovery from `configRoot`
- `.fastedge-debug/` directory as app root marker; `fastedge-config.test.json` as runtime config store
- `resolveConfigRoot()` (finds `.fastedge-debug/`) / `resolveBuildRoot()` for per-app isolation
Expand Down
2 changes: 0 additions & 2 deletions context/features/DOTENV_SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ The dotenv system allows developers to:
- Support large configuration sets
- Share configurations across team

**File**: `src/dotenv/index.ts`

**See also**: `../DOTENV.md` (root) - User-facing documentation

---
Expand Down
105 changes: 105 additions & 0 deletions context/features/MCP_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# MCP Integration

The extension can generate a `.vscode/mcp.json` that wires up the
`fastedge-assistant` MCP server so AI clients (Claude, Codex, Cursor) can
use FastEdge tools from inside the workspace.

---

## How it works

Command: **FastEdge (Generate mcp.json)** → `src/commands/mcpJson.ts`

1. Reads any existing `.vscode/mcp.json` and merges the new server entry in.
2. Detects Codespaces (`CODESPACE_NAME` env): offers `gh secret set` path
(stores key as a Codespace secret, emits `${env:GCORE_API_KEY}` in the
file) or falls back to inline key with a security notice.
3. Prompts for the API key (**masked input**, `password: true`).
4. Writes the file; sets `chmod 0600` on local `file://` URIs (no-op on
Windows / remote providers).
5. Offers to add `.vscode/mcp.json` to `.gitignore`.

The generated entry looks like:

```json
{
"servers": {
"fastedge-assistant": {
"type": "stdio",
"command": "docker",
"args": ["run", "--rm", "-i", "--pull=always",
"-v", "${workspaceFolder}:/workspace",
"-e", "WORKSPACE_ROOT=/workspace",
"-e", "GCORE_API_KEY",
"ghcr.io/g-core/fastedge-mcp-server:0.2.9"],
"env": { "GCORE_API_KEY": "<key>" }
}
}
}
```

---

## Pinned image version — how to bump it

Tags on ghcr.io have no `v` prefix. The Docker image tag (`0.2.9` above) is **not hardcoded in source**. It is
read from a single file at build time and injected by esbuild:

```
mcp-server.version ← edit this file to bump the version
esbuild/build-ext.js ← reads the file, passes to esbuild define
src/globals.d.ts ← TypeScript ambient declaration
src/commands/mcpJson.ts ← uses __MCP_SERVER_VERSION__ (injected constant)
```

**To bump the version:**

```bash
echo "0.3.0" > mcp-server.version
# rebuild — the new tag is baked into dist/extension.js
npm run build
```

A future CI job in the MCP server's release pipeline can automate this step
by committing the updated `mcp-server.version` file and triggering a new
extension release.

**Do not edit the version string in `mcpJson.ts` directly** — it uses the
injected constant and will not reflect manual edits after a rebuild.

---

## Key files

| File | Role |
|------|------|
| `mcp-server.version` | Single source of truth for the pinned image tag |
| `esbuild/build-ext.js` | Reads version file, injects `__MCP_SERVER_VERSION__` via esbuild `define` |
| `src/globals.d.ts` | Ambient TS declaration for `__MCP_SERVER_VERSION__` |
| `src/commands/mcpJson.ts` | `createMCPJson` command + `getDockerCommand` builder |
| `src/commands/mcpJson.test.ts` | Unit tests for `getDockerCommand` argv shape |

---

## `getDockerCommand` — security invariants

The docker command is built as an **argv array** (no shell wrapper), so the
workspace path in `-v ${workspaceFolder}:/workspace` cannot inject shell
syntax. Tests in `mcpJson.test.ts` assert this. Do not add `bash -c` or
`cmd /c` wrappers.

Credentials are forwarded with bare `-e GCORE_API_KEY` (value comes from the
MCP client's `env` block, never from shell expansion).

---

## Codespace path

When `CODESPACE_NAME` is set, the command offers to call `setupCodespaceSecret`
first. That function stores the key via `gh secret set` (spawned with `spawn`,
secret on stdin — not in argv). If the user takes this path, the generated
file uses `${env:GCORE_API_KEY}` instead of an inline key.

---

**Last Updated**: 2026-09-01
11 changes: 11 additions & 0 deletions esbuild/build-ext.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
const esbuild = require("esbuild");
const fs = require("fs");
const path = require("path");

const isProduction = process.argv.includes("--prod");
const isWatching = process.argv.includes("--watch");

// Read the pinned MCP server image version from the version file.
// A future CI job can update this file on MCP server release.
const mcpServerVersion = fs
.readFileSync(path.join(__dirname, "../mcp-server.version"), "utf8")
.trim();

async function main() {
const ctx = await esbuild.context({
entryPoints: ["./src/extension.ts"],
Expand All @@ -16,6 +24,9 @@ async function main() {
external: ["vscode"],
mainFields: ["module", "main"],
logLevel: "info",
define: {
__MCP_SERVER_VERSION__: JSON.stringify(mcpServerVersion),
},
plugins: [
/* add to the end of plugins array */
esbuildProblemMatcherPlugin,
Expand Down
1 change: 1 addition & 0 deletions mcp-server.version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.2.9
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "fastedge",
"displayName": "FastEdge Launcher",
"version": "0.2.5",
"version": "0.2.7-rc1",
"publisher": "g-corelabssa",
"description": "Launcher for FastEdge apps",
"icon": "images/fastedge.png",
Expand Down Expand Up @@ -56,7 +56,7 @@
"fastedge.apiUrl": {
"type": "string",
"default": "https://api.gcore.com",
"description": "Advanced override for the Gcore API base URL used by the FastEdge MCP server. Leave at the default for normal use. Set to a non-prod URL (e.g. https://api.preprod.world) for in-house development \u2014 'FastEdge (Generate mcp.json)' will emit it as GCORE_API_BASE. See DEVELOPMENT.md.",
"description": "Advanced override for the Gcore API base URL used by the FastEdge MCP server. Leave at the default for normal use. Set to a non-prod URL (e.g. https://api.preprod.world) for in-house development 'FastEdge (Generate mcp.json)' will emit it as GCORE_API_BASE. See DEVELOPMENT.md.",
"scope": "application"
}
}
Expand Down
69 changes: 69 additions & 0 deletions src/autorun/triggerFileHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

// Mocks must be declared with vi.hoisted() so they're available inside the
// vi.mock factory, which Vitest hoists above all import statements.
const mocks = vi.hoisted(() => ({
state: { isTrusted: true },
executeCommand: vi.fn().mockResolvedValue(undefined),
showWarningMessage: vi.fn(),
delete: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn(),
}));

vi.mock("vscode", () => ({
workspace: {
get isTrusted() { return mocks.state.isTrusted; },
fs: { readFile: mocks.readFile, delete: mocks.delete },
},
window: {
showWarningMessage: mocks.showWarningMessage,
showErrorMessage: vi.fn(),
showInformationMessage: vi.fn(),
},
commands: { executeCommand: mocks.executeCommand },
}));

import { executeTriggerFile } from "./triggerFileHandler";

const fakeUri = { fsPath: "/ws/.vscode/.fastedge-run-command" } as any;
const fakeOutput = { appendLine: vi.fn() } as any;
const allowedCommand = "fastedge.setup-codespace-secret";

beforeEach(() => {
mocks.state.isTrusted = true;
vi.clearAllMocks();
mocks.readFile.mockResolvedValue(Buffer.from(allowedCommand));
mocks.delete.mockResolvedValue(undefined);
mocks.executeCommand.mockResolvedValue(undefined);
});

describe("executeTriggerFile — security guards", () => {
it("skips executeCommand in an untrusted workspace", async () => {
mocks.state.isTrusted = false;
await executeTriggerFile(fakeUri, fakeOutput);
expect(mocks.executeCommand).not.toHaveBeenCalled();
expect(mocks.delete).toHaveBeenCalled(); // cleans up the trigger file
});

it("skips executeCommand when the user dismisses the confirmation", async () => {
mocks.showWarningMessage.mockResolvedValue("Ignore");
await executeTriggerFile(fakeUri, fakeOutput);
expect(mocks.executeCommand).not.toHaveBeenCalled();
expect(mocks.delete).toHaveBeenCalled();
});

it("runs the command when trusted and user confirms, without workspace-supplied args", async () => {
// File carries args — they must NOT be forwarded to executeCommand.
mocks.readFile.mockResolvedValue(
Buffer.from(JSON.stringify({ command: allowedCommand, args: ["injected-arg"] })),
);
mocks.showWarningMessage.mockResolvedValue("Run");

await executeTriggerFile(fakeUri, fakeOutput);

expect(mocks.executeCommand).toHaveBeenCalledTimes(1);
expect(mocks.executeCommand).toHaveBeenCalledWith(allowedCommand);
// Confirm args were not spread in — call must have exactly one argument.
expect(mocks.executeCommand.mock.calls[0]).toHaveLength(1);
});
});
33 changes: 27 additions & 6 deletions src/autorun/triggerFileHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export function initializeTriggerFileHandler(
/**
* Execute command from trigger file
*/
async function executeTriggerFile(
export async function executeTriggerFile(
uri: vscode.Uri,
outputChannel: vscode.OutputChannel,
): Promise<void> {
Comment on lines +85 to 88
Expand All @@ -91,7 +91,7 @@ async function executeTriggerFile(

// Read file content
const content = await vscode.workspace.fs.readFile(uri);
const contentStr = content.toString().trim();
const contentStr = Buffer.from(content).toString("utf8").trim();

if (!contentStr) {
outputChannel.appendLine("Trigger file is empty, ignoring");
Expand Down Expand Up @@ -125,7 +125,30 @@ async function executeTriggerFile(
return;
}

// Execute command with timeout protection
// Never auto-execute in untrusted workspaces — a workspace-controlled file
// writing the trigger file would get a silent privileged command run.
if (!vscode.workspace.isTrusted) {
outputChannel.appendLine(`Skipping execution in untrusted workspace: ${commandId}`);
await vscode.workspace.fs.delete(uri);
return;
}

// Require explicit user confirmation — the trigger file is workspace-
// authored, so execution without a click is an unintended privilege.
const ok = await vscode.window.showWarningMessage(
`This workspace is asking to run "${commandId}". Only allow this if you trust the workspace.`,
{ modal: true },
"Run",
"Ignore",
);
if (ok !== "Run") {
await vscode.workspace.fs.delete(uri);
return;
}

// Execute command with timeout protection.
// commandArgs from the file are never forwarded — the allowlisted command
// takes no arguments, and workspace-controlled args would be a privilege path.
outputChannel.appendLine(`Executing command: ${commandId}`);
let timeoutHandle: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise((_, reject) => {
Expand All @@ -135,9 +158,7 @@ async function executeTriggerFile(
);
});

const executePromise = commandArgs
? vscode.commands.executeCommand(commandId, ...commandArgs)
: vscode.commands.executeCommand(commandId);
const executePromise = vscode.commands.executeCommand(commandId);

try {
await Promise.race([executePromise, timeoutPromise]);
Expand Down
12 changes: 12 additions & 0 deletions src/commands/mcpJson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ describe("getDockerCommand", () => {
expect(getDockerCommand(false).args).not.toContain("GCORE_API_BASE");
});

it("version file contains a bare semver tag with no v prefix", () => {
// Tags on ghcr.io have no v prefix; mcp-server.version must be x.y.z only.
expect(__MCP_SERVER_VERSION__).toMatch(/^\d+\.\d+\.\d+$/);
});

it("uses a pinned version tag, not :latest", () => {
const { args } = getDockerCommand(false);
const imageArg = args[args.length - 1];
expect(imageArg).not.toContain(":latest");
expect(imageArg).toContain(__MCP_SERVER_VERSION__);
});

it("is platform independent", () => {
const original = Object.getOwnPropertyDescriptor(process, "platform")!;
try {
Expand Down
Loading