Add Microsoft Teams integration#162
Conversation
Implements a full Teams adapter backed by the Microsoft Graph API using device-code OAuth against the Azure CLI public client. Supports multi-tenant token storage with proactive + 401-triggered refresh, covers chats, channels, users, and presence, and ships with field compaction specs for all list/search endpoints. 26 tools: login/poll/status/refresh, tenant management, chats (list/get/messages/send/members), joined teams + channels (list/get/messages/replies/send/reply), users (list/get/search), and presence. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The token refresh goroutine outlives Configure's request-scoped ctx, so derive a non-cancellable context via context.WithoutCancel before spawning rather than calling context.Background() inside the goroutine. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
acmacalister
left a comment
There was a problem hiding this comment.
Solid implementation — the multi-tenant token store, proactive refresh, and 401-triggered retry are all handled cleanly. Tests are thorough and the dispatch/compaction parity tests are all present. Two things inline.
- Store client_secret on the integration struct and read it in Configure, so TEAMS_CLIENT_SECRET is no longer a silent no-op for confidential client deployments. Pass it through to both the device-code and refresh-token flows. - Add client_secret to OptionalKeys (kept out of PlainTextKeys so it stays redacted in the web UI). - Cap all three io.ReadAll calls in oauth.go with io.LimitReader and maxResponseSize, matching graphRequestInner. Prevents OOM from a misconfigured or hostile token endpoint. - Add regression test TestConfigure_ReadsClientSecret. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
acmacalister
left a comment
There was a problem hiding this comment.
Solid integration — clean architecture, good test coverage, proactive token refresh, proper resource limits everywhere. Two concurrency nits and one consistency note inline.
- Move stopBg channel allocation inside t.mu and capture into a local before launching backgroundRefresh, so a re-Configure cannot race with the goroutine's select on the channel value. backgroundRefresh now takes the channel as a parameter instead of reading t.stopBg. - Add a ctx field to oauthFlow (set to context.WithoutCancel of the Configure ctx — request-scoped cancellation would kill the long poll prematurely, but values propagate). Use NewRequestWithContext + an ctx-aware sleep in poll() so cancellation actually exits the loop promptly instead of waiting up to the device-code expiry (~15 min). - Pass tenantHint through url.PathEscape in all three OAuth endpoint constructions (devicecode, token poll, refresh), matching the rest of the package's handling of user-supplied path segments. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
acmacalister
left a comment
There was a problem hiding this comment.
Solid adapter — the multi-tenant token store, proactive refresh, and device-code flow are well implemented. Three things worth a look inline.
- refreshTenant: separate the nil-tenant guard from the empty-refresh-token guard so the error message doesn't dereference a nil pointer (the defensive check no longer introduces the very crash it was meant to prevent). - Configure: parse the optional expires_at credential (RFC3339) into the tenant's ExpiresAt so config-injected tokens get proactive refresh before expiry instead of waiting for a real 401 from Graph. - users.go: introduce sanitizeSearchTerm that strips bare double-quotes and backslashes before interpolating into the OData $search expression. Both listUsers and searchUsers now feed user input through it so values like 'foo" OR "mail:secret' produce a well-formed expression instead of a Graph 400 (or worse, a partial injection). Tests added: TestRefreshTenant_NilTenant, TestConfigure_ParsesExpiresAt, TestSanitizeSearchTerm. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
acmacalister
left a comment
There was a problem hiding this comment.
Solid integration — the multi-tenant token store, proactive refresh, and device-code flow are all well-structured. Two small test hygiene items inline; nothing blocking.
newTestIntegration now always points at a local httptest server (defaulting to a 401 stub when no graph server is supplied), so Configure tests no longer fire live HTTPS calls to graph.microsoft.com via resolveTenantIdentities. The previous behaviour passed only because 401s were non-fatal — air-gapped CI would have been slow or broken. It also registers t.Cleanup to close ti.stopBg, so the backgroundRefresh goroutine spawned by Configure doesn't outlive the test. Goroutines no longer accumulate across the suite run and goleak-style checks will stay quiet. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
acmacalister
left a comment
There was a problem hiding this comment.
Solid integration — auth infrastructure (proactive refresh, 401 retry, device-code poll with slow_down handling, atomic token file writes) is well thought out and the HTTP layer tests are thorough. All previous review comments addressed. Two small things inline.
| } | ||
|
|
||
| // teamsTokenStatus reports per-tenant token health. | ||
| func teamsTokenStatus(_ context.Context, t *teamsIntegration, _ map[string]any) (*mcp.ToolResult, error) { |
There was a problem hiding this comment.
teamsTokenStatus has non-trivial logic — it computes age_minutes, determines status ("expired" / "expiring_soon" / "healthy"), and formats expires_in — but has no unit test. A subtle bug like checking remaining <= 0 vs remaining < 0, or the 5*time.Minute threshold, would be invisible. Same gap covers teamsRefreshTokens, teamsGetMe, formatContentType, and all the chat/channel/user handlers. The HTTP layer is solid, but argument-parsing and query-construction paths are untested.
A lightweight table-driven test covering the three status branches would cost about 20 lines:
func TestTeamsTokenStatus(t *testing.T) {
cases := []struct{
name string
exp time.Time
want string
}{
{"healthy", time.Now().Add(10*time.Minute), "healthy"},
{"expiring", time.Now().Add(2*time.Minute), "expiring_soon"},
{"expired", time.Now().Add(-1*time.Second), "expired"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ti := newTestIntegration(t, nil)
ti.store.upsert(&tenant{TenantID: "t1", AccessToken: "a", ExpiresAt: tc.exp})
res, err := ti.Execute(context.Background(), "teams_token_status", nil)
require.NoError(t, err)
assert.Contains(t, res.Data, tc.want)
})
}
}| } | ||
|
|
||
| if resp.StatusCode == http.StatusUnauthorized && canRetry && tn.RefreshToken != "" { | ||
| if rerr := t.refreshTenant(ctx, tn); rerr == nil { |
There was a problem hiding this comment.
The tn variable here is a snapshot read at line 304, before the HTTP round-trip. If backgroundRefresh fires concurrently during that round-trip and Microsoft rotates the refresh token, tn.RefreshToken is now stale — the refresh will fail with "invalid refresh token" even though the current token is sitting in the store. The window is narrow, but the fix is cheap:
if resp.StatusCode == http.StatusUnauthorized && canRetry {
if fresh := t.store.get(tenantID); fresh != nil && fresh.RefreshToken != "" {
if rerr := t.refreshTenant(ctx, fresh); rerr == nil {
return t.graphRequestInner(ctx, tenantID, method, path, body, extraHeaders, false)
}
}
}
Summary
integrations/teams/) implementing themcp.Integrationinterface against the Microsoft Graph API.~/.teams-mcp-tokens.json.Test plan
go test ./integrations/teams/...passes (29.9% coverage, 30+ unit tests)go tool golangci-lint run ./integrations/teams/...cleanTestDispatchMap_AllToolsCovered+TestDispatchMap_NoOrphanHandlersparity testsTestFieldCompactionSpecs_*parity testsTestNewArgs_ErrCheckParitycovers all new handlers automaticallymake cipasses for all packages exceptbrowser.TestPage_Evaluate(pre-existing failure onmain, unrelated to this change)teams_login→ device-code flow →teams_list_chatsend-to-end against a real tenant💘 Generated with Crush