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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Common variables (subset; see command READMEs for complete lists):
- Read-only tools → `AddReadTools(...)`
- Write tools → `AddWriteTools(...)` (and behind `allowDelete` for deletes)
- Add tests in the matching `*_test.go` file using `mcpServerMock(...)` and `toolRequest` helpers found in `internal/twprojects/main_test.go`.
- A tool that advertises a parameter needs a test that asserts the parameter reaches the wire, not just that the call succeeds: the mocks reply with the same canned body either way, so a silently dropped filter or page argument looks identical to a working one. Use `testutil.ProjectsMCPServerMockWithRequestURL` / `testutil.DeskMCPServerMockWithRequestURL` (or the `...WithRequestBody` variants for writes) to assert the query string. `twdesk-search_tickets` shipped ignoring page, pageSize, orderBy, orderDirection and fields for exactly this reason.
- A tool that advertises a parameter needs a test that asserts the parameter reaches the wire, not just that the call succeeds: the mocks reply with the same canned body either way, so a silently dropped filter or page argument looks identical to a working one. Use `testutil.ProjectsMCPServerMockWithRequestURL` / `testutil.DeskMCPServerMockWithRequestURL` (or the `...WithRequestBody` variants for writes) to assert the query string, and `testutil.DeskMCPServerMockWithRequest` when the HTTP method is what distinguishes two tools (`twdesk-link_task_to_ticket` and `twdesk-unlink_task_from_ticket` share a path and differ only in POST versus DELETE). `twdesk-search_tickets` shipped ignoring page, pageSize, orderBy, orderDirection and fields for exactly this reason.
- Desk SDK gotcha: `desksdkgo`'s `client.Tickets.Search` builds its query solely from the qs-encoded `SearchTicketsFilter`, which carries no pagination, ordering or sparse-fieldset field — anything you set outside the struct is discarded. `internal/twdesk/tickets.go` therefore reaches `/search/tickets.json` through `deskclient.NewService[...]` plus `NewDefaultPathHandler("search/tickets")`, which takes a `url.Values` the handler controls. Check any `<Action><Model>Filter` struct for the params you need before routing a tool through its bespoke SDK method.
- A tool handler never returns a raw Go error for something the API answered. Every API call goes through `helpers.HandleAPIError(err, "failed to …")`, which turns a status the caller can act on into an `IsError` tool result; a raw error becomes a protocol-level error instead, so the model sees a transport failure with no status to read and none of the handler's wrapped message. Local faults are tool results too, via `helpers.NewToolResultTextError(...)` — bad caller input (malformed base64 in `twdesk-create_file`) as much as an encode failure. `TestAPIFailuresAreToolResults` in `internal/twdesk/error_test.go` drives representative Desk tools against 403/404/500 mocks and fails on a Go error before its assertion runs.
- `HandleAPIError` reads the status out of the Desk SDK's *message text*, because `desksdkgo` declares no error type at all: every non-2xx response leaves it as a bare `fmt.Errorf("unexpected status code: %d", …)` (`client/resource.go`, `tickets.go`, `helpdocarticles.go`) or `"failed to upload file, status code: %d, …"` (`Files.Upload`). Without that parse the typed-`*twapi.HTTPError` branch misses and every Desk failure falls through to the raw-error return — so calling `HandleAPIError` in a Desk handler is only load-bearing while `deskStatusCodePattern` in `internal/helpers/error.go` still matches. If a Desk SDK bump introduces a real error type, switch to `errors.AsType` on it and drop the pattern. `TestHandleAPIError` in `internal/helpers/error_test.go` pins both SDKs' shapes, plus that an error carrying no status stays a Go error.
Expand Down
2 changes: 1 addition & 1 deletion docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ Tickets, messages, files, and inboxes in Teamwork Desk.
| Ticket | ✓ | ✓ | — | ✓ |
| File | ✓ | — | — | — |

**Other actions:** `reply_ticket`, `search_tickets`
**Other actions:** `link_task_to_ticket`, `reply_ticket`, `search_tickets`, `unlink_task_from_ticket`

## Spaces

Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ require (
github.com/localit-io/tiktoken-go v0.2.1
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/sonh/qs v0.7.0
github.com/teamwork/desksdkgo v1.0.1
github.com/teamwork/desksdkgo v1.1.0
github.com/teamwork/spacessdkgo v0.0.0-20260518181558-a6af69d00abb
github.com/teamwork/twapi-go-sdk v1.21.2
)
Expand All @@ -39,6 +39,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 @@ -47,6 +48,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.6 // 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.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
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.1 h1:Mi5D1pnGfL5JwD7s7g7OyHml7Y9jsak5MNJaotJt+pE=
github.com/teamwork/desksdkgo v1.0.1/go.mod h1:Mgvw83q8iqHr7Sm9xV1iI/T89o3ObaPU3ChMJheRzwA=
github.com/teamwork/desksdkgo v1.1.0 h1:PpIquf92fAtT0qtSMGK3LGRxAvIPDjl7/jkSh0/F8Yg=
github.com/teamwork/desksdkgo v1.1.0/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.21.2 h1:SsLaxc15Q+m1v+LO0UHWoxCJ+I4wf6uTftfVMzWGOm0=
Expand Down
28 changes: 25 additions & 3 deletions internal/testutil/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,15 +394,37 @@ func DeskMCPServerMockWithRequestURL(
) (*mcp.Server, func() url.URL, func()) {
t.Helper()

mcpServer, lastRequest, cleanup := DeskMCPServerMockWithRequest(t, status, response)
return mcpServer, func() url.URL {
_, requestURL := lastRequest()
return requestURL
}, cleanup
}

