dao-ai 0.1.5__py3-none-any.whl → 0.1.7__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.
dao_ai/cli.py CHANGED
@@ -7,7 +7,7 @@ import sys
7
7
  import traceback
8
8
  from argparse import ArgumentParser, Namespace
9
9
  from pathlib import Path
10
- from typing import Optional, Sequence
10
+ from typing import Any, Optional, Sequence
11
11
 
12
12
  from dotenv import find_dotenv, load_dotenv
13
13
  from loguru import logger
@@ -114,6 +114,7 @@ Examples:
114
114
  dao-ai validate -c config/model_config.yaml # Validate a specific configuration file
115
115
  dao-ai graph -o architecture.png -c my_config.yaml -v # Generate visual graph with verbose output
116
116
  dao-ai chat -c config/retail.yaml --custom-input store_num=87887 # Start interactive chat session
117
+ dao-ai list-mcp-tools -c config/mcp_config.yaml --apply-filters # List filtered MCP tools only
117
118
  dao-ai validate # Validate with detailed logging
118
119
  dao-ai bundle --deploy # Deploy the DAO AI asset bundle
119
120
  """,
@@ -309,6 +310,53 @@ Examples:
309
310
  help="Path to the model configuration file to validate",
310
311
  )
311
312
 
313
+ # List MCP tools command
314
+ list_mcp_parser: ArgumentParser = subparsers.add_parser(
315
+ "list-mcp-tools",
316
+ help="List available MCP tools from configuration",
317
+ description="""
318
+ List all available MCP tools from the configured MCP servers.
319
+ This command shows:
320
+ - All MCP servers/functions in the configuration
321
+ - Available tools from each server
322
+ - Full descriptions for each tool (no truncation)
323
+ - Tool parameters in readable format (type, required/optional, descriptions)
324
+ - Which tools are included/excluded based on filters
325
+ - Filter patterns (include_tools, exclude_tools)
326
+
327
+ Use this command to:
328
+ - Discover available tools before configuring agents
329
+ - Review tool descriptions and parameter schemas
330
+ - Debug tool filtering configuration
331
+ - Verify MCP server connectivity
332
+
333
+ Options:
334
+ - Use --apply-filters to only show tools that will be loaded (hides excluded tools)
335
+ - Without --apply-filters, see all available tools with include/exclude status
336
+
337
+ Note: Schemas are displayed in a concise, readable format instead of verbose JSON
338
+ """,
339
+ epilog="""Examples:
340
+ dao-ai list-mcp-tools -c config/model_config.yaml
341
+ dao-ai list-mcp-tools -c config/model_config.yaml --apply-filters
342
+ """,
343
+ formatter_class=argparse.RawDescriptionHelpFormatter,
344
+ )
345
+ list_mcp_parser.add_argument(
346
+ "-c",
347
+ "--config",
348
+ type=str,
349
+ default="./config/model_config.yaml",
350
+ required=False,
351
+ metavar="FILE",
352
+ help="Path to the model configuration file (default: ./config/model_config.yaml)",
353
+ )
354
+ list_mcp_parser.add_argument(
355
+ "--apply-filters",
356
+ action="store_true",
357
+ help="Only show tools that pass include/exclude filters (hide excluded tools)",
358
+ )
359
+
312
360
  chat_parser: ArgumentParser = subparsers.add_parser(
313
361
  "chat",
314
362
  help="Interactive chat with the DAO AI system",
@@ -704,6 +752,275 @@ def handle_validate_command(options: Namespace) -> None:
704
752
  sys.exit(1)
705
753
 
706
754
 
755
+ def _format_schema_pretty(schema: dict[str, Any], indent: int = 0) -> str:
756
+ """
757
+ Format a JSON schema in a more readable, concise format.
758
+
759
+ Args:
760
+ schema: The JSON schema to format
761
+ indent: Current indentation level
762
+
763
+ Returns:
764
+ Pretty-formatted schema string
765
+ """
766
+ if not schema:
767
+ return ""
768
+
769
+ lines: list[str] = []
770
+ indent_str = " " * indent
771
+
772
+ # Get required fields
773
+ required_fields = set(schema.get("required", []))
774
+
775
+ # Handle object type with properties
776
+ if schema.get("type") == "object" and "properties" in schema:
777
+ properties = schema["properties"]
778
+
779
+ for prop_name, prop_schema in properties.items():
780
+ is_required = prop_name in required_fields
781
+ req_marker = " (required)" if is_required else " (optional)"
782
+
783
+ prop_type = prop_schema.get("type", "any")
784
+ prop_desc = prop_schema.get("description", "")
785
+
786
+ # Handle different types
787
+ if prop_type == "array":
788
+ items = prop_schema.get("items", {})
789
+ item_type = items.get("type", "any")
790
+ type_str = f"array<{item_type}>"
791
+ elif prop_type == "object":
792
+ type_str = "object"
793
+ else:
794
+ type_str = prop_type
795
+
796
+ # Format enum values if present
797
+ if "enum" in prop_schema:
798
+ enum_values = ", ".join(str(v) for v in prop_schema["enum"])
799
+ type_str = f"{type_str} (one of: {enum_values})"
800
+
801
+ # Build the line
802
+ line = f"{indent_str}{prop_name}: {type_str}{req_marker}"
803
+ if prop_desc:
804
+ line += f"\n{indent_str} └─ {prop_desc}"
805
+
806
+ lines.append(line)
807
+
808
+ # Recursively handle nested objects
809
+ if prop_type == "object" and "properties" in prop_schema:
810
+ nested = _format_schema_pretty(prop_schema, indent + 1)
811
+ if nested:
812
+ lines.append(nested)
813
+
814
+ # Handle simple types without properties
815
+ elif "type" in schema:
816
+ schema_type = schema["type"]
817
+ if schema.get("description"):
818
+ lines.append(f"{indent_str}Type: {schema_type}")
819
+ lines.append(f"{indent_str}└─ {schema['description']}")
820
+ else:
821
+ lines.append(f"{indent_str}Type: {schema_type}")
822
+
823
+ return "\n".join(lines)
824
+
825
+
826
+ def handle_list_mcp_tools_command(options: Namespace) -> None:
827
+ """
828
+ List available MCP tools from configuration.
829
+
830
+ Shows all MCP servers and their available tools, indicating which
831
+ are included/excluded based on filter configuration.
832
+ """
833
+ logger.debug(f"Listing MCP tools from configuration: {options.config}")
834
+
835
+ try:
836
+ from dao_ai.config import McpFunctionModel
837
+ from dao_ai.tools.mcp import MCPToolInfo, _matches_pattern, list_mcp_tools
838
+
839
+ # Load configuration
840
+ config: AppConfig = AppConfig.from_file(options.config)
841
+
842
+ # Find all MCP tools in configuration
843
+ mcp_tools_config: list[tuple[str, McpFunctionModel]] = []
844
+ if config.tools:
845
+ for tool_name, tool_model in config.tools.items():
846
+ logger.debug(
847
+ f"Checking tool: {tool_name}, function type: {type(tool_model.function)}"
848
+ )
849
+ if tool_model.function and isinstance(
850
+ tool_model.function, McpFunctionModel
851
+ ):
852
+ mcp_tools_config.append((tool_name, tool_model.function))
853
+
854
+ if not mcp_tools_config:
855
+ logger.warning("No MCP tools found in configuration")
856
+ print("\n⚠️ No MCP tools configured in this file.")
857
+ print(f" Configuration: {options.config}")
858
+ print(
859
+ "\nTo add MCP tools, define them in the 'tools' section with 'type: mcp'"
860
+ )
861
+ sys.exit(0)
862
+
863
+ # Collect all results first (aggregate before displaying)
864
+ results: list[dict[str, Any]] = []
865
+ for tool_name, mcp_function in mcp_tools_config:
866
+ result = {
867
+ "tool_name": tool_name,
868
+ "mcp_function": mcp_function,
869
+ "error": None,
870
+ "all_tools": [],
871
+ "included_tools": [],
872
+ "excluded_tools": [],
873
+ }
874
+
875
+ try:
876
+ logger.info(f"Connecting to MCP server: {mcp_function.mcp_url}")
877
+
878
+ # Get all available tools (unfiltered)
879
+ all_tools: list[MCPToolInfo] = list_mcp_tools(
880
+ mcp_function, apply_filters=False
881
+ )
882
+
883
+ # Get filtered tools (what will actually be loaded)
884
+ filtered_tools: list[MCPToolInfo] = list_mcp_tools(
885
+ mcp_function, apply_filters=True
886
+ )
887
+
888
+ included_names = {t.name for t in filtered_tools}
889
+
890
+ # Categorize tools
891
+ for tool in sorted(all_tools, key=lambda t: t.name):
892
+ if tool.name in included_names:
893
+ result["included_tools"].append(tool)
894
+ else:
895
+ # Determine why it was excluded
896
+ reason = ""
897
+ if mcp_function.exclude_tools:
898
+ if _matches_pattern(tool.name, mcp_function.exclude_tools):
899
+ matching_patterns = [
900
+ p
901
+ for p in mcp_function.exclude_tools
902
+ if _matches_pattern(tool.name, [p])
903
+ ]
904
+ reason = f" (matches exclude pattern: {', '.join(matching_patterns)})"
905
+ if not reason and mcp_function.include_tools:
906
+ reason = " (not in include list)"
907
+ result["excluded_tools"].append((tool, reason))
908
+
909
+ result["all_tools"] = all_tools
910
+
911
+ except KeyboardInterrupt:
912
+ result["error"] = "Connection interrupted by user"
913
+ results.append(result)
914
+ break
915
+ except Exception as e:
916
+ logger.error(f"Failed to list tools from MCP server: {e}")
917
+ result["error"] = str(e)
918
+
919
+ results.append(result)
920
+
921
+ # Now display all results at once (no logging interleaving)
922
+ print(f"\n{'=' * 80}")
923
+ print("MCP TOOLS DISCOVERY")
924
+ print(f"Configuration: {options.config}")
925
+ print(f"{'=' * 80}\n")
926
+
927
+ for result in results:
928
+ tool_name = result["tool_name"]
929
+ mcp_function = result["mcp_function"]
930
+
931
+ print(f"📦 Tool: {tool_name}")
932
+ print(f" Server: {mcp_function.mcp_url}")
933
+
934
+ # Show connection type
935
+ if mcp_function.connection:
936
+ print(f" Connection: UC Connection '{mcp_function.connection.name}'")
937
+ else:
938
+ print(f" Transport: {mcp_function.transport.value}")
939
+
940
+ # Show filters if configured
941
+ if mcp_function.include_tools or mcp_function.exclude_tools:
942
+ print("\n Filters:")
943
+ if mcp_function.include_tools:
944
+ print(f" Include: {', '.join(mcp_function.include_tools)}")
945
+ if mcp_function.exclude_tools:
946
+ print(f" Exclude: {', '.join(mcp_function.exclude_tools)}")
947
+
948
+ # Check for errors
949
+ if result["error"]:
950
+ print(f"\n ❌ Error: {result['error']}")
951
+ print(" Could not connect to MCP server")
952
+ if result["error"] != "Connection interrupted by user":
953
+ print(
954
+ " Tip: Verify server URL, authentication, and network connectivity"
955
+ )
956
+ else:
957
+ all_tools = result["all_tools"]
958
+ included_tools = result["included_tools"]
959
+ excluded_tools = result["excluded_tools"]
960
+
961
+ # Show stats based on --apply-filters flag
962
+ if options.apply_filters:
963
+ # Simplified view: only show filtered tools count
964
+ print(
965
+ f"\n Available Tools: {len(included_tools)} (after filters)"
966
+ )
967
+ else:
968
+ # Full view: show all, included, and excluded counts
969
+ print(f"\n Available Tools: {len(all_tools)} total")
970
+ print(f" ├─ ✓ Included: {len(included_tools)}")
971
+ print(f" └─ ✗ Excluded: {len(excluded_tools)}")
972
+
973
+ # Show included tools with FULL descriptions and schemas
974
+ if included_tools:
975
+ if options.apply_filters:
976
+ print(f"\n Tools ({len(included_tools)}):")
977
+ else:
978
+ print(f"\n ✓ Included Tools ({len(included_tools)}):")
979
+
980
+ for tool in included_tools:
981
+ print(f"\n • {tool.name}")
982
+ if tool.description:
983
+ # Show full description (no truncation)
984
+ print(f" Description: {tool.description}")
985
+ if tool.input_schema:
986
+ # Pretty print schema in readable format
987
+ print(" Parameters:")
988
+ pretty_schema = _format_schema_pretty(
989
+ tool.input_schema, indent=0
990
+ )
991
+ if pretty_schema:
992
+ # Indent the schema for better readability
993
+ for line in pretty_schema.split("\n"):
994
+ print(f" {line}")
995
+ else:
996
+ print(" (none)")
997
+
998
+ # Show excluded tools only if NOT applying filters
999
+ if excluded_tools and not options.apply_filters:
1000
+ print(f"\n ✗ Excluded Tools ({len(excluded_tools)}):")
1001
+ for tool, reason in excluded_tools:
1002
+ print(f" • {tool.name}{reason}")
1003
+
1004
+ print(f"\n{'-' * 80}\n")
1005
+
1006
+ # Summary
1007
+ print(f"{'=' * 80}")
1008
+ print(f"Summary: Found {len(mcp_tools_config)} MCP server(s)")
1009
+ print(f"{'=' * 80}\n")
1010
+
1011
+ sys.exit(0)
1012
+
1013
+ except FileNotFoundError:
1014
+ logger.error(f"Configuration file not found: {options.config}")
1015
+ print(f"\n❌ Error: Configuration file not found: {options.config}")
1016
+ sys.exit(1)
1017
+ except Exception as e:
1018
+ logger.error(f"Failed to list MCP tools: {e}")
1019
+ logger.debug(traceback.format_exc())
1020
+ print(f"\n❌ Error: {e}")
1021
+ sys.exit(1)
1022
+
1023
+
707
1024
  def setup_logging(verbosity: int) -> None:
708
1025
  levels: dict[int, str] = {
709
1026
  0: "ERROR",
@@ -925,6 +1242,8 @@ def main() -> None:
925
1242
  handle_deploy_command(options)
926
1243
  case "chat":
927
1244
  handle_chat_command(options)
1245
+ case "list-mcp-tools":
1246
+ handle_list_mcp_tools_command(options)
928
1247
  case _:
929
1248
  logger.error(f"Unknown command: {options.command}")
930
1249
  sys.exit(1)
dao_ai/config.py CHANGED
@@ -24,6 +24,7 @@ from databricks.sdk.credentials_provider import (
24
24
  ModelServingUserCredentials,
25
25
  )
26
26
  from databricks.sdk.errors.platform import NotFound
27
+ from databricks.sdk.service.apps import App
27
28
  from databricks.sdk.service.catalog import FunctionInfo, TableInfo
28
29
  from databricks.sdk.service.dashboards import GenieSpace
29
30
  from databricks.sdk.service.database import DatabaseInstance
@@ -147,7 +148,7 @@ class PrimitiveVariableModel(BaseModel, HasValue):
147
148
  return str(value)
148
149
 
149
150
  @model_validator(mode="after")
150
- def validate_value(self) -> "PrimitiveVariableModel":
151
+ def validate_value(self) -> Self:
151
152
  if not isinstance(self.as_value(), (str, int, float, bool)):
152
153
  raise ValueError("Value must be a primitive type (str, int, float, bool)")
153
154
  return self
@@ -391,10 +392,17 @@ class PermissionModel(BaseModel):
391
392
 
392
393
  class SchemaModel(BaseModel, HasFullName):
393
394
  model_config = ConfigDict(use_enum_values=True, extra="forbid")
394
- catalog_name: str
395
- schema_name: str
395
+ catalog_name: AnyVariable
396
+ schema_name: AnyVariable
396
397
  permissions: Optional[list[PermissionModel]] = Field(default_factory=list)
397
398
 
399
+ @model_validator(mode="after")
400
+ def resolve_variables(self) -> Self:
401
+ """Resolve AnyVariable fields to their actual string values."""
402
+ self.catalog_name = value_of(self.catalog_name)
403
+ self.schema_name = value_of(self.schema_name)
404
+ return self
405
+
398
406
  @property
399
407
  def full_name(self) -> str:
400
408
  return f"{self.catalog_name}.{self.schema_name}"
@@ -408,9 +416,45 @@ class SchemaModel(BaseModel, HasFullName):
408
416
 
409
417
 
410
418
  class DatabricksAppModel(IsDatabricksResource, HasFullName):
419
+ """
420
+ Configuration for a Databricks App resource.
421
+
422
+ The `name` is the unique instance name of the Databricks App within the workspace.
423
+ The `url` is dynamically retrieved from the workspace client by calling
424
+ `apps.get(name)` and returning the app's URL.
425
+
426
+ Example:
427
+ ```yaml
428
+ resources:
429
+ apps:
430
+ my_app:
431
+ name: my-databricks-app
432
+ ```
433
+ """
434
+
411
435
  model_config = ConfigDict(use_enum_values=True, extra="forbid")
412
436
  name: str
413
- url: str
437
+ """The unique instance name of the Databricks App in the workspace."""
438
+
439
+ @property
440
+ def url(self) -> str:
441
+ """
442
+ Retrieve the URL of the Databricks App from the workspace.
443
+
444
+ Returns:
445
+ The URL of the deployed Databricks App.
446
+
447
+ Raises:
448
+ RuntimeError: If the app is not found or URL is not available.
449
+ """
450
+ app: App = self.workspace_client.apps.get(self.name)
451
+ if app.url is None:
452
+ raise RuntimeError(
453
+ f"Databricks App '{self.name}' does not have a URL. "
454
+ "The app may not be deployed yet."
455
+ )
456
+ return app.url
457
+
414
458
 
415
459
  @property
416
460
  def full_name(self) -> str:
@@ -432,7 +476,7 @@ class TableModel(IsDatabricksResource, HasFullName):
432
476
  name: Optional[str] = None
433
477
 
434
478
  @model_validator(mode="after")
435
- def validate_name_or_schema_required(self) -> "TableModel":
479
+ def validate_name_or_schema_required(self) -> Self:
436
480
  if not self.name and not self.schema_model:
437
481
  raise ValueError(
438
482
  "Either 'name' or 'schema_model' must be provided for TableModel"
@@ -998,7 +1042,7 @@ class VolumePathModel(BaseModel, HasFullName):
998
1042
  path: Optional[str] = None
999
1043
 
1000
1044
  @model_validator(mode="after")
1001
- def validate_path_or_volume(self) -> "VolumePathModel":
1045
+ def validate_path_or_volume(self) -> Self:
1002
1046
  if not self.volume and not self.path:
1003
1047
  raise ValueError("Either 'volume' or 'path' must be provided")
1004
1048
  return self
@@ -1840,11 +1884,32 @@ class McpFunctionModel(BaseFunctionModel, IsDatabricksResource):
1840
1884
  headers: dict[str, AnyVariable] = Field(default_factory=dict)
1841
1885
  args: list[str] = Field(default_factory=list)
1842
1886
  # MCP-specific fields
1887
+ app: Optional[DatabricksAppModel] = None
1843
1888
  connection: Optional[ConnectionModel] = None
1844
1889
  functions: Optional[SchemaModel] = None
1845
1890
  genie_room: Optional[GenieRoomModel] = None
1846
1891
  sql: Optional[bool] = None
1847
1892
  vector_search: Optional[VectorStoreModel] = None
1893
+ # Tool filtering
1894
+ include_tools: Optional[list[str]] = Field(
1895
+ default=None,
1896
+ description=(
1897
+ "Optional list of tool names or glob patterns to include from the MCP server. "
1898
+ "If specified, only tools matching these patterns will be loaded. "
1899
+ "Supports glob patterns: * (any chars), ? (single char), [abc] (char set). "
1900
+ "Examples: ['execute_query', 'list_*', 'get_?_data']"
1901
+ ),
1902
+ )
1903
+ exclude_tools: Optional[list[str]] = Field(
1904
+ default=None,
1905
+ description=(
1906
+ "Optional list of tool names or glob patterns to exclude from the MCP server. "
1907
+ "Tools matching these patterns will not be loaded. "
1908
+ "Takes precedence over include_tools. "
1909
+ "Supports glob patterns: * (any chars), ? (single char), [abc] (char set). "
1910
+ "Examples: ['drop_*', 'delete_*', 'execute_ddl']"
1911
+ ),
1912
+ )
1848
1913
 
1849
1914
  @property
1850
1915
  def api_scopes(self) -> Sequence[str]:
@@ -1907,6 +1972,7 @@ class McpFunctionModel(BaseFunctionModel, IsDatabricksResource):
1907
1972
 
1908
1973
  Returns the URL based on the configured source:
1909
1974
  - If url is set, returns it directly
1975
+ - If app is set, retrieves URL from Databricks App via workspace client
1910
1976
  - If connection is set, constructs URL from connection
1911
1977
  - If genie_room is set, constructs Genie MCP URL
1912
1978
  - If sql is set, constructs DBSQL MCP URL (serverless)
@@ -1919,6 +1985,7 @@ class McpFunctionModel(BaseFunctionModel, IsDatabricksResource):
1919
1985
  - Vector Search: https://{host}/api/2.0/mcp/vector-search/{catalog}/{schema}
1920
1986
  - UC Functions: https://{host}/api/2.0/mcp/functions/{catalog}/{schema}
1921
1987
  - Connection: https://{host}/api/2.0/mcp/external/{connection_name}
1988
+ - Databricks App: Retrieved dynamically from workspace
1922
1989
  """
