diff --git a/internal/testutil/mcp.go b/internal/testutil/mcp.go index 2747a62..a9e3b3c 100644 --- a/internal/testutil/mcp.go +++ b/internal/testutil/mcp.go @@ -118,7 +118,13 @@ func CheckMessage(t *testing.T, result mcp.Result) { return } if toolResult.IsError { - t.Errorf("tool failed to execute: %v", toolResult.Content) + var msg any = toolResult.Content + if len(toolResult.Content) == 1 { + if textContent, ok := toolResult.Content[0].(*mcp.TextContent); ok { + msg = textContent.Text + } + } + t.Errorf("tool failed to execute: %v", msg) } } diff --git a/internal/twprojects/skills.go b/internal/twprojects/skills.go new file mode 100644 index 0000000..4a1a5dd --- /dev/null +++ b/internal/twprojects/skills.go @@ -0,0 +1,336 @@ +package twprojects + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/teamwork/mcp/internal/helpers" + "github.com/teamwork/mcp/internal/toolsets" + "github.com/teamwork/twapi-go-sdk" + "github.com/teamwork/twapi-go-sdk/projects" +) + +// List of methods available in the Teamwork.com MCP service. +// +// The naming convention for methods follows a pattern described here: +// https://github.com/github/github-mcp-server/issues/333 +const ( + MethodSkillCreate toolsets.Method = "twprojects-create_skill" + MethodSkillUpdate toolsets.Method = "twprojects-update_skill" + MethodSkillDelete toolsets.Method = "twprojects-delete_skill" + MethodSkillGet toolsets.Method = "twprojects-get_skill" + MethodSkillList toolsets.Method = "twprojects-list_skills" +) + +const skillDescription = "Skill represents a specific capability, area of expertise, or proficiency that can be " + + "assigned to users to describe what they are good at or qualified to work on. Skills help teams understand the " + + "strengths available across the organization and make it easier to match the right skills to the right work when " + + "planning projects, assigning tasks, or managing resources. By associating skills with users and leveraging them " + + "in planning and reporting, Teamwork enables more effective workload distribution, better project outcomes, and " + + "clearer visibility into whether the team has the capabilities needed to deliver upcoming work." + +var ( + skillGetOutputSchema *jsonschema.Schema + skillListOutputSchema *jsonschema.Schema +) + +func init() { + // register the toolset methods + toolsets.RegisterMethod(MethodSkillCreate) + toolsets.RegisterMethod(MethodSkillUpdate) + toolsets.RegisterMethod(MethodSkillDelete) + toolsets.RegisterMethod(MethodSkillGet) + toolsets.RegisterMethod(MethodSkillList) + + var err error + + // generate the output schemas only once + skillGetOutputSchema, err = jsonschema.For[projects.SkillGetResponse](&jsonschema.ForOptions{}) + if err != nil { + panic(fmt.Sprintf("failed to generate JSON schema for SkillGetResponse: %v", err)) + } + skillListOutputSchema, err = jsonschema.For[projects.SkillListResponse](&jsonschema.ForOptions{}) + if err != nil { + panic(fmt.Sprintf("failed to generate JSON schema for SkillListResponse: %v", err)) + } +} + +// SkillCreate creates a skill in Teamwork.com. +func SkillCreate(engine *twapi.Engine) toolsets.ToolWrapper { + return toolsets.ToolWrapper{ + Tool: &mcp.Tool{ + Name: string(MethodSkillCreate), + Description: "Create a new skill in Teamwork.com. " + skillDescription, + Annotations: &mcp.ToolAnnotations{ + Title: "Create Skill", + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "The name of the skill.", + }, + "user_ids": { + Type: "array", + Description: "The user IDs associated with the skill.", + Items: &jsonschema.Schema{ + Type: "integer", + }, + }, + }, + Required: []string{"name"}, + }, + }, + Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var skillCreateRequest projects.SkillCreateRequest + + var arguments map[string]any + if err := json.Unmarshal(request.Params.Arguments, &arguments); err != nil { + return helpers.NewToolResultTextError(fmt.Sprintf("failed to decode request: %s", err.Error())), nil + } + err := helpers.ParamGroup(arguments, + helpers.RequiredParam(&skillCreateRequest.Name, "name"), + helpers.OptionalNumericListParam(&skillCreateRequest.UserIDs, "user_ids"), + ) + if err != nil { + return helpers.NewToolResultTextError(fmt.Sprintf("invalid parameters: %s", err.Error())), nil + } + + skillResponse, err := projects.SkillCreate(ctx, engine, skillCreateRequest) + if err != nil { + return helpers.HandleAPIError(err, "failed to create skill") + } + return helpers.NewToolResultText("Skill created successfully with ID %d", skillResponse.Skill.ID), nil + }, + } +} + +// SkillUpdate updates a skill in Teamwork.com. +func SkillUpdate(engine *twapi.Engine) toolsets.ToolWrapper { + return toolsets.ToolWrapper{ + Tool: &mcp.Tool{ + Name: string(MethodSkillUpdate), + Description: "Update an existing skill in Teamwork.com. " + skillDescription, + Annotations: &mcp.ToolAnnotations{ + Title: "Update Skill", + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "id": { + Type: "integer", + Description: "The ID of the skill to update.", + }, + "name": { + Type: "string", + Description: "The name of the skill.", + }, + "user_ids": { + Type: "array", + Description: "The user IDs associated with the skill.", + Items: &jsonschema.Schema{ + Type: "integer", + }, + }, + }, + Required: []string{"id"}, + }, + }, + Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var skillUpdateRequest projects.SkillUpdateRequest + + var arguments map[string]any + if err := json.Unmarshal(request.Params.Arguments, &arguments); err != nil { + return helpers.NewToolResultTextError(fmt.Sprintf("failed to decode request: %s", err.Error())), nil + } + err := helpers.ParamGroup(arguments, + helpers.RequiredNumericParam(&skillUpdateRequest.Path.ID, "id"), + helpers.OptionalPointerParam(&skillUpdateRequest.Name, "name"), + helpers.OptionalNumericListParam(&skillUpdateRequest.UserIDs, "user_ids"), + ) + if err != nil { + return helpers.NewToolResultTextError(fmt.Sprintf("invalid parameters: %s", err.Error())), nil + } + + _, err = projects.SkillUpdate(ctx, engine, skillUpdateRequest) + if err != nil { + return helpers.HandleAPIError(err, "failed to update skill") + } + return helpers.NewToolResultText("Skill updated successfully"), nil + }, + } +} + +// SkillDelete deletes a skill in Teamwork.com. +func SkillDelete(engine *twapi.Engine) toolsets.ToolWrapper { + return toolsets.ToolWrapper{ + Tool: &mcp.Tool{ + Name: string(MethodSkillDelete), + Description: "Delete an existing skill in Teamwork.com. " + skillDescription, + Annotations: &mcp.ToolAnnotations{ + Title: "Delete Skill", + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "id": { + Type: "integer", + Description: "The ID of the skill to delete.", + }, + }, + Required: []string{"id"}, + }, + }, + Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var skillDeleteRequest projects.SkillDeleteRequest + + var arguments map[string]any + if err := json.Unmarshal(request.Params.Arguments, &arguments); err != nil { + return helpers.NewToolResultTextError(fmt.Sprintf("failed to decode request: %s", err.Error())), nil + } + err := helpers.ParamGroup(arguments, + helpers.RequiredNumericParam(&skillDeleteRequest.Path.ID, "id"), + ) + if err != nil { + return helpers.NewToolResultTextError(fmt.Sprintf("invalid parameters: %s", err.Error())), nil + } + + _, err = projects.SkillDelete(ctx, engine, skillDeleteRequest) + if err != nil { + return helpers.HandleAPIError(err, "failed to delete skill") + } + return helpers.NewToolResultText("Skill deleted successfully"), nil + }, + } +} + +// SkillGet retrieves a skill in Teamwork.com. +func SkillGet(engine *twapi.Engine) toolsets.ToolWrapper { + return toolsets.ToolWrapper{ + Tool: &mcp.Tool{ + Name: string(MethodSkillGet), + Description: "Get an existing skill in Teamwork.com. " + skillDescription, + Annotations: &mcp.ToolAnnotations{ + Title: "Get Skill", + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "id": { + Type: "integer", + Description: "The ID of the skill to get.", + }, + }, + Required: []string{"id"}, + }, + OutputSchema: skillGetOutputSchema, + }, + Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var skillGetRequest projects.SkillGetRequest + + var arguments map[string]any + if err := json.Unmarshal(request.Params.Arguments, &arguments); err != nil { + return helpers.NewToolResultTextError(fmt.Sprintf("failed to decode request: %s", err.Error())), nil + } + err := helpers.ParamGroup(arguments, + helpers.RequiredNumericParam(&skillGetRequest.Path.ID, "id"), + ) + if err != nil { + return helpers.NewToolResultTextError(fmt.Sprintf("invalid parameters: %s", err.Error())), nil + } + + skill, err := projects.SkillGet(ctx, engine, skillGetRequest) + if err != nil { + return helpers.HandleAPIError(err, "failed to get skill") + } + + encoded, err := json.Marshal(skill) + if err != nil { + return nil, err + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: string(encoded), + }, + }, + StructuredContent: skill, + }, nil + }, + } +} + +// SkillList lists skills in Teamwork.com. +func SkillList(engine *twapi.Engine) toolsets.ToolWrapper { + return toolsets.ToolWrapper{ + Tool: &mcp.Tool{ + Name: string(MethodSkillList), + Description: "List skills in Teamwork.com. " + skillDescription, + Annotations: &mcp.ToolAnnotations{ + Title: "List Skills", + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "search_term": { + Type: "string", + Description: "A search term to filter skills by name, or assigned users. " + + "The skill will be selected if each word of the term matches the name, or assigned user first or last " + + "name, not requiring that the word matches are in the same field.", + }, + "page": { + Type: "integer", + Description: "Page number for pagination of results.", + }, + "page_size": { + Type: "integer", + Description: "Number of results per page for pagination.", + }, + }, + }, + OutputSchema: skillListOutputSchema, + }, + Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var skillListRequest projects.SkillListRequest + + var arguments map[string]any + if err := json.Unmarshal(request.Params.Arguments, &arguments); err != nil { + return helpers.NewToolResultTextError(fmt.Sprintf("failed to decode request: %s", err.Error())), nil + } + err := helpers.ParamGroup(arguments, + helpers.OptionalParam(&skillListRequest.Filters.SearchTerm, "search_term"), + helpers.OptionalNumericParam(&skillListRequest.Filters.Page, "page"), + helpers.OptionalNumericParam(&skillListRequest.Filters.PageSize, "page_size"), + ) + if err != nil { + return helpers.NewToolResultTextError(fmt.Sprintf("invalid parameters: %s", err.Error())), nil + } + + skillList, err := projects.SkillList(ctx, engine, skillListRequest) + if err != nil { + return helpers.HandleAPIError(err, "failed to list skills") + } + + encoded, err := json.Marshal(skillList) + if err != nil { + return nil, err + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: string(helpers.WebLinker(ctx, encoded, + helpers.WebLinkerWithIDPathBuilder("/app/people"), + )), + }, + }, + StructuredContent: skillList, + }, nil + }, + } +} diff --git a/internal/twprojects/skills_test.go b/internal/twprojects/skills_test.go new file mode 100644 index 0000000..ea303c9 --- /dev/null +++ b/internal/twprojects/skills_test.go @@ -0,0 +1,49 @@ +package twprojects_test + +import ( + "net/http" + "testing" + + "github.com/teamwork/mcp/internal/testutil" + "github.com/teamwork/mcp/internal/twprojects" +) + +func TestSkillCreate(t *testing.T) { + mcpServer := mcpServerMock(t, http.StatusCreated, []byte(`{"skill":{"id":123}}`)) + testutil.ExecuteToolRequest(t, mcpServer, twprojects.MethodSkillCreate.String(), map[string]any{ + "name": "Example", + "user_ids": []float64{1, 2, 3}, + }) +} + +func TestSkillUpdate(t *testing.T) { + mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{}`)) + testutil.ExecuteToolRequest(t, mcpServer, twprojects.MethodSkillUpdate.String(), map[string]any{ + "id": float64(123), + "name": "Example", + "user_ids": []float64{1, 2, 3}, + }) +} + +func TestSkillDelete(t *testing.T) { + mcpServer := mcpServerMock(t, http.StatusNoContent, nil) + testutil.ExecuteToolRequest(t, mcpServer, twprojects.MethodSkillDelete.String(), map[string]any{ + "id": float64(123), + }) +} + +func TestSkillGet(t *testing.T) { + mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{}`)) + testutil.ExecuteToolRequest(t, mcpServer, twprojects.MethodSkillGet.String(), map[string]any{ + "id": float64(123), + }) +} + +func TestSkillList(t *testing.T) { + mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{}`)) + testutil.ExecuteToolRequest(t, mcpServer, twprojects.MethodSkillList.String(), map[string]any{ + "search_term": "test", + "page": float64(1), + "page_size": float64(10), + }) +} diff --git a/internal/twprojects/tools.go b/internal/twprojects/tools.go index 5807822..c42a661 100644 --- a/internal/twprojects/tools.go +++ b/internal/twprojects/tools.go @@ -38,6 +38,8 @@ func DefaultToolsetGroup(readOnly, allowDelete bool, engine *twapi.Engine) *tool TimerComplete(engine), NotebookCreate(engine), NotebookUpdate(engine), + SkillCreate(engine), + SkillUpdate(engine), } if allowDelete { writeTools = append(writeTools, []toolsets.ToolWrapper{ @@ -54,6 +56,7 @@ func DefaultToolsetGroup(readOnly, allowDelete bool, engine *twapi.Engine) *tool TimelogDelete(engine), TimerDelete(engine), NotebookDelete(engine), + SkillDelete(engine), }...) } @@ -105,6 +108,8 @@ func DefaultToolsetGroup(readOnly, allowDelete bool, engine *twapi.Engine) *tool NotebookGet(engine), NotebookList(engine), IndustryList(engine), + SkillGet(engine), + SkillList(engine), )) return group }