// DeskMCPServerMockWithRequest is like DeskMCPServerMockWithRequestURL but also
// reports the HTTP method of the most recent request.
//
// The method is what separates some tools from each other: the ticket task link
// and unlink tools address the same path and differ only in POST versus DELETE,
// so a test that checks the URL alone passes when the two are swapped.
func DeskMCPServerMockWithRequest(
t *testing.T,
status int,
response []byte,
) (*mcp.Server, func() (string, url.URL), func()) {
t.Helper()

mcpServer := mcp.NewServer(&mcp.Implementation{
Name: "test-server",
Version: "1.0.0",
}, &mcp.ServerOptions{})

var mu sync.Mutex
var lastMethod string
var lastURL url.URL
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
lastMethod = r.Method
lastURL = *r.URL
mu.Unlock()

Expand All @@ -415,10 +437,10 @@ func DeskMCPServerMockWithRequestURL(
cleanup := func() {
testServer.Close()
}
lastRequestURL := func() url.URL {
lastRequest := func() (string, url.URL) {
mu.Lock()
defer mu.Unlock()
return lastURL
return lastMethod, lastURL
}

httpClient := testServer.Client()
Expand All @@ -437,7 +459,7 @@ func DeskMCPServerMockWithRequestURL(
}
})

return mcpServer, lastRequestURL, cleanup
return mcpServer, lastRequest, cleanup
}

