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.
@@ -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