Skip to content

Commit 98c0eb1

Browse files
committed
augmented the documentation for fenic mcp, added the ability for the user to create their own dynamic tools, clean up tool creation apis
1 parent de2d4b4 commit 98c0eb1

File tree

10 files changed

+352
-52
lines changed

10 files changed

+352
-52
lines changed

docs/topics/fenic-mcp.md

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,91 @@ session.catalog.drop_tool("users_by_age_range", ignore_if_not_exists=True)
138138
session.catalog.drop_tool("users_by_name_regex", ignore_if_not_exists=True)
139139
```
140140

141+
### Step 2b: Create dynamic tools with `@fenic_tool`
142+
143+
Dynamic tools let you expose arbitrary Python logic as an MCP tool. They are defined with the `@fenic_tool` decorator and must return a Fenic `DataFrame`. Annotate parameters with `typing_extensions.Annotated` to provide per-argument descriptions in the tool schema. The server automatically adds `limit` and `table_format` keyword-only parameters for limiting the size of result sets and output formatting.
144+
145+
```python
146+
from typing_extensions import Annotated
147+
148+
from fenic import Session
149+
from fenic.api.dataframe.dataframe import DataFrame
150+
from fenic.api.functions import col, coalesce, lit
151+
from fenic.api.functions import sum as sum_
152+
from fenic.api.mcp.tool_generation import fenic_tool
153+
154+
session = Session.get_or_create()
155+
156+
# Two base DataFrames
157+
users = session.create_dataframe({
158+
"id": [1, 2, 3, 4],
159+
"name": ["Alice", "Bob", "Charlie", "Diana"],
160+
"age": [25, 40, 31, 18],
161+
})
162+
163+
orders = session.create_dataframe({
164+
"order_id": [10, 11, 12, 13, 14],
165+
"user_id": [1, 1, 2, 3, 3],
166+
"amount": [50.0, 75.0, 20.0, 15.0, 120.0],
167+
})
168+
169+
# Aggregate orders per user
170+
orders_total = orders.group_by("user_id").agg(
171+
sum_(col("amount")).alias("total_amount")
172+
)
173+
174+
175+
@fenic_tool(
176+
tool_name="users_with_min_spend",
177+
tool_description="Users whose name matches regex (optional) and total order amount >= min_total",
178+
max_result_limit=100,
179+
default_table_format="markdown",
180+
)
181+
def users_with_min_spend(
182+
name_regex: Annotated[Optional[str], "Regex for user name (use (?i) for case-insensitive)"] = None,
183+
min_total: Annotated[float, "Minimum total order amount"],
184+
) -> DataFrame:
185+
joined = users.join(orders_total, left_on="id", right_on="user_id", how="left")
186+
pred_name = col("name").rlike(name_regex) if name_regex else fc.lit(True)
187+
pred_total = coalesce(col("total_amount"), lit(0.0)) >= min_total
188+
return joined.filter(pred_name & pred_total).select("id", "name", "age", "total_amount")
189+
```
190+
191+
Notes:
192+
193+
- The decorated function must not use `*args` or `**kwargs` and must return a Fenic `DataFrame`.
194+
- Use `Annotated[type, "description"]` for parameters to generate a clear MCP schema.
195+
- Dynamic tools are not stored in the catalog; they exist only while your server process is running.
196+
- Dynamic tools can be used to integrate your MCP server with external data sources or APIs to perform operations.
197+
198+
### Step 2c: Auto-generate core analysis tools from catalog tables
199+
200+
You can generate a suite of reusable data tools (Schema, Profile, Read, Search Summary, Search Content, Analyze) directly from catalog tables and their descriptions. This is helpful for quickly exposing exploratory and read/query capabilities to MCP.
201+
202+
Requirements:
203+
204+
- Each table must exist and have a non-empty description (see Step 1).
205+
206+
Example:
207+
208+
```python
209+
from fenic import Session
210+
from fenic.api.mcp.server import create_mcp_server
211+
from fenic.api.mcp.tool_generation import ToolGenerationConfig
212+
213+
session = Session.get_or_create()
214+
215+
server = create_mcp_server(
216+
session,
217+
server_name="Fenic MCP",
218+
automated_tool_generation=ToolGenerationConfig(
219+
table_names=["orders", "users"],
220+
tool_group_name="Dataset Exploration",
221+
sql_max_rows=200,
222+
),
223+
)
224+
```
225+
141226
## Step 3a: Serve tools programmatically
142227

143228
Use the MCP server helpers to serve existing catalog tools. If you want all registered tools, call `list_tools()`. If you want a subset, fetch by name.
@@ -153,7 +238,7 @@ session = Session.get_or_create(fc.SessionConfig(
153238
# Load all catalog tools
154239
tools = session.catalog.list_tools()
155240

156-
server = create_mcp_server(session, server_name="Fenic MCP", tools=tools)
241+
server = create_mcp_server(session, server_name="Fenic MCP", parameterized_tools=tools)
157242

158243
# Run HTTP server (defaults shown); if additional configuration is required, any argument that can be passed to FastMCP `run` can be passed here
159244
#
@@ -191,6 +276,36 @@ asgi_app = run_mcp_server_asgi(
191276

192277
```
193278