// ToolRequest represents a tool request for testing
Expand Down
24 changes: 20 additions & 4 deletions internal/testutil/schema_validation_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ func NewSchemaValidationTestSuite() *SchemaValidationTestSuite {
"CustomerList": twdesk.CustomerList(httpClient),

// Ticket tools
"TicketCreate": twdesk.TicketCreate(httpClient),
"TicketUpdate": twdesk.TicketUpdate(httpClient),
"TicketGet": twdesk.TicketGet(httpClient),
"TicketSearch": twdesk.TicketSearch(httpClient),
"TicketCreate": twdesk.TicketCreate(httpClient),
"TicketUpdate": twdesk.TicketUpdate(httpClient),
"TicketGet": twdesk.TicketGet(httpClient),
"TicketSearch": twdesk.TicketSearch(httpClient),
"TicketTaskLink": twdesk.TicketTaskLink(httpClient),
"TicketTaskUnlink": twdesk.TicketTaskUnlink(httpClient),

// Priority tools
"PriorityCreate": twdesk.PriorityCreate(httpClient),
Expand Down Expand Up @@ -313,6 +315,12 @@ func GetValidTestData() map[string]map[string]map[string]any {
"TicketGet": {
"valid": {"id": 123, "fields": nil},
},
"TicketTaskLink": {
"valid": {"ticketId": 123, "taskId": 456},
},
"TicketTaskUnlink": {
"valid": {"ticketId": 123, "taskId": 456},
},
"TicketSearch": {
"empty": {
"search": nil, "inboxIDs": nil, "customerIDs": nil,
Expand Down Expand Up @@ -486,6 +494,14 @@ func GetInvalidTestData() map[string]map[string]map[string]any {
"TicketGet": {
"missing_required_id": {},
},
"TicketTaskLink": {
"missing_required_task_id": {"ticketId": 123},
"wrong_type_ticket_id": {"ticketId": "123", "taskId": 456},
},
"TicketTaskUnlink": {
"missing_required_ticket_id": {"taskId": 456},
"wrong_type_task_id": {"ticketId": 123, "taskId": "456"},
},
"PriorityCreate": {
"missing_required_name": {},
},
Expand Down
10 changes: 10 additions & 0 deletions internal/twdesk/error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ func TestAPIFailuresAreToolResults(t *testing.T) {
"page": nil, "pageSize": nil, "orderBy": nil, "orderDirection": nil, "fields": nil,
},
},
{
name: "link_task_to_ticket",
method: twdesk.MethodTicketTaskLink,
args: map[string]any{"ticketId": float64(123), "taskId": float64(456)},
},
{
name: "unlink_task_from_ticket",
method: twdesk.MethodTicketTaskUnlink,
args: map[string]any{"ticketId": float64(123), "taskId": float64(456)},
},
{
name: "get_inbox",
method: twdesk.MethodInboxGet,
Expand Down
121 changes: 117 additions & 4 deletions internal/twdesk/tickets.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ import (
// The naming convention for methods follows a pattern described here:
// https://github.com/github/github-mcp-server/issues/333
const (
MethodTicketCreate toolsets.Method = "twdesk-create_ticket"
MethodTicketUpdate toolsets.Method = "twdesk-update_ticket"
MethodTicketGet toolsets.Method = "twdesk-get_ticket"
MethodTicketSearch toolsets.Method = "twdesk-search_tickets"
MethodTicketCreate toolsets.Method = "twdesk-create_ticket"
MethodTicketUpdate toolsets.Method = "twdesk-update_ticket"
MethodTicketGet toolsets.Method = "twdesk-get_ticket"
MethodTicketSearch toolsets.Method = "twdesk-search_tickets"
MethodTicketTaskLink toolsets.Method = "twdesk-link_task_to_ticket"
MethodTicketTaskUnlink toolsets.Method = "twdesk-unlink_task_from_ticket"
)

// TicketGet finds a ticket in Teamwork Desk. This will find it by ID
Expand Down Expand Up @@ -679,3 +681,114 @@ func TicketUpdate(httpClient *http.Client) toolsets.ToolWrapper {
},
}
}

// ticketTaskSchema is the input schema shared by the link and unlink tools:
// both address a single ticket/task pair and take nothing else.
func ticketTaskSchema() *jsonschema.Schema {
return &jsonschema.Schema{
Type: "object",
AdditionalProperties: falseSchema(),
Properties: map[string]*jsonschema.Schema{
"ticketId": {
Type: "integer",
Description: "The ID of the Desk ticket. Use twdesk-search_tickets to discover.",
},
"taskId": {
Type: "integer",
Description: "The ID of the Teamwork Projects task. " +
"Use twprojects-list_tasks or twprojects-search to discover.",
},
},
Required: []string{"ticketId", "taskId"},
}
}

// ticketTaskIDs reads and checks the ticket/task pair both link tools take.
//
// The SDK rejects a non-positive ID itself, but as a bare Go error with no
// status behind it, which HandleAPIError can only hand back as a Go error and
// therefore a protocol-level failure. Checking here keeps bad caller input a
// tool result the model can read, the same as every other local fault.
func ticketTaskIDs(arguments helpers.ToolArguments) (ticketID, taskID int, errResult *mcp.CallToolResult) {
ticketID = arguments.GetInt("ticketId", 0)
taskID = arguments.GetInt("taskId", 0)
if ticketID <= 0 {
return 0, 0, helpers.NewToolResultTextError("ticketId must be greater than 0")
}
if taskID <= 0 {
return 0, 0, helpers.NewToolResultTextError("taskId must be greater than 0")
}
return ticketID, taskID, nil
}

// TicketTaskLink links a Teamwork Projects task to a Teamwork Desk ticket.
func TicketTaskLink(httpClient *http.Client) toolsets.ToolWrapper {
return toolsets.ToolWrapper{
Tool: &mcp.Tool{
Name: string(MethodTicketTaskLink),
Annotations: &mcp.ToolAnnotations{
Title: "Link Task to Ticket",
// Adding a link neither removes anything nor reaches outside the
// customer's Teamwork account.
DestructiveHint: new(false),
OpenWorldHint: new(false),
},
Description: "Link a Teamwork Projects task to a Desk ticket, so the ticket shows the work tracked by that task.",
InputSchema: ticketTaskSchema(),
},
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
}

ticketID, taskID, errResult := ticketTaskIDs(arguments)
if errResult != nil {
return errResult, nil
}

if err := client.Tickets.LinkTask(ctx, ticketID, taskID); err != nil {
return helpers.HandleAPIError(err, "failed to link task to ticket")
}
return helpers.NewToolResultText("Task %d linked to ticket %d successfully", taskID, ticketID), nil
},
}
}

// TicketTaskUnlink removes the link between a Teamwork Projects task and a
// Teamwork Desk ticket.
func TicketTaskUnlink(httpClient *http.Client) toolsets.ToolWrapper {
return toolsets.ToolWrapper{
Tool: &mcp.Tool{
Name: string(MethodTicketTaskUnlink),
Annotations: &mcp.ToolAnnotations{
Title: "Unlink Task from Ticket",
// Removing the link is a destructive update rather than an additive
// one, even though neither the ticket nor the task is touched and the
// link can be recreated with twdesk-link_task_to_ticket.
DestructiveHint: new(true),
OpenWorldHint: new(false),
},
Description: "Unlink a Teamwork Projects task from a Desk ticket. The task and the ticket themselves are kept.",
InputSchema: ticketTaskSchema(),
},
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
}

ticketID, taskID, errResult := ticketTaskIDs(arguments)
if errResult != nil {
return errResult, nil
}

if err := client.Tickets.UnlinkTask(ctx, ticketID, taskID); err != nil {
return helpers.HandleAPIError(err, "failed to unlink task from ticket")
}
return helpers.NewToolResultText("Task %d unlinked from ticket %d successfully", taskID, ticketID), nil
},
}
}
Loading