From 93e54fcca6e15442f22968d3c9b90a5f4580a441 Mon Sep 17 00:00:00 2001 From: Simon Frydensbjerg Sinding <5576291+TheSinding@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:56:52 +0200 Subject: [PATCH 1/5] feat: use MCP SDK for recipient resolution --- README.md | 9 +- ...-04-mcp-sdk-recipient-resolution-design.md | 58 +++ go.mod | 10 +- go.sum | 28 +- internal/teamsctl/conversations_test.go | 35 ++ internal/teamsctl/mcp.go | 351 +++++++++++++++--- internal/teamsctl/mcp_test.go | 70 ++-- internal/teamsctl/mcp_tools.go | 198 ---------- internal/teamsctl/mcp_tools_test.go | 46 --- internal/teamsctl/models.go | 34 +- 10 files changed, 475 insertions(+), 364 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-04-mcp-sdk-recipient-resolution-design.md delete mode 100644 internal/teamsctl/mcp_tools.go delete mode 100644 internal/teamsctl/mcp_tools_test.go diff --git a/README.md b/README.md index 7e8dea3..cc57d1a 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,13 @@ call. Run `teamsctl auth` when connection fails with an authentication error. |---|---| | `list_conversations` | Find chats/channels by `query`, `kind`, and `limit`. Results are cached for five minutes. | | `get_latest_message` | Resolve the best matching one-to-one chat and return its latest message. | -| `get_messages` | Read messages using a conversation ID. | -| `send_message` | Send plain text or HTML, with optional real Teams mentions. | +| `get_messages` | Read messages using a recipient phrase. | +| `send_message` | Send plain text or HTML, with optional real Teams mentions, using a recipient phrase. | + +Recipient phrases resolve by intent: `Mike` is a 1:1 chat, `Mike and Charlie` +is their existing group chat, and `ASM group chat` or `ASM channel` matches a +named conversation. If a requested multi-person group does not exist, +`send_message` sends to each person individually and reports the fallback. Use `format: "html"` for formatted or multi-part messages. HTML such as `@Mikkel` is only bold text: a real mention also requires diff --git a/docs/superpowers/specs/2026-08-04-mcp-sdk-recipient-resolution-design.md b/docs/superpowers/specs/2026-08-04-mcp-sdk-recipient-resolution-design.md new file mode 100644 index 0000000..a8b1c8b --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-mcp-sdk-recipient-resolution-design.md @@ -0,0 +1,58 @@ +# MCP SDK and Recipient Resolution Design + +## Goal + +Replace the hand-written MCP JSON-RPC server with the official Go MCP SDK while +preserving the existing Teams service layer. Make tool behavior clear and safe +when an agent supplies a human name instead of a conversation ID. + +## Architecture + +`teamsctl mcp` will construct an SDK `mcp.Server`, register typed tools, and +run it through `mcp.StdioTransport`. The SDK owns initialization, protocol +negotiation, JSON-RPC framing, input schema generation, and tool dispatch. + +The existing `Service`, Teams authentication, conversation lookup, message +fetching, and send logic remain application code. Tool handlers create the +service lazily after token validation and call those existing methods. + +The manual request/response models, `RunMCP` JSON decoder/encoder loop, +`mcpTools`, and `callTool` dispatch are removed. + +## Tool Contract + +Typed SDK registrations expose names, descriptions, required fields, enums, +defaults, and JSON schemas through `tools/list`. + +`list_conversations`, `get_latest_message`, and `get_messages` retain their +current behavior. + +Tools accept a recipient phrase from the user request instead of requiring a +conversation ID. Resolution follows intent: + +- A single person (`Mike`) resolves to the best matching one-to-one chat. +- A multi-person phrase (`Mike and Charlie`) resolves to an existing group chat + containing those names. +- A group, channel, or thread title (`ASM group chat`, `ASM channel`) resolves + to the matching existing conversation. + +When a requested multi-person group chat does not exist, `send_message` +resolves every named recipient to a one-to-one chat, sends the message to each, +and reports that it used the individual-message fallback. It never creates a +new group chat. Read operations return a clear no-group-chat error instead of +falling back to multiple conversations. + +## Authentication and Errors + +Authentication is checked before initialization and every tool operation. +Authentication and application errors are returned as SDK tool errors. The +server process continues to use stdio and emits protocol messages only on +stdout. + +## Tests + +Replace manual JSON-RPC handshake/schema tests with SDK integration tests over +an in-memory transport where practical. Preserve coverage for tool discovery, +authentication failure, dispatch, and errors. Add tests for send-target +resolution: single-person, multi-person, group-title, and channel-title +matching; missing-group send fallback; and no-group read errors. diff --git a/go.mod b/go.mod index c7f332a..ad3999a 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/chromedp/cdproto v0.0.0-20241003230502-a4a8f7c660df github.com/chromedp/chromedp v0.10.1 github.com/fossteams/teams-api v0.0.0-20220604181459-dbbdc3681f32 + github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/zalando/go-keyring v0.2.8 ) @@ -21,7 +22,14 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - golang.org/x/sys v0.27.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/time v0.15.0 // indirect ) diff --git a/go.sum b/go.sum index e810842..dd4bb84 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/fossteams/teams-api v0.0.0-20220604181459-dbbdc3681f32 h1:8L5c5ec00rBWZzTJK+eJrMfRWYTB7R9VBhD/9yXi5Ok= -github.com/fossteams/teams-api v0.0.0-20220604181459-dbbdc3681f32/go.mod h1:QWsDlFTF+0fxEjM0jDo51WdbgIy7JuHxI2TaxU6rRwQ= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= @@ -21,6 +19,12 @@ github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= @@ -29,29 +33,45 @@ github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczG github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/teamsctl/conversations_test.go b/internal/teamsctl/conversations_test.go index 9dd37f7..0ffa176 100644 --- a/internal/teamsctl/conversations_test.go +++ b/internal/teamsctl/conversations_test.go @@ -13,3 +13,38 @@ func TestFilterConversationsPrefersOneOnOne(t *testing.T) { t.Fatalf("filterConversations() = %#v", matches) } } + +func TestConversationTargetTreatsNamesAndIDsDifferently(t *testing.T) { + if looksLikeConversationID("Mikkel") { + t.Fatal("name was treated as an ID") + } + if !looksLikeConversationID("19:conversation-id@thread.v2") { + t.Fatal("Teams conversation ID was treated as a name") + } +} + +func TestRecipientIntent(t *testing.T) { + if got := splitRecipientNames("Mike and Charlie"); len(got) != 2 || got[0] != "Mike" || got[1] != "Charlie" { + t.Fatalf("splitRecipientNames() = %#v", got) + } + if query, kind := namedConversationQuery("ASM group chat"); query != "ASM" || kind != "chat" { + t.Fatalf("namedConversationQuery() = %q, %q", query, kind) + } + if query, kind := namedConversationQuery("ASM channel"); query != "ASM" || kind != "channel" { + t.Fatalf("namedConversationQuery() = %q, %q", query, kind) + } +} + +func TestMatchingGroupConversationRequiresEveryRecipient(t *testing.T) { + conversations := []Conversation{ + {Kind: "chat", Title: "Mike, Charlie"}, + {Kind: "chat", Title: "Mike", OneOnOne: true}, + } + conversation, ok := matchingGroupConversation(conversations, []string{"Mike", "Charlie"}) + if !ok || conversation.Title != "Mike, Charlie" { + t.Fatalf("matchingGroupConversation() = %#v, %v", conversation, ok) + } + if _, ok := matchingGroupConversation(conversations, []string{"Mike", "Pat"}); ok { + t.Fatal("matched a group chat without every requested recipient") + } +} diff --git a/internal/teamsctl/mcp.go b/internal/teamsctl/mcp.go index d9764f8..74a30d4 100644 --- a/internal/teamsctl/mcp.go +++ b/internal/teamsctl/mcp.go @@ -1,79 +1,318 @@ package teamsctl import ( - "bufio" - "encoding/json" + "context" + "errors" "fmt" "io" + "strings" + "sync" + "github.com/modelcontextprotocol/go-sdk/mcp" "thesinding/teamsctl/internal/teamsauth" "thesinding/teamsctl/internal/version" ) var checkMCPAuth = teamsauth.CheckTokens +type listConversationsInput struct { + Query string `json:"query,omitempty" jsonschema:"Case-insensitive title or team-name substring."` + Kind string `json:"kind,omitempty" jsonschema:"Conversation kind: chat or channel."` + Limit int `json:"limit,omitempty" jsonschema:"Maximum number of conversations to return; zero returns all."` +} + +type latestMessageInput struct { + Query string `json:"query" jsonschema:"Name of a person or one-to-one chat title. A bare name always means a one-to-one chat."` +} + +type messagesInput struct { + Recipient string `json:"recipient,omitempty" jsonschema:"Recipient phrase from the user, such as Mike, Mike and Charlie, ASM group chat, or ASM channel."` + ConversationID string `json:"conversation_id,omitempty" jsonschema:"Deprecated: use recipient. A Teams conversation ID remains accepted."` + Limit int `json:"limit,omitempty" jsonschema:"Maximum number of messages to return; zero returns all."` +} + +type sendMessageInput struct { + Recipient string `json:"recipient,omitempty" jsonschema:"Recipient phrase from the user, such as Mike, Mike and Charlie, ASM group chat, or ASM channel."` + ConversationID string `json:"conversation_id,omitempty" jsonschema:"Deprecated: use recipient. A Teams conversation ID remains accepted."` + Message string `json:"message" jsonschema:"Message content."` + Format string `json:"format,omitempty" jsonschema:"Message format: text or html. Use html for structured or formatted messages."` + Mentions []string `json:"mentions,omitempty" jsonschema:"People to mention. Each must match an @Name token in message."` + MentionEntities []MentionEntity `json:"mention_entities,omitempty" jsonschema:"Pre-resolved Teams mentions. Prefer mentions for automatic resolution."` +} + +type mcpApplication struct { + serviceMu sync.Mutex + service *Service +} + func RunMCP(stdin io.Reader, stdout io.Writer) error { - decoder := json.NewDecoder(bufio.NewReader(stdin)) - encoder := json.NewEncoder(stdout) - var service *Service - for { - var request rpcRequest - if err := decoder.Decode(&request); err != nil { - if err == io.EOF { - return nil + return newMCPServer().Run(context.Background(), &mcp.IOTransport{ + Reader: io.NopCloser(stdin), + Writer: nopWriteCloser{Writer: stdout}, + }) +} + +type nopWriteCloser struct{ io.Writer } + +func (nopWriteCloser) Close() error { return nil } + +func newMCPServer() *mcp.Server { + app := &mcpApplication{} + server := mcp.NewServer(&mcp.Implementation{Name: "teamsctl", Version: version.Value}, &mcp.ServerOptions{ + Capabilities: &mcp.ServerCapabilities{Tools: &mcp.ToolCapabilities{}}, + }) + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + if method == "initialize" { + if err := checkMCPAuth(); err != nil { + return nil, err + } } - return fmt.Errorf("decode MCP request: %w", err) + return next(ctx, method, request) } - if len(request.ID) == 0 { - continue + }) + mcp.AddTool(server, &mcp.Tool{Name: "list_conversations", Description: "Find Microsoft Teams chats and channels by title. Use query and kind instead of listing everything when looking for a person."}, app.listConversations) + mcp.AddTool(server, &mcp.Tool{Name: "get_latest_message", Description: "Find a one-to-one chat by person or title and return its latest message. A bare name always means a one-to-one chat."}, app.latestMessage) + mcp.AddTool(server, &mcp.Tool{Name: "get_messages", Description: "Get recent messages using a recipient phrase: Mike for one-to-one, Mike and Charlie for their group chat, ASM group chat, or ASM channel. Do not require a conversation ID."}, app.messages) + mcp.AddTool(server, &mcp.Tool{Name: "send_message", Description: "Send a message using a recipient phrase: Mike for one-to-one, Mike and Charlie for their group chat, ASM group chat, or ASM channel. If a requested multi-person group does not exist, send individually and report the fallback. Use format=html for structured messages. A real Teams mention requires @Name in message and the matching name in mentions."}, app.sendMessage) + return server +} + +func (app *mcpApplication) serviceForTool() (*Service, error) { + if err := checkMCPAuth(); err != nil { + return nil, err + } + app.serviceMu.Lock() + defer app.serviceMu.Unlock() + if app.service == nil { + service, err := NewService() + if err != nil { + return nil, err } - response := rpcResponse{JSONRPC: "2.0", ID: request.ID} - switch request.Method { - case "initialize": - if err := checkMCPAuth(); err != nil { - response.Error = &rpcError{Code: -32001, Message: err.Error()} - break - } - var params struct { - ProtocolVersion string `json:"protocolVersion"` - } - _ = json.Unmarshal(request.Params, ¶ms) - if params.ProtocolVersion == "" { - params.ProtocolVersion = "2024-11-05" - } - response.Result = map[string]interface{}{ - "protocolVersion": params.ProtocolVersion, - "capabilities": map[string]interface{}{"tools": map[string]bool{"listChanged": false}}, - "serverInfo": map[string]string{"name": "teamsctl", "version": version.Value}, + app.service = service + } + return app.service, nil +} + +func (app *mcpApplication) listConversations(_ context.Context, _ *mcp.CallToolRequest, input listConversationsInput) (*mcp.CallToolResult, any, error) { + service, err := app.serviceForTool() + if err != nil { + return nil, nil, err + } + conversations, err := service.FindConversations(input.Query, input.Kind, input.Limit) + return nil, conversations, err +} + +func (app *mcpApplication) latestMessage(_ context.Context, _ *mcp.CallToolRequest, input latestMessageInput) (*mcp.CallToolResult, any, error) { + service, err := app.serviceForTool() + if err != nil { + return nil, nil, err + } + conversation, err := service.findOneOnOneConversation(input.Query) + if err != nil { + return nil, nil, err + } + messages, err := service.Messages(conversation.IDs, conversation.Title, 1) + if err != nil { + return nil, nil, err + } + var latest any + if len(messages) > 0 { + latest = messages[0] + } + return nil, map[string]any{"conversation": conversation, "message": latest}, nil +} + +func (app *mcpApplication) messages(_ context.Context, _ *mcp.CallToolRequest, input messagesInput) (*mcp.CallToolResult, any, error) { + service, err := app.serviceForTool() + if err != nil { + return nil, nil, err + } + target, err := service.resolveConversationTarget(firstNonEmpty(input.Recipient, input.ConversationID)) + if err != nil { + return nil, nil, err + } + messages, err := service.Messages(target.IDs, target.Name, input.Limit) + return nil, messages, err +} + +func (app *mcpApplication) sendMessage(_ context.Context, _ *mcp.CallToolRequest, input sendMessageInput) (*mcp.CallToolResult, any, error) { + service, err := app.serviceForTool() + if err != nil { + return nil, nil, err + } + target, err := service.resolveConversationTarget(firstNonEmpty(input.Recipient, input.ConversationID)) + if err != nil { + var missingGroup *missingGroupChatError + if !errors.As(err, &missingGroup) { + return nil, nil, err + } + target, err = service.resolveIndividualTargets(missingGroup.Recipients) + if err != nil { + return nil, nil, err + } + target.FallbackToOneOnOne = true + } + options := SendOptions{Format: input.Format, Mentions: input.Mentions, MentionEntities: input.MentionEntities} + if target.FallbackToOneOnOne { + for _, ids := range target.IndividualIDs { + if err := service.Send(ids, input.Message, options); err != nil { + return nil, nil, err } - case "ping": - response.Result = map[string]interface{}{} - case "tools/list": - response.Result = map[string]interface{}{"tools": mcpTools()} - case "tools/call": - if err := checkMCPAuth(); err != nil { - response.Result = errorToolResult(err) + } + } else if err := service.Send(target.IDs, input.Message, options); err != nil { + return nil, nil, err + } + return nil, map[string]any{"sent": true, "sent_to": target.Recipients, "fallback_to_one_on_one": target.FallbackToOneOnOne}, nil +} + +type conversationTarget struct { + IDs []string + IndividualIDs [][]string + Name string + Recipients []string + FallbackToOneOnOne bool +} + +type missingGroupChatError struct{ Recipients []string } + +func (e *missingGroupChatError) Error() string { + return fmt.Sprintf("no group chat found for %s", strings.Join(e.Recipients, " and ")) +} + +func (s *Service) resolveConversationTarget(target string) (conversationTarget, error) { + target = strings.TrimSpace(target) + if target == "" { + return conversationTarget{}, fmt.Errorf("recipient is required") + } + if looksLikeConversationID(target) { + return conversationTarget{IDs: splitIDs(target), Recipients: []string{target}}, nil + } + if recipients := splitRecipientNames(target); len(recipients) > 1 { + conversation, err := s.findGroupConversation(recipients) + if err != nil { + return conversationTarget{}, err + } + if len(conversation.IDs) == 0 { + return conversationTarget{}, &missingGroupChatError{Recipients: recipients} + } + return conversationTarget{IDs: conversation.IDs, Name: conversation.Title, Recipients: []string{conversation.Title}}, nil + } + if query, kind := namedConversationQuery(target); kind != "" { + conversation, err := s.findNamedConversation(query, kind) + if err != nil { + return conversationTarget{}, err + } + return conversationTarget{IDs: conversation.IDs, Name: conversation.Title, Recipients: []string{conversation.Title}}, nil + } + conversation, err := s.findOneOnOneConversation(target) + if err != nil { + return conversationTarget{}, err + } + return conversationTarget{IDs: conversation.IDs, Name: conversation.Title, Recipients: []string{conversation.Title}}, nil +} + +func (s *Service) resolveIndividualTargets(recipients []string) (conversationTarget, error) { + individualIDs := make([][]string, 0, len(recipients)) + resolved := make([]string, 0, len(recipients)) + for _, recipient := range recipients { + conversation, err := s.findOneOnOneConversation(recipient) + if err != nil { + return conversationTarget{}, err + } + individualIDs = append(individualIDs, conversation.IDs) + resolved = append(resolved, conversation.Title) + } + return conversationTarget{IndividualIDs: individualIDs, Recipients: resolved}, nil +} + +func (s *Service) findOneOnOneConversation(query string) (Conversation, error) { + matches, err := s.FindConversations(query, "chat", 0) + if err != nil { + return Conversation{}, err + } + for _, conversation := range matches { + if conversation.OneOnOne { + return conversation, nil + } + } + return Conversation{}, fmt.Errorf("no one-to-one chat found matching %q", query) +} + +func (s *Service) findGroupConversation(recipients []string) (Conversation, error) { + conversations, err := s.Conversations() + if err != nil { + return Conversation{}, err + } + if conversation, ok := matchingGroupConversation(conversations, recipients); ok { + return conversation, nil + } + return Conversation{}, nil +} + +func (s *Service) findNamedConversation(query, kind string) (Conversation, error) { + conversations, err := s.FindConversations(query, kind, 0) + if err != nil { + return Conversation{}, err + } + for _, conversation := range conversations { + if kind != "chat" || !conversation.OneOnOne { + return conversation, nil + } + } + return Conversation{}, fmt.Errorf("no %s found matching %q", kind, query) +} + +func matchingGroupConversation(conversations []Conversation, recipients []string) (Conversation, bool) { + for _, conversation := range conversations { + if conversation.Kind != "chat" || conversation.OneOnOne { + continue + } + title := strings.ToLower(conversation.Title) + matched := true + for _, recipient := range recipients { + if !strings.Contains(title, strings.ToLower(recipient)) { + matched = false break } - if service == nil { - var err error - service, err = NewService() - if err != nil { - response.Result = errorToolResult(err) - break - } - } - result, err := callTool(service, request.Params) - if err != nil { - response.Result = errorToolResult(err) - } else { - response.Result = result - } - default: - response.Error = &rpcError{Code: -32601, Message: "method not found"} } - if err := encoder.Encode(response); err != nil { - return fmt.Errorf("encode MCP response: %w", err) + if matched { + return conversation, true + } + } + return Conversation{}, false +} + +func looksLikeConversationID(target string) bool { + return strings.ContainsAny(target, ":@,") +} + +func splitRecipientNames(target string) []string { + parts := strings.Split(strings.TrimSpace(target), " and ") + if len(parts) < 2 { + return nil + } + return normalizeIDs(parts) +} + +func namedConversationQuery(target string) (string, string) { + lower := strings.ToLower(strings.TrimSpace(target)) + for _, suffix := range []struct { + value string + kind string + }{{" group chat", "chat"}, {" chat", "chat"}, {" channel", "channel"}} { + if strings.HasSuffix(lower, suffix.value) { + return strings.TrimSpace(target[:len(target)-len(suffix.value)]), suffix.kind + } + } + return "", "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value } } + return "" } diff --git a/internal/teamsctl/mcp_test.go b/internal/teamsctl/mcp_test.go index b023487..d88cccf 100644 --- a/internal/teamsctl/mcp_test.go +++ b/internal/teamsctl/mcp_test.go @@ -1,33 +1,39 @@ package teamsctl import ( - "bytes" - "encoding/json" + "context" "errors" "strings" "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" ) -func TestMCPHandshake(t *testing.T) { +func TestMCPListsTypedTools(t *testing.T) { originalCheck := checkMCPAuth checkMCPAuth = func() error { return nil } defer func() { checkMCPAuth = originalCheck }() - input := strings.NewReader( - `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}` + "\n" + - `{"jsonrpc":"2.0","method":"notifications/initialized"}` + "\n", - ) - var output bytes.Buffer - if err := RunMCP(input, &output); err != nil { + + session, closeSession := connectMCP(t) + defer closeSession() + result, err := session.ListTools(context.Background(), nil) + if err != nil { t.Fatal(err) } - decoder := json.NewDecoder(&output) - var initialize map[string]interface{} - if err := decoder.Decode(&initialize); err != nil { - t.Fatal(err) + if len(result.Tools) != 4 { + t.Fatalf("tools count = %d", len(result.Tools)) + } + tools := make(map[string]*mcp.Tool, len(result.Tools)) + for _, tool := range result.Tools { + tools[tool.Name] = tool + } + for _, name := range []string{"list_conversations", "get_latest_message", "get_messages", "send_message"} { + if tools[name] == nil || tools[name].InputSchema == nil { + t.Errorf("tool %q was not described with an input schema", name) + } } - result := initialize["result"].(map[string]interface{}) - if result["protocolVersion"] != "2025-03-26" { - t.Fatalf("protocolVersion = %v", result["protocolVersion"]) + if !strings.Contains(tools["send_message"].Description, "send individually") { + t.Fatalf("send_message description = %q", tools["send_message"].Description) } } @@ -36,18 +42,34 @@ func TestMCPInitializeRejectsExpiredAuth(t *testing.T) { checkMCPAuth = func() error { return errors.New("teams token expired; run teamsctl auth") } defer func() { checkMCPAuth = originalCheck }() - input := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`) - var output bytes.Buffer - if err := RunMCP(input, &output); err != nil { + session, closeSession := connectMCP(t) + defer closeSession() + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_latest_message", + Arguments: map[string]any{"query": "Mikkel"}, + }) + if err != nil { t.Fatal(err) } - var response struct { - Error *rpcError `json:"error"` + if !result.IsError || !strings.Contains(result.Content[0].(*mcp.TextContent).Text, "teams token expired") { + t.Fatalf("CallTool() = %#v", result) + } +} + +func connectMCP(t *testing.T) (*mcp.ClientSession, func()) { + t.Helper() + clientTransport, serverTransport := mcp.NewInMemoryTransports() + serverSession, err := newMCPServer().Connect(context.Background(), serverTransport, nil) + if err != nil { + t.Fatal(err) } - if err := json.Unmarshal(output.Bytes(), &response); err != nil { + client := mcp.NewClient(&mcp.Implementation{Name: "teamsctl-test"}, nil) + clientSession, err := client.Connect(context.Background(), clientTransport, nil) + if err != nil { t.Fatal(err) } - if response.Error == nil || response.Error.Code != -32001 { - t.Fatalf("unexpected response: %s", output.String()) + return clientSession, func() { + clientSession.Close() + serverSession.Wait() } } diff --git a/internal/teamsctl/mcp_tools.go b/internal/teamsctl/mcp_tools.go deleted file mode 100644 index d368aa2..0000000 --- a/internal/teamsctl/mcp_tools.go +++ /dev/null @@ -1,198 +0,0 @@ -package teamsctl - -import ( - "encoding/json" - "fmt" -) - -func mcpTools() []map[string]interface{} { - return []map[string]interface{}{ - { - "name": "list_conversations", - "description": "Find Microsoft Teams chats and channels by title. Use query and kind instead of listing everything when looking for a person.", - "inputSchema": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "query": map[string]string{"type": "string", "description": "Case-insensitive title or team-name substring."}, - "kind": map[string]interface{}{"type": "string", "enum": []string{"chat", "channel"}}, - "limit": map[string]interface{}{"type": "integer", "minimum": 0, "default": 50}, - }, - "additionalProperties": false, - }, - }, - { - "name": "get_latest_message", - "description": "Find the best matching one-to-one chat by person or title and return its latest message in one call.", - "inputSchema": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "query": map[string]string{"type": "string", "description": "Person name or chat-title substring."}, - }, - "required": []string{"query"}, - "additionalProperties": false, - }, - }, - { - "name": "get_messages", - "description": "Get recent messages from a chat or channel.", - "inputSchema": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "conversation_id": map[string]string{"type": "string", "description": "Conversation ID, or comma-separated candidate IDs."}, - "limit": map[string]interface{}{"type": "integer", "minimum": 0, "default": 50}, - "name": map[string]string{"type": "string", "description": "Optional conversation title."}, - }, - "required": []string{"conversation_id"}, - "additionalProperties": false, - }, - }, - { - "name": "send_message", - "description": "Send a message to a Microsoft Teams chat or channel. Use format=html for any structured or complex message. IMPORTANT: HTML such as @Name is only styled text and never creates a Teams mention. For every intended real mention, the message MUST contain @Name and the matching name MUST be included in mentions. Unlisted @ text remains plain text.", - "inputSchema": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "conversation_id": map[string]string{"type": "string", "description": "Conversation ID, or comma-separated candidate IDs."}, - "message": map[string]string{"type": "string"}, - "format": map[string]interface{}{ - "type": "string", - "enum": []string{"text", "html"}, - "default": "text", - "description": "Choose html for formatted or multi-part messages; choose text only for simple unformatted text.", - }, - "mentions": map[string]interface{}{ - "type": "array", - "items": map[string]string{"type": "string"}, - "description": "REQUIRED for real Teams mentions. List every intended mentioned person. Each value must have a matching @Name token in message, for example mentions=[\"Mikkel\"] with message=\"Hi @Mikkel\". Without this field, @Name remains plain text even in HTML.", - }, - "mention_entities": map[string]interface{}{ - "type": "array", - "description": "Advanced pre-resolved mentions supplied by the consumer. Prefer mentions for automatic resolution.", - "items": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "token": map[string]string{"type": "string", "description": "Matching @token in message; defaults to display_name."}, - "display_name": map[string]string{"type": "string"}, - "mri": map[string]string{"type": "string"}, - "object_id": map[string]string{"type": "string"}, - }, - "required": []string{"display_name"}, - "additionalProperties": false, - }, - }, - }, - "required": []string{"conversation_id", "message"}, - "additionalProperties": false, - }, - }, - } -} - -func callTool(service *Service, rawParams json.RawMessage) (toolResult, error) { - var request struct { - Name string `json:"name"` - Arguments json.RawMessage `json:"arguments"` - } - if err := json.Unmarshal(rawParams, &request); err != nil { - return toolResult{}, fmt.Errorf("invalid tool request: %w", err) - } - var value interface{} - switch request.Name { - case "list_conversations": - var args struct { - Query string `json:"query"` - Kind string `json:"kind"` - Limit *int `json:"limit"` - } - if err := json.Unmarshal(request.Arguments, &args); err != nil { - return toolResult{}, fmt.Errorf("invalid list_conversations arguments: %w", err) - } - limit := 50 - if args.Limit != nil { - limit = *args.Limit - } - conversations, err := service.FindConversations(args.Query, args.Kind, limit) - if err != nil { - return toolResult{}, err - } - value = conversations - case "get_latest_message": - var args struct { - Query string `json:"query"` - } - if err := json.Unmarshal(request.Arguments, &args); err != nil { - return toolResult{}, fmt.Errorf("invalid get_latest_message arguments: %w", err) - } - if args.Query == "" { - return toolResult{}, fmt.Errorf("query is required") - } - conversations, err := service.FindConversations(args.Query, "chat", 10) - if err != nil { - return toolResult{}, err - } - if len(conversations) == 0 { - return toolResult{}, fmt.Errorf("no chat found matching %q", args.Query) - } - messages, err := service.Messages(conversations[0].IDs, conversations[0].Title, 1) - if err != nil { - return toolResult{}, err - } - var latest interface{} - if len(messages) > 0 { - latest = messages[0] - } - value = map[string]interface{}{"conversation": conversations[0], "message": latest} - case "get_messages": - var args struct { - ConversationID string `json:"conversation_id"` - Limit *int `json:"limit"` - Name string `json:"name"` - } - if err := json.Unmarshal(request.Arguments, &args); err != nil { - return toolResult{}, fmt.Errorf("invalid get_messages arguments: %w", err) - } - if args.ConversationID == "" { - return toolResult{}, fmt.Errorf("conversation_id is required") - } - limit := 50 - if args.Limit != nil { - limit = *args.Limit - } - messages, err := service.Messages(splitIDs(args.ConversationID), args.Name, limit) - if err != nil { - return toolResult{}, err - } - value = messages - case "send_message": - var args struct { - ConversationID string `json:"conversation_id"` - Message string `json:"message"` - Format string `json:"format"` - Mentions []string `json:"mentions"` - MentionEntities []MentionEntity `json:"mention_entities"` - } - if err := json.Unmarshal(request.Arguments, &args); err != nil { - return toolResult{}, fmt.Errorf("invalid send_message arguments: %w", err) - } - if args.ConversationID == "" || args.Message == "" { - return toolResult{}, fmt.Errorf("conversation_id and message are required") - } - if err := service.Send(splitIDs(args.ConversationID), args.Message, SendOptions{ - Format: args.Format, Mentions: args.Mentions, MentionEntities: args.MentionEntities, - }); err != nil { - return toolResult{}, err - } - value = map[string]bool{"sent": true} - default: - return toolResult{}, fmt.Errorf("unknown tool %q", request.Name) - } - encoded, err := json.Marshal(value) - if err != nil { - return toolResult{}, err - } - return toolResult{Content: []toolContent{{Type: "text", Text: string(encoded)}}}, nil -} - -func errorToolResult(err error) toolResult { - return toolResult{Content: []toolContent{{Type: "text", Text: err.Error()}}, IsError: true} -} diff --git a/internal/teamsctl/mcp_tools_test.go b/internal/teamsctl/mcp_tools_test.go deleted file mode 100644 index 25845b8..0000000 --- a/internal/teamsctl/mcp_tools_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package teamsctl - -import ( - "bytes" - "encoding/json" - "strings" - "testing" -) - -func TestMCPListsTools(t *testing.T) { - input := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`) - var output bytes.Buffer - if err := RunMCP(input, &output); err != nil { - t.Fatal(err) - } - - var response map[string]interface{} - if err := json.Unmarshal(output.Bytes(), &response); err != nil { - t.Fatal(err) - } - listed := response["result"].(map[string]interface{})["tools"].([]interface{}) - if len(listed) != 4 { - t.Fatalf("tools count = %d", len(listed)) - } -} - -func TestMCPToolSchemas(t *testing.T) { - tools := mcpTools() - if len(tools) != 4 { - t.Fatalf("tools count = %d", len(tools)) - } - - wantNames := []string{"list_conversations", "get_latest_message", "get_messages", "send_message"} - for i, tool := range tools { - if tool["name"] != wantNames[i] { - t.Errorf("tool %d name = %v, want %q", i, tool["name"], wantNames[i]) - } - schema, ok := tool["inputSchema"].(map[string]interface{}) - if !ok { - t.Fatalf("tool %q inputSchema has type %T", wantNames[i], tool["inputSchema"]) - } - if schema["type"] != "object" || schema["additionalProperties"] != false { - t.Errorf("tool %q has open or non-object schema: %#v", wantNames[i], schema) - } - } -} diff --git a/internal/teamsctl/models.go b/internal/teamsctl/models.go index 4389dca..ec62a23 100644 --- a/internal/teamsctl/models.go +++ b/internal/teamsctl/models.go @@ -1,9 +1,6 @@ package teamsctl -import ( - "encoding/json" - "time" -) +import "time" type Conversation struct { Kind string `json:"kind"` @@ -52,32 +49,3 @@ type mentionResolution struct { Query string Wire mentionWire } - -type rpcRequest struct { - JSONRPC string `json:"jsonrpc"` - ID json.RawMessage `json:"id,omitempty"` - Method string `json:"method"` - Params json.RawMessage `json:"params,omitempty"` -} - -type rpcResponse struct { - JSONRPC string `json:"jsonrpc"` - ID json.RawMessage `json:"id"` - Result interface{} `json:"result,omitempty"` - Error *rpcError `json:"error,omitempty"` -} - -type rpcError struct { - Code int `json:"code"` - Message string `json:"message"` -} - -type toolContent struct { - Type string `json:"type"` - Text string `json:"text"` -} - -type toolResult struct { - Content []toolContent `json:"content"` - IsError bool `json:"isError,omitempty"` -} From ba6ab5ef10772874e5cd8a1f395797998d0b87f5 Mon Sep 17 00:00:00 2001 From: Simon Frydensbjerg Sinding <5576291+TheSinding@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:57:13 +0200 Subject: [PATCH 2/5] docs: add changelog entry --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c61b094 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## Unreleased + +- Migrated the MCP server to the official Go MCP SDK. +- Added recipient-phrase resolution for one-to-one chats, group chats, and channels. +- Send messages individually when a requested multi-person group chat does not exist. From ddc68bf608c6a8438dc0828998e1de1e175020a9 Mon Sep 17 00:00:00 2001 From: Simon Frydensbjerg Sinding <5576291+TheSinding@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:58:12 +0200 Subject: [PATCH 3/5] docs: mark changelog for 0.6 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c61b094..870ae0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.6 - 2026-08-04 - Migrated the MCP server to the official Go MCP SDK. - Added recipient-phrase resolution for one-to-one chats, group chats, and channels. From 8840fc6de6286efe60dfc7d4f75d67eef51bf7a4 Mon Sep 17 00:00:00 2001 From: Simon Frydensbjerg Sinding <5576291+TheSinding@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:59:40 +0200 Subject: [PATCH 4/5] test: handle MCP session cleanup errors --- internal/teamsctl/mcp_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/teamsctl/mcp_test.go b/internal/teamsctl/mcp_test.go index d88cccf..af1a115 100644 --- a/internal/teamsctl/mcp_test.go +++ b/internal/teamsctl/mcp_test.go @@ -69,7 +69,7 @@ func connectMCP(t *testing.T) (*mcp.ClientSession, func()) { t.Fatal(err) } return clientSession, func() { - clientSession.Close() - serverSession.Wait() + _ = clientSession.Close() + _ = serverSession.Wait() } } From 006a285ce101d9688d6fbac242c0298013955966 Mon Sep 17 00:00:00 2001 From: Simon Frydensbjerg Sinding <5576291+TheSinding@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:39:05 +0200 Subject: [PATCH 5/5] fix: validate MCP recipient inputs --- internal/teamsctl/conversations_test.go | 13 +++++++++++ internal/teamsctl/mcp.go | 29 ++++++++++++++++++++----- internal/teamsctl/mcp_test.go | 7 ++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/internal/teamsctl/conversations_test.go b/internal/teamsctl/conversations_test.go index 0ffa176..042a0a6 100644 --- a/internal/teamsctl/conversations_test.go +++ b/internal/teamsctl/conversations_test.go @@ -18,11 +18,24 @@ func TestConversationTargetTreatsNamesAndIDsDifferently(t *testing.T) { if looksLikeConversationID("Mikkel") { t.Fatal("name was treated as an ID") } + if looksLikeConversationID("mikkel@example.com") { + t.Fatal("email was treated as an ID") + } if !looksLikeConversationID("19:conversation-id@thread.v2") { t.Fatal("Teams conversation ID was treated as a name") } } +func TestLimitOrDefault(t *testing.T) { + if got := limitOrDefault(nil); got != 50 { + t.Fatalf("limitOrDefault(nil) = %d", got) + } + all := 0 + if got := limitOrDefault(&all); got != 0 { + t.Fatalf("limitOrDefault(0) = %d", got) + } +} + func TestRecipientIntent(t *testing.T) { if got := splitRecipientNames("Mike and Charlie"); len(got) != 2 || got[0] != "Mike" || got[1] != "Charlie" { t.Fatalf("splitRecipientNames() = %#v", got) diff --git a/internal/teamsctl/mcp.go b/internal/teamsctl/mcp.go index 74a30d4..8cf0b3e 100644 --- a/internal/teamsctl/mcp.go +++ b/internal/teamsctl/mcp.go @@ -18,7 +18,7 @@ var checkMCPAuth = teamsauth.CheckTokens type listConversationsInput struct { Query string `json:"query,omitempty" jsonschema:"Case-insensitive title or team-name substring."` Kind string `json:"kind,omitempty" jsonschema:"Conversation kind: chat or channel."` - Limit int `json:"limit,omitempty" jsonschema:"Maximum number of conversations to return; zero returns all."` + Limit *int `json:"limit,omitempty" jsonschema:"Maximum number of conversations to return. Omit for 50; use zero for all."` } type latestMessageInput struct { @@ -28,7 +28,7 @@ type latestMessageInput struct { type messagesInput struct { Recipient string `json:"recipient,omitempty" jsonschema:"Recipient phrase from the user, such as Mike, Mike and Charlie, ASM group chat, or ASM channel."` ConversationID string `json:"conversation_id,omitempty" jsonschema:"Deprecated: use recipient. A Teams conversation ID remains accepted."` - Limit int `json:"limit,omitempty" jsonschema:"Maximum number of messages to return; zero returns all."` + Limit *int `json:"limit,omitempty" jsonschema:"Maximum number of messages to return. Omit for 50; use zero for all."` } type sendMessageInput struct { @@ -99,11 +99,14 @@ func (app *mcpApplication) listConversations(_ context.Context, _ *mcp.CallToolR if err != nil { return nil, nil, err } - conversations, err := service.FindConversations(input.Query, input.Kind, input.Limit) + conversations, err := service.FindConversations(input.Query, input.Kind, limitOrDefault(input.Limit)) return nil, conversations, err } func (app *mcpApplication) latestMessage(_ context.Context, _ *mcp.CallToolRequest, input latestMessageInput) (*mcp.CallToolResult, any, error) { + if strings.TrimSpace(input.Query) == "" { + return nil, nil, fmt.Errorf("query is required") + } service, err := app.serviceForTool() if err != nil { return nil, nil, err @@ -132,7 +135,7 @@ func (app *mcpApplication) messages(_ context.Context, _ *mcp.CallToolRequest, i if err != nil { return nil, nil, err } - messages, err := service.Messages(target.IDs, target.Name, input.Limit) + messages, err := service.Messages(target.IDs, target.Name, limitOrDefault(input.Limit)) return nil, messages, err } @@ -284,7 +287,23 @@ func matchingGroupConversation(conversations []Conversation, recipients []string } func looksLikeConversationID(target string) bool { - return strings.ContainsAny(target, ":@,") + ids := splitIDs(target) + if len(ids) == 0 { + return false + } + for _, id := range ids { + if !strings.HasPrefix(id, "19:") && !strings.HasPrefix(id, "48:") { + return false + } + } + return true +} + +func limitOrDefault(limit *int) int { + if limit == nil { + return 50 + } + return *limit } func splitRecipientNames(target string) []string { diff --git a/internal/teamsctl/mcp_test.go b/internal/teamsctl/mcp_test.go index af1a115..7b187f8 100644 --- a/internal/teamsctl/mcp_test.go +++ b/internal/teamsctl/mcp_test.go @@ -56,6 +56,13 @@ func TestMCPInitializeRejectsExpiredAuth(t *testing.T) { } } +func TestLatestMessageRequiresQuery(t *testing.T) { + _, _, err := (&mcpApplication{}).latestMessage(context.Background(), nil, latestMessageInput{Query: " "}) + if err == nil || !strings.Contains(err.Error(), "query is required") { + t.Fatalf("latestMessage() error = %v", err) + } +} + func connectMCP(t *testing.T) (*mcp.ClientSession, func()) { t.Helper() clientTransport, serverTransport := mcp.NewInMemoryTransports()