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()