From f7d8482122e181dd716b258a4bb4e2171837669d Mon Sep 17 00:00:00 2001 From: Donal Linehan Date: Tue, 2 Jun 2026 13:51:09 +0100 Subject: [PATCH 1/4] Add twchat toolset for Teamwork Chat Wrap six Teamwork Chat API endpoints as MCP tools: get_current_user, list_conversations, get_conversation, list_messages, list_people (reads) and send_message (write, gated by read-only mode). The Chat API is served at /chat/v7/... on the same installation host as the Projects API and uses the same bearer token, so the tools ride the existing twapi.Engine via custom HTTPRequester types and stream the raw JSON response through twapi.ExecuteRaw. No new SDK or config required. Register the group in the stdio and http entry points and add a ChatMCPServerMock test helper reusing ProjectsEngineMock. --- cmd/mcp-http/main.go | 7 + cmd/mcp-stdio/main.go | 8 +- internal/testutil/mcp.go | 19 +++ internal/twchat/chat.go | 318 +++++++++++++++++++++++++++++++++++ internal/twchat/chat_test.go | 64 +++++++ internal/twchat/main_test.go | 10 ++ internal/twchat/requests.go | 179 ++++++++++++++++++++ internal/twchat/tools.go | 59 +++++++ 8 files changed, 663 insertions(+), 1 deletion(-) create mode 100644 internal/twchat/chat.go create mode 100644 internal/twchat/chat_test.go create mode 100644 internal/twchat/main_test.go create mode 100644 internal/twchat/requests.go create mode 100644 internal/twchat/tools.go diff --git a/cmd/mcp-http/main.go b/cmd/mcp-http/main.go index f1f161cc..a7f247b3 100644 --- a/cmd/mcp-http/main.go +++ b/cmd/mcp-http/main.go @@ -25,6 +25,7 @@ import ( "github.com/teamwork/mcp/internal/config" "github.com/teamwork/mcp/internal/request" "github.com/teamwork/mcp/internal/toolsets" + "github.com/teamwork/mcp/internal/twchat" "github.com/teamwork/mcp/internal/twdesk" "github.com/teamwork/mcp/internal/twprojects" "github.com/teamwork/mcp/internal/twspaces" @@ -125,10 +126,16 @@ func newMCPServer(resources config.Resources) (*mcp.Server, error) { return nil, fmt.Errorf("failed to enable spaces toolsets: %w", err) } + chatGroup := twchat.DefaultToolsetGroup(false, resources.TeamworkEngine()) + if err := chatGroup.EnableToolsets(methods.Toolsets()...); err != nil { + return nil, fmt.Errorf("failed to enable chat toolsets: %w", err) + } + return config.NewMCPServer(resources, projectsGroup, deskGroup, spacesGroup, + chatGroup, ), nil } diff --git a/cmd/mcp-stdio/main.go b/cmd/mcp-stdio/main.go index bd4daf77..8ae572de 100644 --- a/cmd/mcp-stdio/main.go +++ b/cmd/mcp-stdio/main.go @@ -15,6 +15,7 @@ import ( "github.com/teamwork/mcp/internal/cli" "github.com/teamwork/mcp/internal/config" "github.com/teamwork/mcp/internal/toolsets" + "github.com/teamwork/mcp/internal/twchat" "github.com/teamwork/mcp/internal/twdesk" "github.com/teamwork/mcp/internal/twprojects" "github.com/teamwork/mcp/internal/twspaces" @@ -128,7 +129,12 @@ func newMCPServer(resources config.Resources) (*mcp.Server, error) { return nil, fmt.Errorf("failed to enable spaces toolsets: %w", err) } - return config.NewMCPServer(resources, projectsGroup, deskGroup, spacesGroup), nil + chatGroup := twchat.DefaultToolsetGroup(readOnly, resources.TeamworkEngine()) + if err := chatGroup.EnableToolsets(methods.Toolsets()...); err != nil { + return nil, fmt.Errorf("failed to enable chat toolsets: %w", err) + } + + return config.NewMCPServer(resources, projectsGroup, deskGroup, spacesGroup, chatGroup), nil } func mcpError(logger *slog.Logger, err error, code jsonRPCErrorCode) { diff --git a/internal/testutil/mcp.go b/internal/testutil/mcp.go index 93a05a67..6fcbb6bb 100644 --- a/internal/testutil/mcp.go +++ b/internal/testutil/mcp.go @@ -14,6 +14,7 @@ import ( deskclient "github.com/teamwork/desksdkgo/client" "github.com/teamwork/mcp/internal/config" "github.com/teamwork/mcp/internal/toolsets" + "github.com/teamwork/mcp/internal/twchat" "github.com/teamwork/mcp/internal/twdesk" "github.com/teamwork/mcp/internal/twprojects" "github.com/teamwork/mcp/internal/twspaces" @@ -80,6 +81,24 @@ func ProjectsMCPServerMock(t *testing.T, status int, response []byte) *mcp.Serve return mcpServer } +// ChatMCPServerMock creates a mock MCP server for twchat testing. The twchat +// tools ride the shared twapi.Engine, so it reuses ProjectsEngineMock to return +// the canned HTTP response. +func ChatMCPServerMock(t *testing.T, status int, response []byte) *mcp.Server { + mcpServer := mcp.NewServer(&mcp.Implementation{ + Name: "test-server", + Version: "1.0.0", + }, &mcp.ServerOptions{}) + + toolsetGroup := twchat.DefaultToolsetGroup(false, ProjectsEngineMock(status, response)) + if err := toolsetGroup.EnableToolsets(toolsets.MethodAll); err != nil { + t.Fatalf("failed to enable toolsets: %v", err) + } + toolsetGroup.RegisterAll(mcpServer) + + return mcpServer +} + // ProjectsMockRoute pairs a substring match against the request URL path with // the status and body to return when it matches. type ProjectsMockRoute struct { diff --git a/internal/twchat/chat.go b/internal/twchat/chat.go new file mode 100644 index 00000000..cac64e0f --- /dev/null +++ b/internal/twchat/chat.go @@ -0,0 +1,318 @@ +package twchat + +import ( + "context" + "fmt" + "io" + "net/http" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/teamwork/mcp/internal/helpers" + "github.com/teamwork/mcp/internal/toolsets" + twapi "github.com/teamwork/twapi-go-sdk" +) + +// execute runs the request through the shared engine and streams the raw JSON +// response body back to the caller. label is used in error messages. +func execute( + ctx context.Context, + engine *twapi.Engine, + req twapi.HTTPRequester, + label string, +) (*mcp.CallToolResult, error) { + resp, err := twapi.ExecuteRaw(ctx, engine, req) + if err != nil { + return helpers.HandleAPIError(err, label) + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return helpers.HandleAPIError(twapi.NewHTTPError(resp, label), label) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(body)}, + }, + }, nil +} + +// CurrentUserGet returns the current authenticated Teamwork Chat user. +func CurrentUserGet(engine *twapi.Engine) toolsets.ToolWrapper { + return toolsets.ToolWrapper{ + Tool: &mcp.Tool{ + Name: string(MethodCurrentUserGet), + Annotations: &mcp.ToolAnnotations{ + Title: "Get Current Chat User", + ReadOnlyHint: true, + }, + Description: "Get the current authenticated Teamwork Chat user, including identity, " + + "counts (unread conversations/messages, mentions), and settings.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{}, + Required: []string{}, + }, + }, + Handler: func(ctx context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return execute(ctx, engine, currentUserGetRequest{}, "failed to get current chat user") + }, + } +} + +// ConversationList lists Teamwork Chat conversations for the current user. +func ConversationList(engine *twapi.Engine) toolsets.ToolWrapper { + return toolsets.ToolWrapper{ + Tool: &mcp.Tool{ + Name: string(MethodConversationList), + Annotations: &mcp.ToolAnnotations{ + Title: "List Chat Conversations", + ReadOnlyHint: true, + }, + Description: "List Teamwork Chat conversations the current user is a member of.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "search_term": { + Description: "Filter conversations by title (substring match).", + AnyOf: []*jsonschema.Schema{{Type: "string"}, {Type: "null"}}, + }, + "status": { + Description: "Filter by conversation status.", + AnyOf: []*jsonschema.Schema{{Type: "string", Enum: []any{"all", "active"}}, {Type: "null"}}, + }, + "sort": { + Description: "Sort order for the returned conversations.", + AnyOf: []*jsonschema.Schema{ + {Type: "string", Enum: []any{"name", "lastActivityAt", "createdAt", "updatedAt", "relevance"}}, + {Type: "null"}, + }, + }, + "include_message_data": { + Description: "Include the latest message in each conversation.", + AnyOf: []*jsonschema.Schema{{Type: "boolean"}, {Type: "null"}}, + }, + "page_offset": { + Description: "Zero-based pagination offset.", + AnyOf: []*jsonschema.Schema{{Type: "integer"}, {Type: "null"}}, + }, + "page_limit": { + Description: "Number of conversations to return (max 10).", + AnyOf: []*jsonschema.Schema{{Type: "integer"}, {Type: "null"}}, + }, + }, + Required: []string{}, + }, + }, + Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + arguments, err := helpers.NewToolArguments(request) + if err != nil { + return helpers.NewToolResultTextError("%v", err), nil + } + req := conversationListRequest{ + PageOffset: arguments.GetInt("page_offset", 0), + PageLimit: arguments.GetInt("page_limit", 0), + SearchTerm: arguments.GetString("search_term", ""), + Status: arguments.GetString("status", ""), + Sort: arguments.GetString("sort", ""), + IncludeMessageData: arguments.GetBool("include_message_data", false), + } + return execute(ctx, engine, req, "failed to list chat conversations") + }, + } +} + +// ConversationGet retrieves a single Teamwork Chat conversation by ID. +func ConversationGet(engine *twapi.Engine) toolsets.ToolWrapper { + return toolsets.ToolWrapper{ + Tool: &mcp.Tool{ + Name: string(MethodConversationGet), + Annotations: &mcp.ToolAnnotations{ + Title: "Get Chat Conversation", + ReadOnlyHint: true, + }, + Description: "Get a single Teamwork Chat conversation by ID.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "conversation_id": { + Type: "integer", + Description: "The ID of the conversation to retrieve.", + }, + }, + Required: []string{"conversation_id"}, + }, + }, + Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + arguments, err := helpers.NewToolArguments(request) + if err != nil { + return helpers.NewToolResultTextError("%v", err), nil + } + req := conversationGetRequest{ID: int64(arguments.GetInt("conversation_id", 0))} + return execute(ctx, engine, req, "failed to get chat conversation") + }, + } +} + +// MessageList lists messages within a Teamwork Chat conversation. +func MessageList(engine *twapi.Engine) toolsets.ToolWrapper { + return toolsets.ToolWrapper{ + Tool: &mcp.Tool{ + Name: string(MethodMessageList), + Annotations: &mcp.ToolAnnotations{ + Title: "List Chat Messages", + ReadOnlyHint: true, + }, + Description: "List messages within a Teamwork Chat conversation. Requires conversation_id.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "conversation_id": { + Type: "integer", + Description: "The ID of the conversation to read messages from.", + }, + "search_term": { + Description: "Filter messages by text content.", + AnyOf: []*jsonschema.Schema{{Type: "string"}, {Type: "null"}}, + }, + "page": { + Description: "One-based page number.", + AnyOf: []*jsonschema.Schema{{Type: "integer"}, {Type: "null"}}, + }, + "page_size": { + Description: "Number of messages per page (1-200).", + AnyOf: []*jsonschema.Schema{{Type: "integer"}, {Type: "null"}}, + }, + "before_message_id": { + Description: "Return messages older than this message ID (cursor).", + AnyOf: []*jsonschema.Schema{{Type: "integer"}, {Type: "null"}}, + }, + "after_message_id": { + Description: "Return messages newer than this message ID (cursor).", + AnyOf: []*jsonschema.Schema{{Type: "integer"}, {Type: "null"}}, + }, + "created_before": { + Description: "Return messages created before this time.", + Examples: []any{"2023-12-31T23:59:59Z"}, + AnyOf: []*jsonschema.Schema{{Type: "string", Format: "date-time"}, {Type: "null"}}, + }, + "created_after": { + Description: "Return messages created after this time.", + Examples: []any{"2023-01-01T00:00:00Z"}, + AnyOf: []*jsonschema.Schema{{Type: "string", Format: "date-time"}, {Type: "null"}}, + }, + }, + Required: []string{"conversation_id"}, + }, + }, + Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + arguments, err := helpers.NewToolArguments(request) + if err != nil { + return helpers.NewToolResultTextError("%v", err), nil + } + req := messageListRequest{ + ConversationID: int64(arguments.GetInt("conversation_id", 0)), + Page: arguments.GetInt("page", 0), + PageSize: arguments.GetInt("page_size", 0), + SearchTerm: arguments.GetString("search_term", ""), + BeforeMessageID: int64(arguments.GetInt("before_message_id", 0)), + AfterMessageID: int64(arguments.GetInt("after_message_id", 0)), + CreatedBefore: arguments.GetString("created_before", ""), + CreatedAfter: arguments.GetString("created_after", ""), + } + return execute(ctx, engine, req, "failed to list chat messages") + }, + } +} + +// PeopleList lists people in the Teamwork Chat installation. +func PeopleList(engine *twapi.Engine) toolsets.ToolWrapper { + return toolsets.ToolWrapper{ + Tool: &mcp.Tool{ + Name: string(MethodPeopleList), + Annotations: &mcp.ToolAnnotations{ + Title: "List Chat People", + ReadOnlyHint: true, + }, + Description: "List people in the Teamwork Chat installation. Useful for resolving names to user IDs.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "search_term": { + Description: "Filter people by name or email.", + AnyOf: []*jsonschema.Schema{{Type: "string"}, {Type: "null"}}, + }, + "page_offset": { + Description: "Zero-based pagination offset.", + AnyOf: []*jsonschema.Schema{{Type: "integer"}, {Type: "null"}}, + }, + "page_limit": { + Description: "Number of people to return.", + AnyOf: []*jsonschema.Schema{{Type: "integer"}, {Type: "null"}}, + }, + }, + Required: []string{}, + }, + }, + Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + arguments, err := helpers.NewToolArguments(request) + if err != nil { + return helpers.NewToolResultTextError("%v", err), nil + } + req := peopleListRequest{ + PageOffset: arguments.GetInt("page_offset", 0), + PageLimit: arguments.GetInt("page_limit", 0), + SearchTerm: arguments.GetString("search_term", ""), + } + return execute(ctx, engine, req, "failed to list chat people") + }, + } +} + +// MessageSend posts a message to a Teamwork Chat conversation. +func MessageSend(engine *twapi.Engine) toolsets.ToolWrapper { + return toolsets.ToolWrapper{ + Tool: &mcp.Tool{ + Name: string(MethodMessageSend), + Annotations: &mcp.ToolAnnotations{ + Title: "Send Chat Message", + }, + Description: "Send a message to a Teamwork Chat conversation. Requires conversation_id and body.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "conversation_id": { + Type: "integer", + Description: "The ID of the conversation to post the message to.", + }, + "body": { + Type: "string", + Description: "The message text. Supports Markdown.", + }, + }, + Required: []string{"conversation_id", "body"}, + }, + }, + Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + arguments, err := helpers.NewToolArguments(request) + if err != nil { + return helpers.NewToolResultTextError("%v", err), nil + } + body := arguments.GetString("body", "") + if body == "" { + return helpers.NewToolResultTextError("body is required"), nil + } + req := messageSendRequest{ + ConversationID: int64(arguments.GetInt("conversation_id", 0)), + Body: body, + } + return execute(ctx, engine, req, "failed to send chat message") + }, + } +} diff --git a/internal/twchat/chat_test.go b/internal/twchat/chat_test.go new file mode 100644 index 00000000..09b77915 --- /dev/null +++ b/internal/twchat/chat_test.go @@ -0,0 +1,64 @@ +package twchat_test + +import ( + "net/http" + "testing" + + "github.com/teamwork/mcp/internal/testutil" + "github.com/teamwork/mcp/internal/twchat" +) + +func TestCurrentUserGet(t *testing.T) { + mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{"account":{"id":1}}`)) + testutil.ExecuteToolRequest(t, mcpServer, twchat.MethodCurrentUserGet.String(), map[string]any{}) +} + +func TestConversationList(t *testing.T) { + mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{"conversations":[]}`)) + testutil.ExecuteToolRequest(t, mcpServer, twchat.MethodConversationList.String(), map[string]any{ + "search_term": "design", + "status": "active", + "sort": "lastActivityAt", + "include_message_data": true, + "page_offset": float64(0), + "page_limit": float64(5), + }) +} + +func TestConversationGet(t *testing.T) { + mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{"conversation":{"id":123}}`)) + testutil.ExecuteToolRequest(t, mcpServer, twchat.MethodConversationGet.String(), map[string]any{ + "conversation_id": float64(123), + }) +} + +func TestMessageList(t *testing.T) { + mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{"messages":[]}`)) + testutil.ExecuteToolRequest(t, mcpServer, twchat.MethodMessageList.String(), map[string]any{ + "conversation_id": float64(123), + "search_term": "release", + "page": float64(1), + "page_size": float64(50), + "before_message_id": float64(999), + "after_message_id": float64(100), + "created_before": "2023-12-31T23:59:59Z", + "created_after": "2023-01-01T00:00:00Z", + }) +} + +func TestPeopleList(t *testing.T) { + mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{"people":[]}`)) + testutil.ExecuteToolRequest(t, mcpServer, twchat.MethodPeopleList.String(), map[string]any{ + "search_term": "jane", + "page_offset": float64(0), + "page_limit": float64(10), + }) +} + +func TestMessageSend(t *testing.T) { + mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{"id":"789","message":{"id":789}}`)) + testutil.ExecuteToolRequest(t, mcpServer, twchat.MethodMessageSend.String(), map[string]any{ + "conversation_id": float64(123), + "body": "Hello from MCP!", + }) +} diff --git a/internal/twchat/main_test.go b/internal/twchat/main_test.go new file mode 100644 index 00000000..e6be47f1 --- /dev/null +++ b/internal/twchat/main_test.go @@ -0,0 +1,10 @@ +package twchat_test + +import ( + "github.com/teamwork/mcp/internal/testutil" +) + +// Re-export the shared utilities for convenience +var ( + mcpServerMock = testutil.ChatMCPServerMock +) diff --git a/internal/twchat/requests.go b/internal/twchat/requests.go new file mode 100644 index 00000000..ea4878b4 --- /dev/null +++ b/internal/twchat/requests.go @@ -0,0 +1,179 @@ +package twchat + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" +) + +// All request types implement twapi.HTTPRequester. The server argument is the +// Teamwork installation base URL (e.g. https://acme.teamwork.com); the engine's +// session fills in the host and Authorization header when it is left empty. We +// only build the path-relative URL under /chat/v7/. + +const chatBasePath = "/chat/v7" + +// currentUserGetRequest fetches the current authenticated chat user. +type currentUserGetRequest struct{} + +// HTTPRequest builds the GET /chat/v7/me request. +func (currentUserGetRequest) HTTPRequest(ctx context.Context, server string) (*http.Request, error) { + return http.NewRequestWithContext(ctx, http.MethodGet, server+chatBasePath+"/me", nil) +} + +// conversationListRequest lists conversations for the current user. +type conversationListRequest struct { + PageOffset int + PageLimit int + SearchTerm string + Status string + Sort string + IncludeMessageData bool +} + +// HTTPRequest builds the GET /chat/v7/conversations request. +func (c conversationListRequest) HTTPRequest(ctx context.Context, server string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, server+chatBasePath+"/conversations", nil) + if err != nil { + return nil, err + } + q := url.Values{} + if c.PageOffset > 0 { + q.Set("page[offset]", strconv.Itoa(c.PageOffset)) + } + if c.PageLimit > 0 { + q.Set("page[limit]", strconv.Itoa(c.PageLimit)) + } + if c.SearchTerm != "" { + q.Set("filter[searchTerm]", c.SearchTerm) + } + if c.Status != "" { + q.Set("filter[status]", c.Status) + } + if c.Sort != "" { + q.Set("sort", c.Sort) + } + if c.IncludeMessageData { + q.Set("includeMessageData", "true") + } + req.URL.RawQuery = q.Encode() + return req, nil +} + +// conversationGetRequest fetches a single conversation by ID. +type conversationGetRequest struct { + ID int64 +} + +// HTTPRequest builds the GET /chat/v7/conversations/{id} request. +func (c conversationGetRequest) HTTPRequest(ctx context.Context, server string) (*http.Request, error) { + uri := server + chatBasePath + "/conversations/" + strconv.FormatInt(c.ID, 10) + return http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) +} + +// messageListRequest lists messages within a conversation. +type messageListRequest struct { + ConversationID int64 + Page int + PageSize int + SearchTerm string + BeforeMessageID int64 + AfterMessageID int64 + CreatedBefore string + CreatedAfter string +} + +// HTTPRequest builds the GET /chat/v7/conversations/{id}/messages request. +func (m messageListRequest) HTTPRequest(ctx context.Context, server string) (*http.Request, error) { + uri := server + chatBasePath + "/conversations/" + strconv.FormatInt(m.ConversationID, 10) + "/messages" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + q := url.Values{} + if m.Page > 0 { + q.Set("page", strconv.Itoa(m.Page)) + } + if m.PageSize > 0 { + q.Set("pageSize", strconv.Itoa(m.PageSize)) + } + if m.SearchTerm != "" { + q.Set("filter[searchTerm]", m.SearchTerm) + } + if m.BeforeMessageID > 0 { + q.Set("beforeMessageId", strconv.FormatInt(m.BeforeMessageID, 10)) + } + if m.AfterMessageID > 0 { + q.Set("afterMessageId", strconv.FormatInt(m.AfterMessageID, 10)) + } + if m.CreatedBefore != "" { + q.Set("createdBefore", m.CreatedBefore) + } + if m.CreatedAfter != "" { + q.Set("createdAfter", m.CreatedAfter) + } + req.URL.RawQuery = q.Encode() + return req, nil +} + +// peopleListRequest lists people in the installation. +type peopleListRequest struct { + PageOffset int + PageLimit int + SearchTerm string +} + +// HTTPRequest builds the GET /chat/v7/people request. +func (p peopleListRequest) HTTPRequest(ctx context.Context, server string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, server+chatBasePath+"/people", nil) + if err != nil { + return nil, err + } + q := url.Values{} + if p.PageOffset > 0 { + q.Set("page[offset]", strconv.Itoa(p.PageOffset)) + } + if p.PageLimit > 0 { + q.Set("page[limit]", strconv.Itoa(p.PageLimit)) + } + if p.SearchTerm != "" { + q.Set("filter[searchTerm]", p.SearchTerm) + } + req.URL.RawQuery = q.Encode() + return req, nil +} + +// messageSendRequest posts a message to a conversation. +type messageSendRequest struct { + ConversationID int64 + Body string +} + +// HTTPRequest builds the POST /chat/v7/conversations/{id}/messages request. +func (m messageSendRequest) HTTPRequest(ctx context.Context, server string) (*http.Request, error) { + uri := server + chatBasePath + "/conversations/" + strconv.FormatInt(m.ConversationID, 10) + "/messages" + + payload := struct { + ConversationID int64 `json:"conversationId"` + Message struct { + Body string `json:"body"` + } `json:"message"` + }{ConversationID: m.ConversationID} + payload.Message.Body = m.Body + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(payload); err != nil { + return nil, fmt.Errorf("failed to encode send message request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uri, &body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + return req, nil +} diff --git a/internal/twchat/tools.go b/internal/twchat/tools.go new file mode 100644 index 00000000..51ff79d1 --- /dev/null +++ b/internal/twchat/tools.go @@ -0,0 +1,59 @@ +// Package twchat exposes a small set of Teamwork Chat API endpoints as MCP +// tools. The Chat API is served at /chat/v7/... on the same installation host +// as the Projects API and authenticates with the same bearer token, so these +// tools reuse the shared twapi.Engine instead of a dedicated SDK. +package twchat + +import ( + "github.com/teamwork/mcp/internal/toolsets" + twapi "github.com/teamwork/twapi-go-sdk" +) + +const chatDescription = "Read conversations, messages, and people, and send messages in Teamwork Chat." + +// Sub-toolset key for twchat. This is the valid value for the -toolsets flag +// when selecting Teamwork Chat functionality. +const ( + // ToolsetChat covers reading conversations/messages/people and sending messages. + ToolsetChat toolsets.Method = "twchat-chat" +) + +// Tool method names as exposed to MCP clients. +const ( + // MethodCurrentUserGet retrieves the current authenticated chat user. + MethodCurrentUserGet toolsets.Method = "twchat-get_current_user" + // MethodConversationList lists conversations for the current user. + MethodConversationList toolsets.Method = "twchat-list_conversations" + // MethodConversationGet retrieves a single conversation. + MethodConversationGet toolsets.Method = "twchat-get_conversation" + // MethodMessageList lists messages within a conversation. + MethodMessageList toolsets.Method = "twchat-list_messages" + // MethodPeopleList lists people in the installation. + MethodPeopleList toolsets.Method = "twchat-list_people" + // MethodMessageSend posts a message to a conversation. + MethodMessageSend toolsets.Method = "twchat-send_message" +) + +func init() { + toolsets.RegisterMethod(ToolsetChat) +} + +// DefaultToolsetGroup creates a default ToolsetGroup for Teamwork Chat. Write +// tools (send_message) are skipped automatically when readOnly is true. +func DefaultToolsetGroup(readOnly bool, engine *twapi.Engine) *toolsets.ToolsetGroup { + group := toolsets.NewToolsetGroup(readOnly) + + group.AddToolset(toolsets.NewToolset(ToolsetChat, chatDescription). + AddWriteTools( + MessageSend(engine), + ). + AddReadTools( + CurrentUserGet(engine), + ConversationList(engine), + ConversationGet(engine), + MessageList(engine), + PeopleList(engine), + )) + + return group +} From 0a6b4be6d247f49c69206ede70e649ed5b1a0ce7 Mon Sep 17 00:00:00 2001 From: Donal Linehan Date: Tue, 2 Jun 2026 14:00:50 +0100 Subject: [PATCH 2/4] Add direct-message tools and conversation type filter to twchat Add get_or_create_dm (GET /chat/v7/people/:id/conversation) to resolve or create the 1:1 pair conversation with a person, and send_dm, a convenience write tool that resolves that conversation and posts a message in one call. There is no server-side "send to person" endpoint, so send_dm composes the two calls client-side. Also expose filter[type] (rooms|pair) on list_conversations so agents can narrow to direct messages or group conversations. --- internal/twchat/chat.go | 136 +++++++++++++++++++++++++++++++++++ internal/twchat/chat_test.go | 25 +++++++ internal/twchat/requests.go | 16 +++++ internal/twchat/tools.go | 6 ++ 4 files changed, 183 insertions(+) diff --git a/internal/twchat/chat.go b/internal/twchat/chat.go index cac64e0f..aec2ce6e 100644 --- a/internal/twchat/chat.go +++ b/internal/twchat/chat.go @@ -2,6 +2,7 @@ package twchat import ( "context" + "encoding/json" "fmt" "io" "net/http" @@ -87,6 +88,11 @@ func ConversationList(engine *twapi.Engine) toolsets.ToolWrapper { Description: "Filter by conversation status.", AnyOf: []*jsonschema.Schema{{Type: "string", Enum: []any{"all", "active"}}, {Type: "null"}}, }, + "type": { + Description: "Filter by conversation type: \"rooms\" for group/channel conversations, " + + "\"pair\" for 1:1 direct messages.", + AnyOf: []*jsonschema.Schema{{Type: "string", Enum: []any{"rooms", "pair"}}, {Type: "null"}}, + }, "sort": { Description: "Sort order for the returned conversations.", AnyOf: []*jsonschema.Schema{ @@ -120,6 +126,7 @@ func ConversationList(engine *twapi.Engine) toolsets.ToolWrapper { PageLimit: arguments.GetInt("page_limit", 0), SearchTerm: arguments.GetString("search_term", ""), Status: arguments.GetString("status", ""), + Type: arguments.GetString("type", ""), Sort: arguments.GetString("sort", ""), IncludeMessageData: arguments.GetBool("include_message_data", false), } @@ -316,3 +323,132 @@ func MessageSend(engine *twapi.Engine) toolsets.ToolWrapper { }, } } + +// DMGetOrCreate resolves the 1:1 conversation with a person, creating it if it +// does not exist yet, and returns the conversation. +func DMGetOrCreate(engine *twapi.Engine) toolsets.ToolWrapper { + return toolsets.ToolWrapper{ + Tool: &mcp.Tool{ + Name: string(MethodDMGetOrCreate), + Annotations: &mcp.ToolAnnotations{ + Title: "Get or Create Direct Message", + ReadOnlyHint: true, + }, + Description: "Get the 1:1 direct-message conversation with a person, creating it if it does not " + + "exist yet. Returns the conversation (use its id with send_message). Use list_people to find user_id.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "user_id": { + Type: "integer", + Description: "The ID of the person to get the direct-message conversation with.", + }, + }, + Required: []string{"user_id"}, + }, + }, + Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + arguments, err := helpers.NewToolArguments(request) + if err != nil { + return helpers.NewToolResultTextError("%v", err), nil + } + req := pairConversationGetRequest{UserID: int64(arguments.GetInt("user_id", 0))} + return execute(ctx, engine, req, "failed to resolve direct message conversation") + }, + } +} + +// SendDM sends a message directly to a person, resolving (or creating) the 1:1 +// conversation first. It is a convenience alias over DMGetOrCreate + MessageSend. +func SendDM(engine *twapi.Engine) toolsets.ToolWrapper { + return toolsets.ToolWrapper{ + Tool: &mcp.Tool{ + Name: string(MethodSendDM), + Annotations: &mcp.ToolAnnotations{ + Title: "Send Direct Message", + }, + Description: "Send a direct message to a person, resolving (or creating) the 1:1 conversation " + + "automatically. Requires user_id and body. Use list_people to find user_id.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "user_id": { + Type: "integer", + Description: "The ID of the person to send the direct message to.", + }, + "body": { + Type: "string", + Description: "The message text. Supports Markdown.", + }, + }, + Required: []string{"user_id", "body"}, + }, + }, + Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + arguments, err := helpers.NewToolArguments(request) + if err != nil { + return helpers.NewToolResultTextError("%v", err), nil + } + userID := int64(arguments.GetInt("user_id", 0)) + body := arguments.GetString("body", "") + if body == "" { + return helpers.NewToolResultTextError("body is required"), nil + } + + // Resolve (or create) the 1:1 conversation, then post the message to it. + conversationID, errResult, err := pairConversationID(ctx, engine, userID) + if err != nil { + return nil, err + } + if errResult != nil { + return errResult, nil + } + + req := messageSendRequest{ConversationID: conversationID, Body: body} + return execute(ctx, engine, req, "failed to send direct message") + }, + } +} + +// pairConversationID resolves the 1:1 conversation id with a person. On a +// tool-level failure (API error, unresolvable conversation) it returns a +// non-nil *mcp.CallToolResult for the caller to return directly; a non-nil +// error indicates an internal failure. +func pairConversationID( + ctx context.Context, + engine *twapi.Engine, + userID int64, +) (int64, *mcp.CallToolResult, error) { + const label = "failed to resolve direct message conversation" + + resp, err := twapi.ExecuteRaw(ctx, engine, pairConversationGetRequest{UserID: userID}) + if err != nil { + result, handleErr := helpers.HandleAPIError(err, label) + return 0, result, handleErr + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + result, handleErr := helpers.HandleAPIError(twapi.NewHTTPError(resp, label), label) + return 0, result, handleErr + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, nil, fmt.Errorf("failed to read response body: %w", err) + } + var parsed struct { + Conversation struct { + ID int64 `json:"id"` + } `json:"conversation"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return 0, nil, fmt.Errorf("failed to decode direct message conversation response: %w", err) + } + if parsed.Conversation.ID == 0 { + return 0, helpers.NewToolResultTextError( + "could not resolve a direct message conversation for user %d", userID), nil + } + return parsed.Conversation.ID, nil, nil +} diff --git a/internal/twchat/chat_test.go b/internal/twchat/chat_test.go index 09b77915..843eeb96 100644 --- a/internal/twchat/chat_test.go +++ b/internal/twchat/chat_test.go @@ -62,3 +62,28 @@ func TestMessageSend(t *testing.T) { "body": "Hello from MCP!", }) } + +func TestConversationListByType(t *testing.T) { + mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{"conversations":[]}`)) + testutil.ExecuteToolRequest(t, mcpServer, twchat.MethodConversationList.String(), map[string]any{ + "type": "pair", + }) +} + +func TestDMGetOrCreate(t *testing.T) { + mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{"conversation":{"id":456,"type":"pair"},"STATUS":"ok"}`)) + testutil.ExecuteToolRequest(t, mcpServer, twchat.MethodDMGetOrCreate.String(), map[string]any{ + "user_id": float64(42), + }) +} + +func TestSendDM(t *testing.T) { + // The single-response mock returns this body for both the get-or-create + // pair-conversation call and the subsequent send-message call. + mcpServer := mcpServerMock(t, http.StatusOK, + []byte(`{"conversation":{"id":456,"type":"pair"},"message":{"id":789},"STATUS":"ok"}`)) + testutil.ExecuteToolRequest(t, mcpServer, twchat.MethodSendDM.String(), map[string]any{ + "user_id": float64(42), + "body": "Hello directly from MCP!", + }) +} diff --git a/internal/twchat/requests.go b/internal/twchat/requests.go index ea4878b4..7976b43f 100644 --- a/internal/twchat/requests.go +++ b/internal/twchat/requests.go @@ -31,6 +31,7 @@ type conversationListRequest struct { PageLimit int SearchTerm string Status string + Type string Sort string IncludeMessageData bool } @@ -54,6 +55,9 @@ func (c conversationListRequest) HTTPRequest(ctx context.Context, server string) if c.Status != "" { q.Set("filter[status]", c.Status) } + if c.Type != "" { + q.Set("filter[type]", c.Type) + } if c.Sort != "" { q.Set("sort", c.Sort) } @@ -75,6 +79,18 @@ func (c conversationGetRequest) HTTPRequest(ctx context.Context, server string) return http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) } +// pairConversationGetRequest gets (or creates) the 1:1 "pair" conversation +// between the current user and the given person. +type pairConversationGetRequest struct { + UserID int64 +} + +// HTTPRequest builds the GET /chat/v7/people/{id}/conversation request. +func (p pairConversationGetRequest) HTTPRequest(ctx context.Context, server string) (*http.Request, error) { + uri := server + chatBasePath + "/people/" + strconv.FormatInt(p.UserID, 10) + "/conversation" + return http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) +} + // messageListRequest lists messages within a conversation. type messageListRequest struct { ConversationID int64 diff --git a/internal/twchat/tools.go b/internal/twchat/tools.go index 51ff79d1..89f9db4c 100644 --- a/internal/twchat/tools.go +++ b/internal/twchat/tools.go @@ -32,6 +32,10 @@ const ( MethodPeopleList toolsets.Method = "twchat-list_people" // MethodMessageSend posts a message to a conversation. MethodMessageSend toolsets.Method = "twchat-send_message" + // MethodDMGetOrCreate resolves (or creates) the 1:1 conversation with a person. + MethodDMGetOrCreate toolsets.Method = "twchat-get_or_create_dm" + // MethodSendDM sends a direct message to a person. + MethodSendDM toolsets.Method = "twchat-send_dm" ) func init() { @@ -46,6 +50,7 @@ func DefaultToolsetGroup(readOnly bool, engine *twapi.Engine) *toolsets.ToolsetG group.AddToolset(toolsets.NewToolset(ToolsetChat, chatDescription). AddWriteTools( MessageSend(engine), + SendDM(engine), ). AddReadTools( CurrentUserGet(engine), @@ -53,6 +58,7 @@ func DefaultToolsetGroup(readOnly bool, engine *twapi.Engine) *toolsets.ToolsetG ConversationGet(engine), MessageList(engine), PeopleList(engine), + DMGetOrCreate(engine), )) return group From 4f64e16bc8ff667a89c013a14fcbea6dad3eda94 Mon Sep 17 00:00:00 2001 From: Donal Linehan Date: Tue, 2 Jun 2026 16:10:24 +0100 Subject: [PATCH 3/4] Address PR review: reuse schema helpers and gate twchat by chat scope - Reuse helpers.SearchTermSchema/PageSchema/PageOffsetSchema and DateTimeFilterSchema in the twchat tools for consistency with the other toolsets; keep page_limit/page_size inline since they carry the API's max-10 / 1-200 ranges. - Add "chat" to scopes_supported in the OAuth protected-resource metadata. - Gate twchat tools behind the "chat" scope in the tool-list filter, mirroring the projects/desk/spaces handling. --- cmd/mcp-http/main.go | 2 +- internal/config/config.go | 4 +++- internal/twchat/chat.go | 42 ++++++++------------------------------- 3 files changed, 12 insertions(+), 36 deletions(-) diff --git a/cmd/mcp-http/main.go b/cmd/mcp-http/main.go index a7f247b3..edec168f 100644 --- a/cmd/mcp-http/main.go +++ b/cmd/mcp-http/main.go @@ -172,7 +172,7 @@ func newRouter(resources config.Resources) *http.ServeMux { "authorization_servers": ["` + resources.Info.APIURL + `"], "bearer_methods_supported": ["header"], "resource_documentation": "https://apidocs.teamwork.com/guides/teamwork/app-login-flow", - "scopes_supported": [ "projects", "desk", "spaces" ] + "scopes_supported": [ "projects", "desk", "spaces", "chat" ] }`)) }) return mux diff --git a/internal/config/config.go b/internal/config/config.go index 7b9ed795..eb5e0e4e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -252,11 +252,13 @@ func NewMCPServer(resources Resources, groups ...*toolsets.ToolsetGroup) *mcp.Se projectsScope := slices.Contains(scopes, "projects") deskScope := slices.Contains(scopes, "desk") spacesScope := slices.Contains(scopes, "spaces") + chatScope := slices.Contains(scopes, "chat") listToolsResult.Tools = slices.DeleteFunc(listToolsResult.Tools, func(tool *mcp.Tool) bool { return (strings.HasPrefix(tool.Name, "twprojects") && !projectsScope) || (strings.HasPrefix(tool.Name, "twdesk") && !deskScope) || - (strings.HasPrefix(tool.Name, "twspaces") && !spacesScope) + (strings.HasPrefix(tool.Name, "twspaces") && !spacesScope) || + (strings.HasPrefix(tool.Name, "twchat") && !chatScope) }) return listToolsResult, nil } diff --git a/internal/twchat/chat.go b/internal/twchat/chat.go index aec2ce6e..bd47ae6a 100644 --- a/internal/twchat/chat.go +++ b/internal/twchat/chat.go @@ -80,10 +80,7 @@ func ConversationList(engine *twapi.Engine) toolsets.ToolWrapper { InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ - "search_term": { - Description: "Filter conversations by title (substring match).", - AnyOf: []*jsonschema.Schema{{Type: "string"}, {Type: "null"}}, - }, + "search_term": helpers.SearchTermSchema("conversations", "title"), "status": { Description: "Filter by conversation status.", AnyOf: []*jsonschema.Schema{{Type: "string", Enum: []any{"all", "active"}}, {Type: "null"}}, @@ -104,10 +101,7 @@ func ConversationList(engine *twapi.Engine) toolsets.ToolWrapper { Description: "Include the latest message in each conversation.", AnyOf: []*jsonschema.Schema{{Type: "boolean"}, {Type: "null"}}, }, - "page_offset": { - Description: "Zero-based pagination offset.", - AnyOf: []*jsonschema.Schema{{Type: "integer"}, {Type: "null"}}, - }, + "page_offset": helpers.PageOffsetSchema(), "page_limit": { Description: "Number of conversations to return (max 10).", AnyOf: []*jsonschema.Schema{{Type: "integer"}, {Type: "null"}}, @@ -184,14 +178,8 @@ func MessageList(engine *twapi.Engine) toolsets.ToolWrapper { Type: "integer", Description: "The ID of the conversation to read messages from.", }, - "search_term": { - Description: "Filter messages by text content.", - AnyOf: []*jsonschema.Schema{{Type: "string"}, {Type: "null"}}, - }, - "page": { - Description: "One-based page number.", - AnyOf: []*jsonschema.Schema{{Type: "integer"}, {Type: "null"}}, - }, + "search_term": helpers.SearchTermSchema("messages", "text content"), + "page": helpers.PageSchema(), "page_size": { Description: "Number of messages per page (1-200).", AnyOf: []*jsonschema.Schema{{Type: "integer"}, {Type: "null"}}, @@ -204,16 +192,8 @@ func MessageList(engine *twapi.Engine) toolsets.ToolWrapper { Description: "Return messages newer than this message ID (cursor).", AnyOf: []*jsonschema.Schema{{Type: "integer"}, {Type: "null"}}, }, - "created_before": { - Description: "Return messages created before this time.", - Examples: []any{"2023-12-31T23:59:59Z"}, - AnyOf: []*jsonschema.Schema{{Type: "string", Format: "date-time"}, {Type: "null"}}, - }, - "created_after": { - Description: "Return messages created after this time.", - Examples: []any{"2023-01-01T00:00:00Z"}, - AnyOf: []*jsonschema.Schema{{Type: "string", Format: "date-time"}, {Type: "null"}}, - }, + "created_before": helpers.DateTimeFilterSchema("Return messages created before this time."), + "created_after": helpers.DateTimeFilterSchema("Return messages created after this time."), }, Required: []string{"conversation_id"}, }, @@ -251,14 +231,8 @@ func PeopleList(engine *twapi.Engine) toolsets.ToolWrapper { InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ - "search_term": { - Description: "Filter people by name or email.", - AnyOf: []*jsonschema.Schema{{Type: "string"}, {Type: "null"}}, - }, - "page_offset": { - Description: "Zero-based pagination offset.", - AnyOf: []*jsonschema.Schema{{Type: "integer"}, {Type: "null"}}, - }, + "search_term": helpers.SearchTermSchema("people", "name or email"), + "page_offset": helpers.PageOffsetSchema(), "page_limit": { Description: "Number of people to return.", AnyOf: []*jsonschema.Schema{{Type: "integer"}, {Type: "null"}}, From e2871059af49fd3a7823771672b0fd98ab4bca54 Mon Sep 17 00:00:00 2001 From: Donal Linehan Date: Fri, 19 Jun 2026 11:02:42 +0100 Subject: [PATCH 4/4] Redact credentials from twchat get_current_user response The current-user payload embeds the caller's API key and auth token. Strip apiKey/authKey fields (at any depth) before returning the response to the MCP client so credentials don't leak into model context or logs. --- internal/twchat/chat.go | 68 +++++++++++++++++++++++++++++++++++- internal/twchat/chat_test.go | 36 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/internal/twchat/chat.go b/internal/twchat/chat.go index bd47ae6a..673afd25 100644 --- a/internal/twchat/chat.go +++ b/internal/twchat/chat.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strings" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -15,6 +16,14 @@ import ( twapi "github.com/teamwork/twapi-go-sdk" ) +// sensitiveFieldNames are JSON keys (compared case-insensitively) stripped from +// chat responses before they are returned to the caller, so credentials never +// leak into model context or client logs. +var sensitiveFieldNames = map[string]struct{}{ + "apikey": {}, + "authkey": {}, +} + // execute runs the request through the shared engine and streams the raw JSON // response body back to the caller. label is used in error messages. func execute( @@ -22,6 +31,19 @@ func execute( engine *twapi.Engine, req twapi.HTTPRequester, label string, +) (*mcp.CallToolResult, error) { + return executeWithTransform(ctx, engine, req, label, nil) +} + +// executeWithTransform behaves like execute but applies transform to the raw +// response body before returning it. A nil transform streams the body +// unchanged. +func executeWithTransform( + ctx context.Context, + engine *twapi.Engine, + req twapi.HTTPRequester, + label string, + transform func([]byte) ([]byte, error), ) (*mcp.CallToolResult, error) { resp, err := twapi.ExecuteRaw(ctx, engine, req) if err != nil { @@ -37,6 +59,11 @@ func execute( if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } + if transform != nil { + if body, err = transform(body); err != nil { + return nil, err + } + } return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: string(body)}, @@ -44,6 +71,42 @@ func execute( }, nil } +// redactSensitiveBody decodes a JSON response body, removes any +// credential-bearing fields (see sensitiveFieldNames) at any depth, and +// re-encodes it. It returns an error rather than the raw body on failure, so a +// parsing problem can never cause secrets to be leaked unredacted. +func redactSensitiveBody(body []byte) ([]byte, error) { + var decoded any + if err := json.Unmarshal(body, &decoded); err != nil { + return nil, fmt.Errorf("failed to decode response for redaction: %w", err) + } + redactSensitive(decoded) + redacted, err := json.Marshal(decoded) + if err != nil { + return nil, fmt.Errorf("failed to re-encode redacted response: %w", err) + } + return redacted, nil +} + +// redactSensitive recursively deletes sensitive keys from a decoded JSON value +// in place. +func redactSensitive(v any) { + switch val := v.(type) { + case map[string]any: + for k := range val { + if _, ok := sensitiveFieldNames[strings.ToLower(k)]; ok { + delete(val, k) + continue + } + redactSensitive(val[k]) + } + case []any: + for _, item := range val { + redactSensitive(item) + } + } +} + // CurrentUserGet returns the current authenticated Teamwork Chat user. func CurrentUserGet(engine *twapi.Engine) toolsets.ToolWrapper { return toolsets.ToolWrapper{ @@ -62,7 +125,10 @@ func CurrentUserGet(engine *twapi.Engine) toolsets.ToolWrapper { }, }, Handler: func(ctx context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return execute(ctx, engine, currentUserGetRequest{}, "failed to get current chat user") + // The current-user payload embeds the caller's API key and auth + // token; strip them before handing the response to the client. + return executeWithTransform(ctx, engine, currentUserGetRequest{}, + "failed to get current chat user", redactSensitiveBody) }, } } diff --git a/internal/twchat/chat_test.go b/internal/twchat/chat_test.go index 843eeb96..6ee45b5b 100644 --- a/internal/twchat/chat_test.go +++ b/internal/twchat/chat_test.go @@ -2,8 +2,11 @@ package twchat_test import ( "net/http" + "strings" "testing" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/teamwork/mcp/internal/testutil" "github.com/teamwork/mcp/internal/twchat" ) @@ -13,6 +16,39 @@ func TestCurrentUserGet(t *testing.T) { testutil.ExecuteToolRequest(t, mcpServer, twchat.MethodCurrentUserGet.String(), map[string]any{}) } +func TestCurrentUserGetRedactsCredentials(t *testing.T) { + body := []byte(`{"account":{"apiKey":"twp_secret","authkey":"tkn_secret",` + + `"user":{"id":1,"apiKey":"twp_secret","authKey":"tkn_secret"}},"status":"ok"}`) + mcpServer := mcpServerMock(t, http.StatusOK, body) + testutil.ExecuteToolRequest(t, mcpServer, twchat.MethodCurrentUserGet.String(), map[string]any{}, + 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.Fatalf("tool returned an error: %v", toolResult.Content) + } + if len(toolResult.Content) != 1 { + t.Fatalf("expected 1 content item, got %d", len(toolResult.Content)) + } + text, ok := toolResult.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("unexpected content type: %T", toolResult.Content[0]) + } + for _, secret := range []string{"apiKey", "authKey", "authkey", "twp_secret", "tkn_secret"} { + if strings.Contains(text.Text, secret) { + t.Errorf("expected %q to be redacted, but it is present in: %s", secret, text.Text) + } + } + // Non-sensitive fields must survive redaction. + if !strings.Contains(text.Text, `"status":"ok"`) { + t.Errorf("expected non-sensitive fields to be preserved, got: %s", text.Text) + } + })) +} + func TestConversationList(t *testing.T) { mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{"conversations":[]}`)) testutil.ExecuteToolRequest(t, mcpServer, twchat.MethodConversationList.String(), map[string]any{