diff --git a/README.md b/README.md index 571db77..b4eba24 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 533f1c8..1f3201c 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 4455c86..811df91 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 6c4e7cb..7637eff 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 0000000..384fd5d --- /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 0000000..0c1f8af --- /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 0000000..fe693d1 --- /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 0000000..216072d --- /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 0000000..82df9f1 --- /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 0000000..78a0819 --- /dev/null +++ b/integrations/teams/oauth.go @@ -0,0 +1,428 @@ +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 + 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 + 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 := t.clientSecret + scopes := t.scopes + t.mu.RUnlock() + + endpoint := fmt.Sprintf("%s/%s/oauth2/v2.0/devicecode", loginBase, url.PathEscape(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(io.LimitReader(resp.Body, maxResponseSize)) + 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, + ctx: context.WithoutCancel(ctx), + 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, url.PathEscape(f.tenantHint)) + + for { + // 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() + 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.NewRequestWithContext(f.ctx, 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(io.LimitReader(resp.Body, maxResponseSize)) + _ = 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 { + return fmt.Errorf("refreshTenant called with nil tenant") + } + if tn.RefreshToken == "" { + return fmt.Errorf("no refresh_token available for tenant %s", tn.TenantID) + } + + t.mu.RLock() + loginBase := t.loginBaseURL + clientID := t.clientID + clientSecret := t.clientSecret + 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, url.PathEscape(tenantHint)) + form := url.Values{ + "client_id": {clientID}, + "grant_type": {"refresh_token"}, + "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 { + 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(io.LimitReader(resp.Body, maxResponseSize)) + 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 0000000..2a13b4a --- /dev/null +++ b/integrations/teams/teams.go @@ -0,0 +1,450 @@ +// 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 + clientSecret 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", "client_secret", "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.clientSecret = strings.TrimSpace(creds["client_secret"]) + 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" + } + 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) + } + } + + // 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 + // non-fatal — tokens may legitimately be expired and refreshable. + t.resolveTenantIdentities(ctx) + + // Background refresh: skips for config-injected tokens since they're + // 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, stopCh) + + 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(ctx context.Context, stop <-chan struct{}) { + ticker := time.NewTicker(15 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + t.refreshAllExpiring(ctx) + case <-stop: + 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 0000000..ffe64de --- /dev/null +++ b/integrations/teams/teams_test.go @@ -0,0 +1,439 @@ +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") + assert.Contains(t, keys, "client_secret") +} + +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, "", 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{ + "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") +} + +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) { + 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. +// +// 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{ + 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() + } 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 +} diff --git a/integrations/teams/tokens.go b/integrations/teams/tokens.go new file mode 100644 index 0000000..d153015 --- /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 0000000..37ea0ff --- /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 0000000..e878b4a --- /dev/null +++ b/integrations/teams/users.go @@ -0,0 +1,136 @@ +package teams + +import ( + "context" + "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) + 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 != "" { + safe := sanitizeSearchTerm(search) + q.Set("$search", fmt.Sprintf(`"displayName:%s" OR "userPrincipalName:%s"`, safe, safe)) + } + 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 + } + safe := sanitizeSearchTerm(query) + q := url.Values{} + q.Set("$top", fmt.Sprintf("%d", top)) + 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"}}) +} + +// 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) +} diff --git a/integrations/teams/users_test.go b/integrations/teams/users_test.go new file mode 100644 index 0000000..80a3cb5 --- /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") + }) + } +}