# Research: slack-mpm User Token Routing for send_message

**Date**: 2026-03-10
**Project**: slack-mpm (`/Users/masa/Projects/slack-mpm`)
**Issue**: Route `send_message` (and other write operations) through user token (`xoxp-`) when available

---

## Directory / File Structure

```
/Users/masa/Projects/slack-mpm/
├── pyproject.toml
├── .env.local.example
├── .env.local
├── src/slack_mpm/
│   ├── __init__.py
│   ├── __version__.py
│   ├── api/
│   │   ├── __init__.py
│   │   ├── _client.py          # slack_get / slack_post primitives
│   │   ├── messages.py         # send_message, search_messages, etc.
│   │   ├── reminders.py        # add/list/complete/delete reminders
│   │   ├── scheduled.py        # schedule_message, list, delete
│   │   ├── channels.py
│   │   ├── users.py
│   │   ├── files.py
│   │   ├── workspace.py
│   │   ├── bookmarks.py
│   ├── auth/
│   │   ├── __init__.py
│   │   ├── token_manager.py    # TokenManager class + _load_env()
│   │   ├── models.py           # SlackToken, TokenStatus, WorkspaceInfo
│   ├── cli/
│   │   ├── __init__.py
│   │   ├── main.py
│   └── server/
│       ├── __init__.py
│       └── slack_mcp_server.py # MCP tool definitions + dispatch
└── tests/
    ├── __init__.py
    ├── test_auth.py
    └── test_server.py
```

---

## 1. Token Resolution: How Bot vs User Token Works

**File**: `/Users/masa/Projects/slack-mpm/src/slack_mpm/auth/token_manager.py`

### Environment variables (`.env.local.example`)
```
SLACK_BOT_TOKEN=xoxb-your-bot-token-here   # required
SLACK_USER_TOKEN=xoxp-your-user-token-here  # optional, needed for search + reminders
SLACK_SIGNING_SECRET=...                     # optional
SLACK_TEAM_ID=...                            # optional
```

### `TokenManager.get_token()` — lines 113-134
```python
def get_token(self, prefer_user: bool = False) -> str:
    if prefer_user and self._user_token:
        return self._user_token
    if self._bot_token:
        return self._bot_token
    if self._user_token:
        return self._user_token
    raise ValueError(
        "No Slack token found. Set SLACK_BOT_TOKEN in your environment, "
        ".env.local file, or in the MCP server's env config."
    )
```

The infrastructure for `prefer_user` is **already fully built** in `TokenManager`. The mechanism just needs to be used at call sites.

### `TokenManager` also exposes
- `token_manager.user_token` — raw `str | None` property (line 91-93)
- `token_manager.has_user_token()` — bool (line 109-111)
- `token_manager.bot_token` — raw `str | None` property (line 85-88)

---

## 2. `send_message` — Full Current Implementation

**File**: `/Users/masa/Projects/slack-mpm/src/slack_mpm/api/messages.py`, lines 10-41

```python
async def send_message(
    token: str,
    channel: str,
    text: str,
    blocks: list[Any] | None = None,
    thread_ts: str | None = None,
    unfurl_links: bool = True,
) -> dict[str, Any]:
    """Send a message to a channel.

    Args:
        token: Slack bot token.
        channel: Channel ID or name to send to.
        text: Message text (also used as fallback for blocks).
        blocks: Optional Block Kit blocks array for rich formatting.
        thread_ts: If provided, send as a reply in this thread.
        unfurl_links: Whether to unfurl links in the message.

    Returns:
        Dict with 'channel', 'ts', and 'message' fields.
    """
    payload: dict[str, Any] = {
        "channel": channel,
        "text": text,
        "unfurl_links": unfurl_links,
    }
    if blocks is not None:
        payload["blocks"] = blocks
    if thread_ts is not None:
        payload["thread_ts"] = thread_ts

    return await slack_post(token, "chat.postMessage", payload)
```

The `token` parameter is documented as "Slack bot token" but `chat.postMessage` works with both bot and user tokens — so there is no API-level barrier; the only thing missing is passing the right token.

