Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,21 +167,35 @@ func Load(logOutput io.Writer) (Resources, func()) {
// group.
func NewMCPServer(resources Resources, groups ...*toolsets.ToolsetGroup) *mcp.Server {
// Determine if any group has tools
hasTools := false
var hasTools, hasPrompts bool
for _, group := range groups {
if group.HasTools() {
hasTools = true
break
}
if group.HasPrompts() {
hasPrompts = true
}
}

serverOptions := &mcp.ServerOptions{
HasTools: hasTools,
HasPrompts: hasPrompts,
Capabilities: &mcp.ServerCapabilities{
Logging: &mcp.LoggingCapabilities{},
},
}
if hasTools {
serverOptions.Capabilities.Tools = &mcp.ToolCapabilities{}
}
if hasPrompts {
serverOptions.Capabilities.Prompts = &mcp.PromptCapabilities{}
}

mcpServer := mcp.NewServer(&mcp.Implementation{
Name: mcpName,
Title: "Teamwork.com Model Context Protocol",
Version: strings.TrimPrefix(resources.Info.Version, "v"),
}, &mcp.ServerOptions{
HasTools: hasTools,
})
}, serverOptions)
mcpServer.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (result mcp.Result, err error) {
result, err = next(ctx, method, req)
Expand Down
12 changes: 12 additions & 0 deletions internal/toolsets/toolsets.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,3 +383,15 @@ func (tg *ToolsetGroup) HasTools() bool {
}
return false
}

// HasPrompts checks if the ToolsetGroup has any enabled Toolsets with available
// prompts. It returns true if at least one Toolset is enabled and has prompts,
// otherwise it returns false.
func (tg *ToolsetGroup) HasPrompts() bool {
for _, toolset := range tg.Toolsets {
if toolset.Enabled && len(toolset.prompts) > 0 {
return true
}
}
return false
}
242 changes: 242 additions & 0 deletions internal/twprojects/tasks_prompts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
package twprojects

import (
"context"
"fmt"
"strconv"
"strings"

"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/teamwork/mcp/internal/toolsets"
"github.com/teamwork/twapi-go-sdk"
"github.com/teamwork/twapi-go-sdk/projects"
)

