diff --git a/.gitignore b/.gitignore
index aaadf73..1a662e5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,7 +18,7 @@ coverage.*
profile.cov
# Dependency directories (remove the comment below to include it)
-# vendor/
+vendor/
# Go workspace file
go.work
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml
new file mode 100644
index 0000000..3d89eb3
--- /dev/null
+++ b/.idea/material_theme_project_new.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/mcp.iml b/.idea/mcp.iml
new file mode 100644
index 0000000..5e764c4
--- /dev/null
+++ b/.idea/mcp.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..229c633
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/go.mod b/go.mod
index df8e311..30bbf3f 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,7 @@ require (
github.com/getsentry/sentry-go v0.35.1
github.com/getsentry/sentry-go/slog v0.35.1
github.com/mark3labs/mcp-go v0.39.1
- github.com/teamwork/twapi-go-sdk v1.2.1
+ github.com/teamwork/twapi-go-sdk v1.0.2-0.20250901080306-a9d27475b0c8
)
require (
diff --git a/go.sum b/go.sum
index 4a00849..f5aca85 100644
--- a/go.sum
+++ b/go.sum
@@ -173,8 +173,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/teamwork/twapi-go-sdk v1.2.1 h1:IO8OUOweALfyxyS6+/x4kuYWpJTjZT7sg6xL07Wj8k4=
-github.com/teamwork/twapi-go-sdk v1.2.1/go.mod h1:+xZf9Xwfo17PWVFiNQwYbL1Ba4r/hUPO3C87n92O5bc=
+github.com/teamwork/twapi-go-sdk v1.0.2-0.20250901080306-a9d27475b0c8 h1:Gz33/AhsMHV8SjI5ksHQam9EorApUzrhWVxEOoOJ+pE=
+github.com/teamwork/twapi-go-sdk v1.0.2-0.20250901080306-a9d27475b0c8/go.mod h1:+xZf9Xwfo17PWVFiNQwYbL1Ba4r/hUPO3C87n92O5bc=
github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=
github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
diff --git a/internal/twprojects/rates.go b/internal/twprojects/rates.go
new file mode 100644
index 0000000..01e9fc7
--- /dev/null
+++ b/internal/twprojects/rates.go
@@ -0,0 +1,672 @@
+package twprojects
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "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"
+)
+
+const (
+ // Read operations
+ MethodRateUserGet toolsets.Method = "twprojects-get_user_rates"
+ MethodRateInstallationUserList toolsets.Method = "twprojects-list_installation_user_rates"
+ MethodRateInstallationUserGet toolsets.Method = "twprojects-get_installation_user_rate"
+ MethodRateProjectGet toolsets.Method = "twprojects-get_project_rate"
+ MethodRateProjectUserList toolsets.Method = "twprojects-list_project_user_rates"
+ MethodRateProjectUserGet toolsets.Method = "twprojects-get_project_user_rate"
+ MethodRateProjectUserHistoryGet toolsets.Method = "twprojects-get_project_user_rate_history"
+
+ // Write operations
+ MethodRateInstallationUserUpdate toolsets.Method = "twprojects-update_installation_user_rate"
+ MethodRateInstallationUserBulkUpdate toolsets.Method = "twprojects-bulk_update_installation_user_rates"
+ MethodRateProjectUpdate toolsets.Method = "twprojects-update_project_rate"
+ MethodRateProjectAndUsersUpdate toolsets.Method = "twprojects-update_project_and_user_rates"
+ MethodRateProjectUserUpdate toolsets.Method = "twprojects-update_project_user_rate"
+)
+
+const ratesDescription = "The rates feature in Teamwork.com enables organizations to manage billing and cost " +
+ "rates for users across projects. Rates can be configured at multiple levels: installation-wide default rates, " +
+ "project-specific rates, and individual user rates. This hierarchical system allows for flexible rate management " +
+ "where project-specific rates override installation defaults, and user-specific rates take precedence over " +
+ "both. Rates support multi-currency configurations and maintain historical tracking for accurate financial " +
+ "reporting and billing. Both billable rates (for client billing) and cost rates (for internal cost tracking) " +
+ "are supported, providing comprehensive financial oversight of project work."
+
+func init() {
+ toolsets.RegisterMethod(MethodRateUserGet)
+ toolsets.RegisterMethod(MethodRateInstallationUserList)
+ toolsets.RegisterMethod(MethodRateInstallationUserGet)
+ toolsets.RegisterMethod(MethodRateProjectGet)
+ toolsets.RegisterMethod(MethodRateProjectUserList)
+ toolsets.RegisterMethod(MethodRateProjectUserGet)
+ toolsets.RegisterMethod(MethodRateProjectUserHistoryGet)
+ toolsets.RegisterMethod(MethodRateInstallationUserUpdate)
+ toolsets.RegisterMethod(MethodRateInstallationUserBulkUpdate)
+ toolsets.RegisterMethod(MethodRateProjectUpdate)
+ toolsets.RegisterMethod(MethodRateProjectAndUsersUpdate)
+ toolsets.RegisterMethod(MethodRateProjectUserUpdate)
+}
+
+// RateUserGet retrieves all rates for a specific user.
+func RateUserGet(engine *twapi.Engine) server.ServerTool {
+ return server.ServerTool{
+ Tool: mcp.NewTool(string(MethodRateUserGet),
+ mcp.WithDescription("Get all rates for a specific user in Teamwork.com. "+ratesDescription),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ ReadOnlyHint: twapi.Ptr(true),
+ }),
+ mcp.WithNumber("id",
+ mcp.Required(),
+ mcp.Description("The ID of the user to get rates for."),
+ ),
+ mcp.WithNumber("page",
+ mcp.Description("Page number for pagination of results. Defaults to 1."),
+ ),
+ mcp.WithNumber("page_size",
+ mcp.Description("Number of results per page for pagination. Defaults to 50."),
+ ),
+ mcp.WithBoolean("include_installation_rate",
+ mcp.Description("Include the installation rate in the response. Defaults to false."),
+ ),
+ mcp.WithBoolean("include_user_cost",
+ mcp.Description("Include the user cost in the response. Defaults to false."),
+ ),
+ mcp.WithBoolean("include_archived_projects",
+ mcp.Description("Include archived projects in the response. Defaults to false."),
+ ),
+ mcp.WithBoolean("include_deleted_projects",
+ mcp.Description("Include deleted projects in the response. Defaults to false."),
+ ),
+ ),
+ Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var rateUserGetRequest projects.RateUserGetRequest
+
+ err := helpers.ParamGroup(request.GetArguments(),
+ helpers.RequiredNumericParam(&rateUserGetRequest.Path.ID, "id"),
+ helpers.OptionalNumericParam(&rateUserGetRequest.Filters.Page, "page"),
+ helpers.OptionalNumericParam(&rateUserGetRequest.Filters.PageSize, "page_size"),
+ helpers.OptionalParam(&rateUserGetRequest.Filters.IncludeInstallationRate, "include_installation_rate"),
+ helpers.OptionalParam(&rateUserGetRequest.Filters.IncludeUserCost, "include_user_cost"),
+ helpers.OptionalParam(&rateUserGetRequest.Filters.IncludeArchivedProjects, "include_archived_projects"),
+ helpers.OptionalParam(&rateUserGetRequest.Filters.IncludeDeletedProjects, "include_deleted_projects"),
+ )
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("invalid parameters", err), nil
+ }
+
+ // Set defaults if not provided
+ if rateUserGetRequest.Filters.Page == 0 {
+ rateUserGetRequest.Filters.Page = 1
+ }
+ if rateUserGetRequest.Filters.PageSize == 0 {
+ rateUserGetRequest.Filters.PageSize = 50
+ }
+
+ userRates, err := projects.RateUserGet(ctx, engine, rateUserGetRequest)
+ if err != nil {
+ return helpers.HandleAPIError(err, "failed to get user rates")
+ }
+
+ encoded, err := json.Marshal(userRates)
+ if err != nil {
+ return nil, err
+ }
+ return mcp.NewToolResultText(string(helpers.WebLinker(ctx, encoded,
+ helpers.WebLinkerWithIDPathBuilder("/app/people"),
+ ))), nil
+ },
+ }
+}
+
+// RateInstallationUserList lists all users' installation rates.
+func RateInstallationUserList(engine *twapi.Engine) server.ServerTool {
+ return server.ServerTool{
+ Tool: mcp.NewTool(string(MethodRateInstallationUserList),
+ mcp.WithDescription("List all users' installation rates in Teamwork.com. "+ratesDescription),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ ReadOnlyHint: twapi.Ptr(true),
+ }),
+ mcp.WithNumber("page",
+ mcp.Description("Page number for pagination of results. Defaults to 1."),
+ ),
+ mcp.WithNumber("page_size",
+ mcp.Description("Number of results per page for pagination. Defaults to 50."),
+ ),
+ ),
+ Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var rateInstallationUserListRequest projects.RateInstallationUserListRequest
+
+ err := helpers.ParamGroup(request.GetArguments(),
+ helpers.OptionalNumericParam(&rateInstallationUserListRequest.Filters.Page, "page"),
+ helpers.OptionalNumericParam(&rateInstallationUserListRequest.Filters.PageSize, "page_size"),
+ )
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("invalid parameters", err), nil
+ }
+
+ // Set defaults if not provided
+ if rateInstallationUserListRequest.Filters.Page == 0 {
+ rateInstallationUserListRequest.Filters.Page = 1
+ }
+ if rateInstallationUserListRequest.Filters.PageSize == 0 {
+ rateInstallationUserListRequest.Filters.PageSize = 50
+ }
+
+ installationUserRates, err := projects.RateInstallationUserList(ctx, engine, rateInstallationUserListRequest)
+ if err != nil {
+ return helpers.HandleAPIError(err, "failed to list installation user rates")
+ }
+
+ encoded, err := json.Marshal(installationUserRates)
+ if err != nil {
+ return nil, err
+ }
+ return mcp.NewToolResultText(string(helpers.WebLinker(ctx, encoded,
+ helpers.WebLinkerWithIDPathBuilder("/app/people"),
+ ))), nil
+ },
+ }
+}
+
+// RateInstallationUserGet retrieves a user's default installation rate.
+func RateInstallationUserGet(engine *twapi.Engine) server.ServerTool {
+ return server.ServerTool{
+ Tool: mcp.NewTool(string(MethodRateInstallationUserGet),
+ mcp.WithDescription("Get a user's default installation rate in Teamwork.com. "+ratesDescription),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ ReadOnlyHint: twapi.Ptr(true),
+ }),
+ mcp.WithNumber("user_id",
+ mcp.Required(),
+ mcp.Description("The ID of the user to get the installation rate for."),
+ ),
+ ),
+ Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var rateInstallationUserGetRequest projects.RateInstallationUserGetRequest
+
+ err := helpers.ParamGroup(request.GetArguments(),
+ helpers.RequiredNumericParam(&rateInstallationUserGetRequest.Path.UserID, "user_id"),
+ )
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("invalid parameters", err), nil
+ }
+
+ installationUserRate, err := projects.RateInstallationUserGet(ctx, engine, rateInstallationUserGetRequest)
+ if err != nil {
+ return helpers.HandleAPIError(err, "failed to get installation user rate")
+ }
+
+ encoded, err := json.Marshal(installationUserRate)
+ if err != nil {
+ return nil, err
+ }
+ return mcp.NewToolResultText(string(helpers.WebLinker(ctx, encoded,
+ helpers.WebLinkerWithIDPathBuilder("/app/people"),
+ ))), nil
+ },
+ }
+}
+
+// RateProjectGet retrieves a project's default rate.
+func RateProjectGet(engine *twapi.Engine) server.ServerTool {
+ return server.ServerTool{
+ Tool: mcp.NewTool(string(MethodRateProjectGet),
+ mcp.WithDescription("Get a project's default rate in Teamwork.com. "+ratesDescription),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ ReadOnlyHint: twapi.Ptr(true),
+ }),
+ mcp.WithNumber("project_id",
+ mcp.Required(),
+ mcp.Description("The ID of the project to get the rate for."),
+ ),
+ ),
+ Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var rateProjectGetRequest projects.RateProjectGetRequest
+
+ err := helpers.ParamGroup(request.GetArguments(),
+ helpers.RequiredNumericParam(&rateProjectGetRequest.Path.ProjectID, "project_id"),
+ )
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("invalid parameters", err), nil
+ }
+
+ projectRate, err := projects.RateProjectGet(ctx, engine, rateProjectGetRequest)
+ if err != nil {
+ return helpers.HandleAPIError(err, "failed to get project rate")
+ }
+
+ encoded, err := json.Marshal(projectRate)
+ if err != nil {
+ return nil, err
+ }
+ return mcp.NewToolResultText(string(helpers.WebLinker(ctx, encoded,
+ helpers.WebLinkerWithIDPathBuilder("/app/projects"),
+ ))), nil
+ },
+ }
+}
+
+// RateProjectUserList lists all users' rates for a project.
+func RateProjectUserList(engine *twapi.Engine) server.ServerTool {
+ return server.ServerTool{
+ Tool: mcp.NewTool(string(MethodRateProjectUserList),
+ mcp.WithDescription("List all users' rates for a project in Teamwork.com. "+ratesDescription),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ ReadOnlyHint: twapi.Ptr(true),
+ }),
+ mcp.WithNumber("project_id",
+ mcp.Required(),
+ mcp.Description("The ID of the project to get user rates for."),
+ ),
+ mcp.WithString("search_term",
+ mcp.Description("A search term to filter users by name."),
+ ),
+ mcp.WithNumber("page",
+ mcp.Description("Page number for pagination of results. Defaults to 1."),
+ ),
+ mcp.WithNumber("page_size",
+ mcp.Description("Number of results per page for pagination. Defaults to 50."),
+ ),
+ ),
+ Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var rateProjectUserListRequest projects.RateProjectUserListRequest
+
+ err := helpers.ParamGroup(request.GetArguments(),
+ helpers.RequiredNumericParam(&rateProjectUserListRequest.Path.ProjectID, "project_id"),
+ helpers.OptionalParam(&rateProjectUserListRequest.Filters.SearchTerm, "search_term"),
+ helpers.OptionalNumericParam(&rateProjectUserListRequest.Filters.Page, "page"),
+ helpers.OptionalNumericParam(&rateProjectUserListRequest.Filters.PageSize, "page_size"),
+ )
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("invalid parameters", err), nil
+ }
+
+ // Set defaults if not provided
+ if rateProjectUserListRequest.Filters.Page == 0 {
+ rateProjectUserListRequest.Filters.Page = 1
+ }
+ if rateProjectUserListRequest.Filters.PageSize == 0 {
+ rateProjectUserListRequest.Filters.PageSize = 50
+ }
+
+ projectUserRates, err := projects.RateProjectUserList(ctx, engine, rateProjectUserListRequest)
+ if err != nil {
+ return helpers.HandleAPIError(err, "failed to list project user rates")
+ }
+
+ encoded, err := json.Marshal(projectUserRates)
+ if err != nil {
+ return nil, err
+ }
+ return mcp.NewToolResultText(string(helpers.WebLinker(ctx, encoded,
+ helpers.WebLinkerWithIDPathBuilder("/app/projects"),
+ ))), nil
+ },
+ }
+}
+
+// RateProjectUserGet retrieves a specific user's rate for a project.
+func RateProjectUserGet(engine *twapi.Engine) server.ServerTool {
+ return server.ServerTool{
+ Tool: mcp.NewTool(string(MethodRateProjectUserGet),
+ mcp.WithDescription("Get a specific user's rate for a project in Teamwork.com. "+ratesDescription),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ ReadOnlyHint: twapi.Ptr(true),
+ }),
+ mcp.WithNumber("project_id",
+ mcp.Required(),
+ mcp.Description("The ID of the project."),
+ ),
+ mcp.WithNumber("user_id",
+ mcp.Required(),
+ mcp.Description("The ID of the user to get the rate for."),
+ ),
+ ),
+ Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var rateProjectUserGetRequest projects.RateProjectUserGetRequest
+
+ err := helpers.ParamGroup(request.GetArguments(),
+ helpers.RequiredNumericParam(&rateProjectUserGetRequest.Path.ProjectID, "project_id"),
+ helpers.RequiredNumericParam(&rateProjectUserGetRequest.Path.UserID, "user_id"),
+ )
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("invalid parameters", err), nil
+ }
+
+ projectUserRate, err := projects.RateProjectUserGet(ctx, engine, rateProjectUserGetRequest)
+ if err != nil {
+ return helpers.HandleAPIError(err, "failed to get project user rate")
+ }
+
+ encoded, err := json.Marshal(projectUserRate)
+ if err != nil {
+ return nil, err
+ }
+ return mcp.NewToolResultText(string(helpers.WebLinker(ctx, encoded,
+ helpers.WebLinkerWithIDPathBuilder("/app/projects"),
+ ))), nil
+ },
+ }
+}
+
+// RateProjectUserHistoryGet retrieves a user's rate history for a project.
+func RateProjectUserHistoryGet(engine *twapi.Engine) server.ServerTool {
+ return server.ServerTool{
+ Tool: mcp.NewTool(string(MethodRateProjectUserHistoryGet),
+ mcp.WithDescription("Get a user's rate history for a project in Teamwork.com. "+ratesDescription),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ ReadOnlyHint: twapi.Ptr(true),
+ }),
+ mcp.WithNumber("project_id",
+ mcp.Required(),
+ mcp.Description("The ID of the project."),
+ ),
+ mcp.WithNumber("user_id",
+ mcp.Required(),
+ mcp.Description("The ID of the user to get the rate history for."),
+ ),
+ mcp.WithNumber("page",
+ mcp.Description("Page number for pagination of results. Defaults to 1."),
+ ),
+ mcp.WithNumber("page_size",
+ mcp.Description("Number of results per page for pagination. Defaults to 50."),
+ ),
+ ),
+ Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var rateProjectUserHistoryGetRequest projects.RateProjectUserHistoryGetRequest
+
+ err := helpers.ParamGroup(request.GetArguments(),
+ helpers.RequiredNumericParam(&rateProjectUserHistoryGetRequest.Path.ProjectID, "project_id"),
+ helpers.RequiredNumericParam(&rateProjectUserHistoryGetRequest.Path.UserID, "user_id"),
+ helpers.OptionalNumericParam(&rateProjectUserHistoryGetRequest.Filters.Page, "page"),
+ helpers.OptionalNumericParam(&rateProjectUserHistoryGetRequest.Filters.PageSize, "page_size"),
+ )
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("invalid parameters", err), nil
+ }
+
+ // Set defaults if not provided
+ if rateProjectUserHistoryGetRequest.Filters.Page == 0 {
+ rateProjectUserHistoryGetRequest.Filters.Page = 1
+ }
+ if rateProjectUserHistoryGetRequest.Filters.PageSize == 0 {
+ rateProjectUserHistoryGetRequest.Filters.PageSize = 50
+ }
+
+ projectUserRateHistory, err := projects.RateProjectUserHistoryGet(ctx, engine, rateProjectUserHistoryGetRequest)
+ if err != nil {
+ return helpers.HandleAPIError(err, "failed to get project user rate history")
+ }
+
+ encoded, err := json.Marshal(projectUserRateHistory)
+ if err != nil {
+ return nil, err
+ }
+ return mcp.NewToolResultText(string(helpers.WebLinker(ctx, encoded,
+ helpers.WebLinkerWithIDPathBuilder("/app/projects"),
+ ))), nil
+ },
+ }
+}
+
+// RateInstallationUserUpdate sets a user's default installation rate.
+func RateInstallationUserUpdate(engine *twapi.Engine) server.ServerTool {
+ return server.ServerTool{
+ Tool: mcp.NewTool(string(MethodRateInstallationUserUpdate),
+ mcp.WithDescription("Set a user's default installation rate in Teamwork.com. "+ratesDescription),
+ mcp.WithNumber("user_id",
+ mcp.Required(),
+ mcp.Description("The ID of the user to set the installation rate for."),
+ ),
+ mcp.WithNumber("user_rate",
+ mcp.Required(),
+ mcp.Description("The rate amount for the user."),
+ ),
+ mcp.WithNumber("currency_id",
+ mcp.Description("The ID of the currency for the rate (optional, only used in multi-currency mode)."),
+ ),
+ ),
+ Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var rateInstallationUserUpdateRequest projects.RateInstallationUserUpdateRequest
+
+ err := helpers.ParamGroup(request.GetArguments(),
+ helpers.RequiredNumericParam(&rateInstallationUserUpdateRequest.Path.UserID, "user_id"),
+ helpers.OptionalNumericPointerParam(&rateInstallationUserUpdateRequest.UserRate, "user_rate"),
+ helpers.OptionalNumericPointerParam(&rateInstallationUserUpdateRequest.CurrencyID, "currency_id"),
+ )
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("invalid parameters", err), nil
+ }
+
+ _, err = projects.RateInstallationUserUpdate(ctx, engine, rateInstallationUserUpdateRequest)
+ if err != nil {
+ return helpers.HandleAPIError(err, "failed to update installation user rate")
+ }
+
+ return mcp.NewToolResultText("Installation user rate updated successfully"), nil
+ },
+ }
+}
+
+// RateInstallationUserBulkUpdate performs bulk update of user installation rates.
+func RateInstallationUserBulkUpdate(engine *twapi.Engine) server.ServerTool {
+ return server.ServerTool{
+ Tool: mcp.NewTool(string(MethodRateInstallationUserBulkUpdate),
+ mcp.WithDescription("Bulk update installation rates for users in Teamwork.com. "+ratesDescription),
+ mcp.WithNumber("user_rate",
+ mcp.Required(),
+ mcp.Description("The rate amount to set for users."),
+ ),
+ mcp.WithBoolean("all",
+ mcp.Description("Whether to update all users. Defaults to false."),
+ ),
+ mcp.WithArray("ids",
+ mcp.Description("Array of user IDs to update (if all is false)."),
+ mcp.Items(map[string]any{
+ "type": "integer",
+ }),
+ ),
+ mcp.WithArray("exclude_ids",
+ mcp.Description("Array of user IDs to exclude (if all is true)."),
+ mcp.Items(map[string]any{
+ "type": "integer",
+ }),
+ ),
+ mcp.WithNumber("currency_id",
+ mcp.Description("The ID of the currency for the rate (optional, only used in multi-currency mode)."),
+ ),
+ ),
+ Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var rateInstallationUserBulkUpdateRequest projects.RateInstallationUserBulkUpdateRequest
+
+ err := helpers.ParamGroup(request.GetArguments(),
+ helpers.OptionalNumericPointerParam(&rateInstallationUserBulkUpdateRequest.UserRate, "user_rate"),
+ helpers.OptionalParam(&rateInstallationUserBulkUpdateRequest.All, "all"),
+ helpers.OptionalNumericListParam(&rateInstallationUserBulkUpdateRequest.IDs, "ids"),
+ helpers.OptionalNumericListParam(&rateInstallationUserBulkUpdateRequest.ExcludeIDs, "exclude_ids"),
+ helpers.OptionalNumericPointerParam(&rateInstallationUserBulkUpdateRequest.CurrencyID, "currency_id"),
+ )
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("invalid parameters", err), nil
+ }
+
+ _, err = projects.RateInstallationUserBulkUpdate(ctx, engine, rateInstallationUserBulkUpdateRequest)
+ if err != nil {
+ return helpers.HandleAPIError(err, "failed to bulk update installation user rates")
+ }
+
+ return mcp.NewToolResultText("Bulk updated installation user rates successfully"), nil
+ },
+ }
+}
+
+// RateProjectUpdate sets a project's default rate.
+func RateProjectUpdate(engine *twapi.Engine) server.ServerTool {
+ return server.ServerTool{
+ Tool: mcp.NewTool(string(MethodRateProjectUpdate),
+ mcp.WithDescription("Set a project's default rate in Teamwork.com. "+ratesDescription),
+ mcp.WithNumber("project_id",
+ mcp.Required(),
+ mcp.Description("The ID of the project to set the rate for."),
+ ),
+ mcp.WithNumber("project_rate",
+ mcp.Required(),
+ mcp.Description("The rate amount for the project."),
+ ),
+ ),
+ Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var rateProjectUpdateRequest projects.RateProjectUpdateRequest
+
+ err := helpers.ParamGroup(request.GetArguments(),
+ helpers.RequiredNumericParam(&rateProjectUpdateRequest.Path.ProjectID, "project_id"),
+ helpers.OptionalNumericPointerParam(&rateProjectUpdateRequest.ProjectRate, "project_rate"),
+ )
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("invalid parameters", err), nil
+ }
+
+ _, err = projects.RateProjectUpdate(ctx, engine, rateProjectUpdateRequest)
+ if err != nil {
+ return helpers.HandleAPIError(err, "failed to update project rate")
+ }
+
+ return mcp.NewToolResultText("Project rate updated successfully"), nil
+ },
+ }
+}
+
+// RateProjectAndUsersUpdate sets project rate and user rates together.
+func RateProjectAndUsersUpdate(engine *twapi.Engine) server.ServerTool {
+ return server.ServerTool{
+ Tool: mcp.NewTool(string(MethodRateProjectAndUsersUpdate),
+ mcp.WithDescription("Set project rate and user rates together in Teamwork.com. "+ratesDescription),
+ mcp.WithNumber("project_id",
+ mcp.Required(),
+ mcp.Description("The ID of the project."),
+ ),
+ mcp.WithNumber("project_rate",
+ mcp.Description("The project's default rate amount."),
+ ),
+ mcp.WithArray("user_rates",
+ mcp.Description("Array of user rate objects to set for the project."),
+ mcp.Items(map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "user_id": map[string]any{
+ "type": "integer",
+ "description": "The ID of the user.",
+ },
+ "user_rate": map[string]any{
+ "type": "number",
+ "description": "The rate amount for the user.",
+ },
+ },
+ }),
+ ),
+ ),
+ Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var rateProjectAndUsersUpdateRequest projects.RateProjectAndUsersUpdateRequest
+
+ args := request.GetArguments()
+
+ err := helpers.ParamGroup(args,
+ helpers.RequiredNumericParam(&rateProjectAndUsersUpdateRequest.Path.ProjectID, "project_id"),
+ helpers.OptionalNumericParam(&rateProjectAndUsersUpdateRequest.ProjectRate, "project_rate"),
+ )
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("invalid parameters", err), nil
+ }
+
+ // Parse user_rates if provided
+ if userRatesRaw, exists := args["user_rates"]; exists && userRatesRaw != nil {
+ userRatesArray, ok := userRatesRaw.([]interface{})
+ if !ok {
+ return mcp.NewToolResultErrorFromErr("invalid parameters", fmt.Errorf("user_rates must be an array")), nil
+ }
+
+ for _, userRateRaw := range userRatesArray {
+ userRateMap, ok := userRateRaw.(map[string]interface{})
+ if !ok {
+ return mcp.NewToolResultErrorFromErr("invalid parameters", fmt.Errorf("each user_rate must be an object")), nil
+ }
+
+ var userRate projects.ProjectUserRateRequest
+ if userID, exists := userRateMap["user_id"]; exists {
+ if userIDFloat, ok := userID.(float64); ok {
+ userRate.User = twapi.Relationship{ID: int64(userIDFloat)}
+ }
+ }
+ if userRateVal, exists := userRateMap["user_rate"]; exists {
+ if userRateFloat, ok := userRateVal.(float64); ok {
+ userRate.UserRate = int64(userRateFloat)
+ }
+ }
+
+ rateProjectAndUsersUpdateRequest.UserRates = append(rateProjectAndUsersUpdateRequest.UserRates, userRate)
+ }
+ }
+
+ _, err = projects.RateProjectAndUsersUpdate(ctx, engine, rateProjectAndUsersUpdateRequest)
+ if err != nil {
+ return helpers.HandleAPIError(err, "failed to update project and user rates")
+ }
+
+ userCount := len(rateProjectAndUsersUpdateRequest.UserRates)
+ if userCount > 0 {
+ return mcp.NewToolResultText(fmt.Sprintf("Project rate and %d user rates updated successfully", userCount)), nil
+ }
+ return mcp.NewToolResultText("Project rate updated successfully"), nil
+ },
+ }
+}
+
+// RateProjectUserUpdate sets a user's rate for a specific project.
+func RateProjectUserUpdate(engine *twapi.Engine) server.ServerTool {
+ return server.ServerTool{
+ Tool: mcp.NewTool(string(MethodRateProjectUserUpdate),
+ mcp.WithDescription("Set a user's rate for a specific project in Teamwork.com. "+ratesDescription),
+ mcp.WithNumber("project_id",
+ mcp.Required(),
+ mcp.Description("The ID of the project."),
+ ),
+ mcp.WithNumber("user_id",
+ mcp.Required(),
+ mcp.Description("The ID of the user to set the rate for."),
+ ),
+ mcp.WithNumber("user_rate",
+ mcp.Required(),
+ mcp.Description("The rate amount for the user."),
+ ),
+ mcp.WithNumber("currency_id",
+ mcp.Description("The ID of the currency for the rate (optional, only used in multi-currency mode)."),
+ ),
+ ),
+ Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var rateProjectUserUpdateRequest projects.RateProjectUserUpdateRequest
+
+ err := helpers.ParamGroup(request.GetArguments(),
+ helpers.RequiredNumericParam(&rateProjectUserUpdateRequest.Path.ProjectID, "project_id"),
+ helpers.RequiredNumericParam(&rateProjectUserUpdateRequest.Path.UserID, "user_id"),
+ helpers.OptionalNumericPointerParam(&rateProjectUserUpdateRequest.UserRate, "user_rate"),
+ helpers.OptionalNumericPointerParam(&rateProjectUserUpdateRequest.CurrencyID, "currency_id"),
+ )
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("invalid parameters", err), nil
+ }
+
+ _, err = projects.RateProjectUserUpdate(ctx, engine, rateProjectUserUpdateRequest)
+ if err != nil {
+ return helpers.HandleAPIError(err, "failed to update project user rate")
+ }
+
+ return mcp.NewToolResultText("Project user rate updated successfully"), nil
+ },
+ }
+}
diff --git a/internal/twprojects/rates_test.go b/internal/twprojects/rates_test.go
new file mode 100644
index 0000000..9181288
--- /dev/null
+++ b/internal/twprojects/rates_test.go
@@ -0,0 +1,346 @@
+package twprojects_test
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/teamwork/mcp/internal/twprojects"
+)
+
+func TestRateUserGet(t *testing.T) {
+ mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{}`))
+
+ request := &toolRequest{
+ JSONRPC: mcp.JSONRPC_VERSION,
+ ID: 1,
+ CallToolRequest: mcp.CallToolRequest{
+ Request: mcp.Request{
+ Method: string(mcp.MethodToolsCall),
+ },
+ },
+ }
+ request.Params.Name = twprojects.MethodRateUserGet.String()
+ request.Params.Arguments = map[string]any{
+ "id": float64(123),
+ "page": float64(1),
+ "page_size": float64(10),
+ "include_installation_rate": true,
+ "include_user_cost": true,
+ "include_archived_projects": false,
+ "include_deleted_projects": false,
+ }
+
+ encodedRequest, err := json.Marshal(request)
+ if err != nil {
+ t.Fatalf("failed to encode request: %v", err)
+ }
+
+ checkMessage(t, mcpServer.HandleMessage(context.Background(), encodedRequest))
+}
+
+func TestRateInstallationUserList(t *testing.T) {
+ mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{}`))
+
+ request := &toolRequest{
+ JSONRPC: mcp.JSONRPC_VERSION,
+ ID: 1,
+ CallToolRequest: mcp.CallToolRequest{
+ Request: mcp.Request{
+ Method: string(mcp.MethodToolsCall),
+ },
+ },
+ }
+ request.Params.Name = twprojects.MethodRateInstallationUserList.String()
+ request.Params.Arguments = map[string]any{
+ "page": float64(1),
+ "page_size": float64(25),
+ }
+
+ encodedRequest, err := json.Marshal(request)
+ if err != nil {
+ t.Fatalf("failed to encode request: %v", err)
+ }
+
+ checkMessage(t, mcpServer.HandleMessage(context.Background(), encodedRequest))
+}
+
+func TestRateInstallationUserGet(t *testing.T) {
+ mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{}`))
+
+ request := &toolRequest{
+ JSONRPC: mcp.JSONRPC_VERSION,
+ ID: 1,
+ CallToolRequest: mcp.CallToolRequest{
+ Request: mcp.Request{
+ Method: string(mcp.MethodToolsCall),
+ },
+ },
+ }
+ request.Params.Name = twprojects.MethodRateInstallationUserGet.String()
+ request.Params.Arguments = map[string]any{
+ "user_id": float64(456),
+ }
+
+ encodedRequest, err := json.Marshal(request)
+ if err != nil {
+ t.Fatalf("failed to encode request: %v", err)
+ }
+
+ checkMessage(t, mcpServer.HandleMessage(context.Background(), encodedRequest))
+}
+
+func TestRateProjectGet(t *testing.T) {
+ mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{}`))
+
+ request := &toolRequest{
+ JSONRPC: mcp.JSONRPC_VERSION,
+ ID: 1,
+ CallToolRequest: mcp.CallToolRequest{
+ Request: mcp.Request{
+ Method: string(mcp.MethodToolsCall),
+ },
+ },
+ }
+ request.Params.Name = twprojects.MethodRateProjectGet.String()
+ request.Params.Arguments = map[string]any{
+ "project_id": float64(789),
+ }
+
+ encodedRequest, err := json.Marshal(request)
+ if err != nil {
+ t.Fatalf("failed to encode request: %v", err)
+ }
+
+ checkMessage(t, mcpServer.HandleMessage(context.Background(), encodedRequest))
+}
+
+func TestRateProjectUserList(t *testing.T) {
+ mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{}`))
+
+ request := &toolRequest{
+ JSONRPC: mcp.JSONRPC_VERSION,
+ ID: 1,
+ CallToolRequest: mcp.CallToolRequest{
+ Request: mcp.Request{
+ Method: string(mcp.MethodToolsCall),
+ },
+ },
+ }
+ request.Params.Name = twprojects.MethodRateProjectUserList.String()
+ request.Params.Arguments = map[string]any{
+ "project_id": float64(789),
+ "search_term": "john",
+ "page": float64(1),
+ "page_size": float64(20),
+ }
+
+ encodedRequest, err := json.Marshal(request)
+ if err != nil {
+ t.Fatalf("failed to encode request: %v", err)
+ }
+
+ checkMessage(t, mcpServer.HandleMessage(context.Background(), encodedRequest))
+}
+
+func TestRateProjectUserGet(t *testing.T) {
+ mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{}`))
+
+ request := &toolRequest{
+ JSONRPC: mcp.JSONRPC_VERSION,
+ ID: 1,
+ CallToolRequest: mcp.CallToolRequest{
+ Request: mcp.Request{
+ Method: string(mcp.MethodToolsCall),
+ },
+ },
+ }
+ request.Params.Name = twprojects.MethodRateProjectUserGet.String()
+ request.Params.Arguments = map[string]any{
+ "project_id": float64(789),
+ "user_id": float64(456),
+ }
+
+ encodedRequest, err := json.Marshal(request)
+ if err != nil {
+ t.Fatalf("failed to encode request: %v", err)
+ }
+
+ checkMessage(t, mcpServer.HandleMessage(context.Background(), encodedRequest))
+}
+
+func TestRateProjectUserHistoryGet(t *testing.T) {
+ mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{}`))
+
+ request := &toolRequest{
+ JSONRPC: mcp.JSONRPC_VERSION,
+ ID: 1,
+ CallToolRequest: mcp.CallToolRequest{
+ Request: mcp.Request{
+ Method: string(mcp.MethodToolsCall),
+ },
+ },
+ }
+ request.Params.Name = twprojects.MethodRateProjectUserHistoryGet.String()
+ request.Params.Arguments = map[string]any{
+ "project_id": float64(789),
+ "user_id": float64(456),
+ "page": float64(1),
+ "page_size": float64(15),
+ }
+
+ encodedRequest, err := json.Marshal(request)
+ if err != nil {
+ t.Fatalf("failed to encode request: %v", err)
+ }
+
+ checkMessage(t, mcpServer.HandleMessage(context.Background(), encodedRequest))
+}
+
+func TestRateInstallationUserUpdate(t *testing.T) {
+ mcpServer := mcpServerMock(t, http.StatusCreated, []byte(`{"STATUS":"OK"}`))
+
+ request := &toolRequest{
+ JSONRPC: mcp.JSONRPC_VERSION,
+ ID: 1,
+ CallToolRequest: mcp.CallToolRequest{
+ Request: mcp.Request{
+ Method: string(mcp.MethodToolsCall),
+ },
+ },
+ }
+ request.Params.Name = twprojects.MethodRateInstallationUserUpdate.String()
+ request.Params.Arguments = map[string]any{
+ "user_id": float64(456),
+ "user_rate": float64(75),
+ "currency_id": float64(1),
+ }
+
+ encodedRequest, err := json.Marshal(request)
+ if err != nil {
+ t.Fatalf("failed to encode request: %v", err)
+ }
+
+ checkMessage(t, mcpServer.HandleMessage(context.Background(), encodedRequest))
+}
+
+func TestRateInstallationUserBulkUpdate(t *testing.T) {
+ mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{}`))
+
+ request := &toolRequest{
+ JSONRPC: mcp.JSONRPC_VERSION,
+ ID: 1,
+ CallToolRequest: mcp.CallToolRequest{
+ Request: mcp.Request{
+ Method: string(mcp.MethodToolsCall),
+ },
+ },
+ }
+ request.Params.Name = twprojects.MethodRateInstallationUserBulkUpdate.String()
+ request.Params.Arguments = map[string]any{
+ "user_rate": float64(80),
+ "all": false,
+ "ids": []float64{456, 457, 458},
+ "exclude_ids": []float64{},
+ "currency_id": float64(1),
+ }
+
+ encodedRequest, err := json.Marshal(request)
+ if err != nil {
+ t.Fatalf("failed to encode request: %v", err)
+ }
+
+ checkMessage(t, mcpServer.HandleMessage(context.Background(), encodedRequest))
+}
+
+func TestRateProjectUpdate(t *testing.T) {
+ mcpServer := mcpServerMock(t, http.StatusNoContent, []byte(`{"STATUS":"OK"}`))
+
+ request := &toolRequest{
+ JSONRPC: mcp.JSONRPC_VERSION,
+ ID: 1,
+ CallToolRequest: mcp.CallToolRequest{
+ Request: mcp.Request{
+ Method: string(mcp.MethodToolsCall),
+ },
+ },
+ }
+ request.Params.Name = twprojects.MethodRateProjectUpdate.String()
+ request.Params.Arguments = map[string]any{
+ "project_id": float64(789),
+ "project_rate": float64(100),
+ }
+
+ encodedRequest, err := json.Marshal(request)
+ if err != nil {
+ t.Fatalf("failed to encode request: %v", err)
+ }
+
+ checkMessage(t, mcpServer.HandleMessage(context.Background(), encodedRequest))
+}
+
+func TestRateProjectAndUsersUpdate(t *testing.T) {
+ mcpServer := mcpServerMock(t, http.StatusNoContent, []byte(`{"STATUS":"OK"}`))
+
+ request := &toolRequest{
+ JSONRPC: mcp.JSONRPC_VERSION,
+ ID: 1,
+ CallToolRequest: mcp.CallToolRequest{
+ Request: mcp.Request{
+ Method: string(mcp.MethodToolsCall),
+ },
+ },
+ }
+ request.Params.Name = twprojects.MethodRateProjectAndUsersUpdate.String()
+ request.Params.Arguments = map[string]any{
+ "project_id": float64(789),
+ "project_rate": float64(100),
+ "user_rates": []map[string]any{
+ {
+ "user_id": float64(456),
+ "user_rate": float64(85),
+ },
+ {
+ "user_id": float64(457),
+ "user_rate": float64(90),
+ },
+ },
+ }
+
+ encodedRequest, err := json.Marshal(request)
+ if err != nil {
+ t.Fatalf("failed to encode request: %v", err)
+ }
+
+ checkMessage(t, mcpServer.HandleMessage(context.Background(), encodedRequest))
+}
+
+func TestRateProjectUserUpdate(t *testing.T) {
+ mcpServer := mcpServerMock(t, http.StatusCreated, []byte(`{"STATUS":"OK"}`))
+
+ request := &toolRequest{
+ JSONRPC: mcp.JSONRPC_VERSION,
+ ID: 1,
+ CallToolRequest: mcp.CallToolRequest{
+ Request: mcp.Request{
+ Method: string(mcp.MethodToolsCall),
+ },
+ },
+ }
+ request.Params.Name = twprojects.MethodRateProjectUserUpdate.String()
+ request.Params.Arguments = map[string]any{
+ "project_id": float64(789),
+ "user_id": float64(456),
+ "user_rate": float64(95),
+ "currency_id": float64(1),
+ }
+
+ encodedRequest, err := json.Marshal(request)
+ if err != nil {
+ t.Fatalf("failed to encode request: %v", err)
+ }
+
+ checkMessage(t, mcpServer.HandleMessage(context.Background(), encodedRequest))
+}
\ No newline at end of file
diff --git a/internal/twprojects/tools.go b/internal/twprojects/tools.go
index 78c0a42..2731fb7 100644
--- a/internal/twprojects/tools.go
+++ b/internal/twprojects/tools.go
@@ -35,6 +35,11 @@ func DefaultToolsetGroup(readOnly, allowDelete bool, engine *twapi.Engine) *tool
TimerPause(engine),
TimerResume(engine),
TimerComplete(engine),
+ RateInstallationUserUpdate(engine),
+ RateInstallationUserBulkUpdate(engine),
+ RateProjectUpdate(engine),
+ RateProjectAndUsersUpdate(engine),
+ RateProjectUserUpdate(engine),
}
if allowDelete {
writeTools = append(writeTools, []server.ServerTool{
@@ -94,6 +99,13 @@ func DefaultToolsetGroup(readOnly, allowDelete bool, engine *twapi.Engine) *tool
TimerList(engine),
ActivityList(engine),
ActivityListByProject(engine),
+ RateUserGet(engine),
+ RateInstallationUserList(engine),
+ RateInstallationUserGet(engine),
+ RateProjectGet(engine),
+ RateProjectUserList(engine),
+ RateProjectUserGet(engine),
+ RateProjectUserHistoryGet(engine),
))
return group
}