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
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
335 changes: 335 additions & 0 deletions internal/twdesk/helpdocs.go
Original file line number Diff line number Diff line change
@@ -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
},
}
}
Loading