279+
Include dynamic tools:
280+
281+
```python
282+
from fenic.api.mcp.tool_generation import fenic_tool
283+
284+
# Assume `users_name_regex` is defined as in Step 2b
285+
server = create_mcp_server(
286+
session,
287+
server_name="Fenic MCP",
288+
parameterized_tools=tools,
289+
dynamic_tools=[users_name_regex],
290+
)
291+
```
292+
293+
Enable automated tool generation (Schema/Profile/Read/Search/Analyze) from catalog tables:
294+
295+
```python
296+
from fenic.api.mcp.tool_generation import ToolGenerationConfig
297+
298+
server = create_mcp_server(
299+
session,
300+
server_name="Fenic MCP",
301+
automated_tool_generation=ToolGenerationConfig(
302+
table_names=["orders", "users"],
303+
tool_group_name="Core Datasets",
304+
sql_max_rows=200,
305+
),
306+
)
307+
```
308+
194309
## Step 3b: Serve tools via CLI (fenic-serve)
195310

196311
The CLI starts an MCP server directly from your catalog. By default, it serves all registered tools in the current database, using uvicorn.
@@ -223,6 +338,18 @@ Example `session.config.json` (minimal):
223338

224339
Environment variables for model providers (if your tools use semantic operators) should be set in your shell, or via your runner (for example: `uv run --env-file .env ...`).
225340

341+
## Dynamic vs Parameterized tools
342+
343+
| Aspect | Dynamic tools (`@fenic_tool`) | Parameterized tools (catalog) |
344+
| ----------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
345+
| Flexibility | Highest: arbitrary Python and Fenic ops; closures; any logic that returns a `DataFrame`. | Moderate: declarative `DataFrame` queries with `tool_param` placeholders. |
346+
| Persistence/Portability | Not persisted; exist only while the server process is running; require source code at runtime. | Persisted in the catalog; portable across sessions and environments without access to original code. |
347+
| Discoverability | Not listed in the catalog; visible only via the MCP server that registers them. | First-class catalog objects: `list_tools()`, `get_tool()`, `drop_tool()`. |
348+
| Parameters/Schema | From function signature using `Annotated[type, "description"]`. `limit` and `table_format` are auto-added. | From `ToolParam` definitions bound to `tool_param(...)` in the plan. Defaults mark params as optional. |
349+
| Execution context | Executes the returned logical plan from your function; can capture `DataFrame`s or use session access in code. | Executes a stored logical plan with bound parameters from the catalog. |
350+
| Result formatting | `table_format` supports `markdown` or `structured`; `limit` caps rows (capped by `max_result_limit` if set). | Same. |
351+
| Best for | Custom logic, semantic/procedural transforms, quick EDA, mixing multiple data sources in code. | Reusable, shareable queries/macros that outlive the application process. |
352+
226353
## Troubleshooting
227354

228355
- No tools found: ensure you have created tools in the current database (`session.catalog.list_tools()`).

examples/mcp/docs-server/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ def main():
212212
server = create_mcp_server(
213213
session=session,
214214
server_name="Fenic Documentation Server",
215-
tools=tools,
215+
parameterized_tools=tools,
216216
concurrency_limit=8
217217
)
218218

