Skip to content

Commit 8596db1

Browse files
authored
List actions dynamically (#35)
1 parent 257e197 commit 8596db1

File tree

11 files changed

+729
-166
lines changed

11 files changed

+729
-166
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.2.13] - 2025-06-15
9+
10+
### Changed
11+
- Added the option to list actions as tools dynamically per user permissions
12+
813
## [0.2.12] - 2025-06-09
914

1015
### Changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mcp-server-port"
3-
version = "0.2.12"
3+
version = "0.2.13"
44
authors = [
55
{ name = "Matan Grady", email = "[email protected]" }
66
]

src/client/actions.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,52 @@ class PortActionClient:
99
def __init__(self, client: PortClient):
1010
self._client = client
1111

12+
async def _get_user_permissions(self) -> list[str]:
13+
"""Get user permissions from auth endpoint"""
14+
logger.info("Getting user permissions")
15+
16+
response = self._client.make_request("GET", "auth/permissions?action_version=v2")
17+
result = response.json()
18+
if result.get("ok"):
19+
permissions = result.get("permissions", [])
20+
logger.debug(f"listed permissions: {permissions}")
21+
if not isinstance(permissions, list):
22+
logger.warning("Permissions response is not a list")
23+
return []
24+
return permissions
25+
else:
26+
logger.warning("Failed to get user permissions")
27+
return []
28+
29+
def _has_action_permission(self, action_identifier: str, permissions: list[str]) -> bool:
30+
"""Check if user has permission to execute the action"""
31+
execute_action_permission = f"execute:actions:{action_identifier}"
32+
team_execute_permission = f"execute:team_entities:actions:{action_identifier}"
33+
34+
return execute_action_permission in permissions or team_execute_permission in permissions
35+
1236
async def get_all_actions(self, trigger_type: str = "self-service") -> list[Action]:
1337
logger.info("Getting all actions")
1438

1539
response = self._client.make_request("GET", f"actions?trigger_type={trigger_type}")
1640
result = response.json().get("actions", [])
1741

42+
user_permissions = await self._get_user_permissions()
43+
44+
filtered_actions = []
45+
for action_data in result:
46+
action_identifier = action_data.get("identifier")
47+
if self._has_action_permission(action_identifier, user_permissions):
48+
filtered_actions.append(action_data)
49+
else:
50+
logger.debug(f"User lacks permission for action: {action_identifier}")
51+
1852
if config.api_validation_enabled:
1953
logger.debug("Validating actions")
20-
return [Action(**action) for action in result]
54+
return [Action(**action) for action in filtered_actions]
2155
else:
2256
logger.debug("Skipping API validation for actions")
23-
return [Action.construct(**action) for action in result]
57+
return [Action.construct(**action) for action in filtered_actions]
2458

2559
async def get_action(self, action_identifier: str) -> Action:
2660
logger.info(f"Getting action: {action_identifier}")

src/models/actions/action.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,46 +12,69 @@
1212
class ActionSchema(BaseModel):
1313
"""Schema for action inputs."""
1414

15-
properties: dict[str, Any] = Field(default_factory=dict, description="Properties schema for action inputs")
16-
required: list[str] = Field(default_factory=list, description="Required properties for the action")
15+
properties: dict[str, Any] = Field(
16+
default_factory=dict, description="Properties schema for action inputs"
17+
)
18+
required: list[str] = Field(
19+
default_factory=list, description="Required properties for the action"
20+
)
1721

1822

1923
class ActionTrigger(BaseModel):
2024
"""Action trigger configuration."""
2125

2226
type: str = Field(..., description="The type of trigger")
23-
event: str | SkipJsonSchema[None] = Field(None, description="The event that triggers the action")
24-
condition: dict[str, Any] | SkipJsonSchema[None] = Field(None, description="Conditions for the trigger")
27+
operation: str | SkipJsonSchema[None] = Field(
28+
None, description="The operation type (CREATE, DAY_2, DELETE)"
29+
)
30+
event: str | SkipJsonSchema[None] = Field(
31+
None, description="The event that triggers the action"
32+
)
33+
condition: dict[str, Any] | SkipJsonSchema[None] = Field(
34+
None, description="Conditions for the trigger"
35+
)
2536

2637

2738
class ActionInvocationMethod(BaseModel):
2839
"""Action invocation method configuration."""
2940

3041
type: str = Field(..., description="The type of invocation method")
3142
url: str | SkipJsonSchema[None] = Field(None, description="URL for webhook invocation")
32-
agent: bool | SkipJsonSchema[None] = Field(None, description="Whether to use agent for invocation")
43+
agent: bool | SkipJsonSchema[None] = Field(
44+
None, description="Whether to use agent for invocation"
45+
)
3346
method: str | SkipJsonSchema[None] = Field(None, description="HTTP method for webhook")
3447
headers: dict[str, str] | SkipJsonSchema[None] = Field(None, description="Headers for webhook")
35-
body: str | SkipJsonSchema[None] = Field(None, description="Body template for webhook")
48+
body: str | dict[str, Any] | SkipJsonSchema[None] = Field(
49+
None, description="Body template for webhook (can be string or dict)"
50+
)
3651