1923
1990
  # Direct URL provided
1924
1991
  if self.url:
@@ -1940,6 +2007,10 @@ class McpFunctionModel(BaseFunctionModel, IsDatabricksResource):
1940
2007
  # DBSQL MCP server (serverless, workspace-level)
1941
2008
  if self.sql:
1942
2009
  return f"{workspace_host}/api/2.0/mcp/sql"
2010
+
2011
+ # Databricks App
2012
+ if self.app:
2013
+ return self.app.url
1943
2014
 
1944
2015
  # Vector Search
1945
2016
  if self.vector_search:
@@ -1950,33 +2021,35 @@ class McpFunctionModel(BaseFunctionModel, IsDatabricksResource):
1950
2021
  raise ValueError(
1951
2022
  "vector_search must have an index with a schema (catalog/schema) configured"
1952
2023
  )
1953
- catalog: str = self.vector_search.index.schema_model.catalog_name
1954
- schema: str = self.vector_search.index.schema_model.schema_name
2024
+ catalog: str = value_of(self.vector_search.index.schema_model.catalog_name)
2025
+ schema: str = value_of(self.vector_search.index.schema_model.schema_name)
1955
2026
  return f"{workspace_host}/api/2.0/mcp/vector-search/{catalog}/{schema}"
1956
2027
 