// TaskSkillsAndRolesPrompt returns the prompt that helps the LLM to identify
// all skills and job roles of a task.
func TaskSkillsAndRolesPrompt(engine *twapi.Engine) toolsets.ServerPrompt {
return toolsets.ServerPrompt{
Prompt: &mcp.Prompt{
Name: "twprojects_task_skills_and_roles",
Title: "Teamwork.com Task Skills and Job Roles Analysis",
Description: "Analyze the details of a task in Teamwork.com and suggest the most suitable skills and job roles " +
"that align with the task requirements and context within the project.",
Arguments: []*mcp.PromptArgument{
{
Name: "task_id",
Title: "Task ID",
Description: "The ID of the task to analyse. You can identify the desire task by using the " +
string(MethodTaskList) + " method or in the Teamwork.com website.",
Required: true,
},
},
},
Handler: func(ctx context.Context, request *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
if request.Params.Arguments == nil {
return nil, fmt.Errorf("arguments are required")
}

taskIDStr := request.Params.Arguments["task_id"]
taskID, err := strconv.ParseInt(taskIDStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid task ID format: %w", err)
}
if taskID <= 0 {
return nil, fmt.Errorf("task ID must be a positive integer")
}

taskResponse, err := projects.TaskGet(ctx, engine, projects.NewTaskGetRequest(taskID))
if err != nil {
return nil, fmt.Errorf("failed to get task: %w", err)
}

tasklistResponse, err := projects.TasklistGet(ctx, engine,
projects.NewTasklistGetRequest(taskResponse.Task.Tasklist.ID))
if err != nil {
return nil, fmt.Errorf("failed to get tasklist: %w", err)
}

projectResponse, err := projects.ProjectGet(ctx, engine,
projects.NewProjectGetRequest(tasklistResponse.Tasklist.Project.ID))
if err != nil {
return nil, fmt.Errorf("failed to get project: %w", err)
}

skillsNext, err := twapi.Iterate[projects.SkillListRequest, *projects.SkillListResponse](
ctx,
engine,
projects.NewSkillListRequest(),
)
if err != nil {
return nil, fmt.Errorf("failed to build skills iterator: %w", err)
}

var skills []string
for {
skillsResponse, hasSkillsNext, err := skillsNext()
if err != nil {
return nil, fmt.Errorf("failed to list skills: %w", err)
}
if skillsResponse == nil {
break
}
for _, skill := range skillsResponse.Skills {
skills = append(skills, skill.Name)
}
if !hasSkillsNext {
break
}
}

jobRolesNext, err := twapi.Iterate[projects.JobRoleListRequest, *projects.JobRoleListResponse](
ctx,
engine,
projects.NewJobRoleListRequest(),
)
if err != nil {
return nil, fmt.Errorf("failed to build job roles iterator: %w", err)
}

var jobRoles []string
for {
jobRolesResponse, hasJobRolesNext, err := jobRolesNext()
if err != nil {
return nil, fmt.Errorf("failed to list job roles: %w", err)
}
if jobRolesResponse == nil {
break
}
for _, jobRole := range jobRolesResponse.JobRoles {
jobRoles = append(jobRoles, jobRole.Name)
}
if !hasJobRolesNext {
break
}
}

if len(skills) == 0 && len(jobRoles) == 0 {
return nil, fmt.Errorf("no skills or job roles found in the organization")
}

return &mcp.GetPromptResult{
Messages: []*mcp.PromptMessage{
{
Role: "user",
Content: &mcp.TextContent{
Text: taskSkillsAndRolesSystemPrompt,
},
},
{
Role: "user",
Content: &mcp.TextContent{
Text: fmt.Sprintf(taskSkillsAndRolesUserPrompt,
func() string {
if len(skills) == 0 {
return "No skills available in the organization."
}
return strings.Join(skills, ",")
}(),
func() string {
if len(jobRoles) == 0 {
return "No job roles available in the organization."
}
return strings.Join(jobRoles, ",")
}(),
taskResponse.Task.Name,
func() string {
if taskResponse.Task.Description == nil {
return ""
}
return *taskResponse.Task.Description
}(),
tasklistResponse.Tasklist.Name,
projectResponse.Project.Name,
func() string {
if projectResponse.Project.Description == nil {
return ""
}
return *projectResponse.Project.Description
}(),
),
},
},
},
}, nil
},
}
}

const taskSkillsAndRolesSystemPrompt = `
You are a project manager expert using the Teamwork.com platform. Your objective is to analyse the task details and
identify what skills and job roles can have better chances to work on it. The chosen skills and/or job roles should
align with the task requirements and context within the project. Only provide skills and job roles that are relevant to
the task and exist in the organization.

Please send back a JSON object with the skills and job role IDs. The format MUST be:

{
"skillIds": [1, 2],
"jobRoleIds": [3, 4],
"reasoning": "The reasoning behind the suggestions"
}

Here is the JSON schema for the response:

{
"type": "object",
"properties": {
"skillIds": {
"type": "array",
"items": {
"type": "integer"
},
"minItems": 0,
"uniqueItems": true,
"description": "List of suggested skill IDs"
},
"jobRoleIds": {
"type": "array",
"items": {
"type": "integer"
},
"minItems": 0,
"uniqueItems": true,
"description": "List of suggested job role IDs"
},
"reasoning": {
"type": "string",
"description": "Explanation behind the suggestions"
}
},
"required": ["skillIds", "jobRoleIds", "reasoning"],
"additionalProperties": false
}

You MUST NOT send anything else, just the JSON object. If there are no skills or job roles, send an empty array. Do not
hallucinate or make up any skills or job roles.
`

const taskSkillsAndRolesUserPrompt = `
Here are the available skills in the organization:
---
%s
---

Here are the available job roles in the organization:
---
%s
---

Here are the details of the task to analyse:
---
Task Name: %s
---
Task Description: %s
---
Tasklist Name: %s
---
Project Name: %s
---
Project Description: %s
---
`
3 changes: 3 additions & 0 deletions internal/twprojects/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ func DefaultToolsetGroup(readOnly, allowDelete bool, engine *twapi.Engine) *tool
SkillList(engine),
JobRoleGet(engine),
JobRoleList(engine),
).
AddPrompts(
TaskSkillsAndRolesPrompt(engine),
))
return group
}