Other write operations with same shape:
- `send_ephemeral` (lines 44-60) — `chat.postEphemeral`
- `update_message` (lines 63-79) — `chat.update`
- `delete_message` (lines 82-93) — `chat.delete`
- `reply_in_thread` (lines 238-254) — `chat.postMessage` with `thread_ts`
- `schedule_message` in `scheduled.py` (lines 10-31) — `chat.scheduleMessage`
- `add_reaction` / `remove_reaction` (lines 172-207) — `reactions.add` / `reactions.remove`

---

## 3. Working User-Token Pattern: `search_messages` and Reminders

### `search_messages` — messages.py lines 114-141
```python
async def search_messages(token: str, query: str, count: int = 20) -> dict[str, Any]:
    """...
    Note: Requires a user token (xoxp-) with search:read scope.
    Bot tokens (xoxb-) cannot access Slack's search API.
    ...
    """
    if token.startswith("xoxb-"):
        raise ValueError(
            "search_messages requires a user token (xoxp-). "
            "Bot tokens (xoxb-) cannot access Slack's search API. "
            "Set SLACK_USER_TOKEN in your environment."
        )
    return await slack_get(
        token,
        "search.messages",
        {"query": query, "count": count, "highlight": "false"},
    )
```

### Reminders — reminders.py (all four functions)
```python
async def add_reminder(token: str, text: str, time: str) -> dict[str, Any]:
    return await slack_post(token, "reminders.add", {"text": text, "time": time})

async def list_reminders(token: str) -> dict[str, Any]:
    return await slack_get(token, "reminders.list")

async def complete_reminder(token: str, reminder: str) -> dict[str, Any]:
    return await slack_post(token, "reminders.complete", {"reminder": reminder})

async def delete_reminder(token: str, reminder: str) -> dict[str, Any]:
    return await slack_post(token, "reminders.delete", {"reminder": reminder})
```

The API functions themselves take `token: str` and are token-agnostic. The user-token routing happens entirely in the MCP server dispatch layer.

---

## 4. MCP Server Dispatch — How Routing Is Done Today

**File**: `/Users/masa/Projects/slack-mpm/src/slack_mpm/server/slack_mcp_server.py`

### Token variables set up at the top of `_dispatch_tool` — lines 898-899
```python
token = self._get_token()           # bot token (default)
user_token = self._token_manager.user_token   # xoxp- or None
```

### Current routing for `search_messages` (line 936-940)
```python
"search_messages": lambda a: messages.search_messages(
    user_token or token,   # <-- uses user token if available, falls back to bot
    a["query"],
    **_pick(a, ["count"]),
),
```

### Current routing for reminders (lines 982-991)
```python
"add_reminder": lambda a: reminders.add_reminder(
    user_token or token, a["text"], a["time"]
),
"list_reminders": lambda a: reminders.list_reminders(user_token or token),
"complete_reminder": lambda a: reminders.complete_reminder(
    user_token or token, a["reminder"]
),
"delete_reminder": lambda a: reminders.delete_reminder(
    user_token or token, a["reminder"]
),
```

### Current routing for `send_message` (lines 922-927) — uses bot token only
```python
"send_message": lambda a: messages.send_message(
    token,          # <-- always bot token, never user_token
    a["channel"],
    a["text"],
    **_pick(a, ["blocks", "thread_ts", "unfurl_links"]),
),
```

---

## 5. MCP Tool Definition for `send_message`

**File**: `slack_mcp_server.py`, lines 182-212

The current tool schema has no `use_user_token` parameter. The full input schema is:
```json
{
  "type": "object",
  "properties": {
    "channel":      {"type": "string"},
    "text":         {"type": "string"},
    "blocks":       {"type": "array"},
    "thread_ts":    {"type": "string"},
    "unfurl_links": {"type": "boolean", "default": true}
  },
  "required": ["channel", "text"]
}
```

---

## 6. Test Structure

- **`tests/test_auth.py`** — Tests for `TokenManager` and `SlackToken` models. Covers `get_token()`, `prefer_user`, fallback behavior, env-loading, masking.
- **`tests/test_server.py`** — Tests for MCP tool listing and dispatch. `test_dispatch_send_message` at line 113 patches `messages.send_message` but does NOT assert which token was passed, only that the result is returned correctly.

