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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(),
Expand Down
14 changes: 14 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": ""},
Expand Down
12 changes: 6 additions & 6 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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) {
Expand Down
257 changes: 257 additions & 0 deletions integrations/teams/auth.go
Original file line number Diff line number Diff line change
@@ -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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

teamsTokenStatus has non-trivial logic — it computes age_minutes, determines status ("expired" / "expiring_soon" / "healthy"), and formats expires_in — but has no unit test. A subtle bug like checking remaining <= 0 vs remaining < 0, or the 5*time.Minute threshold, would be invisible. Same gap covers teamsRefreshTokens, teamsGetMe, formatContentType, and all the chat/channel/user handlers. The HTTP layer is solid, but argument-parsing and query-construction paths are untested.

A lightweight table-driven test covering the three status branches would cost about 20 lines:

func TestTeamsTokenStatus(t *testing.T) {
    cases := []struct{
        name    string
        exp     time.Time
        want    string
    }{
        {"healthy",  time.Now().Add(10*time.Minute), "healthy"},
        {"expiring", time.Now().Add(2*time.Minute),  "expiring_soon"},
        {"expired",  time.Now().Add(-1*time.Second), "expired"},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            ti := newTestIntegration(t, nil)
            ti.store.upsert(&tenant{TenantID: "t1", AccessToken: "a", ExpiresAt: tc.exp})
            res, err := ti.Execute(context.Background(), "teams_token_status", nil)
            require.NoError(t, err)
            assert.Contains(t, res.Data, tc.want)
        })
    }
}

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"
}
Loading
Loading