Skip to content
This repository was archived by the owner on Feb 24, 2026. It is now read-only.

Commit 290d83c

Browse files
authored
feat: add run action tool (#31)
1 parent fddf04f commit 290d83c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+988
-79
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ 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.12] - 2025-06-09
9+
10+
### Changed
11+
- Added `run_action` tool to run action
12+
- Added `get_actions` tool to list actions
13+
- Added `track_action_run` tool to track action runs
14+
- Added `get_action` tool to get action by identifier
15+
- Fixed mypy errors
16+
817
## [0.2.11] - 2025-06-04
918

1019
### Changed

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,4 @@ RUN chmod +x /app/entrypoint.sh
4141
ENTRYPOINT ["/app/entrypoint.sh"]
4242

4343
# The server will expect client_id and client_secret to be provided via environment variables:
44-
# PORT_CLIENT_ID and PORT_CLIENT_SECRET
44+
# PORT_CLIENT_ID and PORT_CLIENT_SECRET

poetry.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mcp-server-port"
3-
version = "0.2.11"
3+
version = "0.2.12"
44
authors = [
55
{ name = "Matan Grady", email = "matan.grady@getport.io" }
66
]
@@ -99,4 +99,4 @@ isort = "^5.12.0"
9999
pre-commit = "^3.5.0"
100100
build = "^1.0.3"
101101
twine = "^4.0.2"
102-
pyright = "^1.1.389"
102+
pyright = "^1.1.389"

src/client/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
"""Client package for Port.io API interactions."""
22

3+
from .actions import PortActionClient
34
from .agent import PortAgentClient
45
from .blueprints import PortBlueprintClient
56
from .client import PortClient
67
from .entities import PortEntityClient
78
from .scorecards import PortScorecardClient
89

9-
__all__ = ["PortClient", "PortAgentClient", "PortBlueprintClient", "PortEntityClient", "PortScorecardClient"]
10+
__all__ = [
11+
"PortClient",
12+
"PortAgentClient",
13+
"PortBlueprintClient",
14+
"PortEntityClient",
15+
"PortScorecardClient",
16+
"PortActionClient",
17+
]

src/client/action_runs.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from pyport import PortClient
2+
3+
from src.models.action_run import ActionRun
4+
from src.utils import logger
5+
6+
7+
class PortActionRunClient:
8+
def __init__(self, client: PortClient):
9+
self._client = client
10+
11+
async def create_global_action_run(self, action_identifier: str, **kwargs) -> ActionRun:
12+
logger.info(f"Creating global action run for: {action_identifier}")
13+
response = self._client.make_request(
14+
"POST", f"actions/{action_identifier}/runs", json=kwargs
15+
)
16+
action_run_data = response.json().get("run", response.json())
17+
return ActionRun.construct(**action_run_data)
18+
19+
async def create_entity_action_run(self, action_identifier: str, **kwargs) -> ActionRun:
20+
logger.info(f"Creating entity action run for {action_identifier} with kwargs: {kwargs}")
21+
response = self._client.make_request(
22+
"POST",
23+
f"actions/{action_identifier}/runs",
24+
json=kwargs,
25+
)
26+
action_run_data = response.json().get("run", response.json())
27+
return ActionRun.construct(**action_run_data)
28+
29+
async def get_action_run(self, run_id: str) -> ActionRun:
30+
logger.debug(f"Getting action run status for: {run_id}")
31+
response = self._client.make_request("GET", f"actions/runs/{run_id}?version=v2")
32+
action_run_data = response.json().get("run", response.json())
33+
return ActionRun.construct(**action_run_data)

src/client/actions.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from pyport import PortClient
2+
3+
from src.config import config
4+
from src.models.actions import Action
5+
from src.utils import logger
6+
7+
8+
class PortActionClient:
9+
def __init__(self, client: PortClient):
10+
self._client = client
11+
12+
async def get_all_actions(self, trigger_type: str = "self-service") -> list[Action]:
13+
logger.info("Getting all actions")
14+
15+
response = self._client.make_request("GET", f"actions?trigger_type={trigger_type}")
16+
result = response.json().get("actions", [])
17+
18+
if config.api_validation_enabled:
19+
logger.debug("Validating actions")
20+
return [Action(**action) for action in result]
21+
else:
22+
logger.debug("Skipping API validation for actions")
23+
return [Action.construct(**action) for action in result]
24+
25+
async def get_action(self, action_identifier: str) -> Action:
26+
logger.info(f"Getting action: {action_identifier}")
27+
28+
response = self._client.make_request("GET", f"actions/{action_identifier}")
29+
result = response.json().get("action")
30+
31+
if config.api_validation_enabled:
32+
logger.debug("Validating action")
33+
return Action(**result)
34+
else:
35+
logger.debug("Skipping API validation for action")
36+
return Action.construct(**result)

src/client/agent.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ async def get_invocation_status(self, identifier: str) -> PortAgentResponse:
6767
error=None if status.lower() != "error" else message,
6868
action_url=action_url,
6969
selected_agent=selected_agent,
70+
raw_output=response_data,
7071
)
7172
else:
7273
return PortAgentResponse.construct(

src/client/client.py

Lines changed: 90 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
1-
from collections.abc import Callable
2-
from typing import Any
1+
from collections.abc import Awaitable, Callable
2+
from typing import Any, TypeVar
33

44
import pyport
5-
import requests
5+
import requests # type: ignore[import-untyped]
66

7+
from src.client.action_runs import PortActionRunClient
8+
from src.client.actions import PortActionClient
79
from src.client.agent import PortAgentClient
810
from src.client.blueprints import PortBlueprintClient
911
from src.client.entities import PortEntityClient
1012
from src.client.scorecards import PortScorecardClient
1113
from src.config import config
14+
from src.models.action_run.action_run import ActionRun
15+
from src.models.actions.action import Action
1216
from src.models.agent import PortAgentResponse
1317
from src.models.agent.port_agent_response import PortAgentTriggerResponse
1418
from src.models.blueprints import Blueprint
1519
from src.models.entities import EntityResult
1620
from src.models.scorecards import Scorecard
1721
from src.utils import PortError, logger
1822

23+
T = TypeVar("T")
24+
1925

2026
class PortClient:
2127
"""Client for interacting with the Port API."""
@@ -27,36 +33,41 @@ def __init__(
2733
region: str = "EU",
2834
base_url: str = config.port_api_base,
2935
):
36+
if not client_id or not client_secret:
37+
logger.warning("PortClient initialized without credentials")
38+
3039
self.base_url = base_url
3140
self.client_id = client_id
3241
self.client_secret = client_secret
3342
self.region = region
34-
35-
if not client_id or not client_secret:
36-
logger.warning("Port client initialized without credentials")
37-
self._client = None
38-
self.agent = None
39-
self.blueprints = None
40-
self.entities = None
41-
self.scorecards = None
42-
else:
43-
self._client = pyport.PortClient(client_id=client_id, client_secret=client_secret, us_region=(region == "US"))
43+
if client_id and client_secret:
44+
self._client = pyport.PortClient(
45+
client_id=client_id,
46+
client_secret=client_secret,
47+
us_region=(region == "US"),
48+
)
4449
self.agent = PortAgentClient(self._client)
4550
self.blueprints = PortBlueprintClient(self._client)
4651
self.entities = PortEntityClient(self._client)
4752
self.scorecards = PortScorecardClient(self._client)
53+
self.actions = PortActionClient(self._client)
54+
self.action_runs = PortActionRunClient(self._client)
4855

4956
def handle_http_error(self, e: requests.exceptions.HTTPError) -> PortError:
5057
result = e.response.json()
51-
message = f"Error in {e.request.method} {e.request.url} - {e.response.status_code}: {result}"
58+
message = (
59+
f"Error in {e.request.method} {e.request.url} - {e.response.status_code}: {result}"
60+
)
5261
logger.error(message)
5362
raise PortError(message)
5463

55-
async def wrap_request(self, request: Callable) -> PortError:
64+
async def wrap_request(self, request: Callable[[], Awaitable[T]]) -> T:
65+
if self._client is None:
66+
raise PortError("PortClient is not properly initialized - missing credentials")
5667
try:
5768
return await request()
5869
except requests.exceptions.HTTPError as e:
59-
self.handle_http_error(e)
70+
raise self.handle_http_error(e) from e
6071

6172
async def trigger_agent(self, prompt: str) -> PortAgentTriggerResponse:
6273
return await self.wrap_request(lambda: self.agent.trigger_agent(prompt))
@@ -77,36 +88,85 @@ async def update_blueprint(self, blueprint_data: dict[str, Any]) -> Blueprint:
7788
return await self.wrap_request(lambda: self.blueprints.update_blueprint(blueprint_data))
7889

7990
async def delete_blueprint(self, blueprint_identifier: str) -> bool:
80-
return await self.wrap_request(lambda: self.blueprints.delete_blueprint(blueprint_identifier))
91+
return await self.wrap_request(
92+
lambda: self.blueprints.delete_blueprint(blueprint_identifier)
93+
)
8194

8295
async def get_entity(self, blueprint_identifier: str, entity_identifier: str) -> EntityResult:
83-
return await self.wrap_request(lambda: self.entities.get_entity(blueprint_identifier, entity_identifier))
96+
return await self.wrap_request(
97+
lambda: self.entities.get_entity(blueprint_identifier, entity_identifier)
98+
)
8499

85100
async def get_entities(self, blueprint_identifier: str) -> list[EntityResult]:
86101
return await self.wrap_request(lambda: self.entities.get_entities(blueprint_identifier))
87102

88-
async def create_entity(self, blueprint_identifier: str, entity_data: dict[str, Any], query: dict[str, Any]) -> EntityResult:
89-
return await self.wrap_request(lambda: self.entities.create_entity(blueprint_identifier, entity_data, query))
103+
async def create_entity(
104+
self, blueprint_identifier: str, entity_data: dict[str, Any], query: dict[str, Any]
105+
) -> EntityResult:
106+
return await self.wrap_request(
107+
lambda: self.entities.create_entity(blueprint_identifier, entity_data, query)
108+
)
90109

91-
async def update_entity(self, blueprint_identifier: str, entity_identifier: str, entity_data: dict[str, Any]) -> EntityResult:
92-
return await self.wrap_request(lambda: self.entities.update_entity(blueprint_identifier, entity_identifier, entity_data))
110+
async def update_entity(
111+
self, blueprint_identifier: str, entity_identifier: str, entity_data: dict[str, Any]
112+
) -> EntityResult:
113+
return await self.wrap_request(
114+
lambda: self.entities.update_entity(
115+
blueprint_identifier, entity_identifier, entity_data
116+
)
117+
)
93118

94-
async def delete_entity(self, blueprint_identifier: str, entity_identifier: str, delete_dependents: bool = False) -> bool:
119+
async def delete_entity(
120+
self, blueprint_identifier: str, entity_identifier: str, delete_dependents: bool = False
121+
) -> bool:
95122
return await self.wrap_request(
96-
lambda: self.entities.delete_entity(blueprint_identifier, entity_identifier, delete_dependents)
123+
lambda: self.entities.delete_entity(
124+
blueprint_identifier, entity_identifier, delete_dependents
125+
)
97126
)
98127

99128
async def get_scorecard(self, blueprint_id: str, scorecard_id: str) -> Scorecard:
100-
return await self.wrap_request(lambda: self.scorecards.get_scorecard(blueprint_id, scorecard_id))
129+
return await self.wrap_request(
130+
lambda: self.scorecards.get_scorecard(blueprint_id, scorecard_id)
131+
)
101132

102133
async def get_scorecards(self, blueprint_identifier: str) -> list[Scorecard]:
103134
return await self.wrap_request(lambda: self.scorecards.get_scorecards(blueprint_identifier))
104135

105-
async def create_scorecard(self, blueprint_id: str, scorecard_data: dict[str, Any]) -> Scorecard:
106-
return await self.wrap_request(lambda: self.scorecards.create_scorecard(blueprint_id, scorecard_data))
136+
async def create_scorecard(
137+
self, blueprint_id: str, scorecard_data: dict[str, Any]
138+
) -> Scorecard:
139+
return await self.wrap_request(
140+
lambda: self.scorecards.create_scorecard(blueprint_id, scorecard_data)
141+
)
107142

108-
async def update_scorecard(self, blueprint_id: str, scorecard_id: str, scorecard_data: dict[str, Any]) -> Scorecard:
109-
return await self.wrap_request(lambda: self.scorecards.update_scorecard(blueprint_id, scorecard_id, scorecard_data))
143+
async def update_scorecard(
144+
self, blueprint_id: str, scorecard_id: str, scorecard_data: dict[str, Any]
145+
) -> Scorecard:
146+
return await self.wrap_request(
147+
lambda: self.scorecards.update_scorecard(blueprint_id, scorecard_id, scorecard_data)
148+
)
110149

111150
async def delete_scorecard(self, scorecard_id: str, blueprint_id: str) -> bool:
112-
return await self.wrap_request(lambda: self.scorecards.delete_scorecard(scorecard_id, blueprint_id))
151+
return await self.wrap_request(
152+
lambda: self.scorecards.delete_scorecard(scorecard_id, blueprint_id)
153+
)
154+
155+
async def get_all_actions(self, trigger_type: str = "self-service") -> list[Action]:
156+
return await self.wrap_request(lambda: self.actions.get_all_actions(trigger_type))
157+
158+
async def get_action(self, action_identifier: str) -> Action:
159+
return await self.wrap_request(lambda: self.actions.get_action(action_identifier))
160+
161+
async def create_global_action_run(self, action_identifier: str, **kwargs) -> ActionRun:
162+
return await self.wrap_request(
163+
lambda: self.action_runs.create_global_action_run(action_identifier, **kwargs)
164+
)
165+
166+
async def create_entity_action_run(self, action_identifier: str, **kwargs) -> ActionRun:
167+
return await self.wrap_request(
168+
lambda: self.action_runs.create_entity_action_run(action_identifier, **kwargs)
169+
)
170+
171+
async def get_action_run(self, run_id: str) -> ActionRun:
172+
return await self.wrap_request(lambda: self.action_runs.get_action_run(run_id))

src/client/entities.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any
1+
from typing import Any, cast
22

33
from pyport import PortClient
44

@@ -110,4 +110,4 @@ async def delete_entity(self, blueprint_identifier: str, entity_identifier: str,
110110
logger.warning(message)
111111
raise PortError(message)
112112
logger.info(f"Deleted entity '{entity_identifier}' from blueprint '{blueprint_identifier}' in Port")
113-
return response_json.get("ok")
113+
return cast(bool, response_json.get("ok"))

0 commit comments

Comments
 (0)