No existing test covers user-token routing for `send_message`.

---

## Summary of What Needs to Change

### Root cause
The `_dispatch_tool` handler for `send_message` (and related write operations) always passes `token` (the bot token). The `user_token or token` pattern used for `search_messages` and reminders is the correct idiom but is not applied to write operations.

### Recommended approach: Auto-routing (preferred, no API surface change)

**Rationale**: The issue states "route through user token when available." Auto-routing matches the existing pattern for reminders, requires no new MCP tool schema, and is transparent to the caller. A `use_user_token=True` parameter is a valid alternative but adds schema complexity that isn't needed if the intent is always "prefer user when available."

**Changes required:**

#### A. `slack_mcp_server.py` — `_dispatch_tool` handler dict

Change `send_message` (line 922):
```python
# BEFORE
"send_message": lambda a: messages.send_message(
    token,
    ...
),

# AFTER
"send_message": lambda a: messages.send_message(
    user_token or token,
    ...
),
```

Apply the same pattern to these write operations (all currently use `token` only):
- `send_ephemeral` (line 928)
- `update_message` (line 931)
- `delete_message` (line 934)
- `reply_in_thread` (line 952)
- `pin_message` (line 950)
- `unpin_message` (line 951)
- `add_reaction` (line 944)
- `remove_reaction` (line 948)
- `schedule_message` in `scheduled.py` dispatch (line 1005)

#### B. `api/messages.py` — Update docstrings

Update the `token` parameter docstrings in `send_message` and affected functions from "Slack bot token" to "Slack API token (bot or user)".

#### C. `tests/test_server.py` — Add new tests

Add a test `test_dispatch_send_message_uses_user_token_when_available` that:
1. Patches env with both `SLACK_BOT_TOKEN` and `SLACK_USER_TOKEN`
2. Calls `_dispatch_tool("send_message", {...})`
3. Asserts `messages.send_message` was called with the `xoxp-` token, not `xoxb-`

### Exact file locations for every change

| File | Line(s) | Change |
|------|---------|--------|
| `src/slack_mpm/server/slack_mcp_server.py` | 922 | `token` → `user_token or token` for `send_message` |
| `src/slack_mpm/server/slack_mcp_server.py` | 928 | Same for `send_ephemeral` |
| `src/slack_mpm/server/slack_mcp_server.py` | 931 | Same for `update_message` |
| `src/slack_mpm/server/slack_mcp_server.py` | 934 | Same for `delete_message` |
| `src/slack_mpm/server/slack_mcp_server.py` | 944 | Same for `add_reaction` |
| `src/slack_mpm/server/slack_mcp_server.py` | 948 | Same for `remove_reaction` |
| `src/slack_mpm/server/slack_mcp_server.py` | 950 | Same for `pin_message` |
| `src/slack_mpm/server/slack_mcp_server.py` | 951 | Same for `unpin_message` |
| `src/slack_mpm/server/slack_mcp_server.py` | 952 | Same for `reply_in_thread` |
| `src/slack_mpm/server/slack_mcp_server.py` | 1005 | Same for `schedule_message` |
| `src/slack_mpm/api/messages.py` | 13, 46, 66, 85 | Docstring: "bot token" → "API token (bot or user)" |
| `tests/test_server.py` | new | Test user-token routing for `send_message` |

### No changes needed in
- `auth/token_manager.py` — `get_token(prefer_user=True)` and `.user_token` property are already complete
- `auth/models.py` — models are already complete
- `api/_client.py` — `slack_post` / `slack_get` are token-agnostic
- `api/reminders.py` — already correct
- MCP tool input schemas — no schema change needed for auto-routing approach
- `.env.local.example` — already documents `SLACK_USER_TOKEN` correctly

### Scope note
`get_permalink` and `get_thread_replies`, `list_history` are read operations — bot token is appropriate and no change is needed there. `search_messages` is already correctly routed. All four reminder operations are already correctly routed.
