diff --git a/go.mod b/go.mod index b1628f1..e2739da 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/google/uuid v1.6.0 github.com/localit-io/tiktoken-go v0.2.1 github.com/modelcontextprotocol/go-sdk v1.6.1 - github.com/teamwork/desksdkgo v1.0.0 + github.com/teamwork/desksdkgo v1.0.1 github.com/teamwork/spacessdkgo v0.0.0-20260518181558-a6af69d00abb github.com/teamwork/twapi-go-sdk v1.19.0 ) @@ -38,6 +38,7 @@ require ( github.com/DataDog/go-tuf v1.1.1-0.5.2 // indirect github.com/DataDog/sketches-go v1.4.8 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/brianvoe/gofakeit/v7 v7.2.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 // indirect @@ -46,6 +47,7 @@ require ( github.com/ebitengine/purego v0.10.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/hashicorp/go-version v1.9.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index 48aa81e..9a75e39 100644 --- a/go.sum +++ b/go.sum @@ -45,6 +45,8 @@ github.com/DataDog/sketches-go v1.4.8/go.mod h1:a/wjRUqzqtGS8qRHRPDCs4EAQfmvPDZG github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/brianvoe/gofakeit/v7 v7.2.1 h1:AGojgaaCdgq4Adzrd2uWdbGNDyX6MWNhHdQBraNfOHI= +github.com/brianvoe/gofakeit/v7 v7.2.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -99,6 +101,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= @@ -182,6 +186,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/teamwork/desksdkgo v1.0.0 h1:m82rwxiQdAtp00XhCRnf5eiq4p0KWI1a5ppvbtkXMJM= github.com/teamwork/desksdkgo v1.0.0/go.mod h1:nAQ9TiITo1WqNnLsifExR7SH/64rGemPIb7yi7KbQ5I= +github.com/teamwork/desksdkgo v1.0.1 h1:Mi5D1pnGfL5JwD7s7g7OyHml7Y9jsak5MNJaotJt+pE= +github.com/teamwork/desksdkgo v1.0.1/go.mod h1:Mgvw83q8iqHr7Sm9xV1iI/T89o3ObaPU3ChMJheRzwA= github.com/teamwork/spacessdkgo v0.0.0-20260518181558-a6af69d00abb h1:bQluDjySZeC5etnWgjk4WFRy0PvzGDw8XEBd4JJYWCQ= github.com/teamwork/spacessdkgo v0.0.0-20260518181558-a6af69d00abb/go.mod h1:jfE0RLsZuk/3Glzs5bJ95pNb92emV7uXZYgoGSLQ76I= github.com/teamwork/twapi-go-sdk v1.19.0 h1:66Ke/LT2fduerAE78dpK3lyVNMjzCUn0tVhMrstdzhc= diff --git a/internal/twdesk/helpdocs.go b/internal/twdesk/helpdocs.go new file mode 100644 index 0000000..50cee1d --- /dev/null +++ b/internal/twdesk/helpdocs.go @@ -0,0 +1,335 @@ +package twdesk + +import ( + "context" + "fmt" + "net/http" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + deskmodels "github.com/teamwork/desksdkgo/models" + "github.com/teamwork/mcp/internal/helpers" + "github.com/teamwork/mcp/internal/toolsets" +) + +// 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 ( + MethodHelpDocArticleCreate toolsets.Method = "twdesk-create_helpdoc_article" + MethodHelpDocArticleUpdate toolsets.Method = "twdesk-update_helpdoc_article" + MethodHelpDocArticleGet toolsets.Method = "twdesk-get_helpdoc_article" + MethodHelpDocArticleSearch toolsets.Method = "twdesk-search_helpdoc_articles" +) + +// HelpDocArticleGet retrieves a single help doc article by ID. +func HelpDocArticleGet(httpClient *http.Client) toolsets.ToolWrapper { + return toolsets.ToolWrapper{ + Tool: &mcp.Tool{ + Name: string(MethodHelpDocArticleGet), + Annotations: &mcp.ToolAnnotations{ + Title: "Get Help Doc Article", + ReadOnlyHint: true, + }, + Description: "Get a help doc article by ID.", + InputSchema: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: falseSchema(), + Properties: map[string]*jsonschema.Schema{ + "id": { + Type: "integer", + Description: "The ID of the help doc article to retrieve.", + }, + "fields": sparseFieldsSchema(), + }, + Required: []string{"id", "fields"}, + }, + }, + Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client := ClientFromContext(ctx, httpClient) + arguments, err := helpers.NewToolArguments(request) + if err != nil { + return helpers.NewToolResultTextError("%v", err), nil + } + + article, err := client.HelpDocArticles.Get(ctx, arguments.GetInt("id", 0), getParams(arguments)) + if err != nil { + return nil, fmt.Errorf("failed to get help doc article: %w", err) + } + return helpers.NewToolResultJSON(article) + }, + } +} + +// HelpDocArticleSearch searches help doc articles using the dedicated search API. +func HelpDocArticleSearch(httpClient *http.Client) toolsets.ToolWrapper { + return toolsets.ToolWrapper{ + Tool: &mcp.Tool{ + Name: string(MethodHelpDocArticleSearch), + Annotations: &mcp.ToolAnnotations{ + Title: "Search Help Doc Articles", + ReadOnlyHint: true, + }, + Description: "Search help doc articles. Filter by search term, status, site, or category.", + InputSchema: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: falseSchema(), + Properties: map[string]*jsonschema.Schema{ + "search": { + Description: "Free-text search term matched against article title and content.", + AnyOf: []*jsonschema.Schema{ + {Type: "string"}, + {Type: "null"}, + }, + }, + "status": { + Description: "Filter by article status (e.g. \"published\", \"draft\").", + AnyOf: []*jsonschema.Schema{ + {Type: "string"}, + {Type: "null"}, + }, + }, + "siteID": { + Description: "Filter by help doc site ID.", + AnyOf: []*jsonschema.Schema{ + {Type: "integer"}, + {Type: "null"}, + }, + }, + "categoryID": { + Description: "Filter by help doc category ID.", + AnyOf: []*jsonschema.Schema{ + {Type: "integer"}, + {Type: "null"}, + }, + }, + "page": { + Description: "Page number (1-based).", + AnyOf: []*jsonschema.Schema{ + {Type: "integer"}, + {Type: "null"}, + }, + }, + "pageSize": { + Description: "Number of results per page.", + AnyOf: []*jsonschema.Schema{ + {Type: "integer"}, + {Type: "null"}, + }, + }, + }, + Required: []string{"search", "status", "siteID", "categoryID", "page", "pageSize"}, + }, + }, + Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client := ClientFromContext(ctx, httpClient) + arguments, err := helpers.NewToolArguments(request) + if err != nil { + return helpers.NewToolResultTextError("%v", err), nil + } + + filter := &deskmodels.SearchHelpdocsFilter{ + Search: arguments.GetString("search", ""), + Status: arguments.GetString("status", ""), + SiteID: int64(arguments.GetInt("siteID", 0)), + CategoryID: int64(arguments.GetInt("categoryID", 0)), + Page: arguments.GetInt("page", 1), + PageSize: arguments.GetInt("pageSize", 10), + } + + articles, err := client.HelpDocArticles.Search(ctx, filter) + if err != nil { + return nil, fmt.Errorf("failed to search help doc articles: %w", err) + } + return helpers.NewToolResultJSON(articles) + }, + } +} + +// HelpDocArticleCreate creates a new help doc article. +func HelpDocArticleCreate(httpClient *http.Client) toolsets.ToolWrapper { + return toolsets.ToolWrapper{ + Tool: &mcp.Tool{ + Name: string(MethodHelpDocArticleCreate), + Annotations: &mcp.ToolAnnotations{ + Title: "Create Help Doc Article", + }, + Description: "Create a new help doc article.", + InputSchema: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: falseSchema(), + Properties: map[string]*jsonschema.Schema{ + "siteID": { + Type: "integer", + Description: "The ID of the help doc site to create the article in.", + }, + "title": { + Type: "string", + Description: "The title of the article.", + }, + "contents": { + Description: "The body content of the article.", + AnyOf: []*jsonschema.Schema{ + {Type: "string"}, + {Type: "null"}, + }, + }, + "description": { + Description: "A short description / summary of the article.", + AnyOf: []*jsonschema.Schema{ + {Type: "string"}, + {Type: "null"}, + }, + }, + "status": { + Description: "Publication status of the article (e.g. \"published\", \"draft\").", + AnyOf: []*jsonschema.Schema{ + {Type: "string"}, + {Type: "null"}, + }, + }, + "isPrivate": { + Description: "Set to true to make the article private.", + AnyOf: []*jsonschema.Schema{ + {Type: "boolean"}, + {Type: "null"}, + }, + }, + }, + Required: []string{"siteID", "title", "contents", "description", "status", "isPrivate"}, + }, + }, + Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client := ClientFromContext(ctx, httpClient) + arguments, err := helpers.NewToolArguments(request) + if err != nil { + return helpers.NewToolResultTextError("%v", err), nil + } + + title := arguments.GetString("title", "") + article := deskmodels.HelpDocArticle{ + Helpdocsite: deskmodels.EntityRef{ + ID: arguments.GetInt("siteID", 0), + Type: "helpdocsites", + }, + Title: &title, + } + + if contents := arguments.GetString("contents", ""); contents != "" { + article.Contents = &contents + } + if description := arguments.GetString("description", ""); description != "" { + article.Description = &description + } + if status := arguments.GetString("status", ""); status != "" { + article.Status = &status + } + if val := arguments["isPrivate"]; val != nil { + isPrivate := arguments.GetBool("isPrivate", false) + article.IsPrivate = &isPrivate + } + + result, err := client.HelpDocArticles.Create(ctx, &deskmodels.HelpDocArticleResponse{ + HelpDocArticle: article, + }) + if err != nil { + return nil, fmt.Errorf("failed to create help doc article: %w", err) + } + return helpers.NewToolResultText("Help doc article created successfully with ID %d", result.HelpDocArticle.ID), nil + }, + } +} + +// HelpDocArticleUpdate updates an existing help doc article. +func HelpDocArticleUpdate(httpClient *http.Client) toolsets.ToolWrapper { + return toolsets.ToolWrapper{ + Tool: &mcp.Tool{ + Name: string(MethodHelpDocArticleUpdate), + Annotations: &mcp.ToolAnnotations{ + Title: "Update Help Doc Article", + }, + Description: "Update an existing help doc article.", + InputSchema: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: falseSchema(), + Properties: map[string]*jsonschema.Schema{ + "id": { + Type: "integer", + Description: "The ID of the help doc article to update.", + }, + "title": { + Description: "The new title of the article.", + AnyOf: []*jsonschema.Schema{ + {Type: "string"}, + {Type: "null"}, + }, + }, + "contents": { + Description: "The new body content of the article.", + AnyOf: []*jsonschema.Schema{ + {Type: "string"}, + {Type: "null"}, + }, + }, + "description": { + Description: "A short description / summary of the article.", + AnyOf: []*jsonschema.Schema{ + {Type: "string"}, + {Type: "null"}, + }, + }, + "status": { + Description: "Publication status (e.g. \"published\", \"draft\").", + AnyOf: []*jsonschema.Schema{ + {Type: "string"}, + {Type: "null"}, + }, + }, + "isPrivate": { + Description: "Set to true to make the article private.", + AnyOf: []*jsonschema.Schema{ + {Type: "boolean"}, + {Type: "null"}, + }, + }, + }, + Required: []string{"id", "title", "contents", "description", "status", "isPrivate"}, + }, + }, + Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client := ClientFromContext(ctx, httpClient) + arguments, err := helpers.NewToolArguments(request) + if err != nil { + return helpers.NewToolResultTextError("%v", err), nil + } + + article := deskmodels.HelpDocArticle{} + + if title := arguments.GetString("title", ""); title != "" { + article.Title = &title + } + if contents := arguments.GetString("contents", ""); contents != "" { + article.Contents = &contents + } + if description := arguments.GetString("description", ""); description != "" { + article.Description = &description + } + if status := arguments.GetString("status", ""); status != "" { + article.Status = &status + } + if val := arguments["isPrivate"]; val != nil { + isPrivate := arguments.GetBool("isPrivate", false) + article.IsPrivate = &isPrivate + } + + _, err = client.HelpDocArticles.Update(ctx, arguments.GetInt("id", 0), &deskmodels.HelpDocArticleResponse{ + HelpDocArticle: article, + }) + if err != nil { + return nil, fmt.Errorf("failed to update help doc article: %w", err) + } + return helpers.NewToolResultText("Help doc article updated successfully"), nil + }, + } +} diff --git a/internal/twdesk/helpdocs_test.go b/internal/twdesk/helpdocs_test.go new file mode 100644 index 0000000..a2b6150 --- /dev/null +++ b/internal/twdesk/helpdocs_test.go @@ -0,0 +1,104 @@ +//nolint:lll +package twdesk_test + +import ( + "net/http" + "testing" + + "github.com/teamwork/mcp/internal/testutil" + "github.com/teamwork/mcp/internal/twdesk" +) + +func TestHelpDocArticleGet(t *testing.T) { + mcpServer, cleanup := mcpServerMock(t, http.StatusOK, []byte(`{"helpDocArticle":{"id":42,"title":"Getting Started","status":"published"}}`)) + defer cleanup() + + testutil.ExecuteToolRequest(t, mcpServer, twdesk.MethodHelpDocArticleGet.String(), map[string]any{ + "id": float64(42), + "fields": nil, + }) +} + +func TestHelpDocArticleSearch(t *testing.T) { + mcpServer, cleanup := mcpServerMock(t, http.StatusOK, []byte(`{"helpdocarticles":[{"id":42,"title":"Getting Started","status":"published"}]}`)) + defer cleanup() + + testutil.ExecuteToolRequest(t, mcpServer, twdesk.MethodHelpDocArticleSearch.String(), map[string]any{ + "search": "Getting Started", + "status": "published", + "siteID": float64(1), + "categoryID": nil, + "page": float64(1), + "pageSize": float64(10), + }) +} + +func TestHelpDocArticleSearchMinimal(t *testing.T) { + mcpServer, cleanup := mcpServerMock(t, http.StatusOK, []byte(`{"helpdocarticles":[]}`)) + defer cleanup() + + testutil.ExecuteToolRequest(t, mcpServer, twdesk.MethodHelpDocArticleSearch.String(), map[string]any{ + "search": nil, + "status": nil, + "siteID": nil, + "categoryID": nil, + "page": nil, + "pageSize": nil, + }) +} + +func TestHelpDocArticleCreate(t *testing.T) { + mcpServer, cleanup := mcpServerMock(t, http.StatusCreated, []byte(`{"helpDocArticle":{"id":99,"title":"New Article","status":"draft"}}`)) + defer cleanup() + + testutil.ExecuteToolRequest(t, mcpServer, twdesk.MethodHelpDocArticleCreate.String(), map[string]any{ + "siteID": float64(1), + "title": "New Article", + "contents": "Article body here.", + "description": "A short summary.", + "status": "draft", + "isPrivate": false, + }) +} + +func TestHelpDocArticleCreateMinimal(t *testing.T) { + mcpServer, cleanup := mcpServerMock(t, http.StatusCreated, []byte(`{"helpDocArticle":{"id":100,"title":"Minimal Article"}}`)) + defer cleanup() + + testutil.ExecuteToolRequest(t, mcpServer, twdesk.MethodHelpDocArticleCreate.String(), map[string]any{ + "siteID": float64(2), + "title": "Minimal Article", + "contents": nil, + "description": nil, + "status": nil, + "isPrivate": nil, + }) +} + +func TestHelpDocArticleUpdate(t *testing.T) { + mcpServer, cleanup := mcpServerMock(t, http.StatusOK, []byte(`{"helpDocArticle":{"id":42,"title":"Updated Article","status":"published"}}`)) + defer cleanup() + + testutil.ExecuteToolRequest(t, mcpServer, twdesk.MethodHelpDocArticleUpdate.String(), map[string]any{ + "id": float64(42), + "title": "Updated Article", + "contents": "Updated body.", + "description": nil, + "status": "published", + "isPrivate": nil, + }) +} + +func TestHelpDocArticleUpdateMinimal(t *testing.T) { + mcpServer, cleanup := mcpServerMock(t, http.StatusOK, []byte(`{"helpDocArticle":{"id":42}}`)) + defer cleanup() + + testutil.ExecuteToolRequest(t, mcpServer, twdesk.MethodHelpDocArticleUpdate.String(), map[string]any{ + "id": float64(42), + "title": nil, + "contents": nil, + "description": nil, + "status": nil, + "isPrivate": nil, + }) +} diff --git a/internal/twdesk/tools.go b/internal/twdesk/tools.go index c1acf0e..7f63d40 100644 --- a/internal/twdesk/tools.go +++ b/internal/twdesk/tools.go @@ -10,6 +10,7 @@ const ( deskTicketsDescription = "Tickets, messages, files, and inboxes in Teamwork Desk." deskCustomersDescription = "Companies, customers, and user management in Teamwork Desk." deskAdminDescription = "Inbox configuration: priorities, statuses, types, and tags in Teamwork Desk." + deskHelpDocsDescription = "Help doc articles in Teamwork Desk." ) // Sub-toolset keys for twdesk. These are the valid values for the @@ -21,12 +22,15 @@ const ( ToolsetCustomers toolsets.Method = "twdesk-customers" // ToolsetAdmin covers priorities, statuses, types, and tags. ToolsetAdmin toolsets.Method = "twdesk-admin" + // ToolsetHelpDocs covers help doc articles. + ToolsetHelpDocs toolsets.Method = "twdesk-helpdocs" ) func init() { toolsets.RegisterMethod(ToolsetTickets) toolsets.RegisterMethod(ToolsetCustomers) toolsets.RegisterMethod(ToolsetAdmin) + toolsets.RegisterMethod(ToolsetHelpDocs) } // DefaultToolsetGroup creates a default ToolsetGroup for Teamwork Desk. @@ -88,5 +92,16 @@ func DefaultToolsetGroup(readOnly bool, httpClient *http.Client) *toolsets.Tools TypeList(httpClient), )) + // --- helpdocs sub-toolset --- + group.AddToolset(toolsets.NewToolset(ToolsetHelpDocs, deskHelpDocsDescription). + AddWriteTools( + HelpDocArticleCreate(httpClient), + HelpDocArticleUpdate(httpClient), + ). + AddReadTools( + HelpDocArticleGet(httpClient), + HelpDocArticleSearch(httpClient), + )) + return group }