Skip to content

Add Microsoft Teams integration#162

Open
daltoniam wants to merge 6 commits into
mainfrom
switchboard-teams
Open

Add Microsoft Teams integration#162
daltoniam wants to merge 6 commits into
mainfrom
switchboard-teams

Conversation

@daltoniam
Copy link
Copy Markdown
Owner

Summary

  • Adds a full Microsoft Teams adapter (integrations/teams/) implementing the mcp.Integration interface against the Microsoft Graph API.
  • 26 tools spanning auth/tenant management, chats, joined teams + channels, users, and presence — multi-tenant token store with proactive + 401-triggered refresh.
  • Uses Microsoft device-code OAuth against the Azure CLI public client (no client secret required for personal/dev use); persists tokens to ~/.teams-mcp-tokens.json.

Test plan

  • go test ./integrations/teams/... passes (29.9% coverage, 30+ unit tests)
  • go tool golangci-lint run ./integrations/teams/... clean
  • TestDispatchMap_AllToolsCovered + TestDispatchMap_NoOrphanHandlers parity tests
  • TestFieldCompactionSpecs_* parity tests
  • TestNewArgs_ErrCheckParity covers all new handlers automatically
  • make ci passes for all packages except browser.TestPage_Evaluate (pre-existing failure on main, unrelated to this change)
  • Manual: teams_login → device-code flow → teams_list_chats end-to-end against a real tenant

💘 Generated with Crush

daltoniam and others added 2 commits May 14, 2026 10:23
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>
Copy link
Copy Markdown
Collaborator

@acmacalister acmacalister left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread integrations/teams/oauth.go Outdated
Comment thread integrations/teams/oauth.go Outdated
- 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>
Copy link
Copy Markdown
Collaborator

@acmacalister acmacalister left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid integration — clean architecture, good test coverage, proactive token refresh, proper resource limits everywhere. Two concurrency nits and one consistency note inline.

Comment thread integrations/teams/teams.go Outdated
Comment thread integrations/teams/oauth.go Outdated
Comment thread integrations/teams/oauth.go Outdated
- 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>
Copy link
Copy Markdown
Collaborator

@acmacalister acmacalister left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid adapter — the multi-tenant token store, proactive refresh, and device-code flow are well implemented. Three things worth a look inline.

Comment thread integrations/teams/oauth.go
Comment thread integrations/teams/teams.go
Comment thread integrations/teams/users.go Outdated
- 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>
Copy link
Copy Markdown
Collaborator

@acmacalister acmacalister left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread integrations/teams/teams_test.go
Comment thread integrations/teams/teams_test.go
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>
Copy link
Copy Markdown
Collaborator

@acmacalister acmacalister left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants