Skip to content
This repository was archived by the owner on Feb 24, 2026. It is now read-only.
Merged
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
=======

## [0.2.19] - 2025-06-29

### Fixed
- Fixed self-service action creation when `invocationMethod` is provided as JSON string instead of object in Claude
- Enhanced tool description and field descriptions to guide AI models to provide correct format from start

## [0.2.19] - 2025-06-30

Expand Down
4 changes: 2 additions & 2 deletions src/models/actions/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ class ActionCommon(BaseModel):
trigger: ActionTrigger = Field(..., description="The trigger configuration")
invocation_method: ActionInvocationMethod = Field(
...,
description="The invocation method configuration",
description="The invocation method configuration. Must be a JSON object (not a string) with 'type' field and method-specific properties.",
alias="invocationMethod",
serialization_alias="invocationMethod",
)
Expand Down Expand Up @@ -180,4 +180,4 @@ class ActionCreate(ActionCommon):
class ActionUpdate(ActionCommon):
"""Model for updating an existing action."""

pass
pass
33 changes: 30 additions & 3 deletions src/tools/action/create_action.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,40 @@
import json
from typing import Any

from pydantic import field_validator, model_validator

from src.client.client import PortClient
from src.models.actions import Action, ActionCreate
from src.models.actions.action import ActionInvocationMethod
from src.models.common.annotations import Annotations
from src.models.tools.tool import Tool


class CreateActionToolSchema(ActionCreate):
pass
@field_validator('invocation_method', mode='before')
@classmethod
def parse_invocation_method(cls, v) -> ActionInvocationMethod | dict:
"""Parse invocation method if it's provided as a JSON string."""
if isinstance(v, str):
try:
# Parse the JSON string into a dictionary
parsed = json.loads(v)
return parsed
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON string for invocationMethod: {e}") from e
return v

@model_validator(mode='before')
@classmethod
def handle_invocation_method_alias(cls, values):
"""Handle both invocationMethod and invocation_method field names."""
if isinstance(values, dict) and 'invocationMethod' in values and isinstance(values['invocationMethod'], str):
# If invocationMethod is provided as a string, parse it
try:
values['invocationMethod'] = json.loads(values['invocationMethod'])
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON string for invocationMethod: {e}") from e
return values


class CreateActionTool(Tool[CreateActionToolSchema]):
Expand All @@ -16,7 +43,7 @@ class CreateActionTool(Tool[CreateActionToolSchema]):
def __init__(self, port_client: PortClient):
super().__init__(
name="create_action",
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/",
description="Create a new self-service action.",
function=self.create_action,
input_schema=CreateActionToolSchema,
output_schema=Action,
Expand All @@ -39,4 +66,4 @@ async def create_action(self, props: CreateActionToolSchema) -> dict[str, Any]:
created_action = await self.port_client.create_action(action_data)
created_action_dict = created_action.model_dump(exclude_unset=True, exclude_none=True)

return created_action_dict
return created_action_dict
99 changes: 98 additions & 1 deletion tests/unit/tools/action/test_create_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,4 +203,101 @@ async def test_create_action_tool_with_complex_trigger(mock_client_with_create_a
assert result["trigger"]["operation"] == "DAY-2"
assert result["trigger"]["blueprintIdentifier"] == "service"
assert "userInputs" in result["trigger"]
assert len(result["trigger"]["userInputs"]["required"]) == 2
assert len(result["trigger"]["userInputs"]["required"]) == 2


@pytest.mark.asyncio
async def test_create_action_tool_with_string_invocation_method(mock_client_with_create_action):
"""Test creating an action when invocationMethod is provided as a JSON string."""
from src.models.actions.action import ActionTrigger, ActionInvocationMethodWebhook
tool = CreateActionTool(mock_client_with_create_action)

# Mock return value with webhook invocation
mock_client_with_create_action.create_action.return_value = Action(
identifier="string-invocation-action",
title="String Invocation Action",
description="Action with string invocation method",
trigger=ActionTrigger(
type="self-service",
operation="CREATE",
user_inputs={"properties": {}, "required": []},
),
invocation_method=ActionInvocationMethodWebhook(
type="WEBHOOK",
url="https://api.github.com/repos/test/test/issues",
method="POST",
headers={"Accept": "application/vnd.github+json"},
body={"title": "{{ .inputs.title }}", "body": "{{ .inputs.body }}"}
),
)

# Input data with invocationMethod as a JSON string (like Claude might provide)
input_data = {
"identifier": "string-invocation-action",
"title": "String Invocation Action",
"description": "Action with string invocation method",
"trigger": {
"type": "self-service",
"operation": "CREATE",
"userInputs": {"properties": {}, "required": []},
},
"invocationMethod": '{"type": "WEBHOOK", "url": "https://api.github.com/repos/test/test/issues", "method": "POST", "headers": {"Accept": "application/vnd.github+json"}, "body": {"title": "{{ .inputs.title }}", "body": "{{ .inputs.body }}"}}'
}

# This should work after our fix
result = await tool.create_action(tool.validate_input(input_data))

# Verify the webhook-specific fields
assert result["identifier"] == "string-invocation-action"
assert result["invocationMethod"]["type"] == "WEBHOOK"
assert result["invocationMethod"]["url"] == "https://api.github.com/repos/test/test/issues"
assert result["invocationMethod"]["method"] == "POST"
assert result["invocationMethod"]["headers"]["Accept"] == "application/vnd.github+json"


@pytest.mark.asyncio
async def test_create_action_tool_with_string_github_invocation_method(mock_client_with_create_action):
"""Test creating an action when GitHub invocationMethod is provided as a JSON string."""
from src.models.actions.action import ActionTrigger, ActionInvocationMethodGitHub
tool = CreateActionTool(mock_client_with_create_action)

# Mock return value with GitHub invocation
mock_client_with_create_action.create_action.return_value = Action(
identifier="string-github-action",
title="String GitHub Action",
description="Action with string GitHub invocation method",
trigger=ActionTrigger(
type="self-service",
operation="CREATE",
user_inputs={"properties": {}, "required": []},
),
invocation_method=ActionInvocationMethodGitHub(
type="GITHUB",
org="test-org",
repo="test-repo",
workflow="test-workflow.yml"
),
)

# Input data with GitHub invocationMethod as a JSON string
input_data = {
"identifier": "string-github-action",
"title": "String GitHub Action",
"description": "Action with string GitHub invocation method",
"trigger": {
"type": "self-service",
"operation": "CREATE",
"userInputs": {"properties": {}, "required": []},
},
"invocationMethod": '{"type": "GITHUB", "org": "test-org", "repo": "test-repo", "workflow": "test-workflow.yml"}'
}

# This should work after our fix
result = await tool.create_action(tool.validate_input(input_data))

# Verify the GitHub-specific fields
assert result["identifier"] == "string-github-action"
assert result["invocationMethod"]["type"] == "GITHUB"
assert result["invocationMethod"]["org"] == "test-org"
assert result["invocationMethod"]["repo"] == "test-repo"
assert result["invocationMethod"]["workflow"] == "test-workflow.yml"