mcptoolforge 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcptoolforge/__init__.py +64 -0
- mcptoolforge/cli/__init__.py +1 -0
- mcptoolforge/cli/commands.py +278 -0
- mcptoolforge/cli/main.py +101 -0
- mcptoolforge/config.py +45 -0
- mcptoolforge/decorators.py +49 -0
- mcptoolforge/errors.py +94 -0
- mcptoolforge/execution.py +25 -0
- mcptoolforge/mcp/__init__.py +4 -0
- mcptoolforge/mcp/adapter.py +405 -0
- mcptoolforge/mcp/server.py +111 -0
- mcptoolforge/middleware/__init__.py +11 -0
- mcptoolforge/middleware/context.py +15 -0
- mcptoolforge/middleware/logging.py +30 -0
- mcptoolforge/middleware/manager.py +68 -0
- mcptoolforge/middleware/timing.py +37 -0
- mcptoolforge/project.py +134 -0
- mcptoolforge/prompts/__init__.py +5 -0
- mcptoolforge/prompts/decorator.py +32 -0
- mcptoolforge/prompts/prompt.py +129 -0
- mcptoolforge/prompts/registry.py +37 -0
- mcptoolforge/registry.py +241 -0
- mcptoolforge/resources/__init__.py +5 -0
- mcptoolforge/resources/decorator.py +33 -0
- mcptoolforge/resources/registry.py +39 -0
- mcptoolforge/resources/resource.py +97 -0
- mcptoolforge/schema.py +146 -0
- mcptoolforge/server.py +91 -0
- mcptoolforge/testing/__init__.py +15 -0
- mcptoolforge/testing/client.py +364 -0
- mcptoolforge/transports/__init__.py +1 -0
- mcptoolforge/validation.py +229 -0
- mcptoolforge-0.1.0.dist-info/METADATA +655 -0
- mcptoolforge-0.1.0.dist-info/RECORD +37 -0
- mcptoolforge-0.1.0.dist-info/WHEEL +4 -0
- mcptoolforge-0.1.0.dist-info/entry_points.txt +2 -0
- mcptoolforge-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import mcp.types as t
|
|
5
|
+
from mcp.server.context import ServerRequestContext
|
|
6
|
+
from mcp.shared.exceptions import MCPError
|
|
7
|
+
|
|
8
|
+
from mcptoolforge.errors import (
|
|
9
|
+
ConfigurationError,
|
|
10
|
+
MiddlewareError,
|
|
11
|
+
ResourceNotFoundError,
|
|
12
|
+
ToolExecutionError,
|
|
13
|
+
ToolNotFoundError,
|
|
14
|
+
ToolValidationError,
|
|
15
|
+
)
|
|
16
|
+
from mcptoolforge.execution import execute_tool
|
|
17
|
+
from mcptoolforge.registry import ToolRegistry
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("mcptoolforge.mcp")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MCPAdapter:
|
|
23
|
+
"""Adapts MCPToolForge ToolRegistry and tools to the MCP protocol server."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
registry: ToolRegistry,
|
|
28
|
+
server: Any = None,
|
|
29
|
+
resource_registry: Any = None,
|
|
30
|
+
prompt_registry: Any = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.registry = registry
|
|
33
|
+
self.server = server
|
|
34
|
+
self.resource_registry = resource_registry or (server.resource_registry if server else None)
|
|
35
|
+
self.prompt_registry = prompt_registry or (server.prompt_registry if server else None)
|
|
36
|
+
|
|
37
|
+
async def handle_list_tools(
|
|
38
|
+
self,
|
|
39
|
+
ctx: ServerRequestContext[Any],
|
|
40
|
+
params: t.PaginatedRequestParams | None,
|
|
41
|
+
) -> t.ListToolsResult:
|
|
42
|
+
"""Map registered MCPToolForge tools to MCP Tool representations."""
|
|
43
|
+
mcp_tools = []
|
|
44
|
+
for tf_tool in self.registry.list():
|
|
45
|
+
mcp_tool = t.Tool(
|
|
46
|
+
name=tf_tool.name,
|
|
47
|
+
description=tf_tool.description,
|
|
48
|
+
input_schema=tf_tool.input_schema,
|
|
49
|
+
)
|
|
50
|
+
mcp_tools.append(mcp_tool)
|
|
51
|
+
return t.ListToolsResult(tools=mcp_tools)
|
|
52
|
+
|
|
53
|
+
async def handle_call_tool(
|
|
54
|
+
self,
|
|
55
|
+
ctx: ServerRequestContext[Any],
|
|
56
|
+
params: t.CallToolRequestParams,
|
|
57
|
+
) -> t.CallToolResult:
|
|
58
|
+
"""Execute the requested tool and translate results/errors to MCP format."""
|
|
59
|
+
try:
|
|
60
|
+
# 1. Locate the tool
|
|
61
|
+
try:
|
|
62
|
+
tf_tool = self.registry.get(params.name)
|
|
63
|
+
except ToolNotFoundError as e:
|
|
64
|
+
raise MCPError(code=-32601, message=str(e)) from e
|
|
65
|
+
|
|
66
|
+
# 2. Extract arguments
|
|
67
|
+
arguments = params.arguments or {}
|
|
68
|
+
|
|
69
|
+
# 3. Execute the tool (wrapping with middlewares if configured)
|
|
70
|
+
try:
|
|
71
|
+
if self.server and self.server.middlewares:
|
|
72
|
+
import inspect
|
|
73
|
+
import time
|
|
74
|
+
|
|
75
|
+
from mcptoolforge.middleware.context import MiddlewareContext
|
|
76
|
+
from mcptoolforge.middleware.manager import build_chain, is_async_callable
|
|
77
|
+
|
|
78
|
+
context = MiddlewareContext(
|
|
79
|
+
tool_name=tf_tool.name,
|
|
80
|
+
tool=tf_tool,
|
|
81
|
+
arguments=arguments,
|
|
82
|
+
server=self.server,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
called_next = False
|
|
86
|
+
|
|
87
|
+
if inspect.iscoroutinefunction(tf_tool.fn):
|
|
88
|
+
|
|
89
|
+
async def final_call() -> Any:
|
|
90
|
+
nonlocal called_next
|
|
91
|
+
called_next = True
|
|
92
|
+
from mcptoolforge.validation import validate_tool_arguments
|
|
93
|
+
|
|
94
|
+
validated_args = validate_tool_arguments(tf_tool, context.arguments)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
return await tf_tool.fn(**validated_args)
|
|
98
|
+
except Exception as inner_e:
|
|
99
|
+
if not isinstance(
|
|
100
|
+
inner_e, (ToolValidationError, ToolExecutionError)
|
|
101
|
+
):
|
|
102
|
+
raise ToolExecutionError(
|
|
103
|
+
f"Error executing tool '{tf_tool.name}': {inner_e}"
|
|
104
|
+
) from inner_e
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
else:
|
|
108
|
+
|
|
109
|
+
def final_call() -> Any:
|
|
110
|
+
nonlocal called_next
|
|
111
|
+
called_next = True
|
|
112
|
+
from mcptoolforge.validation import validate_tool_arguments
|
|
113
|
+
|
|
114
|
+
validated_args = validate_tool_arguments(tf_tool, context.arguments)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
return tf_tool.fn(**validated_args)
|
|
118
|
+
except Exception as inner_e:
|
|
119
|
+
if not isinstance(
|
|
120
|
+
inner_e, (ToolValidationError, ToolExecutionError)
|
|
121
|
+
):
|
|
122
|
+
raise ToolExecutionError(
|
|
123
|
+
f"Error executing tool '{tf_tool.name}': {inner_e}"
|
|
124
|
+
) from inner_e
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
start_time = time.perf_counter()
|
|
128
|
+
chain_callable = build_chain(self.server.middlewares, context, final_call)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
if inspect.iscoroutinefunction(chain_callable) or is_async_callable(
|
|
132
|
+
chain_callable
|
|
133
|
+
):
|
|
134
|
+
result = await chain_callable()
|
|
135
|
+
else:
|
|
136
|
+
result = chain_callable()
|
|
137
|
+
context.duration = time.perf_counter() - start_time
|
|
138
|
+
except Exception as e:
|
|
139
|
+
context.duration = time.perf_counter() - start_time
|
|
140
|
+
context.error = e
|
|
141
|
+
if called_next and isinstance(e, (ToolValidationError, ToolExecutionError)):
|
|
142
|
+
raise
|
|
143
|
+
else:
|
|
144
|
+
raise MiddlewareError(f"Middleware failed during execution: {e}") from e
|
|
145
|
+
else:
|
|
146
|
+
result = await execute_tool(tf_tool, arguments)
|
|
147
|
+
|
|
148
|
+
except ToolValidationError as e:
|
|
149
|
+
return t.CallToolResult(
|
|
150
|
+
content=[t.TextContent(type="text", text=str(e))],
|
|
151
|
+
is_error=True,
|
|
152
|
+
)
|
|
153
|
+
except (ToolExecutionError, MiddlewareError, ConfigurationError) as e:
|
|
154
|
+
logger.error(f"Execution failed for tool '{params.name}': {e}", exc_info=True)
|
|
155
|
+
return t.CallToolResult(
|
|
156
|
+
content=[t.TextContent(type="text", text=str(e))],
|
|
157
|
+
is_error=True,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# 4. Map return result to CallToolResult
|
|
161
|
+
if isinstance(result, dict):
|
|
162
|
+
return t.CallToolResult(
|
|
163
|
+
content=[t.TextContent(type="text", text=str(result))],
|
|
164
|
+
structured_content=result,
|
|
165
|
+
is_error=False,
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
text_value = result if isinstance(result, str) else str(result)
|
|
169
|
+
return t.CallToolResult(
|
|
170
|
+
content=[t.TextContent(type="text", text=text_value)],
|
|
171
|
+
is_error=False,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
except MCPError:
|
|
175
|
+
raise
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.critical(f"Internal MCPToolForge error in call_tool: {e}", exc_info=True)
|
|
178
|
+
raise MCPError(code=-32603, message=f"Internal MCPToolForge error: {e}") from e
|
|
179
|
+
|
|
180
|
+
async def handle_list_resources(
|
|
181
|
+
self,
|
|
182
|
+
ctx: ServerRequestContext[Any],
|
|
183
|
+
params: t.PaginatedRequestParams | None,
|
|
184
|
+
) -> t.ListResourcesResult:
|
|
185
|
+
"""Map registered MCPToolForge resources to MCP Resource representations."""
|
|
186
|
+
mcp_resources = []
|
|
187
|
+
if self.resource_registry:
|
|
188
|
+
for tf_resource in self.resource_registry.list():
|
|
189
|
+
mcp_resource = t.Resource(
|
|
190
|
+
uri=tf_resource.uri,
|
|
191
|
+
name=tf_resource.name,
|
|
192
|
+
description=tf_resource.description,
|
|
193
|
+
mime_type=tf_resource.mime_type,
|
|
194
|
+
)
|
|
195
|
+
mcp_resources.append(mcp_resource)
|
|
196
|
+
return t.ListResourcesResult(resources=mcp_resources)
|
|
197
|
+
|
|
198
|
+
async def handle_read_resource(
|
|
199
|
+
self,
|
|
200
|
+
ctx: ServerRequestContext[Any],
|
|
201
|
+
params: t.ReadResourceRequestParams,
|
|
202
|
+
) -> t.ReadResourceResult:
|
|
203
|
+
"""Read the requested resource and return its contents."""
|
|
204
|
+
try:
|
|
205
|
+
if not self.resource_registry:
|
|
206
|
+
raise MCPError(code=-32601, message="Resources are not supported on this server.")
|
|
207
|
+
|
|
208
|
+
# 1. Locate the resource
|
|
209
|
+
try:
|
|
210
|
+
tf_resource = self.resource_registry.get(params.uri)
|
|
211
|
+
except ResourceNotFoundError as e:
|
|
212
|
+
raise MCPError(code=-32601, message=str(e)) from e
|
|
213
|
+
|
|
214
|
+
# 2. Execute the resource handler
|
|
215
|
+
try:
|
|
216
|
+
import inspect
|
|
217
|
+
|
|
218
|
+
if inspect.iscoroutinefunction(tf_resource.fn):
|
|
219
|
+
raw_result = await tf_resource.fn()
|
|
220
|
+
else:
|
|
221
|
+
raw_result = tf_resource.fn()
|
|
222
|
+
except Exception as e:
|
|
223
|
+
logger.error(f"Execution failed for resource '{params.uri}': {e}", exc_info=True)
|
|
224
|
+
raise MCPError(code=-32603, message=f"Resource execution failed: {e}") from e
|
|
225
|
+
|
|
226
|
+
# 3. Serialize and map the return value to resource contents
|
|
227
|
+
import base64
|
|
228
|
+
import json
|
|
229
|
+
|
|
230
|
+
mime_type = tf_resource.mime_type
|
|
231
|
+
if isinstance(raw_result, (dict, list)):
|
|
232
|
+
if mime_type is None:
|
|
233
|
+
mime_type = "application/json"
|
|
234
|
+
try:
|
|
235
|
+
serialized_text = json.dumps(raw_result)
|
|
236
|
+
except Exception as json_e:
|
|
237
|
+
raise MCPError(
|
|
238
|
+
code=-32603,
|
|
239
|
+
message=f"Failed to serialize resource content to JSON: {json_e}",
|
|
240
|
+
) from json_e
|
|
241
|
+
|
|
242
|
+
contents = [
|
|
243
|
+
t.TextResourceContents(
|
|
244
|
+
uri=tf_resource.uri,
|
|
245
|
+
text=serialized_text,
|
|
246
|
+
mime_type=mime_type,
|
|
247
|
+
)
|
|
248
|
+
]
|
|
249
|
+
elif isinstance(raw_result, str):
|
|
250
|
+
contents = [
|
|
251
|
+
t.TextResourceContents(
|
|
252
|
+
uri=tf_resource.uri,
|
|
253
|
+
text=raw_result,
|
|
254
|
+
mime_type=mime_type,
|
|
255
|
+
)
|
|
256
|
+
]
|
|
257
|
+
elif isinstance(raw_result, bytes):
|
|
258
|
+
b64_str = base64.b64encode(raw_result).decode("utf-8")
|
|
259
|
+
contents = [
|
|
260
|
+
t.BlobResourceContents(
|
|
261
|
+
uri=tf_resource.uri,
|
|
262
|
+
blob=b64_str,
|
|
263
|
+
mime_type=mime_type,
|
|
264
|
+
)
|
|
265
|
+
]
|
|
266
|
+
else:
|
|
267
|
+
raise MCPError(
|
|
268
|
+
code=-32603,
|
|
269
|
+
message=f"Unsupported resource return type: {type(raw_result).__name__}",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
return t.ReadResourceResult(contents=contents)
|
|
273
|
+
|
|
274
|
+
except MCPError:
|
|
275
|
+
raise
|
|
276
|
+
except Exception as e:
|
|
277
|
+
logger.critical(f"Internal MCPToolForge error in read_resource: {e}", exc_info=True)
|
|
278
|
+
raise MCPError(code=-32603, message=f"Internal MCPToolForge error: {e}") from e
|
|
279
|
+
|
|
280
|
+
async def handle_list_prompts(
|
|
281
|
+
self,
|
|
282
|
+
ctx: ServerRequestContext[Any],
|
|
283
|
+
params: t.PaginatedRequestParams | None,
|
|
284
|
+
) -> t.ListPromptsResult:
|
|
285
|
+
"""Map registered MCPToolForge prompts to MCP Prompt representations."""
|
|
286
|
+
mcp_prompts = []
|
|
287
|
+
if self.prompt_registry:
|
|
288
|
+
for tf_prompt in self.prompt_registry.list():
|
|
289
|
+
mcp_args = []
|
|
290
|
+
for p_name, p in tf_prompt.parameters.items():
|
|
291
|
+
mcp_args.append(
|
|
292
|
+
t.PromptArgument(
|
|
293
|
+
name=p_name,
|
|
294
|
+
description=getattr(p, "description", None),
|
|
295
|
+
required=p.required,
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
mcp_prompt = t.Prompt(
|
|
299
|
+
name=tf_prompt.name,
|
|
300
|
+
description=tf_prompt.description,
|
|
301
|
+
arguments=mcp_args if mcp_args else None,
|
|
302
|
+
)
|
|
303
|
+
mcp_prompts.append(mcp_prompt)
|
|
304
|
+
return t.ListPromptsResult(prompts=mcp_prompts)
|
|
305
|
+
|
|
306
|
+
async def handle_get_prompt(
|
|
307
|
+
self,
|
|
308
|
+
ctx: ServerRequestContext[Any],
|
|
309
|
+
params: t.GetPromptRequestParams,
|
|
310
|
+
) -> t.GetPromptResult:
|
|
311
|
+
"""Get the requested prompt with client arguments."""
|
|
312
|
+
try:
|
|
313
|
+
if not self.prompt_registry:
|
|
314
|
+
raise MCPError(code=-32601, message="Prompts are not supported on this server.")
|
|
315
|
+
|
|
316
|
+
# 1. Locate the prompt
|
|
317
|
+
try:
|
|
318
|
+
tf_prompt = self.prompt_registry.get(params.name)
|
|
319
|
+
except ToolNotFoundError as e:
|
|
320
|
+
raise MCPError(code=-32601, message=str(e)) from e
|
|
321
|
+
|
|
322
|
+
# 2. Extract and validate/coerce arguments
|
|
323
|
+
raw_args = params.arguments or {}
|
|
324
|
+
from mcptoolforge.validation import validate_prompt_arguments
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
validated_args = validate_prompt_arguments(tf_prompt, raw_args)
|
|
328
|
+
except Exception as val_e:
|
|
329
|
+
raise MCPError(
|
|
330
|
+
code=-32602, message=f"Prompt argument validation failed: {val_e}"
|
|
331
|
+
) from val_e
|
|
332
|
+
|
|
333
|
+
# 3. Execute prompt template
|
|
334
|
+
try:
|
|
335
|
+
import inspect
|
|
336
|
+
|
|
337
|
+
if inspect.iscoroutinefunction(tf_prompt.fn):
|
|
338
|
+
raw_result = await tf_prompt.fn(**validated_args)
|
|
339
|
+
else:
|
|
340
|
+
raw_result = tf_prompt.fn(**validated_args)
|
|
341
|
+
except Exception as e:
|
|
342
|
+
logger.error(f"Execution failed for prompt '{params.name}': {e}", exc_info=True)
|
|
343
|
+
raise MCPError(code=-32603, message=f"Prompt execution failed: {e}") from e
|
|
344
|
+
|
|
345
|
+
# 4. Map return value to MCP prompt messages format
|
|
346
|
+
messages = []
|
|
347
|
+
|
|
348
|
+
def make_text_message(text_val: str, role_val: str = "user") -> t.PromptMessage:
|
|
349
|
+
return t.PromptMessage(
|
|
350
|
+
role=role_val,
|
|
351
|
+
content=t.TextContent(type="text", text=text_val),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
if isinstance(raw_result, str):
|
|
355
|
+
messages.append(make_text_message(raw_result))
|
|
356
|
+
elif isinstance(raw_result, dict):
|
|
357
|
+
role = raw_result.get("role", "user")
|
|
358
|
+
content = raw_result.get("content", "")
|
|
359
|
+
if not isinstance(content, str):
|
|
360
|
+
tname = type(content).__name__
|
|
361
|
+
raise MCPError(
|
|
362
|
+
code=-32603,
|
|
363
|
+
message=f"Prompt dict content must be a string, received {tname}",
|
|
364
|
+
)
|
|
365
|
+
messages.append(make_text_message(content, role))
|
|
366
|
+
elif isinstance(raw_result, list):
|
|
367
|
+
for item in raw_result:
|
|
368
|
+
if isinstance(item, str):
|
|
369
|
+
messages.append(make_text_message(item))
|
|
370
|
+
elif isinstance(item, dict):
|
|
371
|
+
role = item.get("role", "user")
|
|
372
|
+
content = item.get("content", "")
|
|
373
|
+
if not isinstance(content, str):
|
|
374
|
+
tname = type(content).__name__
|
|
375
|
+
raise MCPError(
|
|
376
|
+
code=-32603,
|
|
377
|
+
message=f"Prompt dict content must be a string, received {tname}",
|
|
378
|
+
)
|
|
379
|
+
messages.append(make_text_message(content, role))
|
|
380
|
+
elif isinstance(item, t.PromptMessage):
|
|
381
|
+
messages.append(item)
|
|
382
|
+
else:
|
|
383
|
+
tname = type(item).__name__
|
|
384
|
+
raise MCPError(
|
|
385
|
+
code=-32603,
|
|
386
|
+
message=f"Unsupported item type in prompt result list: {tname}",
|
|
387
|
+
)
|
|
388
|
+
elif isinstance(raw_result, t.PromptMessage):
|
|
389
|
+
messages.append(raw_result)
|
|
390
|
+
else:
|
|
391
|
+
raise MCPError(
|
|
392
|
+
code=-32603,
|
|
393
|
+
message=f"Unsupported prompt return type: {type(raw_result).__name__}",
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
return t.GetPromptResult(
|
|
397
|
+
description=tf_prompt.description,
|
|
398
|
+
messages=messages,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
except MCPError:
|
|
402
|
+
raise
|
|
403
|
+
except Exception as e:
|
|
404
|
+
logger.critical(f"Internal MCPToolForge error in get_prompt: {e}", exc_info=True)
|
|
405
|
+
raise MCPError(code=-32603, message=f"Internal MCPToolForge error: {e}") from e
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import mcp.types as t
|
|
7
|
+
from mcp.server.lowlevel import Server
|
|
8
|
+
from mcp.server.models import InitializationOptions
|
|
9
|
+
from mcp.server.stdio import stdio_server
|
|
10
|
+
|
|
11
|
+
from mcptoolforge.mcp.adapter import MCPAdapter
|
|
12
|
+
from mcptoolforge.registry import ToolRegistry
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("mcptoolforge")
|
|
15
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
16
|
+
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
17
|
+
handler.setFormatter(formatter)
|
|
18
|
+
logger.addHandler(handler)
|
|
19
|
+
logger.setLevel(logging.INFO)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MCPServerRunner:
|
|
23
|
+
"""Responsible for running a MCPToolForge registry over MCP Stdio transport."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, server_name: str, registry: ToolRegistry, server: Any = None) -> None:
|
|
26
|
+
self.server_name = server_name
|
|
27
|
+
self.registry = registry
|
|
28
|
+
self.server = server
|
|
29
|
+
self.adapter = MCPAdapter(self.registry, server=server)
|
|
30
|
+
|
|
31
|
+
async def run_async(self) -> None:
|
|
32
|
+
"""Asynchronously start the stdio server."""
|
|
33
|
+
# 1. Run startup hooks
|
|
34
|
+
if self.server:
|
|
35
|
+
for hook in self.server.startup_hooks:
|
|
36
|
+
try:
|
|
37
|
+
if inspect.iscoroutinefunction(hook):
|
|
38
|
+
await hook()
|
|
39
|
+
else:
|
|
40
|
+
hook()
|
|
41
|
+
except Exception as e:
|
|
42
|
+
logger.error(f"Error in startup hook '{hook.__name__}': {e}", exc_info=True)
|
|
43
|
+
raise
|
|
44
|
+
|
|
45
|
+
mcp_server = Server(self.server_name)
|
|
46
|
+
|
|
47
|
+
# Register request handlers
|
|
48
|
+
mcp_server.add_request_handler(
|
|
49
|
+
"tools/list",
|
|
50
|
+
t.PaginatedRequestParams,
|
|
51
|
+
self.adapter.handle_list_tools,
|
|
52
|
+
)
|
|
53
|
+
mcp_server.add_request_handler(
|
|
54
|
+
"tools/call",
|
|
55
|
+
t.CallToolRequestParams,
|
|
56
|
+
self.adapter.handle_call_tool,
|
|
57
|
+
)
|
|
58
|
+
mcp_server.add_request_handler(
|
|
59
|
+
"resources/list",
|
|
60
|
+
t.PaginatedRequestParams,
|
|
61
|
+
self.adapter.handle_list_resources,
|
|
62
|
+
)
|
|
63
|
+
mcp_server.add_request_handler(
|
|
64
|
+
"resources/read",
|
|
65
|
+
t.ReadResourceRequestParams,
|
|
66
|
+
self.adapter.handle_read_resource,
|
|
67
|
+
)
|
|
68
|
+
mcp_server.add_request_handler(
|
|
69
|
+
"prompts/list",
|
|
70
|
+
t.PaginatedRequestParams,
|
|
71
|
+
self.adapter.handle_list_prompts,
|
|
72
|
+
)
|
|
73
|
+
mcp_server.add_request_handler(
|
|
74
|
+
"prompts/get",
|
|
75
|
+
t.GetPromptRequestParams,
|
|
76
|
+
self.adapter.handle_get_prompt,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
init_options = InitializationOptions(
|
|
80
|
+
server_name=self.server_name,
|
|
81
|
+
server_version="0.1.0",
|
|
82
|
+
capabilities=t.ServerCapabilities(
|
|
83
|
+
tools=t.ToolsCapability(list_changed=False),
|
|
84
|
+
resources=t.ResourcesCapability(list_changed=False, subscribe=False),
|
|
85
|
+
prompts=t.PromptsCapability(list_changed=False),
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
logger.info(f"Starting MCP stdio transport loop for '{self.server_name}'...")
|
|
90
|
+
try:
|
|
91
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
92
|
+
await mcp_server.run(
|
|
93
|
+
read_stream,
|
|
94
|
+
write_stream,
|
|
95
|
+
initialization_options=init_options,
|
|
96
|
+
raise_exceptions=False,
|
|
97
|
+
)
|
|
98
|
+
finally:
|
|
99
|
+
# 2. Run shutdown hooks
|
|
100
|
+
if self.server:
|
|
101
|
+
for hook in self.server.shutdown_hooks:
|
|
102
|
+
try:
|
|
103
|
+
if inspect.iscoroutinefunction(hook):
|
|
104
|
+
await hook()
|
|
105
|
+
else:
|
|
106
|
+
hook()
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error(
|
|
109
|
+
f"Error in shutdown hook '{hook.__name__}': {e}",
|
|
110
|
+
exc_info=True,
|
|
111
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from mcptoolforge.middleware.context import MiddlewareContext
|
|
2
|
+
from mcptoolforge.middleware.logging import logging_middleware, sync_logging_middleware
|
|
3
|
+
from mcptoolforge.middleware.timing import sync_timing_middleware, timing_middleware
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"MiddlewareContext",
|
|
7
|
+
"logging_middleware",
|
|
8
|
+
"sync_logging_middleware",
|
|
9
|
+
"sync_timing_middleware",
|
|
10
|
+
"timing_middleware",
|
|
11
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from mcptoolforge.registry import Tool
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MiddlewareContext:
|
|
7
|
+
"""Structured context passed to MCPToolForge middleware containing tool execution details."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, tool_name: str, tool: Tool, arguments: dict[str, Any], server: Any) -> None:
|
|
10
|
+
self.tool_name = tool_name
|
|
11
|
+
self.tool = tool
|
|
12
|
+
self.arguments = arguments
|
|
13
|
+
self.server = server
|
|
14
|
+
self.error: Exception | None = None
|
|
15
|
+
self.duration: float | None = None
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from mcptoolforge.middleware.context import MiddlewareContext
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger("mcptoolforge.middleware.logging")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def logging_middleware(context: MiddlewareContext, next_callable: Any) -> Any:
|
|
10
|
+
"""Asynchronous built-in middleware for logging tool invocations to stderr."""
|
|
11
|
+
logger.info(f"Calling tool '{context.tool_name}' with arguments: {context.arguments}")
|
|
12
|
+
try:
|
|
13
|
+
result = await next_callable()
|
|
14
|
+
logger.info(f"Completed tool '{context.tool_name}' successfully.")
|
|
15
|
+
return result
|
|
16
|
+
except Exception as e:
|
|
17
|
+
logger.error(f"Tool '{context.tool_name}' failed with error: {e}")
|
|
18
|
+
raise
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def sync_logging_middleware(context: MiddlewareContext, next_callable: Any) -> Any:
|
|
22
|
+
"""Synchronous built-in middleware for logging tool invocations to stderr."""
|
|
23
|
+
logger.info(f"Calling tool '{context.tool_name}' with arguments: {context.arguments}")
|
|
24
|
+
try:
|
|
25
|
+
result = next_callable()
|
|
26
|
+
logger.info(f"Completed tool '{context.tool_name}' successfully.")
|
|
27
|
+
return result
|
|
28
|
+
except Exception as e:
|
|
29
|
+
logger.error(f"Tool '{context.tool_name}' failed with error: {e}")
|
|
30
|
+
raise
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mcptoolforge.errors import ConfigurationError
|
|
6
|
+
from mcptoolforge.middleware.context import MiddlewareContext
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_async_callable(fn: Any) -> bool:
|
|
10
|
+
"""Helper to check if a callable is asynchronous."""
|
|
11
|
+
if inspect.iscoroutinefunction(fn):
|
|
12
|
+
return True
|
|
13
|
+
return bool(callable(fn) and inspect.iscoroutinefunction(fn.__call__))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_chain(
|
|
17
|
+
middlewares: list[Callable], context: MiddlewareContext, final_call: Callable
|
|
18
|
+
) -> Callable:
|
|
19
|
+
"""Build a nested execution chain of middlewares.
|
|
20
|
+
|
|
21
|
+
Validates sync/async compatibility: synchronous middleware cannot wrap
|
|
22
|
+
asynchronous tools or middlewares.
|
|
23
|
+
"""
|
|
24
|
+
# 1. Determine if each stage is async
|
|
25
|
+
is_async_stage = []
|
|
26
|
+
for mw in middlewares:
|
|
27
|
+
is_async_stage.append(is_async_callable(mw))
|
|
28
|
+
is_async_stage.append(is_async_callable(final_call))
|
|
29
|
+
|
|
30
|
+
# 2. Check for invalid sync -> async transitions
|
|
31
|
+
for i in range(len(middlewares)):
|
|
32
|
+
if not is_async_stage[i] and any(is_async_stage[i + 1 :]):
|
|
33
|
+
raise ConfigurationError(
|
|
34
|
+
f"Synchronous middleware '{middlewares[i].__name__}' cannot be used "
|
|
35
|
+
"because a subsequent middleware or tool in the pipeline is asynchronous."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# 3. Build the closures from the tail to the head
|
|
39
|
+
current_next = final_call
|
|
40
|
+
|
|
41
|
+
for i in reversed(range(len(middlewares))):
|
|
42
|
+
mw = middlewares[i]
|
|
43
|
+
is_mw_async = is_async_stage[i]
|
|
44
|
+
|
|
45
|
+
if is_mw_async:
|
|
46
|
+
# Wrap current_next in a coroutine function if it isn't one,
|
|
47
|
+
# so next() behaves asynchronously
|
|
48
|
+
wrapped_next = current_next
|
|
49
|
+
if not is_async_callable(wrapped_next):
|
|
50
|
+
|
|
51
|
+
async def async_wrapped(*args, _next=wrapped_next, **kwargs):
|
|
52
|
+
return _next(*args, **kwargs)
|
|
53
|
+
|
|
54
|
+
wrapped_next = async_wrapped
|
|
55
|
+
|
|
56
|
+
# Create the async wrapper closure
|
|
57
|
+
async def next_step(m=mw, c=context, n=wrapped_next):
|
|
58
|
+
return await m(c, n)
|
|
59
|
+
|
|
60
|
+
current_next = next_step
|
|
61
|
+
else:
|
|
62
|
+
# Create the sync wrapper closure
|
|
63
|
+
def next_step(m=mw, c=context, n=current_next):
|
|
64
|
+
return m(c, n)
|
|
65
|
+
|
|
66
|
+
current_next = next_step
|
|
67
|
+
|
|
68
|
+
return current_next
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mcptoolforge.middleware.context import MiddlewareContext
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("mcptoolforge.middleware.timing")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def timing_middleware(context: MiddlewareContext, next_callable: Any) -> Any:
|
|
11
|
+
"""Asynchronous built-in middleware for measuring execution duration."""
|
|
12
|
+
start = time.perf_counter()
|
|
13
|
+
try:
|
|
14
|
+
result = await next_callable()
|
|
15
|
+
context.duration = time.perf_counter() - start
|
|
16
|
+
logger.info(f"Tool '{context.tool_name}' execution took {context.duration:.4f} seconds.")
|
|
17
|
+
return result
|
|
18
|
+
except Exception as e:
|
|
19
|
+
context.duration = time.perf_counter() - start
|
|
20
|
+
context.error = e
|
|
21
|
+
logger.error(f"Tool '{context.tool_name}' failed after {context.duration:.4f} seconds.")
|
|
22
|
+
raise
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sync_timing_middleware(context: MiddlewareContext, next_callable: Any) -> Any:
|
|
26
|
+
"""Synchronous built-in middleware for measuring execution duration."""
|
|
27
|
+
start = time.perf_counter()
|
|
28
|
+
try:
|
|
29
|
+
result = next_callable()
|
|
30
|
+
context.duration = time.perf_counter() - start
|
|
31
|
+
logger.info(f"Tool '{context.tool_name}' execution took {context.duration:.4f} seconds.")
|
|
32
|
+
return result
|
|
33
|
+
except Exception as e:
|
|
34
|
+
context.duration = time.perf_counter() - start
|
|
35
|
+
context.error = e
|
|
36
|
+
logger.error(f"Tool '{context.tool_name}' failed after {context.duration:.4f} seconds.")
|
|
37
|
+
raise
|