1957
2028
  # UC Functions MCP server
1958
2029
  if self.functions:
1959
- catalog: str = self.functions.catalog_name
1960
- schema: str = self.functions.schema_name
2030
+ catalog: str = value_of(self.functions.catalog_name)
2031
+ schema: str = value_of(self.functions.schema_name)
1961
2032
  return f"{workspace_host}/api/2.0/mcp/functions/{catalog}/{schema}"
1962
2033
 
1963
2034
  raise ValueError(
1964
- "No URL source configured. Provide one of: url, connection, genie_room, "
2035
+ "No URL source configured. Provide one of: url, app, connection, genie_room, "
1965
2036
  "sql, vector_search, or functions"
1966
2037
  )
1967
2038
 
1968
2039
  @field_serializer("transport")
1969
- def serialize_transport(self, value) -> str:
2040
+ def serialize_transport(self, value: TransportType) -> str:
2041
+ """Serialize transport enum to string."""
1970
2042
  if isinstance(value, TransportType):
1971
2043
  return value.value
1972
2044
  return str(value)
1973
2045
 
1974
2046
  @model_validator(mode="after")
1975
- def validate_mutually_exclusive(self) -> "McpFunctionModel":
2047
+ def validate_mutually_exclusive(self) -> Self:
1976
2048
  """Validate that exactly one URL source is provided."""
