Skip to content

Commit ddded03

Browse files
authored
Merge pull request #305 from Alex-Beng/mmcp
feat: add MCP server support and remove download dependency
2 parents cb162a0 + eeecc09 commit ddded03

7 files changed

Lines changed: 1406 additions & 375 deletions

File tree

build/util.js

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import { join } from "path";
22
import { readFileSync, writeFileSync, createWriteStream } from "fs";
3+
import { Readable } from "stream";
4+
import { pipeline } from "stream/promises";
35
import { dirname } from "node:path";
46
import { fileURLToPath } from "node:url";
5-
import download from "download";
67

78
const __dirname = dirname(fileURLToPath(import.meta.url));
89

910
export async function downloadTo(url, path) {
10-
return new Promise((r, e) => {
11-
const d = download(url);
12-
d.then(r).catch((err) => e(err));
13-
d.pipe(createWriteStream(path));
14-
});
11+
const response = await fetch(url);
12+
if (!response.ok) {
13+
throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
14+
}
15+
await pipeline(Readable.fromWeb(response.body), createWriteStream(path));
1516
}
1617

1718
export function ensureNl(s) {

package.json

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,14 @@
110110
"command": "emmylua.luarocks.checkInstallation",
111111
"title": "Check LuaRocks Installation",
112112
"category": "LuaRocks"
113+
},
114+
{
115+
"command": "emmy.startMcpServer",
116+
"title": "EmmyLua: Start MCP Server"
117+
},
118+
{
119+
"command": "emmy.stopMcpServer",
120+
"title": "EmmyLua: Stop MCP Server"
113121
}
114122
],
115123
"languages": [
@@ -1227,8 +1235,9 @@
12271235
]
12281236
},
12291237
"scripts": {
1230-
"vscode:prepublish": "npm run compile",
1238+
"vscode:prepublish": "npm run compile && npm run build:mcp",
12311239
"compile": "tsc -p ./",
1240+
"build:mcp": "node node_modules/esbuild/bin/esbuild src/mcp/server.ts --bundle --outfile=out/mcp/server.js --platform=node --external:vscode --external:http --external:crypto --external:path --format=cjs",
12321241
"watch": "tsc -watch -p ./",
12331242
"test": "npm run compile && node ./node_modules/vscode/bin/test",
12341243
"release": "node --disable-warning=ExperimentalWarning ./build/release.js"
@@ -1238,18 +1247,21 @@
12381247
"@types/node": "^17.0.21",
12391248
"@types/vscode": "1.89.0",
12401249
"@vscode/vsce": "2.26.1",
1241-
"download": "^7.1.0",
1250+
"decompress": "^4.2.1",
1251+
"decompress-targz": "4.1.1",
1252+
"esbuild": "^0.28.0",
12421253
"eslint": "^8.11.0",
12431254
"filecopy": "^4.0.2",
1244-
"typescript": "^4.0.2",
1245-
"decompress-targz": "4.1.1"
1255+
"typescript": "^5.0"
12461256
},
12471257
"dependencies": {
1258+
"@modelcontextprotocol/sdk": "^1.29.0",
12481259
"@vscode/debugadapter": "^1.61.0",
12491260
"@vscode/debugprotocol": "^1.61.0",
1261+
"concat-map": "0.0.2",
12501262
"iconv-lite": "^0.6.3",
12511263
"smart-buffer": "^4.0.1",
12521264
"vscode-languageclient": "9.0.1",
1253-
"concat-map": "0.0.2"
1265+
"zod": "^3.25"
12541266
}
12551267
}

src/extension.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { EmmyrcSchemaContentProvider } from './emmyrcSchemaContentProvider';
1616
import { SyntaxTreeManager, setClientGetter } from './syntaxTreeProvider';
1717
import { insertEmmyDebugCode, registerDebuggers } from './debugger';
1818
import * as LuaRocks from './luarocks';
19+
import { startMcpServer, stopMcpServer } from './mcp/server';
1920

