Skip to content

Commit 826ef1d

Browse files
authored
chore: added cud for actions tools (#41)
* chore: added cud for actions tools * bump: 0.2.15 * tests: added tests to cud on actions * testS: fix tests * chore: lint
1 parent cfff08d commit 826ef1d

19 files changed

+1075
-60
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ 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.15] - 2025-06-16
9+
10+
### Added
11+
- Added `create_action` tool to create actions
12+
- Added `delete_action` tool to delete actions
13+
- Added `update_action` tool to update actions
14+
815
## [0.2.14] - 2025-06-15
916

1017
### 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.14"
3+
version = "0.2.15"
44
authors = [
55
{ name = "Matan Grady", email = "[email protected]" }
66
]

src/client/actions.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import json
2+
from typing import Any
3+
14
from pyport import PortClient
25

36
from src.config import config
@@ -68,3 +71,67 @@ async def get_action(self, action_identifier: str) -> Action:
6871
else:
6972
logger.debug("Skipping API validation for action")
7073
return Action.construct(**result)
74+
75+
async def create_action(self, action_data: dict[str, Any]) -> Action:
76+
"""Create a new action"""
77+
data_json = json.dumps(action_data)
78+
79+
logger.info("Creating action in Port")
80+
logger.debug(f"Input from tool to create action: {data_json}")
81+
82+
response = self._client.make_request("POST", "actions", json=action_data)
83+
result = response.json()
84+
if not result.get("ok"):
85+
message = f"Failed to create action: {result}"
86+
logger.warning(message)
87+
logger.info("Action created in Port")
88+
89+
result = result.get("action", {})
90+
91+
if config.api_validation_enabled:
92+
logger.debug("Validating action")
93+
action = Action(**result)
94+
else:
95+
logger.debug("Skipping API validation for action")
96+
action = Action.construct(**result)
97+
logger.debug(f"Response for create action: {action}")
98+
return action
99+
100+
async def update_action(self, action_identifier: str, action_data: dict[str, Any]) -> Action:
101+
"""Update an existing action"""
102+
data_json = json.dumps(action_data)
103+
104+
logger.info(f"Updating action '{action_identifier}' in Port")
105+
logger.debug(f"Input from tool to update action: {data_json}")
106+
107+
response = self._client.make_request(
108+
"PUT", f"actions/{action_identifier}", json=action_data
109+
)
110+
result = response.json()
111+
if not result.get("ok"):
112+
message = f"Failed to update action: {result}"
113+
logger.warning(message)
114+
logger.info(f"Action '{action_identifier}' updated in Port")
115+
116+
result = result.get("action", {})
117+
if config.api_validation_enabled:
118+
logger.debug("Validating action")
119+
action = Action(**result)
120+
else:
121+
logger.debug("Skipping API validation for action")
122+
action = Action.construct(**result)
123+
logger.debug(f"Response for update action: {action}")
124+
return action
125+
126+
async def delete_action(self, action_identifier: str) -> bool:
127+
"""Delete an action"""
128+
logger.info(f"Deleting action '{action_identifier}' from Port")
129+
130+
response = self._client.make_request("DELETE", f"actions/{action_identifier}")
131+
result = response.json()
132+
if not result.get("ok"):
133+
message = f"Failed to delete action: {result}"
134+
logger.warning(message)
135+
logger.info(f"Action '{action_identifier}' deleted from Port")
136+
137+
return True

