Skip to content

Commit 3e7b3ed

Browse files
authored
Merge pull request #22625 from dannon/agent-ops-udt-parity
Add user-defined-tool operations to the agent-operations layer
2 parents 0fddeb9 + 2cb7189 commit 3e7b3ed

7 files changed

Lines changed: 352 additions & 42 deletions

File tree

lib/galaxy/agents/operations.py

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@
1010
Optional,
1111
)
1212

13+
from sqlalchemy import select
14+
1315
from galaxy.managers.context import ProvidesUserContext
1416
from galaxy.managers.hdas import HDAManager
17+
from galaxy.managers.tools import DynamicToolManager
18+
from galaxy.model import UserDynamicToolAssociation
1519
from galaxy.schema import (
1620
FilterQueryParams,
1721
SerializationParams,
@@ -26,9 +30,12 @@
2630
from galaxy.schema.schema import (
2731
CreateHistoryPayload,
2832
DatasetSourceType,
33+
InvocationIndexPayload,
34+
WorkflowIndexPayload,
2935
)
3036
from galaxy.schema.workflows import InvokeWorkflowPayload
3137
from galaxy.structured_app import MinimalManagerApp
38+
from galaxy.tool_util_models.dynamic_tool_models import DynamicUnprivilegedToolCreatePayload
3239

3340
log = logging.getLogger(__name__)
3441

@@ -61,6 +68,7 @@ def __init__(self, app: MinimalManagerApp, trans: ProvidesUserContext):
6168
self._invocations_service: Optional[Any] = None
6269
self._hda_manager: Optional[HDAManager] = None
6370
self._dataset_collections_service: Optional[Any] = None
71+
self._dynamic_tools_manager: Optional[Any] = None
6472

6573
def _encode_id(self, value: int) -> str:
6674
return self.trans.security.encode_id(value)
@@ -148,6 +156,12 @@ def dataset_collections_service(self):
148156
self._dataset_collections_service = self.app[DatasetCollectionsService]
149157
return self._dataset_collections_service
150158

159+
@property
160+
def dynamic_tools_manager(self):
161+
if self._dynamic_tools_manager is None:
162+
self._dynamic_tools_manager = self.app[DynamicToolManager]
163+
return self._dynamic_tools_manager
164+
151165
def connect(self) -> dict[str, Any]:
152166
config = self.app.config
153167
user = self.trans.user
@@ -429,8 +443,6 @@ def list_workflows(
429443
show_shared: bool = True,
430444
search: str | None = None,
431445
) -> dict[str, Any]:
432-
from galaxy.webapps.galaxy.services.workflows import WorkflowIndexPayload
433-
434446
payload = WorkflowIndexPayload(
435447
limit=limit,
436448
offset=offset,
@@ -516,8 +528,6 @@ def get_invocations(
516528
if history_id:
517529
decoded_history_id = self.trans.security.decode_id(history_id)
518530

519-
from galaxy.webapps.galaxy.services.invocations import InvocationIndexPayload
520-
521531
payload = InvocationIndexPayload(
522532
workflow_id=decoded_workflow_id,
523533
history_id=decoded_history_id,
@@ -873,3 +883,67 @@ def get_user(self) -> dict[str, Any]:
873883
"deleted": user.deleted,
874884
"create_time": user.create_time.isoformat() if user.create_time else None,
875885
}
886+
887+
# ==================== User-Defined Tools (UDT) ====================
888+
889+
def list_user_tools(self, active: bool = True) -> dict[str, Any]:
890+
user = self.trans.user
891+
if not user:
892+
raise ValueError("User must be authenticated")
893+
894+
tools = list(self.dynamic_tools_manager.list_unprivileged_tools(user, active=active))
895+
return {
896+
"tools": [t.to_dict() for t in tools],
897+
"count": len(tools),
898+
}
899+
900+
def create_user_tool(self, representation: dict[str, Any]) -> dict[str, Any]:
901+
user = self.trans.user
902+
if not user:
903+
raise ValueError("User must be authenticated")
904+
905+
payload = DynamicUnprivilegedToolCreatePayload(src="representation", representation=representation)
906+
dynamic_tool = self.dynamic_tools_manager.create_unprivileged_tool(user, payload)
907+
return dynamic_tool.to_dict()
908+
909+
def delete_user_tool(self, uuid: str) -> dict[str, Any]:
910+
user = self.trans.user
911+
if not user:
912+
raise ValueError("User must be authenticated")
913+
914+
dynamic_tool = self.dynamic_tools_manager.get_unprivileged_tool_by_uuid(user, uuid)
915+
if dynamic_tool is None:
916+
raise ValueError(f"User-defined tool {uuid!r} not found")
917+
918+
self.dynamic_tools_manager.deactivate_unprivileged_tool(user, dynamic_tool)
919+
return {"uuid": uuid, "deactivated": True}
920+
921+
def run_user_tool(self, history_id: str, tool_uuid: str, inputs: dict[str, Any]) -> dict[str, Any]:
922+
user = self.trans.user
923+
if not user:
924+
raise ValueError("User must be authenticated")
925+
926+
dynamic_tool = self.dynamic_tools_manager.get_unprivileged_tool_by_uuid(user, tool_uuid)
927+
if dynamic_tool is None:
928+
raise ValueError(f"User-defined tool {tool_uuid!r} not found")
929+
# UDT deactivation is per-user by design: deactivate_unprivileged_tool only
930+
# flips the user-association, leaving DynamicTool.active intact so other
931+
# users sharing the underlying tool aren't affected. The runtime check has
932+
# to look at the association, not just dynamic_tool.active.
933+
session = self.dynamic_tools_manager.session()
934+
assoc_active = session.scalar(
935+
select(UserDynamicToolAssociation.active).where(
936+
UserDynamicToolAssociation.user_id == user.id,
937+
UserDynamicToolAssociation.dynamic_tool_id == dynamic_tool.id,
938+
)
939+
)
940+
if not dynamic_tool.active or not assoc_active:
941+
raise ValueError(f"User-defined tool {tool_uuid!r} is deactivated")
942+
943+
payload = {
944+
"history_id": history_id,
945+
"tool_uuid": tool_uuid,
946+
"inputs": inputs,
947+
}
948+
result = self.tools_service._create(self.trans, payload)
949+
return self._encode_ids_in_response(result)

lib/galaxy/schema/schema.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1637,6 +1637,10 @@ class WorkflowIndexQueryPayload(Model):
16371637
skip_step_counts: bool = False
16381638

16391639

1640+
class WorkflowIndexPayload(WorkflowIndexQueryPayload):
1641+
missing_tools: bool = False
1642+
1643+
16401644
class JobIndexSortByEnum(str, Enum):
16411645
create_time = "create_time"
16421646
update_time = "update_time"
@@ -1689,6 +1693,10 @@ class InvocationIndexQueryPayload(Model):
16891693
include_nested_invocations: bool = True
16901694

16911695

1696+
class InvocationIndexPayload(InvocationIndexQueryPayload):
1697+
instance: bool = Field(default=False, description="Is provided workflow id for Workflow instead of StoredWorkflow?")
1698+
1699+
16921700
PageSortByEnum = Literal["create_time", "title", "update_time", "username"]
16931701

16941702

lib/galaxy/webapps/galaxy/api/mcp.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,140 @@ def get_user(api_key: str, ctx: MCPContext) -> dict[str, Any]:
429429
ops_manager = get_operations_manager(api_key, ctx)
430430
return ops_manager.get_user()
431431

432+
# ==================== User-Defined Tools (UDT) ====================
433+
434+
@mcp.tool()
435+
def list_user_tools(api_key: str, ctx: MCPContext, active: bool = True) -> dict[str, Any]:
436+
"""List user-defined tools belonging to the current user.
437+
438+
Args:
439+
active: If True (default), only show active tools. Set False to
440+
include deactivated tools.
441+
442+
Returns:
443+
Dict with 'tools' (list of user tools, each with id, uuid, tool_id,
444+
name, and active status) and 'count'.
445+
"""
446+
with _mcp_error_handler("list_user_tools"):
447+
ops_manager = get_operations_manager(api_key, ctx)
448+
return ops_manager.list_user_tools(active)
449+
450+
@mcp.tool()
451+
def create_user_tool(representation: dict[str, Any], api_key: str, ctx: MCPContext) -> dict[str, Any]:
452+
"""Create a user-defined tool in Galaxy from a YAML tool definition.
453+
454+
User-defined tools are lightweight, containerized tools that can be
455+
created without admin privileges. They are stored in the database,
456+
scoped to the creating user, and can be embedded in workflows
457+
(importing the workflow automatically creates the tool for the
458+
importing user).
459+
460+
Requires the USER_TOOL_EXECUTE role on the calling user and
461+
enable_beta_tool_formats=true in the Galaxy config; both are enforced
462+
by the underlying manager and surface as permission/config errors here.
463+
464+
Args:
465+
representation: The tool definition as a dictionary matching the
466+
GalaxyUserTool schema. Required fields:
467+
- class: "GalaxyUserTool" (exactly this string)
468+
- id: tool identifier (lowercase, no spaces, 3-255 chars)
469+
- version: version string (e.g. "0.1.0")
470+
- name: display name shown in Galaxy tool menu
471+
- container: container image as a STRING (e.g. "python:3.12-slim"),
472+
NOT a dict -- this is a common mistake
473+
- shell_command: the command to execute, with $(inputs.name.path)
474+
for data inputs and $(inputs.name) for parameter inputs
475+
- inputs: list of input dicts, each with "name" and "type"
476+
(type can be: "data", "integer", "float", "text", "boolean")
477+
- outputs: list of output dicts, each with "name", "type": "data",
478+
"format" (e.g. "tabular", "vcf", "bed"), and "from_work_dir"
479+
480+
Returns:
481+
Dict with the created tool's id, uuid, tool_id, active status, and
482+
the validated representation.
483+
484+
Example:
485+
create_user_tool({
486+
"class": "GalaxyUserTool",
487+
"id": "my_filter",
488+
"version": "0.1.0",
489+
"name": "My Filter",
490+
"container": "python:3.12-slim",
491+
"shell_command": "python3 -c 'import sys; ...'",
492+
"inputs": [{"name": "input1", "type": "data", "format": "tabular"}],
493+
"outputs": [
494+
{"name": "output1", "type": "data",
495+
"format": "tabular", "from_work_dir": "out.tsv"}
496+
]
497+
})
498+
499+
NEXT STEPS:
500+
- Run the tool: run_user_tool(history_id, tool_uuid, inputs)
501+
- List your tools: list_user_tools()
502+
- Delete a tool: delete_user_tool(uuid)
503+
"""
504+
with _mcp_error_handler("create_user_tool"):
505+
ops_manager = get_operations_manager(api_key, ctx)
506+
return ops_manager.create_user_tool(representation)
507+
508+
@mcp.tool()
509+
def delete_user_tool(uuid: str, api_key: str, ctx: MCPContext) -> dict[str, Any]:
510+
"""Deactivate a user-defined tool. Deactivated tools are not loaded into the toolbox.
511+
512+
Existing job history that referenced the tool is preserved; only
513+
future runs are blocked.
514+
515+
Args:
516+
uuid: The UUID of the tool to deactivate. Get this from list_user_tools().
517+
518+
Returns:
519+
Dict confirming deactivation: {"uuid": ..., "deactivated": True}.
520+
"""
521+
with _mcp_error_handler("delete_user_tool"):
522+
ops_manager = get_operations_manager(api_key, ctx)
523+
return ops_manager.delete_user_tool(uuid)
524+
525+
@mcp.tool()
526+
def run_user_tool(
527+
history_id: str,
528+
tool_uuid: str,
529+
inputs: dict[str, Any],
530+
api_key: str,
531+
ctx: MCPContext,
532+
) -> dict[str, Any]:
533+
"""Run a user-defined tool by UUID, producing outputs in the given history.
534+
535+
Resolution happens through the tool service's standard run path,
536+
which accepts tool_uuid in the payload and dispatches via the
537+
toolbox's unprivileged-tool resolver -- so this is functionally a
538+
UUID-keyed counterpart to run_tool().
539+
540+
Args:
541+
history_id: Galaxy history ID where outputs will be placed.
542+
tool_uuid: The UUID of the user-defined tool (from create_user_tool
543+
or list_user_tools).
544+
inputs: Tool input parameters keyed by input name.
545+
- Dataset inputs: {"input_name": {"src": "hda", "id": "<dataset_id>"}}
546+
- Collection inputs: {"input_name": {"src": "hdca", "id": "<collection_id>"}}
547+
- Scalar parameters: {"param_name": value}
548+
549+
Returns:
550+
Dict with job info (job_id, history_id, state) and output dataset IDs.
551+
552+
Example:
553+
run_user_tool(
554+
history_id="abc123",
555+
tool_uuid="61d15277-a911-45ef-aa66-5385146578cc",
556+
inputs={
557+
"scorer_output": {"src": "hda", "id": "59ace41fc068d3ad"},
558+
"top_tracks_per_variant": 5,
559+
},
560+
)
561+
"""
562+
with _mcp_error_handler("run_user_tool"):
563+
ops_manager = get_operations_manager(api_key, ctx)
564+
return ops_manager.run_user_tool(history_id, tool_uuid, inputs)
565+
432566
mcp_app = mcp.http_app(path="/")
433567
mcp_app.state.mcp_server = mcp
434568

lib/galaxy/webapps/galaxy/api/workflows.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,14 @@
7373
AsyncTaskResultSummary,
7474
ClaimLandingPayload,
7575
CreateWorkflowLandingRequestPayload,
76+
InvocationIndexPayload,
7677
InvocationSortByEnum,
7778
InvocationsStateCounts,
7879
SetSlugPayload,
7980
ShareWithPayload,
8081
ShareWithStatus,
8182
SharingStatus,
83+
WorkflowIndexPayload,
8284
WorkflowJobMetric,
8385
WorkflowLandingRequest,
8486
WorkflowSortByEnum,
@@ -120,15 +122,11 @@
120122
ServesExportStores,
121123
)
122124
from galaxy.webapps.galaxy.services.invocations import (
123-
InvocationIndexPayload,
124125
InvocationsService,
125126
PrepareStoreDownloadPayload,
126127
WriteInvocationStoreToPayload,
127128
)
128-
from galaxy.webapps.galaxy.services.workflows import (
129-
WorkflowIndexPayload,
130-
WorkflowsService,
131-
)
129+
from galaxy.webapps.galaxy.services.workflows import WorkflowsService
132130
from galaxy.workflow.extract import extract_workflow
133131
from galaxy.workflow.modules import module_factory
134132

lib/galaxy/webapps/galaxy/services/invocations.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
Any,
55
)
66

7-
from pydantic import Field
8-
97
from galaxy.celery.helpers import async_task_summary
108
from galaxy.celery.tasks import (
119
prepare_invocation_download,
@@ -51,7 +49,7 @@
5149
AsyncTaskResultSummary,
5250
BcoGenerationParametersMixin,
5351
ExportObjectType,
54-
InvocationIndexQueryPayload,
52+
InvocationIndexPayload,
5553
StoreExportPayload,
5654
WriteStoreToPayload,
5755
)
@@ -71,10 +69,6 @@
7169
log = logging.getLogger(__name__)
7270

7371

74-
class InvocationIndexPayload(InvocationIndexQueryPayload):
75-
instance: bool = Field(default=False, description="Is provided workflow id for Workflow instead of StoredWorkflow?")
76-
77-
7872
class PrepareStoreDownloadPayload(StoreExportPayload, BcoGenerationParametersMixin):
7973
pass
8074

lib/galaxy/webapps/galaxy/services/workflows.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from galaxy.schema.invocation import WorkflowInvocationResponse
3030
from galaxy.schema.schema import (
3131
InvocationsStateCounts,
32-
WorkflowIndexQueryPayload,
32+
WorkflowIndexPayload,
3333
)
3434
from galaxy.schema.workflows import (
3535
InvokeWorkflowPayload,
@@ -45,10 +45,6 @@
4545
log = logging.getLogger(__name__)
4646

4747

48-
class WorkflowIndexPayload(WorkflowIndexQueryPayload):
49-
missing_tools: bool = False
50-
51-
5248
class WorkflowsService(ServiceBase):
5349
def __init__(
5450
self,

0 commit comments

Comments
 (0)