src/fenic/api/mcp/server.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ def create_mcp_server(
2020
session: Session,
2121
server_name: str,
2222
*,
23-
tools: Optional[List[ParameterizedToolDefinition]] = None,
23+
parameterized_tools: Optional[List[ParameterizedToolDefinition]] = None,
24+
dynamic_tools: Optional[List[DynamicToolDefinition]] = None,
2425
automated_tool_generation: Optional[ToolGenerationConfig] = None,
2526
concurrency_limit: int = 8,
2627
) -> FenicMCPServer:
@@ -29,23 +30,24 @@ def create_mcp_server(
2930
Args:
3031
session: Fenic session used to execute tools.
3132
server_name: Name of the MCP server.
32-
tools: Tools to register (optional).
33+
dynamic_tools: Dynamic tools to register (optional).
34+
parameterized_tools: Tools to register (optional).
3335
automated_tool_generation: Generate automated tools for one or more Dataframes.
3436
concurrency_limit: Maximum number of concurrent tool executions.
3537
"""
36-
dynamic_tools: List[DynamicToolDefinition] = []
37-
if tools is None:
38-
tools = []
38+
dynamic_tools: List[DynamicToolDefinition] = dynamic_tools or []
39+
if parameterized_tools is None:
40+
parameterized_tools = []
3941
if automated_tool_generation:
4042
dynamic_tools.extend(auto_generate_core_tools_from_tables(
4143
automated_tool_generation.table_names,
4244
session,
4345
tool_group_name=automated_tool_generation.tool_group_name,
4446
sql_max_rows=automated_tool_generation.sql_max_rows)
4547
)
46-
if not (tools or dynamic_tools):
48+
if not (parameterized_tools or dynamic_tools):
4749
raise ConfigurationError("No tools provided. Either provide tools or set generate_automated_tools=True and provide datasets.")
48-
return FenicMCPServer(session._session_state, tools, dynamic_tools, server_name, concurrency_limit)
50+
return FenicMCPServer(session._session_state, parameterized_tools, dynamic_tools, server_name, concurrency_limit)
4951

5052
def run_mcp_server_asgi(
5153
server: FenicMCPServer,

src/fenic/api/mcp/tool_generation.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def auto_generate_core_tools_from_tables(
9191
sql_max_rows=sql_max_rows,
9292
)
9393

94-
def dynamic_tool(
94+
def fenic_tool(
9595
tool_name: str,
9696
tool_description: str,
9797
max_result_limit: Optional[int] = None,
@@ -103,15 +103,24 @@ def dynamic_tool(
103103
@dynamic_tool(tool_name="find_rust", tool_description="...")
104104
def find_rust(
105105
query: Annotated[str, "Natural language query"],
106-
limit: Annotated[int, "Max rows"] = 50,
107106
) -> DataFrame:
108-
pred = fc.semantic.predicate("Matches: {{q}}", q=query, bio=fc.col("bio"))
109-
return df.filter(pred).limit(limit)
107+
pred = fc.semantic.predicate("Matches: {{q}} Data: {{bio}}", q=query, bio=fc.col("bio"))
108+
return df.filter(pred)
109+
110+
mcp_server = fc.create_mcp_server(
111+
local_session,
112+
"...",
113+
dynamic_tools=[find_rust],
114+
)
115+
fc.run_mcp_server_sync(mcp_server)
110116
111117
Notes:
112-
- The decorated function MUST NOT use *args/**kwargs, and should annotate parameters with Annotated descriptions.
118+
- The decorated function MUST NOT use *args/**kwargs
113119
- The decorated function MUST return a fenic DataFrame.
120+
- The decorated function SHOULD annotate parameters with `Annotated` types and descriptions.
114121
- The returned object is a DynamicTool ready for registration.
122+
- A `limit` parameter is automatically added to the function signature, which can be used to limit the number of rows returned up to the tool's `max_result_limit`.
123+
- A `table_format` parameter is automatically added to the function signature, which can be used to specify the format of the returned data (markdown, structured)
115124
"""
116125

117126
def decorator(func: Callable[..., DataFrame]) -> DynamicToolDefinition:
@@ -125,9 +134,9 @@ def wrapper(*args, **kwargs) -> LogicalPlan:
125134
return DynamicToolDefinition(
126135
name=tool_name,
127136
description=tool_description,
128-
func=wrapper,
129137
max_result_limit=max_result_limit,
130138
default_table_format=default_table_format,
139+
_func=wrapper,
131140
)
132141

133142
return decorator
@@ -192,7 +201,7 @@ def read_func(
192201
return DynamicToolDefinition(
193202
name=tool_name,
194203
description=tool_description,
195-
func=read_func,
204+
_func=read_func,
196205
max_result_limit=result_limit,
197206
)
198207

@@ -237,7 +246,7 @@ def search_summary(
237246
return DynamicToolDefinition(
238247
name=tool_name,
239248
description=tool_description,
240-
func=search_summary,
249+
_func=search_summary,
241250
max_result_limit=None,
242251
)
243252

@@ -306,7 +315,7 @@ def search_rows(
306315
return DynamicToolDefinition(
307316
name=tool_name,
308317
description=tool_description,
309-
func=search_rows,
318+
_func=search_rows,
310319
max_result_limit=result_limit,
311320
)
312321

@@ -373,9 +382,8 @@ def schema_func(
373382
return DynamicToolDefinition(
374383
name=tool_name,
375384
description=enhanced_description,
376-
func=schema_func,
385+
_func=schema_func,
377386
max_result_limit=None,
378-
default_table_format="structured"
379387
)
380388

381389

@@ -439,7 +447,7 @@ def analyze_func(
439447
tool = DynamicToolDefinition(
440448
name=tool_name,
441449
description=enhanced_description,
442-
func=analyze_func,
450+
_func=analyze_func,
443451
max_result_limit=result_limit,
444452
)
445453
return tool
@@ -658,9 +666,8 @@ def profile_func(
658666
return DynamicToolDefinition(
659667
name=tool_name,
660668
description=enhanced_description,
661-
func=profile_func,
669+
_func=profile_func,
662670
max_result_limit=None,
663-
default_table_format="structured"
664671
)
665672

666673

0 commit comments

Comments
 (0)