Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Changelog

## 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.
- Send messages individually when a requested multi-person group chat does not exist.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<strong>@Mikkel</strong>` is only bold text: a real mention also requires
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 9 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand All @@ -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
)
28 changes: 24 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down
48 changes: 48 additions & 0 deletions internal/teamsctl/conversations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,51 @@ 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("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)
}
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")
}
}
Loading
Loading