diff --git a/cmd/mcp-http/main.go b/cmd/mcp-http/main.go index f1f161cc..edec168f 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 } @@ -165,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/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/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/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..673afd25 --- /dev/null +++ b/internal/twchat/chat.go @@ -0,0 +1,494 @@ +package twchat + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "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" +) + +// 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( + ctx context.Context, + 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 { + 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) + } + if transform != nil { + if body, err = transform(body); err != nil { + return nil, err + } + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(body)}, + }, + }, 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{ + 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) { + // 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) + }, + } +} + +// 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": helpers.SearchTermSchema("conversations", "title"), + "status": { + 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{ + {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": helpers.PageOffsetSchema(), + "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", ""), + Type: arguments.GetString("type", ""), + 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": 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"}}, + }, + "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": helpers.DateTimeFilterSchema("Return messages created before this time."), + "created_after": helpers.DateTimeFilterSchema("Return messages created after this time."), + }, + 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": 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"}}, + }, + }, + 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") + }, + } +} + +// 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 new file mode 100644 index 00000000..6ee45b5b --- /dev/null +++ b/internal/twchat/chat_test.go @@ -0,0 +1,125 @@ +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" +) + +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 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{ + "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!", + }) +} + +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/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..7976b43f --- /dev/null +++ b/internal/twchat/requests.go @@ -0,0 +1,195 @@ +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 + Type 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.Type != "" { + q.Set("filter[type]", c.Type) + } + 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) +} + +// 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 + 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..89f9db4c --- /dev/null +++ b/internal/twchat/tools.go @@ -0,0 +1,65 @@ +// 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" + // 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() { + 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), + SendDM(engine), + ). + AddReadTools( + CurrentUserGet(engine), + ConversationList(engine), + ConversationGet(engine), + MessageList(engine), + PeopleList(engine), + DMGetOrCreate(engine), + )) + + return group +}