diff --git a/AGENTS.md b/AGENTS.md index bad31102..74d92740 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `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. diff --git a/docs/tool-reference.md b/docs/tool-reference.md index b7e4e0e2..dbaadac4 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -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 diff --git a/go.mod b/go.mod index 7d075023..bc8eb955 100644 --- a/go.mod +++ b/go.mod @@ -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 ) @@ -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 @@ -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 diff --git a/go.sum b/go.sum index 39924378..ce4d0558 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.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= @@ -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= diff --git a/internal/testutil/mcp.go b/internal/testutil/mcp.go index 02b71b33..0b18e51f 100644 --- a/internal/testutil/mcp.go +++ b/internal/testutil/mcp.go @@ -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() @@ -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() @@ -437,7 +459,7 @@ func DeskMCPServerMockWithRequestURL( } }) - return mcpServer, lastRequestURL, cleanup + return mcpServer, lastRequest, cleanup } // ToolRequest represents a tool request for testing diff --git a/internal/testutil/schema_validation_helpers.go b/internal/testutil/schema_validation_helpers.go index dec6a7ba..6ec6106a 100644 --- a/internal/testutil/schema_validation_helpers.go +++ b/internal/testutil/schema_validation_helpers.go @@ -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), @@ -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, @@ -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": {}, }, diff --git a/internal/twdesk/error_test.go b/internal/twdesk/error_test.go index 6f0b899e..9b107ae3 100644 --- a/internal/twdesk/error_test.go +++ b/internal/twdesk/error_test.go @@ -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, diff --git a/internal/twdesk/tickets.go b/internal/twdesk/tickets.go index c86d11ab..fc1b92b3 100644 --- a/internal/twdesk/tickets.go +++ b/internal/twdesk/tickets.go @@ -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 @@ -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 + }, + } +} diff --git a/internal/twdesk/tickets_test.go b/internal/twdesk/tickets_test.go index bf48890d..ce279343 100644 --- a/internal/twdesk/tickets_test.go +++ b/internal/twdesk/tickets_test.go @@ -9,6 +9,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/teamwork/mcp/internal/testutil" + "github.com/teamwork/mcp/internal/toolsets" "github.com/teamwork/mcp/internal/twdesk" ) @@ -316,3 +317,121 @@ func TestTicketSearchRejectsInvalidCreatedDate(t *testing.T) { } })) } + +func TestTicketTaskLink(t *testing.T) { + mcpServer, cleanup := mcpServerMock(t, http.StatusOK, nil) + defer cleanup() + + testutil.ExecuteToolRequest(t, mcpServer, twdesk.MethodTicketTaskLink.String(), map[string]any{ + "ticketId": float64(123), + "taskId": float64(456), + }) +} + +func TestTicketTaskUnlink(t *testing.T) { + mcpServer, cleanup := mcpServerMock(t, http.StatusNoContent, nil) + defer cleanup() + + testutil.ExecuteToolRequest(t, mcpServer, twdesk.MethodTicketTaskUnlink.String(), map[string]any{ + "ticketId": float64(123), + "taskId": float64(456), + }) +} + +// TestTicketTaskLinkRequests pins the method and path each tool sends. The two +// tools address the same endpoint and differ only in POST versus DELETE, so a +// check on the URL alone would pass with the two swapped. +func TestTicketTaskLinkRequests(t *testing.T) { + tests := []struct { + name string + method toolsets.Method + wantMethod string + }{ + {name: "link", method: twdesk.MethodTicketTaskLink, wantMethod: http.MethodPost}, + {name: "unlink", method: twdesk.MethodTicketTaskUnlink, wantMethod: http.MethodDelete}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mcpServer, lastRequest, cleanup := testutil.DeskMCPServerMockWithRequest(t, http.StatusOK, nil) + defer cleanup() + + testutil.ExecuteToolRequest(t, mcpServer, tt.method.String(), map[string]any{ + "ticketId": float64(123), + "taskId": float64(456), + }) + + method, requestURL := lastRequest() + if method != tt.wantMethod { + t.Errorf("unexpected request method: got %q, want %q", method, tt.wantMethod) + } + if got, want := requestURL.Path, "/desk/api/v2/tickets/123/tasks/456.json"; got != want { + t.Errorf("unexpected request path: got %q, want %q", got, want) + } + }) + } +} + +// TestTicketTaskLinkRejectsNonPositiveIDs keeps bad caller input a tool result. +// The SDK rejects a non-positive ID with a bare Go error carrying no status, +// which HandleAPIError can only hand back as a protocol-level failure. +func TestTicketTaskLinkRejectsNonPositiveIDs(t *testing.T) { + tests := []struct { + name string + method toolsets.Method + args map[string]any + wantText string + }{ + { + name: "link/ticket", + method: twdesk.MethodTicketTaskLink, + args: map[string]any{"ticketId": float64(0), "taskId": float64(456)}, + wantText: "ticketId", + }, + { + name: "link/task", + method: twdesk.MethodTicketTaskLink, + args: map[string]any{"ticketId": float64(123), "taskId": float64(-1)}, + wantText: "taskId", + }, + { + name: "unlink/ticket", + method: twdesk.MethodTicketTaskUnlink, + args: map[string]any{"ticketId": float64(-1), "taskId": float64(456)}, + wantText: "ticketId", + }, + { + name: "unlink/task", + method: twdesk.MethodTicketTaskUnlink, + args: map[string]any{"ticketId": float64(123), "taskId": float64(0)}, + wantText: "taskId", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mcpServer, cleanup := mcpServerMock(t, http.StatusOK, nil) + defer cleanup() + + testutil.ExecuteToolRequest(t, mcpServer, tt.method.String(), tt.args, + testutil.ExecuteToolRequestWithCheckMessage(func(t *testing.T, result mcp.Result) { + t.Helper() + + toolResult, ok := result.(*mcp.CallToolResult) + if !ok { + t.Fatalf("unexpected result type: %T", result) + } + if !toolResult.IsError { + t.Fatal("a non-positive ID should be an error tool result") + } + textContent, ok := toolResult.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("unexpected content type: %T", toolResult.Content[0]) + } + if !strings.Contains(textContent.Text, tt.wantText) { + t.Errorf("error should name the offending parameter, got %q", textContent.Text) + } + })) + }) + } +} diff --git a/internal/twdesk/tools.go b/internal/twdesk/tools.go index b3a9e534..8173d887 100644 --- a/internal/twdesk/tools.go +++ b/internal/twdesk/tools.go @@ -44,6 +44,8 @@ func DefaultToolsetGroup(readOnly bool, httpClient *http.Client) *toolsets.Tools MessageCreate(httpClient), TicketCreate(httpClient), TicketUpdate(httpClient), + TicketTaskLink(httpClient), + TicketTaskUnlink(httpClient), ). AddReadTools( InboxGet(httpClient),