Skip to content

Commit e3c2f3b

Browse files
author
BrokenDuck
committed
Initial commit
1 parent 62f4ddc commit e3c2f3b

File tree

2 files changed

+185
-71
lines changed

2 files changed

+185
-71
lines changed

mcp-run-python/src/main.ts

Lines changed: 166 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -11,50 +11,84 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'
1111
import { type LoggingLevel, SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'
1212
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
1313
import { z } from 'zod'
14-
15-
import { asXml, runCode } from './runCode.ts'
14+
import { asXml, getRootDir, runCode } from './runCode.ts'
1615
import { Buffer } from 'node:buffer'
16+
import * as path from 'node:path'
1717

1818
const VERSION = '0.0.13'
1919

2020
export async function main() {
21-
const { args } = Deno
22-
if (args.length === 1 && args[0] === 'stdio') {
23-
await runStdio()
24-
} else if (args.length >= 1 && args[0] === 'streamable_http') {
25-
const flags = parseArgs(Deno.args, {
26-
string: ['port'],
27-
default: { port: '3001' },
28-
})
29-
const port = parseInt(flags.port)
30-
runStreamableHttp(port)
31-
} else if (args.length >= 1 && args[0] === 'sse') {
32-
const flags = parseArgs(Deno.args, {
33-
string: ['port'],
34-
default: { port: '3001' },
35-
})
36-
const port = parseInt(flags.port)
37-
runSse(port)
38-
} else if (args.length === 1 && args[0] === 'warmup') {
39-
await warmup()
21+
// Parse global flags once, then branch on subcommand
22+
const flags = parseArgs(Deno.args, {
23+
string: ['port'],
24+
default: { port: '3001', mount: false },
25+
})
26+
const mode = (flags._[0] as string | undefined) ?? ''
27+
const port = parseInt(flags.port as string)
28+
const mount = flags.mount as string | boolean
29+
30+
if (mode === 'stdio') {
31+
await runStdio(mount)
32+
} else if (mode === 'streamable_http') {
33+
runStreamableHttp(port, mount)
34+
} else if (mode === 'sse') {
35+
runSse(port, mount)
36+
} else if (mode === 'warmup') {
37+
await warmup(mount)
4038
} else {
4139
console.error(
4240
`\
4341
Invalid arguments.
4442
45-
Usage: deno run -N -R=node_modules -W=node_modules --node-modules-dir=auto jsr:@pydantic/mcp-run-python [stdio|streamable_http|sse|warmup]
43+
Usage: deno run -N -R=node_modules -W=node_modules --node-modules-dir=auto jsr:@pydantic/mcp-run-python [stdio|streamable_http|sse|warmup] [--port <port>] [--mount [dir]]
4644
4745
options:
48-
--port <port> Port to run the SSE server on (default: 3001)`,
46+
--port <port> Port to run the SSE/HTTP server on (default: 3001)
47+
--mount [dir] Relative or absolute directory path or boolean. If omitted: false; if provided without value: true`,
4948
)
5049
Deno.exit(1)
5150
}
5251
}
5352

53+
/*
54+
* Resolve a mountDir cli option to a specific directory
55+
*/
56+
export function resolveMountDir(mountDir: string): string {
57+
// Base dir created by emscriptem
58+
// See https://emscripten.org/docs/api_reference/Filesystem-API.html#file-system-api
59+
const baseDir = '/home/web_user'
60+
61+
if (mountDir.trim() === '') {
62+
return path.join(baseDir, 'persistent')
63+
}
64+
65+
if (path.isAbsolute(mountDir)) {
66+
return mountDir
67+
}
68+
69+
// relative path
70+
return path.join(baseDir, mountDir)
71+
}
72+
73+
/*
74+
* Ensure and cleanup the root directory used by the MCP server
75+
*/
76+
function ensureRootDir() {
77+
Deno.mkdirSync(getRootDir(), { recursive: true })
78+
}
79+
80+
function cleanupRootDir() {
81+
try {
82+
Deno.removeSync(getRootDir(), { recursive: true })
83+
} catch (err) {
84+
if (!(err instanceof Deno.errors.NotFound)) throw err
85+
}
86+
}
87+
5488
/*
5589
* Create an MCP server with the `run_python_code` tool registered.
5690
*/
57-
function createServer(): McpServer {
91+
function createServer(mount: string | boolean): McpServer {
5892
const server = new McpServer(
5993
{
6094
name: 'MCP Run Python',
@@ -68,12 +102,25 @@ function createServer(): McpServer {
68102
},
69103
)
70104

105+
let mountDirDescription: string
106+
let mountDir: string | null
107+
if (mount !== false) {
108+
// Create temporary directory
109+
ensureRootDir()
110+
// Resolve mounted directory
111+
mountDir = resolveMountDir(typeof mount === 'string' ? mount : '')
112+
mountDirDescription = `To store files permanently use the directory at: ${mountDir}\n`
113+
} else {
114+
mountDir = null
115+
mountDirDescription = ''
116+
}
117+
71118
const toolDescription = `Tool to execute Python code and return stdout, stderr, and return value.
72119
73120
The code may be async, and the value on the last line will be returned as the return value.
74121
75122
The code will be executed with Python 3.12.
76-
123+
${mountDirDescription}
77124
Dependencies may be defined via PEP 723 script metadata, e.g. to install "pydantic", the script should start
78125
with a comment of the form:
79126
@@ -96,15 +143,21 @@ print('python code here')
96143
{ python_code: z.string().describe('Python code to run') },
97144
async ({ python_code }: { python_code: string }) => {
98145
const logPromises: Promise<void>[] = []
99-
const result = await runCode([{
100-
name: 'main.py',
101-
content: python_code,
102-
active: true,
103-
}], (level, data) => {
104-
if (LogLevels.indexOf(level) >= LogLevels.indexOf(setLogLevel)) {
105-
logPromises.push(server.server.sendLoggingMessage({ level, data }))
106-
}
107-
})
146+
const result = await runCode(
147+
[
148+
{
149+
name: 'main.py',
150+
content: python_code,
151+
active: true,
152+
},
153+
],
154+
(level, data) => {
155+
if (LogLevels.indexOf(level) >= LogLevels.indexOf(setLogLevel)) {
156+
logPromises.push(server.server.sendLoggingMessage({ level, data }))
157+
}
158+
},
159+
mountDir,
160+
)
108161
await Promise.all(logPromises)
109162
return {
110163
content: [{ type: 'text', text: asXml(result) }],
@@ -118,10 +171,7 @@ print('python code here')
118171
* Define some QOL functions for both the SSE and Streamable HTTP server implementation
119172
*/
120173
function httpGetUrl(req: http.IncomingMessage): URL {
121-
return new URL(
122-
req.url ?? '',
123-
`http://${req.headers.host ?? 'unknown'}`,
124-
)
174+
return new URL(req.url ?? '', `http://${req.headers.host ?? 'unknown'}`)
125175
}
126176

127177
function httpGetBody(req: http.IncomingMessage): Promise<JSON> {
@@ -130,41 +180,54 @@ function httpGetBody(req: http.IncomingMessage): Promise<JSON> {
130180
// deno-lint-ignore no-explicit-any
131181
const bodyParts: any[] = []
132182
let body
133-
req.on('data', (chunk) => {
134-
bodyParts.push(chunk)
135-
}).on('end', () => {
136-
body = Buffer.concat(bodyParts).toString()
137-
resolve(JSON.parse(body))
138-
})
183+
req
184+
.on('data', (chunk) => {
185+
bodyParts.push(chunk)
186+
})
187+
.on('end', () => {
188+
body = Buffer.concat(bodyParts).toString()
189+
resolve(JSON.parse(body))
190+
})
139191
})
140192
}
141193

142-
function httpSetTextResponse(res: http.ServerResponse, status: number, text: string) {
194+
function httpSetTextResponse(
195+
res: http.ServerResponse,
196+
status: number,
197+
text: string,
198+
) {
143199
res.setHeader('Content-Type', 'text/plain')
144200
res.statusCode = status
145201
res.end(`${text}\n`)
146202
}
147203

148-
function httpSetJsonResponse(res: http.ServerResponse, status: number, text: string, code: number) {
204+
function httpSetJsonResponse(
205+
res: http.ServerResponse,
206+
status: number,
207+
text: string,
208+
code: number,
209+
) {
149210
res.setHeader('Content-Type', 'application/json')
150211
res.statusCode = status
151-
res.write(JSON.stringify({
152-
jsonrpc: '2.0',
153-
error: {
154-
code: code,
155-
message: text,
156-
},
157-
id: null,
158-
}))
212+
res.write(
213+
JSON.stringify({
214+
jsonrpc: '2.0',
215+
error: {
216+
code: code,
217+
message: text,
218+
},
219+
id: null,
220+
}),
221+
)
159222
res.end()
160223
}
161224

162225
/*
163226
* Run the MCP server using the Streamable HTTP transport
164227
*/
165-
function runStreamableHttp(port: number) {
228+
function runStreamableHttp(port: number, mount: string | boolean) {
166229
// https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#with-session-management
167-
const mcpServer = createServer()
230+
const mcpServer = createServer(mount)
168231
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}
169232

170233
const server = http.createServer(async (req, res) => {
@@ -220,7 +283,12 @@ function runStreamableHttp(port: number) {
220283

221284
await mcpServer.connect(transport)
222285
} else {
223-
httpSetJsonResponse(res, 400, 'Bad Request: No valid session ID provided', -32000)
286+
httpSetJsonResponse(
287+
res,
288+
400,
289+
'Bad Request: No valid session ID provided',
290+
-32000,
291+
)
224292
return
225293
}
226294

@@ -239,6 +307,11 @@ function runStreamableHttp(port: number) {
239307
}
240308
})
241309

310+
// Cleanup root dir on server close
311+
server.on('close', () => {
312+
cleanupRootDir()
313+
})
314+
242315
server.listen(port, () => {
243316
console.log(
244317
`Running MCP Run Python version ${VERSION} with Streamable HTTP transport on port ${port}`,
@@ -249,8 +322,8 @@ function runStreamableHttp(port: number) {
249322
/*
250323
* Run the MCP server using the SSE transport, e.g. over HTTP.
251324
*/
252-
function runSse(port: number) {
253-
const mcpServer = createServer()
325+
function runSse(port: number, mount: string | boolean) {
326+
const mcpServer = createServer(mount)
254327
const transports: { [sessionId: string]: SSEServerTransport } = {}
255328

256329
const server = http.createServer(async (req, res) => {
@@ -277,7 +350,11 @@ function runSse(port: number) {
277350
if (transport) {
278351
await transport.handlePostMessage(req, res)
279352
} else {
280-
httpSetTextResponse(res, 400, `No transport found for sessionId '${sessionId}'`)
353+
httpSetTextResponse(
354+
res,
355+
400,
356+
`No transport found for sessionId '${sessionId}'`,
357+
)
281358
}
282359
} else if (pathMatch) {
283360
httpSetTextResponse(res, 405, 'Method not allowed')
@@ -286,6 +363,11 @@ function runSse(port: number) {
286363
}
287364
})
288365

366+
// Cleanup root dir on server close
367+
server.on('close', () => {
368+
cleanupRootDir()
369+
})
370+
289371
server.listen(port, () => {
290372
console.log(
291373
`Running MCP Run Python version ${VERSION} with SSE transport on port ${port}`,
@@ -296,32 +378,45 @@ function runSse(port: number) {
296378
/*
297379
* Run the MCP server using the Stdio transport.
298380
*/
299-
async function runStdio() {
300-
const mcpServer = createServer()
381+
async function runStdio(mount: string | boolean) {
382+
const mcpServer = createServer(mount)
301383
const transport = new StdioServerTransport()
384+
385+
// Cleanup root dir on transport close
386+
transport.onclose = () => {
387+
cleanupRootDir()
388+
}
389+
302390
await mcpServer.connect(transport)
303391
}
304392

305393
/*
306394
* Run pyodide to download packages which can otherwise interrupt the server
307395
*/
308-
async function warmup() {
396+
async function warmup(mount?: string | boolean) {
309397
console.error(
310-
`Running warmup script for MCP Run Python version ${VERSION}...`,
398+
`Running warmup script for MCP Run Python version ${VERSION}...` +
399+
(mount ? ` (mount: ${typeof mount === 'string' ? mount : 'enabled'})` : ''),
311400
)
312401
const code = `
313402
import numpy
314403
a = numpy.array([1, 2, 3])
315404
print('numpy array:', a)
316405
a
317406
`
318-
const result = await runCode([{
319-
name: 'warmup.py',
320-
content: code,
321-
active: true,
322-
}], (level, data) =>
323-
// use warn to avoid recursion since console.log is patched in runCode
324-
console.error(`${level}: ${data}`))
407+
const result = await runCode(
408+
[
409+
{
410+
name: 'warmup.py',
411+
content: code,
412+
active: true,
413+
},
414+
],
415+
(level, data) =>
416+
// use warn to avoid recursion since console.log is patched in runCode
417+
console.error(`${level}: ${data}`),
418+
null,
419+
)
325420
console.log('Tool return value:')
326421
console.log(asXml(result))
327422
console.log('\nwarmup successful 🎉')

0 commit comments

Comments
 (0)