3752

3853
class ActionSummary(BaseModel):
3954
"""Simplified Action model with only basic information."""
4055

4156
identifier: str = Field(..., description="The unique identifier of the action")
4257
title: str = Field(..., description="The title of the action")
43-
description: str | SkipJsonSchema[None] = Field(None, description="The description of the action")
44-
blueprint: str | SkipJsonSchema[None] = Field(None, description="The blueprint this action belongs to")
58+
description: str | SkipJsonSchema[None] = Field(
59+
None, description="The description of the action"
60+
)
61+
blueprint: str | SkipJsonSchema[None] = Field(
62+
None, description="The blueprint this action belongs to"
63+
)
4564

4665

4766
class Action(BaseModel):
4867
"""Port.io Action model."""
4968

5069
identifier: str = Field(..., description="The unique identifier of the action")
5170
title: str = Field(..., description="The title of the action")
52-
description: str | SkipJsonSchema[None] = Field(None, description="The description of the action")
71+
description: str | SkipJsonSchema[None] = Field(
72+
None, description="The description of the action"
73+
)
5374
icon: Icon | SkipJsonSchema[None] = Field(None, description="The icon of the action")
54-
blueprint: str | SkipJsonSchema[None] = Field(None, description="The blueprint this action belongs to")
75+
blueprint: str | SkipJsonSchema[None] = Field(
76+
None, description="The blueprint this action belongs to"
77+
)
5578
trigger: ActionTrigger = Field(..., description="The trigger configuration")
5679
invocation_method: ActionInvocationMethod = Field(
5780
...,

src/models/tools/tool_map.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import src.tools as mcp_tools
66
from src.client.client import PortClient
77
from src.models.tools.tool import Tool
8+
from src.tools.action.dynamic_actions import DynamicActionToolsManager
89
from src.utils import logger
910

1011

@@ -14,10 +15,25 @@ class ToolMap:
1415
tools: dict[str, Tool] = field(default_factory=dict)
1516

1617
def __post_init__(self):
18+
# Register static tools
1719
for tool in mcp_tools.__all__:
1820
module = mcp_tools.__dict__[tool]
1921
self.register_tool(module(self.port_client))
20-
logger.info(f"ToolMap initialized with {len(self.tools)} tools")
22+
logger.info(f"ToolMap initialized with {len(self.tools)} static tools")
23+
self._register_dynamic_action_tools()
24+
25+
def _register_dynamic_action_tools(self) -> None:
26+
"""Register dynamic tools for each Port action."""
27+
try:
28+
dynamic_manager = DynamicActionToolsManager(self.port_client)
29+
dynamic_tools = dynamic_manager.get_dynamic_action_tools_sync()
30+
31+
for tool in dynamic_tools:
32+
self.register_tool(tool)
33+
34+
logger.info(f"Registered {len(dynamic_tools)} dynamic action tools")
35+
except Exception as e:
36+
logger.error(f"Failed to register dynamic action tools: {e}")
2137

2238
def list_tools(self) -> list[types.Tool]:
2339
return [

src/tools/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from src.tools.action import (
77
GetActionTool,
88
ListActionsTool,
9-
RunActionTool,
109
TrackActionRunTool,
1110
)
1211
from src.tools.ai_agent import InvokeAIAGentTool
@@ -51,6 +50,5 @@
5150
"DeleteEntityTool",
5251
"GetActionTool",
5352
"ListActionsTool",
54-
"RunActionTool",
5553
"TrackActionRunTool",
5654
]

src/tools/action/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
from .dynamic_actions import DynamicActionToolsManager
12
from .get_action import GetActionTool
23
from .list_actions import ListActionsTool
3-
from .run_action import RunActionTool
44
from .track_action_run import TrackActionRunTool
55

6-
__all__ = ["GetActionTool", "ListActionsTool", "RunActionTool", "TrackActionRunTool"]
6+
__all__ = [
7+
"GetActionTool",
8+
"ListActionsTool",
9+
"TrackActionRunTool",
10+
"DynamicActionToolsManager",
11+
]
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""
2+
Dynamic action tools for Port MCP server.
3+
4+
This module provides functionality to dynamically create tools for Port actions.
5+
"""
6+
7+
import asyncio
8+
import re
9+
from typing import Any
10+
11+
from pydantic import BaseModel, Field
12+
from pydantic.json_schema import SkipJsonSchema
13+
14+
from src.client.client import PortClient
15+
from src.models.action_run.action_run import ActionRun
16+
from src.models.actions.action import Action
17+
from src.models.common.annotations import Annotations
18+
from src.models.common.base_pydantic import BaseModel as PortBaseModel
19+
from src.models.tools.tool import Tool
20+
from src.tools.action.get_action import GetActionTool, GetActionToolSchema
21+
from src.tools.action.list_actions import ListActionsTool, ListActionsToolSchema
22+
from src.utils import logger
23+
24+
25+
class DynamicActionToolSchema(BaseModel):
26+
"""Simple schema for dynamic action tools."""
27+
28+
entity_identifier: str | SkipJsonSchema[None] = Field(
29+
default=None,
30+
description="Optional entity identifier if action is entity-specific, if the action contains blueprint and the type is DAY-2 or DELETE, create an entity first and pass the identifier here",
31+
)
32+
properties: dict[str, Any] | SkipJsonSchema[None] = Field(
33+
default=None,
34+
description="Properties for the action. To see required properties, first call get_action with action_identifier to view the userInputs schema.",
35+
)
36+
37+
38+
class DynamicActionToolResponse(PortBaseModel):
39+
"""Response model for dynamic action tools."""
40+
41+
action_run: ActionRun = Field(description="Action run details including run_id for tracking")
42+
43+
44+
def _camel_to_snake(name: str) -> str:
45+
"""Convert CamelCase to snake_case."""
46+
s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
47+
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
48+
49+
50+
class DynamicActionToolsManager:
51+
"""Manager for creating and registering dynamic action tools."""
52+
53+
def __init__(self, port_client: PortClient):
54+
self.port_client = port_client
55+
56+
def _create_dynamic_action_tool(self, action: Action) -> Tool:
57+
"""Create a dynamic tool for a specific Port action."""
58+
59+
async def dynamic_action_function(props: DynamicActionToolSchema) -> dict[str, Any]:
60+
if not self.port_client.action_runs:
61+
raise ValueError("Action runs client not available")
62+
63+
if props.entity_identifier:
64+
action_run = await self.port_client.create_entity_action_run(
65+
action_identifier=action.identifier,
66+
entity=props.entity_identifier,
67+
properties=props.properties or {},
68+
)
69+
else:
70+
action_run = await self.port_client.create_global_action_run(
71+
action_identifier=action.identifier,
72+
properties=props.properties or {},
73+
)
74+
75+
return DynamicActionToolResponse(action_run=action_run).model_dump()
76+
77+
base_tool_name = f"run_{_camel_to_snake(action.identifier)}"
78+
tool_name = base_tool_name[:40] if len(base_tool_name) > 40 else base_tool_name
79+
80+
description = f"Execute the '{action.title}' action"
81+
if action.description:
82+
description += f": {action.description}"
83+
description += f"\n\nTo see required properties, first call get_action with action_identifier='{action.identifier}' to view the userInputs schema."
84+
85+
return Tool(
86+
name=tool_name,
87+
description=description,
88+
function=dynamic_action_function,
89+
input_schema=DynamicActionToolSchema,
90+
output_schema=DynamicActionToolResponse,
91+
annotations=Annotations(
92+
title=f"Run {action.title}",
93+
readOnlyHint=False,
94+
destructiveHint=False,
95+
idempotentHint=False,
96+
openWorldHint=True,
97+
),
98+
)
99+
100+
async def get_dynamic_action_tools(self) -> list[Tool]:
101+
"""Get all dynamic action tools by fetching actions from Port."""
102+
tools = []
103+
try:
104+
list_actions_tool = ListActionsTool(self.port_client)
105+
actions_response = await list_actions_tool.list_actions(ListActionsToolSchema())
106+
actions = actions_response.get("actions", [])
107+
108+
get_action_tool = GetActionTool(self.port_client)
109+
110+
for action_data in actions:
111+
try:
112+
action_identifier = (
113+
action_data.get("identifier")
114+
if isinstance(action_data, dict)
115+
else action_data.identifier
116+
)
117+
118+
if not action_identifier:
119+
logger.warning("Skipping action with no identifier")
120+
continue
121+
122+
action_response = await get_action_tool.get_action(
123+
GetActionToolSchema(action_identifier=str(action_identifier))
124+
)
125+
126+
action = Action.model_validate(action_response, strict=False)
127+
128+
if action:
129+
dynamic_tool = self._create_dynamic_action_tool(action)
130+
tools.append(dynamic_tool)
131+
132+
except Exception as e:
133+
logger.warning(
134+
f"Failed to create dynamic tool for action {action_identifier}: {e}"
135+
)
136+
continue
137+
138+
logger.info(f"Created {len(tools)} dynamic action tools")
139+
140+
except Exception as e:
141+
logger.error(f"Failed to create dynamic action tools: {e}")
142+
143+
return tools
144+
145+
def get_dynamic_action_tools_sync(self) -> list[Tool]:
146+
"""Synchronous wrapper for getting dynamic action tools."""
147+
return asyncio.run(self.get_dynamic_action_tools())

0 commit comments

Comments
 (0)