From bd59d8e7ac87d7393e03df64916d6d4da680098b Mon Sep 17 00:00:00 2001 From: Dalton Cherry Date: Thu, 14 May 2026 10:23:22 -0500 Subject: [PATCH 1/6] Add Microsoft Teams integration 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 --- README.md | 9 + cmd/server/main.go | 2 + config/config.go | 14 + config/config_test.go | 12 +- integrations/teams/auth.go | 257 ++++++++++++++ integrations/teams/channels.go | 225 ++++++++++++ integrations/teams/chats.go | 181 ++++++++++ integrations/teams/compact_specs.go | 111 ++++++ integrations/teams/compact_specs_test.go | 54 +++ integrations/teams/oauth.go | 409 +++++++++++++++++++++ integrations/teams/teams.go | 434 +++++++++++++++++++++++ integrations/teams/teams_test.go | 376 ++++++++++++++++++++ integrations/teams/tokens.go | 238 +++++++++++++ integrations/teams/tools.go | 256 +++++++++++++ integrations/teams/users.go | 123 +++++++ 15 files changed, 2695 insertions(+), 6 deletions(-) create mode 100644 integrations/teams/auth.go create mode 100644 integrations/teams/channels.go create mode 100644 integrations/teams/chats.go create mode 100644 integrations/teams/compact_specs.go create mode 100644 integrations/teams/compact_specs_test.go create mode 100644 integrations/teams/oauth.go create mode 100644 integrations/teams/teams.go create mode 100644 integrations/teams/teams_test.go create mode 100644 integrations/teams/tokens.go create mode 100644 integrations/teams/tools.go create mode 100644 integrations/teams/users.go diff --git a/README.md b/README.md index 571db77b..b4eba240 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,14 @@ Any integration with credentials provided via env vars will auto-enable without | Readarr | `api_key` | `READARR_API_KEY` | | Readarr | `base_url` | `READARR_URL` | | DigitalOcean | `api_token` | `DIGITALOCEAN_TOKEN` | +| Teams | `tenant_id` | `TEAMS_TENANT_ID` | +| Teams | `client_id` | `TEAMS_CLIENT_ID` | +| Teams | `client_secret` | `TEAMS_CLIENT_SECRET` | +| Teams | `graph_base_url` | `TEAMS_GRAPH_BASE_URL` | +| Teams | `login_base_url` | `TEAMS_LOGIN_BASE_URL` | +| Teams | `scopes` | `TEAMS_SCOPES` | +| Teams | `access_token` | `TEAMS_ACCESS_TOKEN` | +| Teams | `refresh_token` | `TEAMS_REFRESH_TOKEN` | ### OAuth Setup @@ -189,6 +197,7 @@ Some integrations support OAuth flows through the web UI at `http://localhost:38 | Metabase | API Key | Set `METABASE_API_KEY` and `METABASE_URL` env vars or enter in web UI | | PostHog | Personal API Key | Set `POSTHOG_API_KEY` env var or enter in web UI | | Postgres | Connection String | Set `DATABASE_URL` env var or enter in web UI | +| Teams | OAuth Device Flow | Web UI → Teams → Setup, or use `teams_login` tool (Microsoft device-code flow) | ## Adding to Cursor / Claude Desktop diff --git a/cmd/server/main.go b/cmd/server/main.go index 533f1c81..1f3201c6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -47,6 +47,7 @@ import ( snowflakeInt "github.com/daltoniam/switchboard/integrations/snowflake" "github.com/daltoniam/switchboard/integrations/suno" switchboardInt "github.com/daltoniam/switchboard/integrations/switchboard" + teamsInt "github.com/daltoniam/switchboard/integrations/teams" webfetchInt "github.com/daltoniam/switchboard/integrations/webfetch" xInt "github.com/daltoniam/switchboard/integrations/x" "github.com/daltoniam/switchboard/integrations/ynab" @@ -209,6 +210,7 @@ func runServer(stdioMode bool, port int, discoverAll bool) { linear.New("https://mcp.linear.app"), sentry.New(), slackInt.New(), + teamsInt.New(), metabase.New(), awsInt.New(), posthog.New(), diff --git a/config/config.go b/config/config.go index 4455c864..811df917 100644 --- a/config/config.go +++ b/config/config.go @@ -39,6 +39,16 @@ var envMapping = map[string]map[string]string{ "cookie": "SLACK_COOKIE", "team_id": "SLACK_TEAM_ID", }, + "teams": { + "tenant_id": "TEAMS_TENANT_ID", + "client_id": "TEAMS_CLIENT_ID", + "client_secret": "TEAMS_CLIENT_SECRET", + "graph_base_url": "TEAMS_GRAPH_BASE_URL", + "login_base_url": "TEAMS_LOGIN_BASE_URL", + "scopes": "TEAMS_SCOPES", + "access_token": "TEAMS_ACCESS_TOKEN", + "refresh_token": "TEAMS_REFRESH_TOKEN", + }, "metabase": { "api_key": "METABASE_API_KEY", "url": "METABASE_URL", @@ -199,6 +209,10 @@ func defaultConfig() *mcp.Config { Enabled: false, Credentials: mcp.Credentials{"token": "", "cookie": "", "team_id": "", mcp.CredKeyTokenSource: ""}, }, + "teams": { + Enabled: false, + Credentials: mcp.Credentials{"tenant_id": "", "client_id": "", "client_secret": "", "graph_base_url": "", "login_base_url": "", "scopes": "", "access_token": "", "refresh_token": "", "expires_at": "", mcp.CredKeyTokenSource: ""}, + }, "metabase": { Enabled: false, Credentials: mcp.Credentials{"api_key": "", "url": ""}, diff --git a/config/config_test.go b/config/config_test.go index 6c4e7cb1..7637effe 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -32,8 +32,8 @@ func TestLoad_CreatesDefaultWhenMissing(t *testing.T) { _, err = os.Stat(path) assert.NoError(t, err) - assert.Len(t, m.cfg.Integrations, 35) - for _, name := range []string{"github", "datadog", "linear", "sentry", "slack", "metabase", "aws", "posthog", "postgres", "clickhouse", "elasticsearch", "pganalyze", "rwx", "gmail", "notion", "ollama", "ynab", "gcp", "suno", "amazon", "jira", "confluence", "salesforce", "cloudflare", "digitalocean", "fly", "snowflake", "acp", "web", "botidentity", "x", "signoz", "nomad", "switchboard"} { + assert.Len(t, m.cfg.Integrations, 36) + for _, name := range []string{"github", "datadog", "linear", "sentry", "slack", "teams", "metabase", "aws", "posthog", "postgres", "clickhouse", "elasticsearch", "pganalyze", "rwx", "gmail", "notion", "ollama", "ynab", "gcp", "suno", "amazon", "jira", "confluence", "salesforce", "cloudflare", "digitalocean", "fly", "snowflake", "acp", "web", "botidentity", "x", "signoz", "nomad", "switchboard"} { ic, ok := m.cfg.Integrations[name] assert.True(t, ok, "missing default integration: %s", name) assert.False(t, ic.Enabled) @@ -134,7 +134,7 @@ func TestSave(t *testing.T) { var cfg mcp.Config require.NoError(t, json.Unmarshal(data, &cfg)) - assert.Len(t, cfg.Integrations, 35) + assert.Len(t, cfg.Integrations, 36) } func TestGet(t *testing.T) { @@ -143,7 +143,7 @@ func TestGet(t *testing.T) { cfg := m.Get() assert.NotNil(t, cfg) - assert.Len(t, cfg.Integrations, 35) + assert.Len(t, cfg.Integrations, 36) } func TestUpdate(t *testing.T) { @@ -253,7 +253,7 @@ func TestEnabledIntegrations_Multiple(t *testing.T) { func TestDefaultConfig(t *testing.T) { cfg := defaultConfig() require.NotNil(t, cfg) - assert.Len(t, cfg.Integrations, 35) + assert.Len(t, cfg.Integrations, 36) expected := map[string][]string{ "github": {"token", "client_id", "token_source"}, @@ -503,7 +503,7 @@ func TestEnvMapping_ReturnsMapping(t *testing.T) { assert.Equal(t, "SIGNOZ_API_KEY", m["signoz"]["api_key"]) assert.Equal(t, "NOMAD_ADDR", m["nomad"]["address"]) assert.Equal(t, "NOMAD_TOKEN", m["nomad"]["token"]) - assert.Len(t, m, 25) + assert.Len(t, m, 26) } func TestToolGlobs_PersistThroughSaveLoad(t *testing.T) { diff --git a/integrations/teams/auth.go b/integrations/teams/auth.go new file mode 100644 index 00000000..384fd5d1 --- /dev/null +++ b/integrations/teams/auth.go @@ -0,0 +1,257 @@ +package teams + +import ( + "context" + "encoding/json" + "fmt" + "math" + "strings" + "time" + + mcp "github.com/daltoniam/switchboard" +) + +// teamsLogin starts (or returns the in-progress) device-code OAuth flow. +func teamsLogin(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + r := mcp.NewArgs(args) + tenantHint := r.Str("tenant") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + + // If a flow is already in progress and not yet done, return the same prompt + // so the human can keep using the existing user_code. + activeFlow.mu.Lock() + existing := activeFlow.flow + activeFlow.mu.Unlock() + if existing != nil { + existing.mu.Lock() + stillRunning := !existing.done && time.Since(existing.startedAt) < time.Duration(existing.deviceResp.ExpiresIn)*time.Second + var dcr *deviceCodeResponse + if stillRunning { + dcr = existing.deviceResp + } + existing.mu.Unlock() + if dcr != nil { + return loginResponse(dcr, "resumed"), nil + } + } + + dcr, err := t.startOAuth(ctx, tenantHint) + if err != nil { + return mcp.ErrResult(err) + } + return loginResponse(dcr, "started"), nil +} + +func loginResponse(dcr *deviceCodeResponse, status string) *mcp.ToolResult { + payload := map[string]any{ + "status": status, + "user_code": dcr.UserCode, + "verification_uri": dcr.VerificationURI, + "expires_in": dcr.ExpiresIn, + "interval_seconds": dcr.Interval, + "message": dcr.Message, + "next_step": "Visit verification_uri in a browser, enter user_code, then call teams_login_poll to detect completion.", + } + data, _ := json.Marshal(payload) + return &mcp.ToolResult{Data: string(data)} +} + +// teamsLoginPoll reports the state of the active device-code flow. +func teamsLoginPoll(_ context.Context, _ *teamsIntegration, _ map[string]any) (*mcp.ToolResult, error) { + res := pollOAuth() + return mcp.JSONResult(res) +} + +// teamsTokenStatus reports per-tenant token health. +func teamsTokenStatus(_ context.Context, t *teamsIntegration, _ map[string]any) (*mcp.ToolResult, error) { + type entry struct { + TenantID string `json:"tenant_id"` + TenantName string `json:"tenant_name,omitempty"` + UserUPN string `json:"user_upn,omitempty"` + UserDisplay string `json:"user_display,omitempty"` + Status string `json:"status"` + HasRefresh bool `json:"has_refresh"` + AgeMinutes float64 `json:"age_minutes"` + ExpiresIn string `json:"expires_in,omitempty"` + Source string `json:"source,omitempty"` + IsDefault bool `json:"is_default"` + } + defaultID := t.store.defaultID() + var entries []entry + for _, tn := range t.store.all() { + age := 0.0 + if !tn.UpdatedAt.IsZero() { + age = math.Round(time.Since(tn.UpdatedAt).Minutes()*10) / 10 + } + status := "healthy" + expiresIn := "" + if !tn.ExpiresAt.IsZero() { + remaining := time.Until(tn.ExpiresAt) + if remaining <= 0 { + status = "expired" + expiresIn = "0s" + } else { + if remaining < 5*time.Minute { + status = "expiring_soon" + } + expiresIn = remaining.Round(time.Second).String() + } + } + if tn.AccessToken == "" { + status = "missing" + } + entries = append(entries, entry{ + TenantID: tn.TenantID, + TenantName: tn.TenantName, + UserUPN: tn.UserUPN, + UserDisplay: tn.UserDisplay, + Status: status, + HasRefresh: tn.RefreshToken != "", + AgeMinutes: age, + ExpiresIn: expiresIn, + Source: tn.Source, + IsDefault: tn.TenantID == defaultID, + }) + } + return mcp.JSONResult(map[string]any{ + "tenant_count": len(entries), + "default_tenant_id": defaultID, + "tenants": entries, + "auto_refresh": map[string]any{ + "enabled": true, + "interval_minutes": 15, + "skew_seconds": int(refreshSkew.Seconds()), + }, + }) +} + +// teamsRefreshTokens forces a refresh for the (default or specified) tenant. +func teamsRefreshTokens(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + r := mcp.NewArgs(args) + tid := r.Str("tenant_id") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + var tn *tenant + if tid != "" { + tn = t.store.get(tid) + if tn == nil { + return mcp.ErrResult(fmt.Errorf("unknown tenant: %s", tid)) + } + } else { + tn = t.activeTenant() + if tn == nil { + return mcp.ErrResult(fmt.Errorf("no tenant configured — run teams_login first")) + } + } + if tn.RefreshToken == "" { + return &mcp.ToolResult{ + Data: fmt.Sprintf("tenant %s has no refresh_token; re-run teams_login to acquire one", tn.TenantID), + IsError: true, + }, nil + } + if err := t.refreshTenant(ctx, tn); err != nil { + return mcp.ErrResult(err) + } + refreshed := t.store.get(tn.TenantID) + return mcp.JSONResult(map[string]any{ + "status": "refreshed", + "tenant_id": refreshed.TenantID, + "expires_at": refreshed.ExpiresAt.UTC().Format(time.RFC3339), + }) +} + +// teamsListTenants returns all configured tenants for the user. +func teamsListTenants(_ context.Context, t *teamsIntegration, _ map[string]any) (*mcp.ToolResult, error) { + type info struct { + TenantID string `json:"tenant_id"` + TenantName string `json:"tenant_name,omitempty"` + UserUPN string `json:"user_upn,omitempty"` + UserDisplay string `json:"user_display,omitempty"` + IsDefault bool `json:"is_default"` + } + defaultID := t.store.defaultID() + all := t.store.all() + out := make([]info, 0, len(all)) + for _, tn := range all { + out = append(out, info{ + TenantID: tn.TenantID, + TenantName: tn.TenantName, + UserUPN: tn.UserUPN, + UserDisplay: tn.UserDisplay, + IsDefault: tn.TenantID == defaultID, + }) + } + return mcp.JSONResult(map[string]any{ + "count": len(out), + "default_tenant_id": defaultID, + "tenants": out, + }) +} + +// teamsRemoveTenant forgets cached tokens for a tenant. +func teamsRemoveTenant(_ context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + r := mcp.NewArgs(args) + tid := r.Str("tenant_id") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if tid == "" { + return mcp.ErrResult(fmt.Errorf("tenant_id is required")) + } + if t.store.get(tid) == nil { + return mcp.ErrResult(fmt.Errorf("unknown tenant: %s", tid)) + } + t.store.remove(tid) + _ = t.store.saveToFile() + return mcp.JSONResult(map[string]any{ + "status": "removed", + "tenant_id": tid, + "default_tenant_id": t.store.defaultID(), + }) +} + +// teamsSetDefault pins the default tenant for tools called without tenant_id. +func teamsSetDefault(_ context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + r := mcp.NewArgs(args) + tid := r.Str("tenant_id") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if tid == "" { + return mcp.ErrResult(fmt.Errorf("tenant_id is required")) + } + if t.store.get(tid) == nil { + return mcp.ErrResult(fmt.Errorf("unknown tenant: %s", tid)) + } + t.store.setDefault(tid) + _ = t.store.saveToFile() + return mcp.JSONResult(map[string]any{ + "status": "ok", + "default_tenant_id": tid, + }) +} + +// teamsGetMe returns the /me Graph profile for the chosen tenant. +func teamsGetMe(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + data, err := t.graphGet(ctx, tn.TenantID, "/me") + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +// formatContentType is shared by chat/channel senders. +func formatContentType(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" || s == "html" { + return "html" + } + return "text" +} diff --git a/integrations/teams/channels.go b/integrations/teams/channels.go new file mode 100644 index 00000000..0c1f8afa --- /dev/null +++ b/integrations/teams/channels.go @@ -0,0 +1,225 @@ +package teams + +import ( + "context" + "fmt" + "net/url" + + mcp "github.com/daltoniam/switchboard" +) + +// listJoinedTeams -> GET /me/joinedTeams +func listJoinedTeams(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + data, err := t.graphGet(ctx, tn.TenantID, "/me/joinedTeams") + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +// listChannels -> GET /teams/{tid}/channels +func listChannels(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + r := mcp.NewArgs(args) + teamID := r.Str("team_id") + filter := r.Str("filter") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if teamID == "" { + return mcp.ErrResult(fmt.Errorf("team_id is required")) + } + path := fmt.Sprintf("/teams/%s/channels", url.PathEscape(teamID)) + if filter != "" { + path += "?$filter=" + url.QueryEscape(filter) + } + data, err := t.graphGet(ctx, tn.TenantID, path) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +// getChannel -> GET /teams/{tid}/channels/{cid} +func getChannel(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + r := mcp.NewArgs(args) + teamID := r.Str("team_id") + channelID := r.Str("channel_id") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if teamID == "" || channelID == "" { + return mcp.ErrResult(fmt.Errorf("team_id and channel_id are required")) + } + path := fmt.Sprintf("/teams/%s/channels/%s", url.PathEscape(teamID), url.PathEscape(channelID)) + data, err := t.graphGet(ctx, tn.TenantID, path) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +// listChannelMessages -> GET /teams/{tid}/channels/{cid}/messages +func listChannelMessages(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + r := mcp.NewArgs(args) + teamID := r.Str("team_id") + channelID := r.Str("channel_id") + top := r.OptInt("top", 20) + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if teamID == "" || channelID == "" { + return mcp.ErrResult(fmt.Errorf("team_id and channel_id are required")) + } + if top > 50 { + top = 50 + } + q := url.Values{} + q.Set("$top", fmt.Sprintf("%d", top)) + path := fmt.Sprintf("/teams/%s/channels/%s/messages?%s", url.PathEscape(teamID), url.PathEscape(channelID), q.Encode()) + data, err := t.graphGet(ctx, tn.TenantID, path) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +// getChannelMessage -> GET /teams/{tid}/channels/{cid}/messages/{mid} +func getChannelMessage(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + r := mcp.NewArgs(args) + teamID := r.Str("team_id") + channelID := r.Str("channel_id") + msgID := r.Str("message_id") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if teamID == "" || channelID == "" || msgID == "" { + return mcp.ErrResult(fmt.Errorf("team_id, channel_id, and message_id are required")) + } + path := fmt.Sprintf("/teams/%s/channels/%s/messages/%s", + url.PathEscape(teamID), url.PathEscape(channelID), url.PathEscape(msgID)) + data, err := t.graphGet(ctx, tn.TenantID, path) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +// listMessageReplies -> GET /teams/{tid}/channels/{cid}/messages/{mid}/replies +func listMessageReplies(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + r := mcp.NewArgs(args) + teamID := r.Str("team_id") + channelID := r.Str("channel_id") + msgID := r.Str("message_id") + top := r.OptInt("top", 20) + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if teamID == "" || channelID == "" || msgID == "" { + return mcp.ErrResult(fmt.Errorf("team_id, channel_id, and message_id are required")) + } + if top > 50 { + top = 50 + } + q := url.Values{} + q.Set("$top", fmt.Sprintf("%d", top)) + path := fmt.Sprintf("/teams/%s/channels/%s/messages/%s/replies?%s", + url.PathEscape(teamID), url.PathEscape(channelID), url.PathEscape(msgID), q.Encode()) + data, err := t.graphGet(ctx, tn.TenantID, path) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +// sendChannelMessage -> POST /teams/{tid}/channels/{cid}/messages +func sendChannelMessage(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + r := mcp.NewArgs(args) + teamID := r.Str("team_id") + channelID := r.Str("channel_id") + content := r.Str("content") + contentType := r.Str("content_type") + subject := r.Str("subject") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if teamID == "" || channelID == "" || content == "" { + return mcp.ErrResult(fmt.Errorf("team_id, channel_id, and content are required")) + } + body := map[string]any{ + "body": map[string]any{ + "contentType": formatContentType(contentType), + "content": content, + }, + } + if subject != "" { + body["subject"] = subject + } + path := fmt.Sprintf("/teams/%s/channels/%s/messages", + url.PathEscape(teamID), url.PathEscape(channelID)) + data, err := t.graphPost(ctx, tn.TenantID, path, body) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +// replyToChannelMessage -> POST /teams/{tid}/channels/{cid}/messages/{mid}/replies +func replyToChannelMessage(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + r := mcp.NewArgs(args) + teamID := r.Str("team_id") + channelID := r.Str("channel_id") + msgID := r.Str("message_id") + content := r.Str("content") + contentType := r.Str("content_type") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if teamID == "" || channelID == "" || msgID == "" || content == "" { + return mcp.ErrResult(fmt.Errorf("team_id, channel_id, message_id, and content are required")) + } + body := map[string]any{ + "body": map[string]any{ + "contentType": formatContentType(contentType), + "content": content, + }, + } + path := fmt.Sprintf("/teams/%s/channels/%s/messages/%s/replies", + url.PathEscape(teamID), url.PathEscape(channelID), url.PathEscape(msgID)) + data, err := t.graphPost(ctx, tn.TenantID, path, body) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} diff --git a/integrations/teams/chats.go b/integrations/teams/chats.go new file mode 100644 index 00000000..fe693d1b --- /dev/null +++ b/integrations/teams/chats.go @@ -0,0 +1,181 @@ +package teams + +import ( + "context" + "fmt" + "net/url" + + mcp "github.com/daltoniam/switchboard" +) + +// listChats -> GET /me/chats +func listChats(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + r := mcp.NewArgs(args) + top := r.OptInt("top", 20) + filter := r.Str("filter") + orderby := r.Str("orderby") + expand := r.Str("expand") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if top > 50 { + top = 50 + } + q := url.Values{} + q.Set("$top", fmt.Sprintf("%d", top)) + if filter != "" { + q.Set("$filter", filter) + } + if orderby != "" { + q.Set("$orderby", orderby) + } + if expand != "" { + q.Set("$expand", expand) + } + data, err := t.graphGet(ctx, tn.TenantID, "/me/chats?"+q.Encode()) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +// getChat -> GET /chats/{id} +func getChat(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + r := mcp.NewArgs(args) + chatID := r.Str("chat_id") + expand := r.Str("expand") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if chatID == "" { + return mcp.ErrResult(fmt.Errorf("chat_id is required")) + } + path := "/chats/" + url.PathEscape(chatID) + if expand != "" { + path += "?$expand=" + url.QueryEscape(expand) + } + data, err := t.graphGet(ctx, tn.TenantID, path) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +// listChatMessages -> GET /chats/{id}/messages +func listChatMessages(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + r := mcp.NewArgs(args) + chatID := r.Str("chat_id") + top := r.OptInt("top", 20) + orderby := r.Str("orderby") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if chatID == "" { + return mcp.ErrResult(fmt.Errorf("chat_id is required")) + } + if top > 50 { + top = 50 + } + q := url.Values{} + q.Set("$top", fmt.Sprintf("%d", top)) + if orderby != "" { + q.Set("$orderby", orderby) + } + path := fmt.Sprintf("/chats/%s/messages?%s", url.PathEscape(chatID), q.Encode()) + data, err := t.graphGet(ctx, tn.TenantID, path) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +// getChatMessage -> GET /chats/{id}/messages/{mid} +func getChatMessage(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + r := mcp.NewArgs(args) + chatID := r.Str("chat_id") + msgID := r.Str("message_id") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if chatID == "" || msgID == "" { + return mcp.ErrResult(fmt.Errorf("chat_id and message_id are required")) + } + path := fmt.Sprintf("/chats/%s/messages/%s", url.PathEscape(chatID), url.PathEscape(msgID)) + data, err := t.graphGet(ctx, tn.TenantID, path) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +// sendChatMessage -> POST /chats/{id}/messages +func sendChatMessage(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + r := mcp.NewArgs(args) + chatID := r.Str("chat_id") + content := r.Str("content") + contentType := r.Str("content_type") + subject := r.Str("subject") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if chatID == "" || content == "" { + return mcp.ErrResult(fmt.Errorf("chat_id and content are required")) + } + body := map[string]any{ + "body": map[string]any{ + "contentType": formatContentType(contentType), + "content": content, + }, + } + if subject != "" { + body["subject"] = subject + } + path := fmt.Sprintf("/chats/%s/messages", url.PathEscape(chatID)) + data, err := t.graphPost(ctx, tn.TenantID, path, body) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +// listChatMembers -> GET /chats/{id}/members +func listChatMembers(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + r := mcp.NewArgs(args) + chatID := r.Str("chat_id") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if chatID == "" { + return mcp.ErrResult(fmt.Errorf("chat_id is required")) + } + path := fmt.Sprintf("/chats/%s/members", url.PathEscape(chatID)) + data, err := t.graphGet(ctx, tn.TenantID, path) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} diff --git a/integrations/teams/compact_specs.go b/integrations/teams/compact_specs.go new file mode 100644 index 00000000..216072d4 --- /dev/null +++ b/integrations/teams/compact_specs.go @@ -0,0 +1,111 @@ +package teams + +import ( + "fmt" + + mcp "github.com/daltoniam/switchboard" +) + +// rawFieldCompactionSpecs declares which response fields to retain for the +// list/get tools that benefit from compaction. The Microsoft Graph response +// envelope is { "value": [...] } for list endpoints; the columnarizer treats +// "value[]" as the array root. +var rawFieldCompactionSpecs = map[mcp.ToolName][]string{ + mcp.ToolName("teams_list_chats"): { + "value[].id", + "value[].topic", + "value[].chatType", + "value[].createdDateTime", + "value[].lastUpdatedDateTime", + "value[].webUrl", + "value[].members[].displayName", + "value[].lastMessagePreview.body.content", + "@odata.nextLink", + }, + mcp.ToolName("teams_list_chat_messages"): { + "value[].id", + "value[].chatId", + "value[].createdDateTime", + "value[].lastModifiedDateTime", + "value[].from.user.id", + "value[].from.user.displayName", + "value[].subject", + "value[].body.contentType", + "value[].body.content", + "value[].messageType", + "value[].importance", + "@odata.nextLink", + }, + mcp.ToolName("teams_list_chat_members"): { + "value[].id", + "value[].displayName", + "value[].roles", + "value[].userId", + "value[].email", + }, + mcp.ToolName("teams_list_joined_teams"): { + "value[].id", + "value[].displayName", + "value[].description", + "value[].visibility", + "value[].webUrl", + }, + mcp.ToolName("teams_list_channels"): { + "value[].id", + "value[].displayName", + "value[].description", + "value[].membershipType", + "value[].webUrl", + }, + mcp.ToolName("teams_list_channel_messages"): { + "value[].id", + "value[].createdDateTime", + "value[].from.user.id", + "value[].from.user.displayName", + "value[].subject", + "value[].body.contentType", + "value[].body.content", + "value[].importance", + "@odata.nextLink", + }, + mcp.ToolName("teams_list_message_replies"): { + "value[].id", + "value[].createdDateTime", + "value[].from.user.id", + "value[].from.user.displayName", + "value[].body.contentType", + "value[].body.content", + "@odata.nextLink", + }, + mcp.ToolName("teams_list_users"): { + "value[].id", + "value[].displayName", + "value[].userPrincipalName", + "value[].mail", + "value[].jobTitle", + "value[].officeLocation", + "@odata.nextLink", + }, + mcp.ToolName("teams_search_users"): { + "value[].id", + "value[].displayName", + "value[].userPrincipalName", + "value[].mail", + "value[].jobTitle", + "@odata.nextLink", + }, +} + +var fieldCompactionSpecs = mustBuildFieldCompactionSpecs(rawFieldCompactionSpecs) + +func mustBuildFieldCompactionSpecs(raw map[mcp.ToolName][]string) map[mcp.ToolName][]mcp.CompactField { + parsed := make(map[mcp.ToolName][]mcp.CompactField, len(raw)) + for tool, specs := range raw { + fields, err := mcp.ParseCompactSpecs(specs) + if err != nil { + panic(fmt.Sprintf("teams: invalid field compaction spec for %q: %v", tool, err)) + } + parsed[tool] = fields + } + return parsed +} diff --git a/integrations/teams/compact_specs_test.go b/integrations/teams/compact_specs_test.go new file mode 100644 index 00000000..82df9f11 --- /dev/null +++ b/integrations/teams/compact_specs_test.go @@ -0,0 +1,54 @@ +package teams + +import ( + "testing" + + mcp "github.com/daltoniam/switchboard" + "github.com/stretchr/testify/assert" +) + +// TestFieldCompactionSpecs_NoOrphanSpecs asserts every spec entry corresponds +// to a real tool definition so old specs can't silently linger after a rename. +func TestFieldCompactionSpecs_NoOrphanSpecs(t *testing.T) { + defs := make(map[mcp.ToolName]bool) + for _, d := range New().Tools() { + defs[d.Name] = true + } + for name := range rawFieldCompactionSpecs { + assert.True(t, defs[name], "compact spec for unknown tool: %s", name) + } +} + +// TestFieldCompactionSpecs_AllListToolsCovered keeps every list/search tool +// honest about declaring a spec — guards against the "ship a list tool that +// dumps the whole envelope" regression. +func TestFieldCompactionSpecs_AllListToolsCovered(t *testing.T) { + // Tools that intentionally don't need compaction (single-entity GETs, + // auth flows, sends that return small confirmations). + exempt := map[mcp.ToolName]bool{ + mcp.ToolName("teams_login"): true, + mcp.ToolName("teams_login_poll"): true, + mcp.ToolName("teams_token_status"): true, + mcp.ToolName("teams_refresh_tokens"): true, + mcp.ToolName("teams_list_tenants"): true, + mcp.ToolName("teams_remove_tenant"): true, + mcp.ToolName("teams_set_default"): true, + mcp.ToolName("teams_get_me"): true, + mcp.ToolName("teams_get_chat"): true, + mcp.ToolName("teams_get_chat_message"): true, + mcp.ToolName("teams_send_chat_message"): true, + mcp.ToolName("teams_get_channel"): true, + mcp.ToolName("teams_get_channel_message"): true, + mcp.ToolName("teams_send_channel_message"): true, + mcp.ToolName("teams_reply_to_channel_message"): true, + mcp.ToolName("teams_get_user"): true, + mcp.ToolName("teams_get_presence"): true, + } + for _, d := range New().Tools() { + if exempt[d.Name] { + continue + } + _, ok := rawFieldCompactionSpecs[d.Name] + assert.True(t, ok, "tool %s should declare a field compaction spec (or be exempted)", d.Name) + } +} diff --git a/integrations/teams/oauth.go b/integrations/teams/oauth.go new file mode 100644 index 00000000..0e12abed --- /dev/null +++ b/integrations/teams/oauth.go @@ -0,0 +1,409 @@ +package teams + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// Microsoft Entra device-code OAuth flow. +// +// Endpoint shape (v2.0): +// +// POST {login_base_url}/{tenant_or_common}/oauth2/v2.0/devicecode +// client_id={client_id} +// scope={space-separated scopes} +// +// -> { "device_code", "user_code", "verification_uri", "expires_in", "interval", "message" } +// +// POST {login_base_url}/{tenant_or_common}/oauth2/v2.0/token +// client_id={client_id} +// grant_type=urn:ietf:params:oauth:grant-type:device_code +// device_code={device_code} +// +// -> { "access_token", "refresh_token", "expires_in", "token_type", "id_token", "scope" } +// or { "error": "authorization_pending" | "slow_down" | "expired_token" | "access_denied" | ... } +// +// We deliberately do NOT require a client_secret — the default Azure CLI client_id is +// a "public client" (no secret). Customer apps that DO have a secret should set it +// via credentials["client_secret"] and we'll add it to the form payload. + +type deviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` + Message string `json:"message"` +} + +type accessTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +// oauthFlow tracks an in-progress device-code authorization on behalf of a +// single tenant (or "common"). Only one flow can be in progress at a time, +// matching the Sentry adapter's pattern. +type oauthFlow struct { + mu sync.Mutex + integration *teamsIntegration + clientID string + clientSecret string + tenantHint string // "common" or a specific tenant id; sent in the URL + scopes string + loginBaseURL string + + deviceResp *deviceCodeResponse + startedAt time.Time + + done bool + tenantID string // resolved from id_token after success + userOID string + userUPN string + userName string + errMsg string +} + +var activeFlow struct { + mu sync.Mutex + flow *oauthFlow +} + +// startOAuth kicks off a device-code flow. The returned response includes the +// user_code + verification_uri the human needs. +func (t *teamsIntegration) startOAuth(ctx context.Context, tenantHint string) (*deviceCodeResponse, error) { + if tenantHint == "" { + tenantHint = "common" + } + t.mu.RLock() + loginBase := t.loginBaseURL + clientID := t.clientID + clientSecret := "" // We'll allow override via creds later; not stored on the struct yet. + scopes := t.scopes + t.mu.RUnlock() + + endpoint := fmt.Sprintf("%s/%s/oauth2/v2.0/devicecode", loginBase, tenantHint) + + form := url.Values{ + "client_id": {clientID}, + "scope": {scopes}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := t.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("device code request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("device code endpoint returned %d: %s", resp.StatusCode, string(body)) + } + + var dcr deviceCodeResponse + if err := json.Unmarshal(body, &dcr); err != nil { + return nil, fmt.Errorf("parse device code response: %w", err) + } + + flow := &oauthFlow{ + integration: t, + clientID: clientID, + clientSecret: clientSecret, + tenantHint: tenantHint, + scopes: scopes, + loginBaseURL: loginBase, + deviceResp: &dcr, + startedAt: time.Now(), + } + + activeFlow.mu.Lock() + activeFlow.flow = flow + activeFlow.mu.Unlock() + + go flow.poll() + + return &dcr, nil +} + +func (f *oauthFlow) poll() { + interval := time.Duration(f.deviceResp.Interval) * time.Second + if interval < 5*time.Second { + interval = 5 * time.Second + } + deadline := f.startedAt.Add(time.Duration(f.deviceResp.ExpiresIn) * time.Second) + + endpoint := fmt.Sprintf("%s/%s/oauth2/v2.0/token", f.loginBaseURL, f.tenantHint) + + for { + time.Sleep(interval) + + if time.Now().After(deadline) { + f.mu.Lock() + f.errMsg = "Authorization timed out. Please try teams_login again." + f.done = true + f.mu.Unlock() + return + } + + form := url.Values{ + "client_id": {f.clientID}, + "device_code": {f.deviceResp.DeviceCode}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + } + if f.clientSecret != "" { + form.Set("client_secret", f.clientSecret) + } + + req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + continue + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := f.integration.httpClient.Do(req) + if err != nil { + continue + } + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + + var atr accessTokenResponse + if err := json.Unmarshal(body, &atr); err != nil { + continue + } + + switch atr.Error { + case "": + if atr.AccessToken == "" { + continue + } + tn := &tenant{ + AccessToken: atr.AccessToken, + RefreshToken: atr.RefreshToken, + ExpiresAt: time.Now().Add(time.Duration(atr.ExpiresIn) * time.Second), + Source: "device_code", + } + // id_token carries the tenant + user identity. + if atr.IDToken != "" { + if claims, err := parseIDToken(atr.IDToken); err == nil { + tn.TenantID = claims.TID + tn.UserOID = claims.OID + tn.UserUPN = claims.UPN + if tn.UserUPN == "" { + tn.UserUPN = claims.PreferredUsername + } + tn.UserDisplay = claims.Name + } + } + if tn.TenantID == "" { + // As a last resort, hit /me with the new token to discover tenantId. + tn.TenantID = f.tenantHint + } + f.integration.store.upsert(tn) + f.integration.store.setDefault(tn.TenantID) + _ = f.integration.store.saveToFile() + + f.mu.Lock() + f.done = true + f.tenantID = tn.TenantID + f.userOID = tn.UserOID + f.userUPN = tn.UserUPN + f.userName = tn.UserDisplay + f.mu.Unlock() + return + + case "authorization_pending": + continue + case "slow_down": + interval += 5 * time.Second + case "expired_token": + f.mu.Lock() + f.errMsg = "Device code expired. Please try teams_login again." + f.done = true + f.mu.Unlock() + return + case "access_denied": + f.mu.Lock() + f.errMsg = "Authorization was denied." + f.done = true + f.mu.Unlock() + return + default: + msg := atr.Error + if atr.ErrorDescription != "" { + msg = atr.Error + ": " + atr.ErrorDescription + } + f.mu.Lock() + f.errMsg = msg + f.done = true + f.mu.Unlock() + return + } + } +} + +type pollResult struct { + Status string `json:"status"` + TenantID string `json:"tenant_id,omitempty"` + UserOID string `json:"user_oid,omitempty"` + UserUPN string `json:"user_upn,omitempty"` + UserDisplay string `json:"user_display,omitempty"` + Error string `json:"error,omitempty"` +} + +// pollOAuth returns the current state of the in-progress flow. +func pollOAuth() pollResult { + activeFlow.mu.Lock() + flow := activeFlow.flow + activeFlow.mu.Unlock() + if flow == nil { + return pollResult{Status: "no_flow", Error: "No OAuth flow in progress. Run teams_login first."} + } + flow.mu.Lock() + defer flow.mu.Unlock() + if !flow.done { + return pollResult{Status: "pending"} + } + if flow.errMsg != "" { + return pollResult{Status: "error", Error: flow.errMsg} + } + return pollResult{ + Status: "complete", + TenantID: flow.tenantID, + UserOID: flow.userOID, + UserUPN: flow.userUPN, + UserDisplay: flow.userName, + } +} + +// idTokenClaims captures the fields we care about from an id_token JWT. +type idTokenClaims struct { + TID string `json:"tid"` + OID string `json:"oid"` + UPN string `json:"upn"` + PreferredUsername string `json:"preferred_username"` + Name string `json:"name"` +} + +func parseIDToken(idToken string) (*idTokenClaims, error) { + parts := strings.Split(idToken, ".") + if len(parts) < 2 { + return nil, fmt.Errorf("malformed id_token") + } + payload, err := base64URLDecode(parts[1]) + if err != nil { + return nil, err + } + var c idTokenClaims + if err := json.Unmarshal(payload, &c); err != nil { + return nil, err + } + return &c, nil +} + +func base64URLDecode(s string) ([]byte, error) { + // JWT spec uses base64url without padding. + return base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")) +} + +// refreshTenant exchanges a refresh token for a fresh access token. Called by +// graphRequestInner on 401 and proactively before expiry. +func (t *teamsIntegration) refreshTenant(ctx context.Context, tn *tenant) error { + if tn == nil || tn.RefreshToken == "" { + return fmt.Errorf("no refresh_token available for tenant %s", tn.TenantID) + } + + t.mu.RLock() + loginBase := t.loginBaseURL + clientID := t.clientID + scopes := t.scopes + t.mu.RUnlock() + + tenantHint := tn.TenantID + if tenantHint == "" || tenantHint == "_config" { + tenantHint = "common" + } + + endpoint := fmt.Sprintf("%s/%s/oauth2/v2.0/token", loginBase, tenantHint) + form := url.Values{ + "client_id": {clientID}, + "grant_type": {"refresh_token"}, + "refresh_token": {tn.RefreshToken}, + "scope": {scopes}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := t.httpClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("refresh failed (%d): %s", resp.StatusCode, string(body)) + } + + var atr accessTokenResponse + if err := json.Unmarshal(body, &atr); err != nil { + return err + } + if atr.Error != "" { + return fmt.Errorf("refresh error: %s — %s", atr.Error, atr.ErrorDescription) + } + if atr.AccessToken == "" { + return fmt.Errorf("refresh returned no access_token") + } + + updated := &tenant{ + TenantID: tn.TenantID, + TenantName: tn.TenantName, + UserOID: tn.UserOID, + UserUPN: tn.UserUPN, + UserDisplay: tn.UserDisplay, + AccessToken: atr.AccessToken, + RefreshToken: tn.RefreshToken, // Microsoft may rotate; prefer new when present. + ExpiresAt: time.Now().Add(time.Duration(atr.ExpiresIn) * time.Second), + Source: tn.Source, + } + if atr.RefreshToken != "" { + updated.RefreshToken = atr.RefreshToken + } + t.store.upsert(updated) + _ = t.store.saveToFile() + return nil +} diff --git a/integrations/teams/teams.go b/integrations/teams/teams.go new file mode 100644 index 00000000..8f112249 --- /dev/null +++ b/integrations/teams/teams.go @@ -0,0 +1,434 @@ +// Package teams provides a Microsoft Teams integration that impersonates the +// authenticated user via OAuth (device code flow). Tokens are obtained against +// a public Microsoft Entra client (Azure CLI by default) and persisted to +// ~/.teams-mcp-tokens.json. Calls go to Microsoft Graph (graph.microsoft.com). +// +// Auth model parallels the Slack adapter's "act as the real user" guarantee, +// but the underlying mechanism is OAuth + refresh tokens rather than browser +// cookie extraction. A future phase can add Electron/Edge token extraction for +// zero-prompt setup; Phase 1 ships device-code OAuth only. +package teams + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "sync" + "time" + + mcp "github.com/daltoniam/switchboard" +) + +// Compile-time interface assertions. +var ( + _ mcp.Integration = (*teamsIntegration)(nil) + _ mcp.FieldCompactionIntegration = (*teamsIntegration)(nil) + _ mcp.PlainTextCredentials = (*teamsIntegration)(nil) + _ mcp.OptionalCredentials = (*teamsIntegration)(nil) + _ mcp.PlaceholderHints = (*teamsIntegration)(nil) +) + +const ( + // Default Microsoft Graph endpoint. Override via credentials["graph_base_url"] + // for sovereign clouds (GCC-High, DOD, China). + defaultGraphBaseURL = "https://graph.microsoft.com/v1.0" + + // Default Microsoft Entra (Azure AD) authorize/token endpoint host. + // Override via credentials["login_base_url"] for sovereign clouds. + defaultLoginBaseURL = "https://login.microsoftonline.com" + + // Default public client_id (Microsoft Azure CLI). Pre-consented in nearly + // every Entra tenant for delegated Microsoft Graph access. Override via + // credentials["client_id"] when a tenant requires a customer-registered + // app instead. + defaultClientID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" + + // Default OAuth scopes — chosen to require NO admin consent in most tenants. + // "ChannelMessage.Read.All" is deliberately excluded because it requires + // admin consent; channel message reads should be added per-tenant when + // available. Chat reads (1:1, group, meeting chats) work without admin + // consent and cover the primary "impersonate the user" use case. + defaultScopes = "offline_access User.Read User.ReadBasic.All Chat.ReadWrite Team.ReadBasic.All Channel.ReadBasic.All ChannelMessage.Send Presence.Read" + + // Refresh tokens are typically valid 90 days with rolling renewal; access + // tokens are typically valid 60-90 minutes. We refresh proactively when + // the cached access token is within this window of expiry. + refreshSkew = 2 * time.Minute + + maxResponseSize = 10 * 1024 * 1024 // 10 MB +) + +type teamsIntegration struct { + mu sync.RWMutex + store *tokenStore + httpClient *http.Client + clientID string + loginBaseURL string + graphBaseURL string + scopes string + defaultTenant string // configured default; empty means "use whatever is in the store" + stopBg chan struct{} +} + +// New constructs a Teams integration. +func New() mcp.Integration { + return &teamsIntegration{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (t *teamsIntegration) Name() string { return "teams" } + +func (t *teamsIntegration) PlainTextKeys() []string { + return []string{"tenant_id", "client_id", "graph_base_url", "login_base_url", "scopes"} +} + +func (t *teamsIntegration) OptionalKeys() []string { + return []string{"tenant_id", "client_id", "graph_base_url", "login_base_url", "scopes", "access_token", "refresh_token", "expires_at"} +} + +func (t *teamsIntegration) Placeholders() map[string]string { + return map[string]string{ + "tenant_id": "common (multi-tenant) or a specific tenant GUID/domain", + "client_id": "Public client ID (defaults to Azure CLI: " + defaultClientID + ")", + "graph_base_url": defaultGraphBaseURL, + "login_base_url": defaultLoginBaseURL, + "scopes": "Space-separated OAuth scopes (default omits admin-consent-only scopes)", + "access_token": "Filled by the OAuth flow — usually leave blank", + "refresh_token": "Filled by the OAuth flow — usually leave blank", + } +} + +func (t *teamsIntegration) Configure(ctx context.Context, creds mcp.Credentials) error { + t.mu.Lock() + + t.clientID = strings.TrimSpace(creds["client_id"]) + if t.clientID == "" { + t.clientID = defaultClientID + } + t.loginBaseURL = strings.TrimRight(strings.TrimSpace(creds["login_base_url"]), "/") + if t.loginBaseURL == "" { + t.loginBaseURL = defaultLoginBaseURL + } + t.graphBaseURL = strings.TrimRight(strings.TrimSpace(creds["graph_base_url"]), "/") + if t.graphBaseURL == "" { + t.graphBaseURL = defaultGraphBaseURL + } + t.scopes = strings.TrimSpace(creds["scopes"]) + if t.scopes == "" { + t.scopes = defaultScopes + } + t.defaultTenant = strings.TrimSpace(creds["tenant_id"]) + + t.store = newTokenStore() + t.store.loadFromFile() + + // If credentials carry an explicit token (typical hosted/OAuth-in-config + // deployments), inject it as a tenant entry. The tenant key in that case + // falls back to "_config" when not provided. + if at := strings.TrimSpace(creds["access_token"]); at != "" { + tid := t.defaultTenant + if tid == "" { + tid = "_config" + } + t.store.upsert(&tenant{ + TenantID: tid, + AccessToken: at, + RefreshToken: strings.TrimSpace(creds["refresh_token"]), + Source: "config", + }) + if t.defaultTenant != "" { + t.store.setDefault(t.defaultTenant) + } + } + + t.mu.Unlock() + + // Resolve user identity for each loaded tenant. Failures are logged but + // non-fatal — tokens may legitimately be expired and refreshable. + t.resolveTenantIdentities(ctx) + + // Background refresh: skips for config-injected tokens since they're + // managed externally. + if t.stopBg != nil { + close(t.stopBg) + } + t.stopBg = make(chan struct{}) + go t.backgroundRefresh() + + return nil +} + +func (t *teamsIntegration) Tools() []mcp.ToolDefinition { return tools } + +func (t *teamsIntegration) CompactSpec(toolName mcp.ToolName) ([]mcp.CompactField, bool) { + fields, ok := fieldCompactionSpecs[toolName] + return fields, ok +} + +func (t *teamsIntegration) Execute(ctx context.Context, toolName mcp.ToolName, args map[string]any) (*mcp.ToolResult, error) { + fn, ok := dispatch[toolName] + if !ok { + return &mcp.ToolResult{Data: fmt.Sprintf("unknown tool: %s", toolName), IsError: true}, nil + } + return fn(ctx, t, args) +} + +func (t *teamsIntegration) Healthy(ctx context.Context) bool { + tn := t.activeTenant() + if tn == nil { + return false + } + _, err := t.graphGet(ctx, tn.TenantID, "/me") + return err == nil +} + +// activeTenant returns the configured default tenant, or the first stored +// tenant when no default is set. Returns nil when no tenant is configured. +func (t *teamsIntegration) activeTenant() *tenant { + t.mu.RLock() + def := t.defaultTenant + t.mu.RUnlock() + if def != "" { + if tn := t.store.get(def); tn != nil { + return tn + } + } + return t.store.getDefault() +} + +// tenantFromArgs picks a tenant by explicit args["tenant_id"], falling back to +// the active tenant. Returns a wrapped error suitable for ErrResult when the +// tenant is missing. +func (t *teamsIntegration) tenantFromArgs(args map[string]any) (*tenant, error) { + tid, _ := mcp.ArgStr(args, "tenant_id") + if tid != "" { + if tn := t.store.get(tid); tn != nil { + return tn, nil + } + return nil, fmt.Errorf("unknown tenant: %s — use teams_list_tenants to see configured tenants, or teams_login to add one", tid) + } + if tn := t.activeTenant(); tn != nil { + return tn, nil + } + return nil, fmt.Errorf("no teams tenant configured — run teams_login to authenticate") +} + +func (t *teamsIntegration) resolveTenantIdentities(ctx context.Context) { + for _, tn := range t.store.all() { + if tn.AccessToken == "" { + continue + } + raw, err := t.graphGet(ctx, tn.TenantID, "/me") + if err != nil { + log.Printf("teams: identity probe failed for tenant %s: %v", tn.TenantID, err) + continue + } + var me struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + UserPrincipalName string `json:"userPrincipalName"` + TenantID string `json:"tenantId"` + } + if err := json.Unmarshal(raw, &me); err != nil { + continue + } + // Some tenants strip tenantId from /me; rely on cached value otherwise. + if me.UserPrincipalName != "" && tn.UserUPN == "" { + tn.UserUPN = me.UserPrincipalName + } + if me.DisplayName != "" && tn.UserDisplay == "" { + tn.UserDisplay = me.DisplayName + } + if me.ID != "" && tn.UserOID == "" { + tn.UserOID = me.ID + } + t.store.upsert(tn) + } + _ = t.store.saveToFile() +} + +// --- Graph HTTP helpers --- + +// graphGet issues a GET against the Graph API for the given tenant. +func (t *teamsIntegration) graphGet(ctx context.Context, tenantID, path string) (json.RawMessage, error) { + return t.graphRequest(ctx, tenantID, http.MethodGet, path, nil) +} + +// graphPost issues a POST against the Graph API for the given tenant. +func (t *teamsIntegration) graphPost(ctx context.Context, tenantID, path string, body any) (json.RawMessage, error) { + return t.graphRequest(ctx, tenantID, http.MethodPost, path, body) +} + +// graphDelete issues a DELETE against the Graph API for the given tenant. +func (t *teamsIntegration) graphDelete(ctx context.Context, tenantID, path string) (json.RawMessage, error) { + return t.graphRequest(ctx, tenantID, http.MethodDelete, path, nil) +} + +func (t *teamsIntegration) graphRequest(ctx context.Context, tenantID, method, path string, body any) (json.RawMessage, error) { + return t.graphRequestInner(ctx, tenantID, method, path, body, nil, true) +} + +// graphGetWithHeaders issues a GET with extra headers (e.g. ConsistencyLevel: +// eventual for $search) and returns the response wrapped in a ToolResult. +func (t *teamsIntegration) graphGetWithHeaders(ctx context.Context, tenantID, path string, headers http.Header) (*mcp.ToolResult, error) { + data, err := t.graphRequestInner(ctx, tenantID, http.MethodGet, path, nil, headers, true) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +func (t *teamsIntegration) graphRequestInner(ctx context.Context, tenantID, method, path string, body any, extraHeaders http.Header, canRetry bool) (json.RawMessage, error) { + tn := t.store.get(tenantID) + if tn == nil { + return nil, fmt.Errorf("unknown tenant: %s", tenantID) + } + + // Proactive refresh: avoid a guaranteed 401 round-trip when we already + // know the access token is expired (or about to be). + if canRetry && t.shouldProactivelyRefresh(tn) { + if err := t.refreshTenant(ctx, tn); err == nil { + tn = t.store.get(tenantID) + } + } + + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(data) + } + + endpoint := t.graphBaseURL + path + req, err := http.NewRequestWithContext(ctx, method, endpoint, bodyReader) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+tn.AccessToken) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + for k, vv := range extraHeaders { + for _, v := range vv { + req.Header.Set(k, v) + } + } + + resp, err := t.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize)) + if err != nil { + return nil, err + } + + if resp.StatusCode == http.StatusUnauthorized && canRetry && tn.RefreshToken != "" { + if rerr := t.refreshTenant(ctx, tn); rerr == nil { + return t.graphRequestInner(ctx, tenantID, method, path, body, extraHeaders, false) + } + } + + if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { + re := &mcp.RetryableError{ + StatusCode: resp.StatusCode, + Err: fmt.Errorf("teams graph API error (%d): %s", resp.StatusCode, string(data)), + RetryAfter: mcp.ParseRetryAfter(resp.Header.Get("Retry-After")), + } + return nil, re + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("teams graph API error (%d): %s", resp.StatusCode, string(data)) + } + if resp.StatusCode == http.StatusNoContent || len(data) == 0 { + return json.RawMessage(`{"status":"success"}`), nil + } + return json.RawMessage(data), nil +} + +// shouldProactivelyRefresh reports whether the access token for tn is +// expired-or-about-to-expire and a refresh token is available. +func (t *teamsIntegration) shouldProactivelyRefresh(tn *tenant) bool { + if tn.RefreshToken == "" { + return false + } + if tn.ExpiresAt.IsZero() { + return false + } + return time.Until(tn.ExpiresAt) <= refreshSkew +} + +func (t *teamsIntegration) backgroundRefresh() { + ticker := time.NewTicker(15 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + t.refreshAllExpiring(context.Background()) + case <-t.stopBg: + return + } + } +} + +func (t *teamsIntegration) refreshAllExpiring(ctx context.Context) { + for _, tn := range t.store.all() { + if !t.shouldProactivelyRefresh(tn) { + continue + } + if err := t.refreshTenant(ctx, tn); err != nil { + log.Printf("teams: background refresh failed for tenant %s: %v", tn.TenantID, err) + } + } +} + +// --- handler dispatch --- + +type handlerFunc func(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) + +var dispatch = map[mcp.ToolName]handlerFunc{ + // Auth + tenants + mcp.ToolName("teams_login"): teamsLogin, + mcp.ToolName("teams_login_poll"): teamsLoginPoll, + mcp.ToolName("teams_token_status"): teamsTokenStatus, + mcp.ToolName("teams_refresh_tokens"): teamsRefreshTokens, + mcp.ToolName("teams_list_tenants"): teamsListTenants, + mcp.ToolName("teams_remove_tenant"): teamsRemoveTenant, + mcp.ToolName("teams_set_default"): teamsSetDefault, + mcp.ToolName("teams_get_me"): teamsGetMe, + + // Chats + mcp.ToolName("teams_list_chats"): listChats, + mcp.ToolName("teams_get_chat"): getChat, + mcp.ToolName("teams_list_chat_messages"): listChatMessages, + mcp.ToolName("teams_get_chat_message"): getChatMessage, + mcp.ToolName("teams_send_chat_message"): sendChatMessage, + mcp.ToolName("teams_list_chat_members"): listChatMembers, + + // Teams + channels + mcp.ToolName("teams_list_joined_teams"): listJoinedTeams, + mcp.ToolName("teams_list_channels"): listChannels, + mcp.ToolName("teams_get_channel"): getChannel, + mcp.ToolName("teams_list_channel_messages"): listChannelMessages, + mcp.ToolName("teams_get_channel_message"): getChannelMessage, + mcp.ToolName("teams_list_message_replies"): listMessageReplies, + mcp.ToolName("teams_send_channel_message"): sendChannelMessage, + mcp.ToolName("teams_reply_to_channel_message"): replyToChannelMessage, + + // Users + mcp.ToolName("teams_list_users"): listUsers, + mcp.ToolName("teams_get_user"): getUser, + mcp.ToolName("teams_search_users"): searchUsers, + mcp.ToolName("teams_get_presence"): getPresence, +} diff --git a/integrations/teams/teams_test.go b/integrations/teams/teams_test.go new file mode 100644 index 00000000..d900716f --- /dev/null +++ b/integrations/teams/teams_test.go @@ -0,0 +1,376 @@ +package teams + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + mcp "github.com/daltoniam/switchboard" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + i := New() + require.NotNil(t, i) + assert.Equal(t, "teams", i.Name()) +} + +func TestTools(t *testing.T) { + i := New() + defs := i.Tools() + assert.NotEmpty(t, defs) + for _, d := range defs { + assert.NotEmpty(t, d.Name, "tool has empty name") + assert.NotEmpty(t, d.Description, "tool %s has empty description", d.Name) + } +} + +func TestTools_AllHaveTeamsPrefix(t *testing.T) { + for _, d := range New().Tools() { + assert.True(t, strings.HasPrefix(string(d.Name), "teams_"), + "tool %s missing teams_ prefix", d.Name) + } +} + +func TestTools_NoDuplicateNames(t *testing.T) { + seen := make(map[mcp.ToolName]bool) + for _, d := range New().Tools() { + assert.False(t, seen[d.Name], "duplicate tool name: %s", d.Name) + seen[d.Name] = true + } +} + +func TestDispatchMap_AllToolsCovered(t *testing.T) { + for _, d := range New().Tools() { + _, ok := dispatch[d.Name] + assert.True(t, ok, "tool %s has no dispatch handler", d.Name) + } +} + +func TestDispatchMap_NoOrphanHandlers(t *testing.T) { + names := make(map[mcp.ToolName]bool) + for _, d := range New().Tools() { + names[d.Name] = true + } + for name := range dispatch { + assert.True(t, names[name], "dispatch handler %s has no tool definition", name) + } +} + +func TestExecute_UnknownTool(t *testing.T) { + ti := newTestIntegration(t, nil) + result, err := ti.Execute(context.Background(), "teams_nonexistent", nil) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Contains(t, result.Data, "unknown tool") +} + +func TestExecute_NoTenantConfigured(t *testing.T) { + ti := newTestIntegration(t, nil) + result, err := ti.Execute(context.Background(), "teams_get_me", nil) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Contains(t, result.Data, "no teams tenant configured") +} + +func TestCompactSpec_KnownTool(t *testing.T) { + ti := newTestIntegration(t, nil) + fields, ok := ti.CompactSpec(mcp.ToolName("teams_list_chats")) + assert.True(t, ok) + assert.NotEmpty(t, fields) +} + +func TestCompactSpec_UnknownTool(t *testing.T) { + ti := newTestIntegration(t, nil) + _, ok := ti.CompactSpec(mcp.ToolName("teams_does_not_exist")) + assert.False(t, ok) +} + +func TestPlainTextKeys(t *testing.T) { + i := New().(*teamsIntegration) + keys := i.PlainTextKeys() + assert.Contains(t, keys, "tenant_id") + assert.Contains(t, keys, "client_id") +} + +func TestOptionalKeys(t *testing.T) { + i := New().(*teamsIntegration) + keys := i.OptionalKeys() + assert.Contains(t, keys, "access_token") + assert.Contains(t, keys, "refresh_token") +} + +func TestPlaceholders(t *testing.T) { + i := New().(*teamsIntegration) + p := i.Placeholders() + assert.NotEmpty(t, p["tenant_id"]) + assert.Equal(t, defaultGraphBaseURL, p["graph_base_url"]) +} + +// --- Configure --- + +func TestConfigure_DefaultsApplied(t *testing.T) { + ti := newTestIntegration(t, nil) + err := ti.Configure(context.Background(), mcp.Credentials{}) + require.NoError(t, err) + assert.Equal(t, defaultClientID, ti.clientID) + assert.Equal(t, defaultLoginBaseURL, ti.loginBaseURL) + assert.Equal(t, defaultScopes, ti.scopes) +} + +func TestConfigure_AcceptsExplicitTokenAsTenant(t *testing.T) { + ti := newTestIntegration(t, nil) + err := ti.Configure(context.Background(), mcp.Credentials{ + "tenant_id": "tenant-abc", + "access_token": "at-1", + "refresh_token": "rt-1", + }) + require.NoError(t, err) + tn := ti.store.get("tenant-abc") + require.NotNil(t, tn) + assert.Equal(t, "at-1", tn.AccessToken) + assert.Equal(t, "rt-1", tn.RefreshToken) + assert.Equal(t, "tenant-abc", ti.store.defaultID()) +} + +// --- HTTP helpers --- + +func TestGraphGet_AddsBearerToken(t *testing.T) { + var seenAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"value":[{"id":"abc"}]}`)) + })) + defer srv.Close() + + ti := newTestIntegration(t, srv) + ti.store.upsert(&tenant{TenantID: "t1", AccessToken: "hello"}) + + data, err := ti.graphGet(context.Background(), "t1", "/me/chats") + require.NoError(t, err) + assert.Contains(t, string(data), "abc") + assert.Equal(t, "Bearer hello", seenAuth) +} + +func TestGraphRequest_RetryOn401WithRefresh(t *testing.T) { + var graphCalls, refreshCalls int + var mu sync.Mutex + + graph := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + graphCalls++ + auth := r.Header.Get("Authorization") + mu.Unlock() + if auth == "Bearer stale" { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"code":"InvalidAuthenticationToken"}}`)) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"after-refresh"}`)) + })) + defer graph.Close() + + login := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + refreshCalls++ + mu.Unlock() + assert.Contains(t, r.URL.Path, "/oauth2/v2.0/token") + body, _ := json.Marshal(accessTokenResponse{AccessToken: "fresh", ExpiresIn: 3600}) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer login.Close() + + ti := newTestIntegration(t, graph) + ti.loginBaseURL = login.URL + ti.store.upsert(&tenant{TenantID: "t1", AccessToken: "stale", RefreshToken: "rt"}) + + data, err := ti.graphGet(context.Background(), "t1", "/me") + require.NoError(t, err) + assert.Contains(t, string(data), "after-refresh") + assert.Equal(t, 1, refreshCalls) + assert.Equal(t, 2, graphCalls) + assert.Equal(t, "fresh", ti.store.get("t1").AccessToken) +} + +func TestGraphRequest_RetryableOn429(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "30") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"error":{"code":"throttled"}}`)) + })) + defer srv.Close() + + ti := newTestIntegration(t, srv) + ti.store.upsert(&tenant{TenantID: "t1", AccessToken: "ok"}) + _, err := ti.graphGet(context.Background(), "t1", "/me") + require.Error(t, err) + assert.True(t, mcp.IsRetryable(err)) +} + +func TestGraphRequest_204NoContent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + ti := newTestIntegration(t, srv) + ti.store.upsert(&tenant{TenantID: "t1", AccessToken: "ok"}) + data, err := ti.graphDelete(context.Background(), "t1", "/anything") + require.NoError(t, err) + assert.Contains(t, string(data), "success") +} + +func TestProactiveRefresh_OnSoonExpiry(t *testing.T) { + var refreshed int + var mu sync.Mutex + login := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + refreshed++ + mu.Unlock() + body, _ := json.Marshal(accessTokenResponse{AccessToken: "new", ExpiresIn: 3600}) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer login.Close() + graph := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer graph.Close() + + ti := newTestIntegration(t, graph) + ti.loginBaseURL = login.URL + ti.store.upsert(&tenant{ + TenantID: "t1", + AccessToken: "expiring", + RefreshToken: "rt", + ExpiresAt: time.Now().Add(10 * time.Second), // within refreshSkew + }) + _, err := ti.graphGet(context.Background(), "t1", "/me") + require.NoError(t, err) + assert.Equal(t, 1, refreshed) + assert.Equal(t, "new", ti.store.get("t1").AccessToken) +} + +// --- OAuth --- + +func TestParseIDToken(t *testing.T) { + // payload: {"tid":"tenantX","oid":"user1","upn":"alice@x.com","name":"Alice"} + // header.payload.signature, payload is base64url-encoded. + payload := `eyJ0aWQiOiJ0ZW5hbnRYIiwib2lkIjoidXNlcjEiLCJ1cG4iOiJhbGljZUB4LmNvbSIsIm5hbWUiOiJBbGljZSJ9` + jwt := "header." + payload + ".sig" + claims, err := parseIDToken(jwt) + require.NoError(t, err) + assert.Equal(t, "tenantX", claims.TID) + assert.Equal(t, "alice@x.com", claims.UPN) + assert.Equal(t, "Alice", claims.Name) +} + +func TestRefreshTenant_NoRefreshToken(t *testing.T) { + ti := newTestIntegration(t, nil) + err := ti.refreshTenant(context.Background(), &tenant{TenantID: "t1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no refresh_token") +} + +// --- Token store --- + +func TestTokenStore_UpsertSetsDefault(t *testing.T) { + store := &tokenStore{tenants: map[string]*tenant{}, filePath: t.TempDir() + "/tokens.json"} + store.upsert(&tenant{TenantID: "first", AccessToken: "a"}) + assert.Equal(t, "first", store.defaultID()) + store.upsert(&tenant{TenantID: "second", AccessToken: "b"}) + assert.Equal(t, "first", store.defaultID(), "default should not change on second upsert") +} + +func TestTokenStore_RemovePromotesNewDefault(t *testing.T) { + store := &tokenStore{tenants: map[string]*tenant{}, filePath: t.TempDir() + "/tokens.json"} + store.upsert(&tenant{TenantID: "alpha", AccessToken: "a"}) + store.upsert(&tenant{TenantID: "bravo", AccessToken: "b"}) + store.remove("alpha") + assert.Equal(t, "bravo", store.defaultID()) + store.remove("bravo") + assert.Equal(t, "", store.defaultID()) +} + +func TestTokenStore_PersistRoundTrip(t *testing.T) { + path := t.TempDir() + "/tokens.json" + store := &tokenStore{tenants: map[string]*tenant{}, filePath: path} + store.upsert(&tenant{TenantID: "t1", AccessToken: "a", RefreshToken: "r", UserUPN: "x@y.com", ExpiresAt: time.Now().Add(time.Hour)}) + require.NoError(t, store.saveToFile()) + + store2 := &tokenStore{tenants: map[string]*tenant{}, filePath: path} + store2.loadFromFile() + tn := store2.get("t1") + require.NotNil(t, tn) + assert.Equal(t, "a", tn.AccessToken) + assert.Equal(t, "x@y.com", tn.UserUPN) +} + +// --- Auth handler smoke --- + +func TestTeamsListTenants_Empty(t *testing.T) { + ti := newTestIntegration(t, nil) + res, err := ti.Execute(context.Background(), "teams_list_tenants", nil) + require.NoError(t, err) + assert.False(t, res.IsError) + assert.Contains(t, res.Data, `"count":0`) +} + +func TestTeamsListTenants_OneTenant(t *testing.T) { + ti := newTestIntegration(t, nil) + ti.store.upsert(&tenant{TenantID: "t1", AccessToken: "a", UserUPN: "x@y.com"}) + res, err := ti.Execute(context.Background(), "teams_list_tenants", nil) + require.NoError(t, err) + assert.False(t, res.IsError) + assert.Contains(t, res.Data, `"count":1`) + assert.Contains(t, res.Data, "x@y.com") +} + +func TestTeamsSetDefault_UnknownTenant(t *testing.T) { + ti := newTestIntegration(t, nil) + res, err := ti.Execute(context.Background(), "teams_set_default", map[string]any{"tenant_id": "bogus"}) + require.NoError(t, err) + assert.True(t, res.IsError) + assert.Contains(t, res.Data, "unknown tenant") +} + +func TestTeamsRemoveTenant_OK(t *testing.T) { + ti := newTestIntegration(t, nil) + ti.store.upsert(&tenant{TenantID: "t1", AccessToken: "a"}) + res, err := ti.Execute(context.Background(), "teams_remove_tenant", map[string]any{"tenant_id": "t1"}) + require.NoError(t, err) + assert.False(t, res.IsError) + assert.Nil(t, ti.store.get("t1")) +} + +// --- helpers --- + +// newTestIntegration builds an integration wired to optional httptest server URLs. +// It avoids Configure() (which spawns background goroutines and reads disk) and +// instead initializes the fields needed by the tests directly. +func newTestIntegration(t *testing.T, graphServer *httptest.Server) *teamsIntegration { + t.Helper() + ti := &teamsIntegration{ + httpClient: &http.Client{Timeout: 5 * time.Second}, + store: &tokenStore{tenants: map[string]*tenant{}, filePath: t.TempDir() + "/tokens.json"}, + clientID: defaultClientID, + loginBaseURL: defaultLoginBaseURL, + graphBaseURL: defaultGraphBaseURL, + scopes: defaultScopes, + } + if graphServer != nil { + ti.graphBaseURL = graphServer.URL + ti.httpClient = graphServer.Client() + } + return ti +} diff --git a/integrations/teams/tokens.go b/integrations/teams/tokens.go new file mode 100644 index 00000000..d1530158 --- /dev/null +++ b/integrations/teams/tokens.go @@ -0,0 +1,238 @@ +package teams + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +// tenant holds OAuth credentials and resolved identity for a single Microsoft +// Entra tenant (or the magic "_config" tenant for tokens injected via plain +// credentials). The "default" tenant is selected by tenant ID; identity fields +// are populated lazily by the /me probe in Configure. +type tenant struct { + TenantID string `json:"tenant_id"` + TenantName string `json:"tenant_name,omitempty"` + UserOID string `json:"user_oid,omitempty"` + UserUPN string `json:"user_upn,omitempty"` + UserDisplay string `json:"user_display,omitempty"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + Source string `json:"source,omitempty"` // "device_code", "config", etc. + UpdatedAt time.Time `json:"updated_at"` +} + +type tokenStore struct { + mu sync.RWMutex + tenants map[string]*tenant // keyed by TenantID + defaultTenantID string + filePath string +} + +func newTokenStore() *tokenStore { + home, _ := os.UserHomeDir() + return &tokenStore{ + tenants: make(map[string]*tenant), + filePath: filepath.Join(home, ".teams-mcp-tokens.json"), + } +} + +func (ts *tokenStore) get(tenantID string) *tenant { + ts.mu.RLock() + defer ts.mu.RUnlock() + if tenantID == "" { + tenantID = ts.defaultTenantID + } + tn, ok := ts.tenants[tenantID] + if !ok { + return nil + } + cp := *tn + return &cp +} + +func (ts *tokenStore) getDefault() *tenant { return ts.get("") } + +func (ts *tokenStore) defaultID() string { + ts.mu.RLock() + defer ts.mu.RUnlock() + return ts.defaultTenantID +} + +func (ts *tokenStore) setDefault(tenantID string) { + ts.mu.Lock() + defer ts.mu.Unlock() + if _, ok := ts.tenants[tenantID]; ok { + ts.defaultTenantID = tenantID + } +} + +func (ts *tokenStore) all() []*tenant { + ts.mu.RLock() + defer ts.mu.RUnlock() + out := make([]*tenant, 0, len(ts.tenants)) + for _, tn := range ts.tenants { + cp := *tn + out = append(out, &cp) + } + sort.Slice(out, func(i, j int) bool { return out[i].TenantID < out[j].TenantID }) + return out +} + +func (ts *tokenStore) upsert(tn *tenant) { + ts.mu.Lock() + defer ts.mu.Unlock() + tn.UpdatedAt = time.Now() + existing, ok := ts.tenants[tn.TenantID] + if ok { + // Preserve identity fields when not supplied on the update. + if tn.UserOID == "" { + tn.UserOID = existing.UserOID + } + if tn.UserUPN == "" { + tn.UserUPN = existing.UserUPN + } + if tn.UserDisplay == "" { + tn.UserDisplay = existing.UserDisplay + } + if tn.TenantName == "" { + tn.TenantName = existing.TenantName + } + } + cp := *tn + ts.tenants[tn.TenantID] = &cp + if ts.defaultTenantID == "" { + ts.defaultTenantID = tn.TenantID + } +} + +func (ts *tokenStore) remove(tenantID string) { + ts.mu.Lock() + defer ts.mu.Unlock() + delete(ts.tenants, tenantID) + if ts.defaultTenantID == tenantID { + ts.defaultTenantID = "" + // Promote the lexicographically smallest remaining tenant. + for id := range ts.tenants { + if ts.defaultTenantID == "" || id < ts.defaultTenantID { + ts.defaultTenantID = id + } + } + } +} + +// tokenFile is the on-disk shape. +type tokenFile struct { + Version int `json:"version"` + DefaultTenantID string `json:"default_tenant_id"` + Tenants []*tokenFileEntry `json:"tenants"` +} + +type tokenFileEntry struct { + TenantID string `json:"tenant_id"` + TenantName string `json:"tenant_name,omitempty"` + UserOID string `json:"user_oid,omitempty"` + UserUPN string `json:"user_upn,omitempty"` + UserDisplay string `json:"user_display,omitempty"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + Source string `json:"source,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// loadFromFile populates the store from disk. Missing or malformed files are +// treated as empty stores (callers don't care; they'll just see "no tenants"). +func (ts *tokenStore) loadFromFile() { + data, err := os.ReadFile(ts.filePath) + if err != nil { + return + } + var tf tokenFile + if err := json.Unmarshal(data, &tf); err != nil { + return + } + ts.mu.Lock() + defer ts.mu.Unlock() + for _, entry := range tf.Tenants { + if entry.AccessToken == "" && entry.RefreshToken == "" { + continue + } + tn := &tenant{ + TenantID: entry.TenantID, + TenantName: entry.TenantName, + UserOID: entry.UserOID, + UserUPN: entry.UserUPN, + UserDisplay: entry.UserDisplay, + AccessToken: entry.AccessToken, + RefreshToken: entry.RefreshToken, + Source: entry.Source, + } + if t, err := time.Parse(time.RFC3339, entry.ExpiresAt); err == nil { + tn.ExpiresAt = t + } + if t, err := time.Parse(time.RFC3339, entry.UpdatedAt); err == nil { + tn.UpdatedAt = t + } else { + tn.UpdatedAt = time.Now() + } + ts.tenants[tn.TenantID] = tn + } + if tf.DefaultTenantID != "" { + if _, ok := ts.tenants[tf.DefaultTenantID]; ok { + ts.defaultTenantID = tf.DefaultTenantID + } + } + if ts.defaultTenantID == "" { + for id := range ts.tenants { + if ts.defaultTenantID == "" || id < ts.defaultTenantID { + ts.defaultTenantID = id + } + } + } +} + +// saveToFile persists the store atomically (write-tmp + rename). +func (ts *tokenStore) saveToFile() error { + ts.mu.RLock() + tf := tokenFile{ + Version: 1, + DefaultTenantID: ts.defaultTenantID, + } + for _, tn := range ts.tenants { + entry := &tokenFileEntry{ + TenantID: tn.TenantID, + TenantName: tn.TenantName, + UserOID: tn.UserOID, + UserUPN: tn.UserUPN, + UserDisplay: tn.UserDisplay, + AccessToken: tn.AccessToken, + RefreshToken: tn.RefreshToken, + Source: tn.Source, + UpdatedAt: tn.UpdatedAt.UTC().Format(time.RFC3339), + } + if !tn.ExpiresAt.IsZero() { + entry.ExpiresAt = tn.ExpiresAt.UTC().Format(time.RFC3339) + } + tf.Tenants = append(tf.Tenants, entry) + } + ts.mu.RUnlock() + + sort.Slice(tf.Tenants, func(i, j int) bool { + return tf.Tenants[i].TenantID < tf.Tenants[j].TenantID + }) + + data, err := json.MarshalIndent(tf, "", " ") + if err != nil { + return err + } + tmp := ts.filePath + ".tmp" + if err := os.WriteFile(tmp, data, 0600); err != nil { + return err + } + return os.Rename(tmp, ts.filePath) +} diff --git a/integrations/teams/tools.go b/integrations/teams/tools.go new file mode 100644 index 00000000..37ea0ff6 --- /dev/null +++ b/integrations/teams/tools.go @@ -0,0 +1,256 @@ +package teams + +import mcp "github.com/daltoniam/switchboard" + +const tenantIDDesc = "Tenant ID (omit to use default tenant). Use teams_list_tenants to see configured tenants." + +var tools = []mcp.ToolDefinition{ + // --- Auth + tenant management --- + { + Name: mcp.ToolName("teams_login"), + Description: "Start here to authenticate. Begins the Microsoft device-code OAuth flow. Returns a user_code + verification URL that the human must visit and approve. Call teams_login_poll afterwards to detect when authorization completes. Reuses any in-progress flow on repeat calls.", + Parameters: map[string]string{ + "tenant": "Optional tenant hint (\"common\" works for personal + work accounts; pass a tenant ID/domain to restrict). Defaults to common.", + }, + }, + { + Name: mcp.ToolName("teams_login_poll"), + Description: "Poll the in-progress OAuth flow started by teams_login. Returns status=pending until the user approves, then status=complete with the resolved tenant_id / user identity. Returns status=error on rejection or timeout.", + Parameters: map[string]string{}, + }, + { + Name: mcp.ToolName("teams_token_status"), + Description: "Show token health for every configured tenant: access-token expiry, refresh-token availability, default tenant, identity. Use to diagnose auth issues before opening a support ticket.", + Parameters: map[string]string{}, + }, + { + Name: mcp.ToolName("teams_refresh_tokens"), + Description: "Force a refresh of the access token for the given (or default) tenant. Useful after long idle periods or when teams_token_status shows expiry warnings.", + Parameters: map[string]string{ + "tenant_id": tenantIDDesc, + }, + }, + { + Name: mcp.ToolName("teams_list_tenants"), + Description: "List all configured Microsoft 365 tenants for this user, with their tenant IDs and identities. Use to find the tenant_id to pass into other tools when working across multiple organizations.", + Parameters: map[string]string{}, + }, + { + Name: mcp.ToolName("teams_remove_tenant"), + Description: "Forget the stored tokens for a tenant. The user will need to teams_login again to use it.", + Parameters: map[string]string{ + "tenant_id": "Tenant ID to remove", + }, + Required: []string{"tenant_id"}, + }, + { + Name: mcp.ToolName("teams_set_default"), + Description: "Set the default tenant used when tools are called without an explicit tenant_id.", + Parameters: map[string]string{ + "tenant_id": "Tenant ID to make default", + }, + Required: []string{"tenant_id"}, + }, + { + Name: mcp.ToolName("teams_get_me"), + Description: "Return the authenticated user's Microsoft Graph profile (id, displayName, userPrincipalName, mail, jobTitle). Use to confirm whose account the integration is acting as.", + Parameters: map[string]string{ + "tenant_id": tenantIDDesc, + }, + }, + + // --- Chats (1:1, group, meeting) --- + { + Name: mcp.ToolName("teams_list_chats"), + Description: "Start here to discover chats. Lists the signed-in user's 1:1, group, and meeting chats in Microsoft Teams. Returns chat IDs needed by teams_list_chat_messages and teams_send_chat_message.", + Parameters: map[string]string{ + "top": "Max chats to return (default 20, max 50)", + "filter": "OData $filter expression (e.g. \"chatType eq 'oneOnOne'\")", + "orderby": "OData $orderby (e.g. \"lastMessagePreview/createdDateTime desc\")", + "expand": "Optional expand clause (e.g. \"members,lastMessagePreview\")", + "tenant_id": tenantIDDesc, + }, + }, + { + Name: mcp.ToolName("teams_get_chat"), + Description: "Get metadata for a single chat by ID. Use after teams_list_chats to inspect topic, members, or chat type.", + Parameters: map[string]string{ + "chat_id": "Chat ID (e.g. 19:xxx@thread.v2)", + "expand": "Optional expand clause (e.g. \"members\")", + "tenant_id": tenantIDDesc, + }, + Required: []string{"chat_id"}, + }, + { + Name: mcp.ToolName("teams_list_chat_messages"), + Description: "Start here to read chat messages. Returns messages in a 1:1, group, or meeting chat in reverse chronological order. Requires chat_id from teams_list_chats.", + Parameters: map[string]string{ + "chat_id": "Chat ID", + "top": "Number of messages to return (default 20, max 50)", + "orderby": "OData $orderby (default: createdDateTime desc)", + "tenant_id": tenantIDDesc, + }, + Required: []string{"chat_id"}, + }, + { + Name: mcp.ToolName("teams_get_chat_message"), + Description: "Get a single chat message by ID, including full HTML body and attachments.", + Parameters: map[string]string{ + "chat_id": "Chat ID", + "message_id": "Message ID", + "tenant_id": tenantIDDesc, + }, + Required: []string{"chat_id", "message_id"}, + }, + { + Name: mcp.ToolName("teams_send_chat_message"), + Description: "Send a message to an existing chat as the signed-in user. Content is HTML by default; set content_type=text for plain text.", + Parameters: map[string]string{ + "chat_id": "Chat ID", + "content": "Message body (HTML or plain text)", + "content_type": "Either \"html\" (default) or \"text\"", + "subject": "Optional subject line", + "tenant_id": tenantIDDesc, + }, + Required: []string{"chat_id", "content"}, + }, + { + Name: mcp.ToolName("teams_list_chat_members"), + Description: "List the participants in a chat, with display names and user IDs.", + Parameters: map[string]string{ + "chat_id": "Chat ID", + "tenant_id": tenantIDDesc, + }, + Required: []string{"chat_id"}, + }, + + // --- Teams + channels --- + { + Name: mcp.ToolName("teams_list_joined_teams"), + Description: "Start here to discover teams. Lists the Microsoft Teams (groups) the signed-in user belongs to. Returns team IDs needed by teams_list_channels and downstream tools.", + Parameters: map[string]string{ + "tenant_id": tenantIDDesc, + }, + }, + { + Name: mcp.ToolName("teams_list_channels"), + Description: "List channels in a Team. Requires team_id from teams_list_joined_teams.", + Parameters: map[string]string{ + "team_id": "Team (group) ID", + "filter": "Optional OData $filter (e.g. \"membershipType eq 'standard'\")", + "tenant_id": tenantIDDesc, + }, + Required: []string{"team_id"}, + }, + { + Name: mcp.ToolName("teams_get_channel"), + Description: "Get metadata for a single channel (display name, description, membership type, web URL).", + Parameters: map[string]string{ + "team_id": "Team ID", + "channel_id": "Channel ID", + "tenant_id": tenantIDDesc, + }, + Required: []string{"team_id", "channel_id"}, + }, + { + Name: mcp.ToolName("teams_list_channel_messages"), + Description: "List the top-level messages in a channel. Note: this endpoint requires application or admin-consent permissions (ChannelMessage.Read.All); it may fail with 403 on tenants that did not consent. Use teams_list_chat_messages for chat-based reads.", + Parameters: map[string]string{ + "team_id": "Team ID", + "channel_id": "Channel ID", + "top": "Number of messages to return (default 20, max 50)", + "tenant_id": tenantIDDesc, + }, + Required: []string{"team_id", "channel_id"}, + }, + { + Name: mcp.ToolName("teams_get_channel_message"), + Description: "Get a single channel message by ID, including HTML body and attachments. Subject to the same admin-consent requirement as teams_list_channel_messages.", + Parameters: map[string]string{ + "team_id": "Team ID", + "channel_id": "Channel ID", + "message_id": "Message ID", + "tenant_id": tenantIDDesc, + }, + Required: []string{"team_id", "channel_id", "message_id"}, + }, + { + Name: mcp.ToolName("teams_list_message_replies"), + Description: "List the replies (thread) to a channel message. Subject to the same admin-consent requirement as teams_list_channel_messages.", + Parameters: map[string]string{ + "team_id": "Team ID", + "channel_id": "Channel ID", + "message_id": "Root message ID", + "top": "Number of replies (default 20, max 50)", + "tenant_id": tenantIDDesc, + }, + Required: []string{"team_id", "channel_id", "message_id"}, + }, + { + Name: mcp.ToolName("teams_send_channel_message"), + Description: "Post a new top-level message in a channel as the signed-in user. Uses ChannelMessage.Send which does NOT require admin consent in most tenants.", + Parameters: map[string]string{ + "team_id": "Team ID", + "channel_id": "Channel ID", + "content": "Message body (HTML or plain text)", + "content_type": "Either \"html\" (default) or \"text\"", + "subject": "Optional subject line", + "tenant_id": tenantIDDesc, + }, + Required: []string{"team_id", "channel_id", "content"}, + }, + { + Name: mcp.ToolName("teams_reply_to_channel_message"), + Description: "Post a reply in an existing channel thread as the signed-in user.", + Parameters: map[string]string{ + "team_id": "Team ID", + "channel_id": "Channel ID", + "message_id": "Root message ID being replied to", + "content": "Reply body (HTML or plain text)", + "content_type": "Either \"html\" (default) or \"text\"", + "tenant_id": tenantIDDesc, + }, + Required: []string{"team_id", "channel_id", "message_id", "content"}, + }, + + // --- Users + presence --- + { + Name: mcp.ToolName("teams_list_users"), + Description: "List users in the directory. Supports OData $filter, $select, $top. Returns user IDs and UPNs needed by chat lookups and mentions.", + Parameters: map[string]string{ + "top": "Max users (default 20, max 100)", + "filter": "OData $filter (e.g. \"startswith(displayName,'Jane')\")", + "select": "OData $select comma-separated fields", + "search": "Search across displayName + UPN (uses Graph $search with ConsistencyLevel=eventual)", + "tenant_id": tenantIDDesc, + }, + }, + { + Name: mcp.ToolName("teams_get_user"), + Description: "Get a single user by id, UPN, or email. Includes job title, mail, office location.", + Parameters: map[string]string{ + "user": "User ID, UPN, or email", + "select": "Optional OData $select", + "tenant_id": tenantIDDesc, + }, + Required: []string{"user"}, + }, + { + Name: mcp.ToolName("teams_search_users"), + Description: "Convenience wrapper over teams_list_users for keyword search across displayName + UPN.", + Parameters: map[string]string{ + "query": "Search query", + "top": "Max results (default 10, max 25)", + "tenant_id": tenantIDDesc, + }, + Required: []string{"query"}, + }, + { + Name: mcp.ToolName("teams_get_presence"), + Description: "Get the current Teams presence (availability + activity) for a user. Useful to check whether someone is Available, Busy, DoNotDisturb, or Away before paging them.", + Parameters: map[string]string{ + "user": "User ID or UPN. Defaults to the signed-in user.", + "tenant_id": tenantIDDesc, + }, + }, +} diff --git a/integrations/teams/users.go b/integrations/teams/users.go new file mode 100644 index 00000000..92186df7 --- /dev/null +++ b/integrations/teams/users.go @@ -0,0 +1,123 @@ +package teams + +import ( + "context" + "fmt" + "net/http" + "net/url" + + mcp "github.com/daltoniam/switchboard" +) + +// listUsers -> GET /users +func listUsers(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + r := mcp.NewArgs(args) + top := r.OptInt("top", 20) + filter := r.Str("filter") + selectFields := r.Str("select") + search := r.Str("search") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if top > 100 { + top = 100 + } + q := url.Values{} + q.Set("$top", fmt.Sprintf("%d", top)) + if filter != "" { + q.Set("$filter", filter) + } + if selectFields != "" { + q.Set("$select", selectFields) + } + if search != "" { + q.Set("$search", fmt.Sprintf("\"displayName:%s\" OR \"userPrincipalName:%s\"", search, search)) + } + path := "/users?" + q.Encode() + if search != "" { + // $search on /users requires ConsistencyLevel: eventual. + return t.graphGetWithHeaders(ctx, tn.TenantID, path, http.Header{"ConsistencyLevel": []string{"eventual"}}) + } + data, err := t.graphGet(ctx, tn.TenantID, path) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +// getUser -> GET /users/{id-or-upn} +func getUser(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + r := mcp.NewArgs(args) + user := r.Str("user") + selectFields := r.Str("select") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if user == "" { + return mcp.ErrResult(fmt.Errorf("user is required")) + } + path := "/users/" + url.PathEscape(user) + if selectFields != "" { + path += "?$select=" + url.QueryEscape(selectFields) + } + data, err := t.graphGet(ctx, tn.TenantID, path) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +// searchUsers -> /users?$search=... with ConsistencyLevel: eventual +func searchUsers(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + r := mcp.NewArgs(args) + query := r.Str("query") + top := r.OptInt("top", 10) + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + if query == "" { + return mcp.ErrResult(fmt.Errorf("query is required")) + } + if top > 25 { + top = 25 + } + q := url.Values{} + q.Set("$top", fmt.Sprintf("%d", top)) + q.Set("$search", fmt.Sprintf("\"displayName:%s\" OR \"userPrincipalName:%s\"", query, query)) + path := "/users?" + q.Encode() + return t.graphGetWithHeaders(ctx, tn.TenantID, path, http.Header{"ConsistencyLevel": []string{"eventual"}}) +} + +// getPresence -> GET /users/{id}/presence or /me/presence +func getPresence(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { + tn, err := t.tenantFromArgs(args) + if err != nil { + return mcp.ErrResult(err) + } + r := mcp.NewArgs(args) + user := r.Str("user") + if err := r.Err(); err != nil { + return mcp.ErrResult(err) + } + path := "/me/presence" + if user != "" { + path = "/users/" + url.PathEscape(user) + "/presence" + } + data, err := t.graphGet(ctx, tn.TenantID, path) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} From 180c5bb5ce147564ef2ad32e5dca59a6e7c6b6a0 Mon Sep 17 00:00:00 2001 From: Dalton Cherry Date: Thu, 14 May 2026 11:40:23 -0500 Subject: [PATCH 2/6] Detach background refresh context to satisfy gosec G118 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 --- integrations/teams/teams.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/integrations/teams/teams.go b/integrations/teams/teams.go index 8f112249..359347ce 100644 --- a/integrations/teams/teams.go +++ b/integrations/teams/teams.go @@ -159,7 +159,10 @@ func (t *teamsIntegration) Configure(ctx context.Context, creds mcp.Credentials) close(t.stopBg) } t.stopBg = make(chan struct{}) - go t.backgroundRefresh() + // Detach from the request-scoped ctx: the background goroutine outlives + // Configure. WithoutCancel preserves values but drops cancellation. + bgCtx := context.WithoutCancel(ctx) + go t.backgroundRefresh(bgCtx) return nil } @@ -369,13 +372,13 @@ func (t *teamsIntegration) shouldProactivelyRefresh(tn *tenant) bool { return time.Until(tn.ExpiresAt) <= refreshSkew } -func (t *teamsIntegration) backgroundRefresh() { +func (t *teamsIntegration) backgroundRefresh(ctx context.Context) { ticker := time.NewTicker(15 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: - t.refreshAllExpiring(context.Background()) + t.refreshAllExpiring(ctx) case <-t.stopBg: return } From ac2e59f817d9e5daf83973afacf1c8f14bc6dee0 Mon Sep 17 00:00:00 2001 From: Dalton Cherry Date: Thu, 14 May 2026 14:16:26 -0500 Subject: [PATCH 3/6] Address PR review: wire client_secret and cap OAuth response bodies - 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 --- integrations/teams/oauth.go | 12 ++++++++---- integrations/teams/teams.go | 4 +++- integrations/teams/teams_test.go | 13 +++++++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/integrations/teams/oauth.go b/integrations/teams/oauth.go index 0e12abed..4ab25e80 100644 --- a/integrations/teams/oauth.go +++ b/integrations/teams/oauth.go @@ -92,7 +92,7 @@ func (t *teamsIntegration) startOAuth(ctx context.Context, tenantHint string) (* t.mu.RLock() loginBase := t.loginBaseURL clientID := t.clientID - clientSecret := "" // We'll allow override via creds later; not stored on the struct yet. + clientSecret := t.clientSecret scopes := t.scopes t.mu.RUnlock() @@ -116,7 +116,7 @@ func (t *teamsIntegration) startOAuth(ctx context.Context, tenantHint string) (* } defer func() { _ = resp.Body.Close() }() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize)) if err != nil { return nil, err } @@ -189,7 +189,7 @@ func (f *oauthFlow) poll() { if err != nil { continue } - body, _ := io.ReadAll(resp.Body) + body, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize)) _ = resp.Body.Close() var atr accessTokenResponse @@ -341,6 +341,7 @@ func (t *teamsIntegration) refreshTenant(ctx context.Context, tn *tenant) error t.mu.RLock() loginBase := t.loginBaseURL clientID := t.clientID + clientSecret := t.clientSecret scopes := t.scopes t.mu.RUnlock() @@ -356,6 +357,9 @@ func (t *teamsIntegration) refreshTenant(ctx context.Context, tn *tenant) error "refresh_token": {tn.RefreshToken}, "scope": {scopes}, } + if clientSecret != "" { + form.Set("client_secret", clientSecret) + } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) if err != nil { @@ -370,7 +374,7 @@ func (t *teamsIntegration) refreshTenant(ctx context.Context, tn *tenant) error } defer func() { _ = resp.Body.Close() }() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize)) if err != nil { return err } diff --git a/integrations/teams/teams.go b/integrations/teams/teams.go index 359347ce..094f0c65 100644 --- a/integrations/teams/teams.go +++ b/integrations/teams/teams.go @@ -68,6 +68,7 @@ type teamsIntegration struct { store *tokenStore httpClient *http.Client clientID string + clientSecret string loginBaseURL string graphBaseURL string scopes string @@ -89,7 +90,7 @@ func (t *teamsIntegration) PlainTextKeys() []string { } func (t *teamsIntegration) OptionalKeys() []string { - return []string{"tenant_id", "client_id", "graph_base_url", "login_base_url", "scopes", "access_token", "refresh_token", "expires_at"} + return []string{"tenant_id", "client_id", "client_secret", "graph_base_url", "login_base_url", "scopes", "access_token", "refresh_token", "expires_at"} } func (t *teamsIntegration) Placeholders() map[string]string { @@ -111,6 +112,7 @@ func (t *teamsIntegration) Configure(ctx context.Context, creds mcp.Credentials) if t.clientID == "" { t.clientID = defaultClientID } + t.clientSecret = strings.TrimSpace(creds["client_secret"]) t.loginBaseURL = strings.TrimRight(strings.TrimSpace(creds["login_base_url"]), "/") if t.loginBaseURL == "" { t.loginBaseURL = defaultLoginBaseURL diff --git a/integrations/teams/teams_test.go b/integrations/teams/teams_test.go index d900716f..819c7aa7 100644 --- a/integrations/teams/teams_test.go +++ b/integrations/teams/teams_test.go @@ -104,6 +104,7 @@ func TestOptionalKeys(t *testing.T) { keys := i.OptionalKeys() assert.Contains(t, keys, "access_token") assert.Contains(t, keys, "refresh_token") + assert.Contains(t, keys, "client_secret") } func TestPlaceholders(t *testing.T) { @@ -120,10 +121,22 @@ func TestConfigure_DefaultsApplied(t *testing.T) { err := ti.Configure(context.Background(), mcp.Credentials{}) require.NoError(t, err) assert.Equal(t, defaultClientID, ti.clientID) + assert.Equal(t, "", ti.clientSecret) assert.Equal(t, defaultLoginBaseURL, ti.loginBaseURL) assert.Equal(t, defaultScopes, ti.scopes) } +func TestConfigure_ReadsClientSecret(t *testing.T) { + ti := newTestIntegration(t, nil) + err := ti.Configure(context.Background(), mcp.Credentials{ + "client_id": "custom-client", + "client_secret": "shhh", + }) + require.NoError(t, err) + assert.Equal(t, "custom-client", ti.clientID) + assert.Equal(t, "shhh", ti.clientSecret) +} + func TestConfigure_AcceptsExplicitTokenAsTenant(t *testing.T) { ti := newTestIntegration(t, nil) err := ti.Configure(context.Background(), mcp.Credentials{ From 388b125e3e9023e0e118dffda8481b0a97174cb0 Mon Sep 17 00:00:00 2001 From: Dalton Cherry Date: Thu, 14 May 2026 14:46:49 -0500 Subject: [PATCH 4/6] Address PR review round 2: race, ctx in poll, tenant URL escaping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- integrations/teams/oauth.go | 22 +++++++++++++++++----- integrations/teams/teams.go | 23 +++++++++++++---------- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/integrations/teams/oauth.go b/integrations/teams/oauth.go index 4ab25e80..025382d0 100644 --- a/integrations/teams/oauth.go +++ b/integrations/teams/oauth.go @@ -61,6 +61,7 @@ type accessTokenResponse struct { type oauthFlow struct { mu sync.Mutex integration *teamsIntegration + ctx context.Context //nolint:containedctx // long-lived poll goroutine needs cancellation clientID string clientSecret string tenantHint string // "common" or a specific tenant id; sent in the URL @@ -96,7 +97,7 @@ func (t *teamsIntegration) startOAuth(ctx context.Context, tenantHint string) (* scopes := t.scopes t.mu.RUnlock() - endpoint := fmt.Sprintf("%s/%s/oauth2/v2.0/devicecode", loginBase, tenantHint) + endpoint := fmt.Sprintf("%s/%s/oauth2/v2.0/devicecode", loginBase, url.PathEscape(tenantHint)) form := url.Values{ "client_id": {clientID}, @@ -131,6 +132,7 @@ func (t *teamsIntegration) startOAuth(ctx context.Context, tenantHint string) (* flow := &oauthFlow{ integration: t, + ctx: context.WithoutCancel(ctx), clientID: clientID, clientSecret: clientSecret, tenantHint: tenantHint, @@ -156,10 +158,20 @@ func (f *oauthFlow) poll() { } deadline := f.startedAt.Add(time.Duration(f.deviceResp.ExpiresIn) * time.Second) - endpoint := fmt.Sprintf("%s/%s/oauth2/v2.0/token", f.loginBaseURL, f.tenantHint) + endpoint := fmt.Sprintf("%s/%s/oauth2/v2.0/token", f.loginBaseURL, url.PathEscape(f.tenantHint)) for { - time.Sleep(interval) + // Sleep with cancellation support so a context-cancel exits promptly + // rather than waiting up to `interval` seconds. + select { + case <-f.ctx.Done(): + f.mu.Lock() + f.errMsg = "Authorization cancelled." + f.done = true + f.mu.Unlock() + return + case <-time.After(interval): + } if time.Now().After(deadline) { f.mu.Lock() @@ -178,7 +190,7 @@ func (f *oauthFlow) poll() { form.Set("client_secret", f.clientSecret) } - req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(form.Encode())) + req, err := http.NewRequestWithContext(f.ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) if err != nil { continue } @@ -350,7 +362,7 @@ func (t *teamsIntegration) refreshTenant(ctx context.Context, tn *tenant) error tenantHint = "common" } - endpoint := fmt.Sprintf("%s/%s/oauth2/v2.0/token", loginBase, tenantHint) + endpoint := fmt.Sprintf("%s/%s/oauth2/v2.0/token", loginBase, url.PathEscape(tenantHint)) form := url.Values{ "client_id": {clientID}, "grant_type": {"refresh_token"}, diff --git a/integrations/teams/teams.go b/integrations/teams/teams.go index 094f0c65..e7411adb 100644 --- a/integrations/teams/teams.go +++ b/integrations/teams/teams.go @@ -149,6 +149,14 @@ func (t *teamsIntegration) Configure(ctx context.Context, creds mcp.Credentials) } } + // Manage the background-refresh stop channel under t.mu so that a + // concurrent Configure cannot race with the goroutine's select. + if t.stopBg != nil { + close(t.stopBg) + } + t.stopBg = make(chan struct{}) + stopCh := t.stopBg + t.mu.Unlock() // Resolve user identity for each loaded tenant. Failures are logged but @@ -156,15 +164,10 @@ func (t *teamsIntegration) Configure(ctx context.Context, creds mcp.Credentials) t.resolveTenantIdentities(ctx) // Background refresh: skips for config-injected tokens since they're - // managed externally. - if t.stopBg != nil { - close(t.stopBg) - } - t.stopBg = make(chan struct{}) - // Detach from the request-scoped ctx: the background goroutine outlives - // Configure. WithoutCancel preserves values but drops cancellation. + // managed externally. Detach from the request-scoped ctx — the goroutine + // outlives Configure. WithoutCancel preserves values but drops cancellation. bgCtx := context.WithoutCancel(ctx) - go t.backgroundRefresh(bgCtx) + go t.backgroundRefresh(bgCtx, stopCh) return nil } @@ -374,14 +377,14 @@ func (t *teamsIntegration) shouldProactivelyRefresh(tn *tenant) bool { return time.Until(tn.ExpiresAt) <= refreshSkew } -func (t *teamsIntegration) backgroundRefresh(ctx context.Context) { +func (t *teamsIntegration) backgroundRefresh(ctx context.Context, stop <-chan struct{}) { ticker := time.NewTicker(15 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: t.refreshAllExpiring(ctx) - case <-t.stopBg: + case <-stop: return } } From 0e690303f99b91695f111859bf39912a34f6ba94 Mon Sep 17 00:00:00 2001 From: Dalton Cherry Date: Thu, 14 May 2026 15:09:52 -0500 Subject: [PATCH 5/6] Address PR review round 3: nil-tenant panic, expires_at, $search escape - 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 --- integrations/teams/oauth.go | 5 ++++- integrations/teams/teams.go | 12 ++++++++++-- integrations/teams/teams_test.go | 24 ++++++++++++++++++++++++ integrations/teams/users.go | 17 +++++++++++++++-- integrations/teams/users_test.go | 30 ++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 integrations/teams/users_test.go diff --git a/integrations/teams/oauth.go b/integrations/teams/oauth.go index 025382d0..78a08194 100644 --- a/integrations/teams/oauth.go +++ b/integrations/teams/oauth.go @@ -346,7 +346,10 @@ func base64URLDecode(s string) ([]byte, error) { // refreshTenant exchanges a refresh token for a fresh access token. Called by // graphRequestInner on 401 and proactively before expiry. func (t *teamsIntegration) refreshTenant(ctx context.Context, tn *tenant) error { - if tn == nil || tn.RefreshToken == "" { + if tn == nil { + return fmt.Errorf("refreshTenant called with nil tenant") + } + if tn.RefreshToken == "" { return fmt.Errorf("no refresh_token available for tenant %s", tn.TenantID) } diff --git a/integrations/teams/teams.go b/integrations/teams/teams.go index e7411adb..2a13b4a5 100644 --- a/integrations/teams/teams.go +++ b/integrations/teams/teams.go @@ -138,12 +138,20 @@ func (t *teamsIntegration) Configure(ctx context.Context, creds mcp.Credentials) if tid == "" { tid = "_config" } - t.store.upsert(&tenant{ + tn := &tenant{ TenantID: tid, AccessToken: at, RefreshToken: strings.TrimSpace(creds["refresh_token"]), Source: "config", - }) + } + // Honour an optional expires_at so proactive refresh kicks in instead + // of waiting for a 401 from Graph. + if raw := strings.TrimSpace(creds["expires_at"]); raw != "" { + if exp, err := time.Parse(time.RFC3339, raw); err == nil { + tn.ExpiresAt = exp + } + } + t.store.upsert(tn) if t.defaultTenant != "" { t.store.setDefault(t.defaultTenant) } diff --git a/integrations/teams/teams_test.go b/integrations/teams/teams_test.go index 819c7aa7..d5b1dcf1 100644 --- a/integrations/teams/teams_test.go +++ b/integrations/teams/teams_test.go @@ -295,6 +295,30 @@ func TestRefreshTenant_NoRefreshToken(t *testing.T) { assert.Contains(t, err.Error(), "no refresh_token") } +func TestRefreshTenant_NilTenant(t *testing.T) { + ti := newTestIntegration(t, nil) + // Should not panic — the nil guard must return an error rather than + // dereferencing the nil pointer in the error message. + err := ti.refreshTenant(context.Background(), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "nil tenant") +} + +func TestConfigure_ParsesExpiresAt(t *testing.T) { + ti := newTestIntegration(t, nil) + exp := "2099-01-02T15:04:05Z" + err := ti.Configure(context.Background(), mcp.Credentials{ + "tenant_id": "tenant-z", + "access_token": "at-z", + "expires_at": exp, + }) + require.NoError(t, err) + tn := ti.store.get("tenant-z") + require.NotNil(t, tn) + want, _ := time.Parse(time.RFC3339, exp) + assert.True(t, tn.ExpiresAt.Equal(want), "ExpiresAt %v != %v", tn.ExpiresAt, want) +} + // --- Token store --- func TestTokenStore_UpsertSetsDefault(t *testing.T) { diff --git a/integrations/teams/users.go b/integrations/teams/users.go index 92186df7..e878b4a5 100644 --- a/integrations/teams/users.go +++ b/integrations/teams/users.go @@ -5,10 +5,21 @@ import ( "fmt" "net/http" "net/url" + "strings" mcp "github.com/daltoniam/switchboard" ) +// sanitizeSearchTerm strips characters that would break the OData $search +// expression. Inner double-quotes terminate the quoted string and yield a +// Graph 400; backslashes can introduce escape ambiguity. We never need them +// for the displayName/UPN lookups in this package. +func sanitizeSearchTerm(s string) string { + s = strings.ReplaceAll(s, `"`, "") + s = strings.ReplaceAll(s, `\`, "") + return strings.TrimSpace(s) +} + // listUsers -> GET /users func listUsers(ctx context.Context, t *teamsIntegration, args map[string]any) (*mcp.ToolResult, error) { tn, err := t.tenantFromArgs(args) @@ -35,7 +46,8 @@ func listUsers(ctx context.Context, t *teamsIntegration, args map[string]any) (* q.Set("$select", selectFields) } if search != "" { - q.Set("$search", fmt.Sprintf("\"displayName:%s\" OR \"userPrincipalName:%s\"", search, search)) + safe := sanitizeSearchTerm(search) + q.Set("$search", fmt.Sprintf(`"displayName:%s" OR "userPrincipalName:%s"`, safe, safe)) } path := "/users?" + q.Encode() if search != "" { @@ -93,9 +105,10 @@ func searchUsers(ctx context.Context, t *teamsIntegration, args map[string]any) if top > 25 { top = 25 } + safe := sanitizeSearchTerm(query) q := url.Values{} q.Set("$top", fmt.Sprintf("%d", top)) - q.Set("$search", fmt.Sprintf("\"displayName:%s\" OR \"userPrincipalName:%s\"", query, query)) + q.Set("$search", fmt.Sprintf(`"displayName:%s" OR "userPrincipalName:%s"`, safe, safe)) path := "/users?" + q.Encode() return t.graphGetWithHeaders(ctx, tn.TenantID, path, http.Header{"ConsistencyLevel": []string{"eventual"}}) } diff --git a/integrations/teams/users_test.go b/integrations/teams/users_test.go new file mode 100644 index 00000000..80a3cb53 --- /dev/null +++ b/integrations/teams/users_test.go @@ -0,0 +1,30 @@ +package teams + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSanitizeSearchTerm(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"plain", "alice", "alice"}, + {"strips_double_quotes", `foo" OR "mail:secret`, "foo OR mail:secret"}, + {"strips_backslash", `bob\admin`, "bobadmin"}, + {"trims_whitespace", " carol ", "carol"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := sanitizeSearchTerm(tc.in) + assert.Equal(t, tc.want, got) + // Result must never contain a bare double-quote, else the OData + // expression we interpolate it into would break. + assert.False(t, strings.Contains(got, `"`), "result contains a double-quote") + }) + } +} From 4d10779fa27b5374beafac15be4b199a91f6c81f Mon Sep 17 00:00:00 2001 From: Dalton Cherry Date: Thu, 14 May 2026 16:02:10 -0500 Subject: [PATCH 6/6] Stub Graph in tests + clean up background goroutines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- integrations/teams/teams_test.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/integrations/teams/teams_test.go b/integrations/teams/teams_test.go index d5b1dcf1..ffe64dea 100644 --- a/integrations/teams/teams_test.go +++ b/integrations/teams/teams_test.go @@ -395,6 +395,12 @@ func TestTeamsRemoveTenant_OK(t *testing.T) { // newTestIntegration builds an integration wired to optional httptest server URLs. // It avoids Configure() (which spawns background goroutines and reads disk) and // instead initializes the fields needed by the tests directly. +// +// When the caller passes nil, the integration is still pointed at a local stub +// graph server (returning 401 to every request) so that tests which go on to +// call Configure() don't fire live HTTPS calls to graph.microsoft.com via +// resolveTenantIdentities. The stub server and any background goroutines +// spawned by Configure are cleaned up via t.Cleanup. func newTestIntegration(t *testing.T, graphServer *httptest.Server) *teamsIntegration { t.Helper() ti := &teamsIntegration{ @@ -408,6 +414,26 @@ func newTestIntegration(t *testing.T, graphServer *httptest.Server) *teamsIntegr if graphServer != nil { ti.graphBaseURL = graphServer.URL ti.httpClient = graphServer.Client() + } else { + // Default stub: refuse every call so resolveTenantIdentities and any + // other Configure-spawned probe never escapes the test process. + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(stub.Close) + ti.graphBaseURL = stub.URL + ti.httpClient = stub.Client() } + // Configure spawns a long-lived backgroundRefresh goroutine and stores + // the stop channel on ti.stopBg. Ensure we always close it after the + // test so goroutines don't accumulate across the suite. + t.Cleanup(func() { + ti.mu.Lock() + defer ti.mu.Unlock() + if ti.stopBg != nil { + close(ti.stopBg) + ti.stopBg = nil + } + }) return ti }