Skip to content

Commit 04124c2

Browse files
committed
refactor: optimized mcp and tool elicitation
1 parent 7433802 commit 04124c2

File tree

3 files changed

+118
-105
lines changed

3 files changed

+118
-105
lines changed

mcp-run-python/src/main.ts

Lines changed: 88 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { type LoggingLevel, SetLevelRequestSchema } from '@modelcontextprotocol/
1212
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
1313
import { z } from 'zod'
1414
import { Buffer } from 'node:buffer'
15-
import { asXml, runCode, runCodeWithToolInjection, type ToolInjectionConfig } from './runCode.ts'
15+
import { asXml, runCode, type RunError, type RunSuccess, type ToolInjectionConfig } from './runCode.ts'
1616

1717
const VERSION = '0.0.14'
1818

@@ -50,6 +50,85 @@ options:
5050
}
5151
}
5252

53+
/*
54+
* Helper function to create a logging handler
55+
*/
56+
function createLogHandler(
57+
setLogLevel: LoggingLevel,
58+
logPromises: Promise<void>[],
59+
server: McpServer,
60+
) {
61+
return (level: LoggingLevel, data: string) => {
62+
if (LogLevels.indexOf(level) >= LogLevels.indexOf(setLogLevel)) {
63+
logPromises.push(server.server.sendLoggingMessage({ level, data }))
64+
}
65+
}
66+
}
67+
68+
/*
69+
* Helper function to build unified response
70+
*/
71+
async function buildResponse(
72+
result: RunSuccess | RunError,
73+
logPromises: Promise<void>[],
74+
) {
75+
await Promise.all(logPromises)
76+
return {
77+
content: [{ type: 'text' as const, text: asXml(result) }],
78+
}
79+
}
80+
81+
/*
82+
* Create elicitation callback for tool execution
83+
*/
84+
function createElicitationCallback(
85+
server: McpServer,
86+
logPromises: Promise<void>[],
87+
) {
88+
// deno-lint-ignore no-explicit-any
89+
return async (elicitationRequest: any) => {
90+
// Convert Python dict to JavaScript object if needed
91+
let jsRequest
92+
if (elicitationRequest && typeof elicitationRequest === 'object' && elicitationRequest.toJs) {
93+
jsRequest = elicitationRequest.toJs()
94+
} else if (elicitationRequest && typeof elicitationRequest === 'object') {
95+
// Handle Python dict-like objects
96+
jsRequest = {
97+
message: elicitationRequest.message || elicitationRequest.get?.('message'),
98+
requestedSchema: elicitationRequest.requestedSchema || elicitationRequest.get?.('requestedSchema'),
99+
}
100+
} else {
101+
jsRequest = elicitationRequest
102+
}
103+
104+
try {
105+
const elicitationResult = await server.server.request(
106+
{
107+
method: 'elicitation/create',
108+
params: {
109+
message: jsRequest.message,
110+
requestedSchema: jsRequest.requestedSchema,
111+
},
112+
},
113+
z.object({
114+
action: z.enum(['accept', 'decline', 'cancel']),
115+
content: z.optional(z.record(z.string(), z.unknown())),
116+
}),
117+
)
118+
119+
return elicitationResult
120+
} catch (error) {
121+
logPromises.push(
122+
server.server.sendLoggingMessage({
123+
level: 'error',
124+
data: `Elicitation error: ${error}`,
125+
}),
126+
)
127+
throw error
128+
}
129+
}
130+
}
131+
53132
/*
54133
* Create an MCP server with the `run_python_code` tool registered.
55134
*/
@@ -118,64 +197,18 @@ The tools are injected into the global namespace automatically - no discovery fu
118197