2021
/**
2122
* Command registration entry
@@ -92,6 +93,9 @@ function registerCommands(context: vscode.ExtensionContext): void {
9293
{ id: 'emmylua.luarocks.showPackages', handler: LuaRocks.showPackagesView },
9394
{ id: 'emmylua.luarocks.clearSearch', handler: LuaRocks.clearSearch },
9495
{ id: 'emmylua.luarocks.checkInstallation', handler: LuaRocks.checkLuaRocksInstallation },
96+
// MCP commands
97+
{ id: 'emmy.startMcpServer', handler: startMcpServer },
98+
{ id: 'emmy.stopMcpServer', handler: stopMcpServer },
9599
];
96100

97101
// Register all commands

src/mcp/server.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import * as crypto from 'crypto';
2+
import * as http from 'http';
3+
import * as vscode from 'vscode';
4+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5+
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
6+
import { registerTools, setStopMcpCallback } from './tools';
7+
import { SessionManager } from './sessionManager';
8+
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
9+
10+
const DEFAULT_HOST = '127.0.0.1';
11+
const DEFAULT_PORT = 8827;
12+
13+
let mcpServer: any;
14+
let httpServer: any;
15+
let sessionManager: SessionManager | undefined;
16+
let transport: StreamableHTTPServerTransport | undefined;
17+
const sseTransports = new Map<string, SSEServerTransport>();
18+
19+
function createMcpServer(): any {
20+
const server = new Server(
21+
{ name: 'emmylua-mcp', version: '0.1.0' },
22+
{ capabilities: { tools: {} } },
23+
);
24+
registerTools(server, sessionManager!);
25+
return server;
26+
}
27+
export const mcpOutput = vscode.window.createOutputChannel('EmmyLua MCP');
28+
29+
function log(msg: string): void {
30+
mcpOutput.appendLine(`[${new Date().toLocaleTimeString()}] ${msg}`);
31+
}
32+
33+
function corsWrap(res: any): void {
34+
const orig = res.writeHead.bind(res);
35+
res.writeHead = function (this: any, status: number, ...args: any[]) {
36+
if (!this.hasHeader('Access-Control-Allow-Origin')) {
37+
this.setHeader('Access-Control-Allow-Origin', '*');
38+
this.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE');
39+
this.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id, Mcp-Protocol-Version');
40+
this.setHeader('Access-Control-Expose-Headers', 'mcp-session-id');
41+
}
42+
return orig(status, ...args);
43+
};
44+
}
45+
46+
function tryListen(host: string, startPort: number, maxRetries: number): Promise<{ server: http.Server; port: number }> {
47+
return new Promise((resolve, reject) => {
48+
const attempt = (i: number) => {
49+
if (i >= maxRetries) {
50+
reject(new Error(`All ports ${startPort}-${startPort + maxRetries - 1} in use`));
51+
return;
52+
}
53+
const p = startPort + i;
54+
const s = http.createServer(async (req, res) => {
55+
corsWrap(res);
56+
if (req.method === 'OPTIONS') {
57+
res.writeHead(204);
58+
res.end();
59+
return;
60+
}
61+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
62+
if (url.pathname === '/mcp' && transport) {
63+
try {
64+
await transport.handleRequest(req, res);
65+
} catch (e: any) {
66+
if (!res.headersSent) {
67+
try { res.writeHead(400).end(e.message); } catch {}
68+
}
69+
}
70+
} else if (url.pathname === '/sse') {
71+
try {
72+
const sseTransport = new SSEServerTransport('/messages', res);
73+
sseTransports.set(sseTransport.sessionId, sseTransport);
74+
res.on('close', () => {
75+
sseTransports.delete(sseTransport.sessionId);
76+
});
77+
const sseServer = createMcpServer();
78+
await sseServer.connect(sseTransport);
79+
} catch (e: any) {
80+
if (!res.headersSent) {
81+
try { res.writeHead(500).end(e.message); } catch {}
82+
}
83+
}
84+
} else if (url.pathname === '/messages' && req.method === 'POST') {
85+
const sessionId = url.searchParams.get('sessionId');
86+
if (!sessionId) {
87+
res.writeHead(400).end('Missing sessionId parameter');
88+
return;
89+
}
90+
const sseTransport = sseTransports.get(sessionId);
91+
if (!sseTransport) {
92+
res.writeHead(404).end('Session not found');
93+
return;
94+
}
95+
try {
96+
await sseTransport.handlePostMessage(req, res);
97+
} catch (e: any) {
98+
if (!res.headersSent) {
99+
try { res.writeHead(500).end(e.message); } catch {}
100+
}
101+
}
102+
} else {
103+
res.writeHead(404);
104+
res.end();
105+
}
106+
});
107+
s.once('error', (e: any) => {
108+
s.close();
109+
if (e.code === 'EADDRINUSE') {
110+
attempt(i + 1);
111+
} else {
112+
reject(e);
113+
}
114+
});
115+
s.listen(p, host, () => {
116+
resolve({ server: s, port: p });
117+
});
118+
};
119+
attempt(0);
120+
});
121+
}
122+
123+
export async function startMcpServer(): Promise<void> {
124+
const host = process.env['EMMY_MCP_HOST'] || DEFAULT_HOST;
125+
const port = parseInt(process.env['EMMY_MCP_PORT'] || String(DEFAULT_PORT), 10);
126+
127+
sessionManager = new SessionManager();
128+
setStopMcpCallback(stopMcpServer);
129+
130+
const mcpServerInstance = createMcpServer();
131+
transport = new StreamableHTTPServerTransport({
132+
sessionIdGenerator: () => crypto.randomUUID(),
133+
});
134+
await mcpServerInstance.connect(transport);
135+
mcpServer = mcpServerInstance;
136+
137+
try {
138+
const { server: httpSrv, port: actualPort } = await tryListen(host, port, 10);
139+
httpServer = httpSrv;
140+
log(`MCP server started at http://${host}:${actualPort}/mcp (Streamable HTTP) and /sse (SSE)`);
141+
} catch (e: any) {
142+
log(`Failed to start: ${e.message}`);
143+
}
144+
}
145+
146+
export function stopMcpServer(): void {
147+
sessionManager?.dispose();
148+
for (const [, st] of sseTransports) {
149+
st.close().catch(() => {});
150+
}
151+
sseTransports.clear();
152+
if (mcpServer) {
153+
mcpServer.close().catch(() => {});
154+
}
155+
if (transport) {
156+
transport.close().catch(() => {});
157+
}
158+
if (httpServer) {
159+
httpServer.close();
160+
}
161+
mcpServer = undefined;
162+
httpServer = undefined;
163+
sessionManager = undefined;
164+
transport = undefined;
165+
}

src/mcp/sessionManager.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import * as vscode from 'vscode';
2+
import * as path from 'path';
3+
4+
interface SessionInfo {
5+
session: vscode.DebugSession;
6+
startedAt: Date;
7+
}
8+
9+
interface StoredBreakpoint {
10+
id: string;
11+
enabled: boolean;
12+
source?: string;
13+
line?: number;
14+
column?: number;
15+
}
16+
17+
export class SessionManager implements vscode.Disposable {
18+
private sessions = new Map<string, SessionInfo>();
19+
private breakpoints = new Map<string, StoredBreakpoint>();
20+
private mcpBps = new Map<string, StoredBreakpoint>();
21+
private disposables: vscode.Disposable[] = [];
22+
23+
constructor() {
24+
this.disposables.push(
25+
vscode.debug.onDidStartDebugSession(session => {
26+
this.sessions.set(session.id, { session, startedAt: new Date() });
27+
}),
28+
vscode.debug.onDidTerminateDebugSession(session => {
29+
this.sessions.delete(session.id);
30+
}),
31+
vscode.debug.onDidChangeBreakpoints(e => {
32+
for (const bp of e.added) this.cacheBreakpoint(bp);
33+
for (const bp of e.removed) {
34+
this.breakpoints.delete(bp.id);
35+
const sbp = bp as any;
36+
const loc = sbp.location;
37+
const line = loc?.line !== undefined ? loc.line + 1 : loc?.range?.start?.line !== undefined ? loc.range.start.line + 1 : undefined;
38+
const src = this.norm(sbp._source?.path || sbp.source?.path || sbp.uri?.fsPath || sbp.uri?.toString() || loc?.uri?.fsPath || loc?.uri?.toString());
39+
if (src && line !== undefined) this.mcpBps.delete(`${src}:${line}`);
40+
}
41+
for (const bp of e.changed) this.cacheBreakpoint(bp);
42+
}),
43+
);
44+
for (const bp of vscode.debug.breakpoints) this.cacheBreakpoint(bp);
45+
}
46+
47+
private norm(s: string | undefined): string | undefined {
48+
return s ? path.normalize(s) : undefined;
49+
}
50+
51+
private cacheBreakpoint(bp: vscode.Breakpoint): void {
52+
if (this.breakpoints.has(bp.id)) return;
53+
const sbp = bp as any;
54+
let line: number | undefined;
55+
let column: number | undefined;
56+
const loc = sbp.location;
57+
if (loc) {
58+
if (loc.line !== undefined) {
59+
line = loc.line + 1;
60+
column = loc.character;
61+
} else if (loc.range) {
62+
line = loc.range.start.line + 1;
63+
column = loc.range.start.character;
64+
}
65+
}
66+
let source = this.norm(sbp._source?.path || sbp.source?.path || sbp.uri?.fsPath || sbp.uri?.toString());
67+
if (!source && loc?.uri) source = this.norm(loc.uri.fsPath || loc.uri.toString());
68+
if (!source) {
69+
const editor = vscode.window.activeTextEditor;
70+
if (editor) source = this.norm(editor.document.uri.fsPath);
71+
}
72+
this.breakpoints.set(bp.id, { id: bp.id, enabled: bp.enabled, source, line, column });
73+
}
74+
75+
addBreakpoints(source: string, breakpoints: { id?: string; line: number; column?: number }[]): void {
76+
const src = this.norm(source) || source;
77+
for (const bp of breakpoints) {
78+
const key = `${src}:${bp.line}`;
79+
this.mcpBps.set(key, { id: key, enabled: true, source: src, line: bp.line, column: bp.column });
80+
}
81+
}
82+
83+
getAllBreakpoints(): StoredBreakpoint[] {
84+
const result = Array.from(this.breakpoints.values());
85+
for (const mbp of this.mcpBps.values()) {
86+
if (!result.some(r => this.norm(r.source) === this.norm(mbp.source) && r.line === mbp.line)) {
87+
result.push(mbp);
88+
}
89+
}
90+
return result;
91+
}
92+
93+
getActiveSessions(): SessionInfo[] {
94+
return Array.from(this.sessions.values());
95+
}
96+
97+
getSession(id: string): SessionInfo | undefined {
98+
return this.sessions.get(id);
99+
}
100+
101+
dispose(): void {
102+
this.disposables.forEach(d => d.dispose());
103+
this.sessions.clear();
104+
this.breakpoints.clear();
105+
this.mcpBps.clear();
106+
}
107+
}

0 commit comments

Comments
 (0)