1977
2049
  # Count how many URL sources are provided
1978
2050
  url_sources: list[tuple[str, Any]] = [
1979
2051
  ("url", self.url),
2052
+ ("app", self.app),
1980
2053
  ("connection", self.connection),
1981
2054
  ("genie_room", self.genie_room),
1982
2055
  ("sql", self.sql),
@@ -1992,13 +2065,13 @@ class McpFunctionModel(BaseFunctionModel, IsDatabricksResource):
1992
2065
  if len(provided_sources) == 0:
1993
2066
  raise ValueError(
1994
2067
  "For STREAMABLE_HTTP transport, exactly one of the following must be provided: "
1995
- "url, connection, genie_room, sql, vector_search, or functions"
2068
+ "url, app, connection, genie_room, sql, vector_search, or functions"
1996
2069
  )
1997
2070
  if len(provided_sources) > 1:
1998
2071
  raise ValueError(
1999
2072
  f"For STREAMABLE_HTTP transport, only one URL source can be provided. "
2000
2073
  f"Found: {', '.join(provided_sources)}. "
2001
- f"Please provide only one of: url, connection, genie_room, sql, vector_search, or functions"
2074
+ f"Please provide only one of: url, app, connection, genie_room, sql, vector_search, or functions"
2002
2075
  )
2003
2076
 
2004
2077
  if self.transport == TransportType.STDIO:
@@ -2010,14 +2083,41 @@ class McpFunctionModel(BaseFunctionModel, IsDatabricksResource):
2010
2083
  return self
2011
2084
 
2012
2085
  @model_validator(mode="after")
2013
- def update_url(self) -> "McpFunctionModel":
2014
- self.url = value_of(self.url)
2086
+ def update_url(self) -> Self:
2087
+ """Resolve AnyVariable to concrete value for URL."""
2088
+ if self.url is not None:
2089
+ resolved_value: Any = value_of(self.url)
2090
+ # Cast to string since URL must be a string
2091
+ self.url = str(resolved_value) if resolved_value else None
2015
2092
  return self
2016
2093
 
2017
2094
  @model_validator(mode="after")
2018
- def update_headers(self) -> "McpFunctionModel":
2095
+ def update_headers(self) -> Self:
2096
+ """Resolve AnyVariable to concrete values for headers."""
2019
2097
  for key, value in self.headers.items():
2020
- self.headers[key] = value_of(value)
2098
+ resolved_value: Any = value_of(value)
2099
+ # Headers must be strings
2100
+ self.headers[key] = str(resolved_value) if resolved_value else ""
2101
+ return self
2102
+
2103
+ @model_validator(mode="after")
2104
+ def validate_tool_filters(self) -> Self:
2105
+ """Validate tool filter configuration."""
2106
+ from loguru import logger
2107
+
2108
+ # Warn if both are empty lists (explicit but pointless)
2109
+ if self.include_tools is not None and len(self.include_tools) == 0:
2110
+ logger.warning(
2111
+ "include_tools is empty list - no tools will be loaded. "
2112
+ "Remove field to load all tools."
2113
+ )
2114
+
2115
+ if self.exclude_tools is not None and len(self.exclude_tools) == 0:
2116
+ logger.warning(
2117
+ "exclude_tools is empty list - has no effect. "
2118
+ "Remove field or add patterns."
2119
+ )
2120
+
2021
2121
  return self
2022
2122
 
2023
2123
  def as_tools(self, **kwargs: Any) -> Sequence[RunnableLike]:
@@ -6,6 +6,7 @@ from langchain.agents.middleware import (
6
6
  ClearToolUsesEdit,
7
7
  ContextEditingMiddleware,
8
8
  HumanInTheLoopMiddleware,
9
+ LLMToolSelectorMiddleware,
9
10
  ModelCallLimitMiddleware,
10
11
  ModelRetryMiddleware,
11
12
  PIIMiddleware,
@@ -82,6 +83,7 @@ from dao_ai.middleware.summarization import (
82
83
  )
83
84
  from dao_ai.middleware.tool_call_limit import create_tool_call_limit_middleware
84
85
  from dao_ai.middleware.tool_retry import create_tool_retry_middleware
86
+ from dao_ai.middleware.tool_selector import create_llm_tool_selector_middleware
85
87
 
86
88
  __all__ = [
87
89
  # Base class (from LangChain)
@@ -105,6 +107,7 @@ __all__ = [
105
107
  "ModelCallLimitMiddleware",
106
108
  "ToolRetryMiddleware",
107
109
  "ModelRetryMiddleware",
110
+ "LLMToolSelectorMiddleware",
108
111
  "ContextEditingMiddleware",
109
112
  "ClearToolUsesEdit",
110
113
  "PIIMiddleware",
@@ -150,6 +153,8 @@ __all__ = [
150
153
  "create_model_call_limit_middleware",
151
154
  "create_tool_retry_middleware",
152
155
  "create_model_retry_middleware",
156
+ # Tool selection middleware factory functions
157
+ "create_llm_tool_selector_middleware",
153
158
  # Context editing middleware factory functions
154
159
  "create_context_editing_middleware",
155
160
  "create_clear_tool_uses_edit",