src/client/client.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,18 @@ async def get_all_actions(self, trigger_type: str = "self-service") -> list[Acti
157157

158158
async def get_action(self, action_identifier: str) -> Action:
159159
return await self.wrap_request(lambda: self.actions.get_action(action_identifier))
160+
161+
async def create_action(self, action_data: dict[str, Any]) -> Action:
162+
return await self.wrap_request(lambda: self.actions.create_action(action_data))
160163

164+
async def update_action(self, action_identifier: str, action_data: dict[str, Any]) -> Action:
165+
return await self.wrap_request(
166+
lambda: self.actions.update_action(action_identifier, action_data)
167+
)
168+
169+
async def delete_action(self, action_identifier: str) -> bool:
170+
return await self.wrap_request(lambda: self.actions.delete_action(action_identifier))
171+
161172
async def create_global_action_run(self, action_identifier: str, **kwargs) -> ActionRun:
162173
return await self.wrap_request(
163174
lambda: self.action_runs.create_global_action_run(action_identifier, **kwargs)

src/models/actions/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""Action related data models for Port.io."""
22

3-
from .action import Action, ActionSummary
3+
from .action import Action, ActionCreate, ActionSummary, ActionUpdate
44

5-
__all__ = ["Action", "ActionSummary"]
5+
__all__ = ["Action", "ActionCreate", "ActionUpdate", "ActionSummary"]

src/models/actions/action.py

Lines changed: 105 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Port.io action model."""
22

3-
from typing import Any
3+
from typing import Any, Literal
44

55
from pydantic import Field
66
from pydantic.json_schema import SkipJsonSchema
@@ -25,76 +25,153 @@ class ActionTrigger(BaseModel):
2525

2626
type: str = Field(..., description="The type of trigger")
2727
operation: str | SkipJsonSchema[None] = Field(
28-
None, description="The operation type (CREATE, DAY_2, DELETE)"
28+
None, description="The operation type (CREATE, DAY-2, DELETE)"
2929
)
3030
event: str | SkipJsonSchema[None] = Field(
3131
None, description="The event that triggers the action"
3232
)
3333
condition: dict[str, Any] | SkipJsonSchema[None] = Field(
3434
None, description="Conditions for the trigger"
3535
)
36+
user_inputs: ActionSchema | SkipJsonSchema[None] = Field(
37+
None, description="User input schema for the trigger", alias="userInputs"
38+
)
39+
blueprint_identifier: str | SkipJsonSchema[None] = Field(
40+
None, description="The blueprint identifier for the trigger", alias="blueprintIdentifier"
41+
)
42+
43+
44+
class ActionInvocationMethodGitHub(BaseModel):
45+
"""GitHub invocation method configuration."""
46+
47+
type: Literal["GITHUB"] = Field(..., description="The type of invocation method")
48+
org: str = Field(..., description="GitHub organization")
49+
repo: str = Field(..., description="GitHub repository")
50+
workflow: str = Field(..., description="GitHub workflow filename")
51+
omit_payload: bool | SkipJsonSchema[None] = Field(None, description="Whether to omit payload")
52+
omit_user_inputs: bool | SkipJsonSchema[None] = Field(
53+
None, description="Whether to omit user inputs"
54+
)
55+
report_workflow_status: bool | SkipJsonSchema[None] = Field(
56+
None, description="Whether to report workflow status"
57+
)
58+
59+
60+
class ActionInvocationMethodGitLab(BaseModel):
61+
"""GitLab invocation method configuration."""
62+
63+
type: Literal["GITLAB"] = Field(..., description="The type of invocation method")
64+
project_name: str = Field(..., description="GitLab project name", alias="projectName")
65+
group_name: str = Field(..., description="GitLab group name", alias="groupName")
66+
agent: Literal[True] = Field(..., description="Agent must be true for GitLab")
67+
omit_payload: bool | SkipJsonSchema[None] = Field(
68+
None, description="Whether to omit payload", alias="omitPayload"
69+
)
70+
omit_user_inputs: bool | SkipJsonSchema[None] = Field(
71+
None, description="Whether to omit user inputs", alias="omitUserInputs"
72+
)
73+
default_ref: str | SkipJsonSchema[None] = Field(
74+
None, description="Default Git reference", alias="defaultRef"
75+
)
76+
77+
78+
class ActionInvocationMethodAzureDevOps(BaseModel):
79+
"""Azure DevOps invocation method configuration."""
3680

81+
type: Literal["AZURE-DEVOPS"] = Field(..., description="The type of invocation method")
82+
org: str = Field(..., description="Azure DevOps organization")
83+
webhook: str = Field(..., description="Azure DevOps webhook URL")
3784

38-
class ActionInvocationMethod(BaseModel):
39-
"""Action invocation method configuration."""
4085

41-
type: str = Field(..., description="The type of invocation method")
42-
url: str | SkipJsonSchema[None] = Field(None, description="URL for webhook invocation")
86+
class ActionInvocationMethodWebhook(BaseModel):
87+
"""Webhook invocation method configuration."""
88+
89+
type: Literal["WEBHOOK"] = Field(..., description="The type of invocation method")
90+
url: str = Field(..., description="Webhook URL")
4391
agent: bool | SkipJsonSchema[None] = Field(
4492
None, description="Whether to use agent for invocation"
4593
)
46-
method: str | SkipJsonSchema[None] = Field(None, description="HTTP method for webhook")
94+
synchronized: bool | SkipJsonSchema[None] = Field(
95+
None, description="Whether the webhook is synchronized"
96+
)
97+
method: Literal["POST", "DELETE", "PATCH", "PUT"] | SkipJsonSchema[None] = Field(
98+
None, description="HTTP method for webhook"
99+
)
47100
headers: dict[str, str] | SkipJsonSchema[None] = Field(None, description="Headers for webhook")
48101
body: str | dict[str, Any] | SkipJsonSchema[None] = Field(
49102
None, description="Body template for webhook (can be string or dict)"
50103
)
51104

52105

53-
class ActionSummary(BaseModel):
54-
"""Simplified Action model with only basic information."""
106+
class ActionInvocationMethodKafka(BaseModel):
107+
"""Kafka invocation method configuration."""
55108

56-
identifier: str = Field(..., description="The unique identifier of the action")
57-
title: str = Field(..., description="The title of the action")
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-
)
109+
type: Literal["KAFKA"] = Field(..., description="The type of invocation method")
64110

65111

66-
class Action(BaseModel):
67-
"""Port.io Action model."""
112+
# Union type for all invocation methods
113+
ActionInvocationMethod = (
114+
ActionInvocationMethodGitHub
115+
| ActionInvocationMethodGitLab
116+
| ActionInvocationMethodAzureDevOps
117+
| ActionInvocationMethodWebhook
118+
| ActionInvocationMethodKafka
119+
)
120+
121+
122+
class ActionCommon(BaseModel):
123+
"""Common fields for action models."""
68124

69125
identifier: str = Field(..., description="The unique identifier of the action")
70126
title: str = Field(..., description="The title of the action")
71127
description: str | SkipJsonSchema[None] = Field(
72128
None, description="The description of the action"
73129
)
74130
icon: Icon | SkipJsonSchema[None] = Field(None, description="The icon of the action")
75-
blueprint: str | SkipJsonSchema[None] = Field(
76-
None, description="The blueprint this action belongs to"
77-
)
78131
trigger: ActionTrigger = Field(..., description="The trigger configuration")
79132
invocation_method: ActionInvocationMethod = Field(
80133
...,
81134
description="The invocation method configuration",
82135
alias="invocationMethod",
83136
serialization_alias="invocationMethod",
84137
)
85-
user_inputs: ActionSchema = Field(
86-
default_factory=ActionSchema,
87-
description="User input schema for the action",
88-
alias="userInputs",
89-
serialization_alias="userInputs",
90-
)
91138
approval_notification: dict[str, Any] | SkipJsonSchema[None] = Field(
92139
None,
93140
description="Approval notification configuration",
94141
alias="approvalNotification",
95142
serialization_alias="approvalNotification",
96143
)
144+
145+
146+
class ActionSummary(BaseModel):
147+
"""Simplified Action model with only basic information."""
148+
149+
identifier: str = Field(..., description="The unique identifier of the action")
150+
title: str = Field(..., description="The title of the action")
151+
description: str | SkipJsonSchema[None] = Field(
152+
None, description="The description of the action"
153+
)
154+
blueprint: str | SkipJsonSchema[None] = Field(
155+
None, description="The blueprint this action belongs to"
156+
)
157+
158+
159+
class Action(ActionCommon):
160+
"""Port.io Action model."""
161+
97162
created_at: str | SkipJsonSchema[None] = Field(None, description="Creation timestamp")
98163
created_by: str | SkipJsonSchema[None] = Field(None, description="Creator user")
99164
updated_at: str | SkipJsonSchema[None] = Field(None, description="Last update timestamp")
100165
updated_by: str | SkipJsonSchema[None] = Field(None, description="Last updater user")
166+
167+
168+
class ActionCreate(ActionCommon):
169+
"""Model for creating a new action."""
170+
171+
pass
172+
173+
174+
class ActionUpdate(ActionCommon):
175+
"""Model for updating an existing action."""
176+
177+
pass

src/tools/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
"""
55

66
from src.tools.action import (
7+
CreateActionTool,
8+
DeleteActionTool,
79
GetActionTool,
810
ListActionsTool,
911
TrackActionRunTool,
12+
UpdateActionTool,
1013
)
1114
from src.tools.ai_agent import InvokeAIAGentTool
1215
from src.tools.blueprint import (
@@ -32,6 +35,9 @@
3235
)
3336

3437
__all__ = [
38+
"CreateActionTool",
39+
"DeleteActionTool",
40+
"UpdateActionTool",
3541
"CreateScorecardTool",
3642
"DeleteScorecardTool",
3743
"GetScorecardTool",

src/tools/action/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
from .create_action import CreateActionTool
2+
from .delete_action import DeleteActionTool
13
from .dynamic_actions import DynamicActionToolsManager
24
from .get_action import GetActionTool
35
from .list_actions import ListActionsTool
46
from .track_action_run import TrackActionRunTool
7+
from .update_action import UpdateActionTool
58

69
__all__ = [
10+
"CreateActionTool",
11+
"DeleteActionTool",
12+
"UpdateActionTool",
713
"GetActionTool",
814
"ListActionsTool",
915
"TrackActionRunTool",

src/tools/action/create_action.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from typing import Any
2+
3+
from src.client.client import PortClient
4+
from src.models.actions import Action, ActionCreate
5+
from src.models.common.annotations import Annotations
6+
from src.models.tools.tool import Tool
7+
8+
9+
class CreateActionToolSchema(ActionCreate):
10+
pass
11+
12+
13+
class CreateActionTool(Tool[CreateActionToolSchema]):
14+
port_client: PortClient
15+
16+
def __init__(self, port_client: PortClient):
17+
super().__init__(
18+
name="create_action",
19+
description="Create a new self-service action or automation in your Port account. To learn more about actions and automations, check out the documentation at https://docs.port.io/actions-and-automations/",
20+
function=self.create_action,
21+
input_schema=CreateActionToolSchema,
22+
output_schema=Action,
23+
annotations=Annotations(
24+
title="Create Action",
25+
readOnlyHint=False,
26+
destructiveHint=False,
27+
idempotentHint=False,
28+
openWorldHint=True,
29+
),
30+
)
31+
self.port_client = port_client
32+
33+
async def create_action(self, props: CreateActionToolSchema) -> dict[str, Any]:
34+
"""
35+
Create a new action or automation.
36+
"""
37+
action_data = props.model_dump(exclude_none=True, exclude_unset=True)
38+
39+
created_action = await self.port_client.create_action(action_data)
40+
created_action_dict = created_action.model_dump(exclude_unset=True, exclude_none=True)
41+
42+
return created_action_dict

0 commit comments

Comments
 (0)