|
11 | 11 |
|
12 | 12 | from __future__ import annotations |
13 | 13 |
|
| 14 | +import functools |
14 | 15 | import hashlib |
| 16 | +import inspect |
15 | 17 | import json |
16 | 18 | import re |
17 | 19 | from dataclasses import dataclass |
18 | | -from typing import Dict, List, Optional, Union |
| 20 | +from typing import Callable, Dict, List, Optional, Union |
19 | 21 |
|
20 | 22 | import polars as pl |
21 | 23 | from typing_extensions import Annotated |
|
34 | 36 | from fenic.core._logical_plan.plans.base import LogicalPlan |
35 | 37 | from fenic.core._utils.schema import convert_custom_dtype_to_polars |
36 | 38 | from fenic.core.error import ConfigurationError, ValidationError |
37 | | -from fenic.core.mcp.types import DynamicToolDefinition |
| 39 | +from fenic.core.mcp.types import DynamicToolDefinition, TableFormat |
38 | 40 | from fenic.core.types.datatypes import ( |
39 | 41 | BooleanType, |
40 | 42 | DoubleType, |
@@ -89,6 +91,56 @@ def auto_generate_core_tools_from_tables( |
89 | 91 | sql_max_rows=sql_max_rows, |
90 | 92 | ) |
91 | 93 |
|
| 94 | +def dynamic_tool( |
| 95 | + tool_name: str, |
| 96 | + tool_description: str, |
| 97 | + max_result_limit: Optional[int] = None, |
| 98 | + default_table_format: TableFormat = "markdown", |
| 99 | +): |
| 100 | + """Decorator to bind a DataFrame to a user-authored tool function. |
| 101 | +
|
| 102 | + Example: |
| 103 | + @dynamic_tool(tool_name="find_rust", tool_description="...") |
| 104 | + def find_rust( |
| 105 | + query: Annotated[str, "Natural language query"], |
| 106 | + limit: Annotated[int, "Max rows"] = 50, |
| 107 | + ) -> DataFrame: |
| 108 | + pred = fc.semantic.predicate("Matches: {{q}}", q=query, bio=fc.col("bio")) |
| 109 | + return df.filter(pred).limit(limit) |
| 110 | +
|
| 111 | + Notes: |
| 112 | + - The decorated function MUST NOT use *args/**kwargs, and should annotate parameters with Annotated descriptions. |
| 113 | + - The decorated function MUST return a fenic DataFrame. |
| 114 | + - The returned object is a DynamicTool ready for registration. |
| 115 | + """ |
| 116 | + |
| 117 | + def decorator(func: Callable[..., DataFrame]) -> DynamicToolDefinition: |
| 118 | + _ensure_no_var_args(func, func_label=tool_name) |
| 119 | + |
| 120 | + @functools.wraps(func) |
| 121 | + def wrapper(*args, **kwargs) -> LogicalPlan: |
| 122 | + result_df = func(*args, **kwargs) |
| 123 | + return result_df._logical_plan |
| 124 | + |
| 125 | + return DynamicToolDefinition( |
| 126 | + name=tool_name, |
| 127 | + description=tool_description, |
| 128 | + func=wrapper, |
| 129 | + max_result_limit=max_result_limit, |
| 130 | + default_table_format=default_table_format, |
| 131 | + ) |
| 132 | + |
| 133 | + return decorator |
| 134 | + |
| 135 | +def _ensure_no_var_args(func: Callable[..., object], *, func_label: str) -> None: |
| 136 | + sig = inspect.signature(func) |
| 137 | + for p in sig.parameters.values(): |
| 138 | + if p.kind.name in {"VAR_POSITIONAL", "VAR_KEYWORD"}: |
| 139 | + raise ValueError( |
| 140 | + f"{func_label} must not use *args or **kwargs for MCP tool introspection." |
| 141 | + ) |
| 142 | + |
| 143 | + |
92 | 144 | def _auto_generate_read_tool( |
93 | 145 | datasets: List[DatasetSpec], |
94 | 146 | session: Session, |
|
0 commit comments