119198
// Check if tools are provided
120199
if (tools.length > 0) {
121-
// Create elicitation callback
122-
// deno-lint-ignore no-explicit-any
123-
const elicitationCallback = async (elicitationRequest: any) => {
124-
// Convert Python dict to JavaScript object if needed
125-
let jsRequest
126-
if (elicitationRequest && typeof elicitationRequest === 'object' && elicitationRequest.toJs) {
127-
jsRequest = elicitationRequest.toJs()
128-
} else if (elicitationRequest && typeof elicitationRequest === 'object') {
129-
// Handle Python dict-like objects
130-
jsRequest = {
131-
message: elicitationRequest.message || elicitationRequest.get?.('message'),
132-
requestedSchema: elicitationRequest.requestedSchema || elicitationRequest.get?.('requestedSchema'),
133-
}
134-
} else {
135-
jsRequest = elicitationRequest
136-
}
137-
138-
try {
139-
const elicitationResult = await server.server.request(
140-
{
141-
method: 'elicitation/create',
142-
params: {
143-
message: jsRequest.message,
144-
requestedSchema: jsRequest.requestedSchema,
145-
},
146-
},
147-
z.object({
148-
action: z.enum(['accept', 'decline', 'cancel']),
149-
content: z.optional(z.record(z.string(), z.unknown())),
150-
}),
151-
)
152-
153-
return elicitationResult
154-
} catch (error) {
155-
logPromises.push(
156-
server.server.sendLoggingMessage({
157-
level: 'error',
158-
data: `Elicitation error: ${error}`,
159-
}),
160-
)
161-
throw error
162-
}
163-
}
200+
const elicitationCallback = createElicitationCallback(server, logPromises)
164201

165202
// Use tool injection mode
166-
const result = await runCodeWithToolInjection(
203+
const result = await runCode(
167204
[
168205
{
169206
name: 'main.py',
170207
content: python_code,
171208
active: true,
172209
},
173210
],
174-
(level, data) => {
175-
if (LogLevels.indexOf(level) >= LogLevels.indexOf(setLogLevel)) {
176-
logPromises.push(server.server.sendLoggingMessage({ level, data }))
177-
}
178-
},
211+
createLogHandler(setLogLevel, logPromises, server),
179212
{
180213
enableToolInjection: true,
181214
availableTools: tools,
@@ -184,11 +217,7 @@ The tools are injected into the global namespace automatically - no discovery fu
184217
} as ToolInjectionConfig,
185218
)
186219

187-
await Promise.all(logPromises)
188-
189-
return {
190-
content: [{ type: 'text', text: asXml(result) }],
191-
}
220+
return await buildResponse(result, logPromises)
192221
} else {
193222
// Use basic mode without tool injection
194223
const result = await runCode(
@@ -199,16 +228,10 @@ The tools are injected into the global namespace automatically - no discovery fu
199228
active: true,
200229
},
201230
],
202-
(level, data) => {
203-
if (LogLevels.indexOf(level) >= LogLevels.indexOf(setLogLevel)) {
204-
logPromises.push(server.server.sendLoggingMessage({ level, data }))
205-
}
206-
},
231+
createLogHandler(setLogLevel, logPromises, server),
232+
undefined,
207233
)
208-
await Promise.all(logPromises)
209-
return {
210-
content: [{ type: 'text', text: asXml(result) }],
211-
}
234+
return await buildResponse(result, logPromises)
212235
}
213236
},
214237
)
@@ -439,7 +462,7 @@ print(f"Tool result: {result}")
439462
`
440463

441464
try {
442-
const toolResult = await runCodeWithToolInjection(
465+
const toolResult = await runCode(
443466
[
444467
{
445468
name: 'tool_test.py',

mcp-run-python/src/runCode.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,6 @@ export interface ToolInjectionConfig {
2121
export async function runCode(
2222
files: CodeFile[],
2323
log: (level: LoggingLevel, data: string) => void,
24-
): Promise<RunSuccess | RunError> {
25-
// Use enhanced version without tool injection for backwards compatibility
26-
const result = await runCodeWithToolInjection(files, log, undefined)
27-
28-
// Convert to original format
29-
return result
30-
}
31-
32-
// Enhanced version that supports optional tool injection
33-
export async function runCodeWithToolInjection(
34-
files: CodeFile[],
35-
log: (level: LoggingLevel, data: string) => void,
3624
toolConfig?: ToolInjectionConfig,
3725
): Promise<RunSuccess | RunError> {
3826
// remove once we can upgrade to pyodide 0.27.7 and console.log is no longer used.
@@ -71,7 +59,11 @@ export async function runCodeWithToolInjection(
7159
const dirPath = '/tmp/mcp_run_python'
7260
sys.path.append(dirPath)
7361
const pathlib = pyodide.pyimport('pathlib')
74-
pathlib.Path(dirPath).mkdir()
62+
try {
63+
pathlib.Path(dirPath).mkdir()
64+
} catch (_error) {
65+
// Directory already exists, which is fine
66+
}
7567
const moduleName = '_prepare_env'
7668

7769
pathlib.Path(`${dirPath}/${moduleName}.py`).write_text(preparePythonCode)
@@ -162,15 +154,15 @@ function injectToolFunctions(
162154
log('info', `Tool injection complete. Available tools: ${config.availableTools.join(', ')}`)
163155
}
164156

165-
interface RunSuccess {
157+
export interface RunSuccess {
166158
status: 'success'
167159
// we could record stdout and stderr separately, but I suspect simplicity is more important
168160
output: string[]
169161
dependencies: string[]
170162
returnValueJson: string | null
171163
}
172164

173-
interface RunError {
165+
export interface RunError {
174166
status: 'install-error' | 'run-error'
175167
output: string[]
176168
dependencies?: string[]

mcp-run-python/src/tool_injection.py

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,46 @@
11
"""Tool injection for MCP elicitation support."""
22

33
import json
4+
from functools import lru_cache
45
from typing import Any, Callable, cast
56

7+
from pyodide.webloop import run_sync # type: ignore[import-untyped]
8+
69

710
def _create_elicitation_request(tool_name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
811
"""Create an elicitation request object for tool execution."""
9-
tool_args: dict[str, Any] = {}
12+
if args and len(args) == 1:
13+
first_arg = args[0]
14+
if isinstance(first_arg, str):
15+
tool_args = {'query': first_arg, **kwargs}
16+
elif isinstance(first_arg, dict):
17+
tool_args = {**first_arg, **kwargs} # type: ignore[arg-type]
18+
else:
19+
tool_args = kwargs.copy()
20+
else:
21+
tool_args = kwargs.copy()
1022

11-
# Handle positional arguments
12-
if args:
13-
if len(args) == 1 and isinstance(args[0], str):
14-
# Single string argument - assume it's a query
15-
tool_args['query'] = args[0]
16-
elif len(args) == 1 and isinstance(args[0], dict):
17-
# Single dict argument
18-
tool_args.update(args[0]) # type: ignore[arg-type]
23+
return {
24+
'message': json.dumps({'tool_name': tool_name, 'arguments': tool_args}),
25+
'requestedSchema': _get_tool_schema(tool_name),
26+
}
1927

20-
# Add keyword arguments
21-
tool_args.update(kwargs)
2228

23-
tool_execution_data: dict[str, Any] = {'tool_name': tool_name, 'arguments': tool_args}
29+
@lru_cache(maxsize=128)
30+
def _get_tool_schema(tool_name: str) -> dict[str, Any]:
31+
"""Get cached schema for a tool."""
2432
return {
25-
'message': json.dumps(tool_execution_data),
26-
'requestedSchema': {
27-
'type': 'object',
28-
'properties': {'result': {'type': 'string', 'description': f'Result of executing {tool_name} tool'}},
29-
'required': ['result'],
30-
},
33+
'type': 'object',
34+
'properties': {'result': {'type': 'string', 'description': f'Result of executing {tool_name} tool'}},
35+
'required': ['result'],
3136
}
3237

3338

3439
def _handle_tool_callback_result(result: Any, tool_name: str) -> Any:
3540
"""Handle the result from a tool callback, including promise resolution."""
36-
# Handle PyodideFuture (JavaScript Promise)
3741
if hasattr(result, 'then'):
3842
try:
39-
# Import at runtime to avoid dependency issues
40-
from pyodide.webloop import run_sync # type: ignore[import-untyped]
41-
42-
# Use cast to tell the type checker what we expect
4343
resolved_result = cast(dict[str, Any], run_sync(result))
44-
45-
# Extract result from elicitation response
4644
if isinstance(resolved_result, dict):
4745
if resolved_result.get('action') == 'accept':
4846
content = cast(dict[str, Any], resolved_result.get('content', {}))

0 commit comments

Comments
 (0)