Skip to content

Commit a8c2c17

Browse files
committed
feat(auth): add limited mode (X-UserToken) with scoped tool subset (#2)
Add a runtime-selected limited mode that authenticates via the X-UserToken service token instead of login/password/2FA. Selection is implicit: setting PARARAM_USER_TOKEN in the environment switches to limited mode; login/password are then mutually exclusive and rejected by Config.validate_credentials. Limited mode registers a tightly scoped subset of MCP tools matching the service-token allow-list: - get_chat (existing aio: get_chat_by_id) - create_private_chat, create_group_chat, create_thread_chat (new) - get_chat_messages, get_message_from_url (existing) - get_reply_thread (new, wraps Post.rerere) - get_replies_to_post (new, wraps Post.load_reply_posts) - send_message (existing) - edit_post (new, wraps Post.edit) - delete_post (new, wraps Post.delete) 8 new MCP tools, with payload schemas added in models.py. Tool registration is pruned in server.py after-the-fact via mcp.local_provider.remove_tool() so existing tool functions don't need to know about mode. search_chats is intentionally excluded from limited mode — the /core/chat/search endpoint demands a `session_id` field whose source is undocumented for service tokens. Add back once the team documents the mechanism. Tool-side notes: - PararamClient (client.py) branches on config.mode; in limited mode it passes user_token= through to AsyncPararamio and never instantiates a cookie manager. - Stale `from pararamio_aio._core import …` imports across client.py and tools/*.py are migrated to `pararamio_aio.exceptions` — the `_core` shim isn't present in current pararamio-aio HEAD. - [tool.uv.sources] override pins pararamio-aio to the local feat/user-token-auth branch of py-pararamio for development. Revert this and bump the version pin to the released aio version before merging — marked with a TODO in pyproject.toml. Verified end-to-end against the real api.pararam.io: - limited-mode boot succeeds with a real X-UserToken - get_chat returns chat 360 metadata - get_reply_thread + get_replies_to_post work for an arbitrary post - full-mode validation still passes; existing 9 tools still register plus 8 net-new tools (20 total in full mode) - 11 tools register in limited mode (matches the curated allow-list)
1 parent b657c2d commit a8c2c17

12 files changed

Lines changed: 656 additions & 69 deletions

File tree

.env.example

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
# Example environment variables for pararam-nexus-mcp
2-
# Copy this file to .env and fill in the values
3-
4-
# Pararam.io credentials
2+
# Copy this file to .env and fill in the values.
3+
#
4+
# Two authentication modes — pick one:
5+
#
6+
# --- Mode A: Full (login + password + optional 2FA) ----------------------
7+
# All MCP tools are registered. Session is persisted via a cookie file.
58
PARARAM_LOGIN=your_login
69
PARARAM_PASSWORD=your_password
710

8-
# Pararam.io 2FA key (required if your account uses 2FA)
9-
# Get this from your authenticator app settings
11+
# 2FA key (required if your account has 2FA). From your authenticator app.
1012
PARARAM_2FA_KEY=your_2fa_key
1113

1214
# Cookie storage path (optional, default: .pararam_cookies.json)
13-
# Session will be saved here after first successful authentication
1415
# PARARAM_COOKIE_FILE=.pararam_cookies.json
1516

17+
# --- Mode B: Limited (X-UserToken service token) -------------------------
18+
# Set PARARAM_USER_TOKEN to enable limited mode. Mutually exclusive with
19+
# PARARAM_LOGIN/PARARAM_PASSWORD — leave those unset/empty in this mode.
20+
# Only chat / message / post / replies / edit / delete tools are exposed
21+
# (no user lookup, no file uploads, no global search). See README "Modes".
22+
# PARARAM_USER_TOKEN=your_service_token
23+
1624
# Channel settings (optional — enables Claude Code channel notifications)
1725
# Bot secret key from @infobot new_bot command
1826
# PARARAM_BOT_SECRET=your_full_bot_secret_key

CLAUDE.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,18 @@ FastMCP server providing tools for pararam.io (messaging/collaboration platform)
4848

4949
**Entry point:** `server.py` creates FastMCP instance, registers tools from three modules, runs server on stdio transport.
5050

51-
**Client:** `client.py` — singleton `PararamClient` wrapping `AsyncPararamio` with cookie-based session persistence and TOTP 2FA support.
51+
**Client:** `client.py` — singleton `PararamClient` wrapping `AsyncPararamio`. In **full** mode it uses cookie-based session persistence + optional TOTP 2FA; in **limited** mode it passes `user_token=` through to `AsyncPararamio` and the cookie manager is never created.
5252

53-
**Config:** `config.py` — Pydantic BaseSettings loading from `.env`. Required: `PARARAM_LOGIN`, `PARARAM_PASSWORD`. Optional: `PARARAM_2FA_KEY`, `PARARAM_COOKIE_FILE`.
53+
**Config:** `config.py` — Pydantic BaseSettings loading from `.env`. Either set `PARARAM_LOGIN`/`PARARAM_PASSWORD` (+ optional `PARARAM_2FA_KEY`/`PARARAM_COOKIE_FILE`) for full mode, **or** set `PARARAM_USER_TOKEN` for limited mode. The two sets are mutually exclusive; `Config.validate_credentials()` enforces XOR.
54+
55+
**Modes:**
56+
- `full` (default) — every registered tool is available.
57+
- `limited` (set when `PARARAM_USER_TOKEN` is present) — the server prunes its tool list to the allow-list defined as `LIMITED_MODE_TOOLS` in `server.py`. User tools (`users.py`) aren't registered at all in limited mode.
5458

5559
**Tools** (in `tools/`):
56-
- `posts.py` — message search, send, thread building, file upload/download (8 tools)
57-
- `chats.py` — chat search (1 tool)
58-
- `users.py` — user search, info, team status (3 tools)
60+
- `posts.py` — message search, send, thread building, file upload/download, plus limited-mode tools `get_reply_thread`, `get_replies_to_post`, `edit_post`, `delete_post` (12 tools)
61+
- `chats.py` — chat search/get + create variants (`create_private_chat`, `create_group_chat`, `create_thread_chat`) (5 tools)
62+
- `users.py` — user search, info, team status (3 tools, full mode only)
5963

6064
Each tool module exports a `register_*_tools(mcp)` function. Tools use `@mcp.tool()` decorator, return `ToolResponse[T]` (generic wrapper with success/message/error/payload).
6165

README.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,40 @@ uv sync --dev
133133

134134
## Configuration
135135

136-
Create a `.env` file with your pararam.io credentials:
136+
The server runs in one of two modes depending on which env vars are set.
137+
138+
### Full mode — login + password (+ optional 2FA)
139+
140+
All tools are registered. Cookies are persisted between runs.
137141

138142
```env
139143
PARARAM_LOGIN=your_login
140144
PARARAM_PASSWORD=your_password
141-
PARARAM_2FA_KEY=your_2fa_key # Optional
145+
PARARAM_2FA_KEY=your_2fa_key # optional
146+
```
147+
148+
### Limited mode — `X-UserToken` service token
149+
150+
Set `PARARAM_USER_TOKEN` (mutually exclusive with `PARARAM_LOGIN`/`PARARAM_PASSWORD`).
151+
Only the chat / message / post / replies / edit / delete tools are registered;
152+
user lookups, global search, and file ops are excluded.
153+
154+
```env
155+
PARARAM_USER_TOKEN=your_service_token
142156
```
143157

158+
Tools available in limited mode:
159+
160+
- **Chats**`get_chat`, `create_private_chat`, `create_group_chat`,
161+
`create_thread_chat`
162+
- **Posts**`get_chat_messages`, `get_message_from_url`,
163+
`get_reply_thread`, `get_replies_to_post`, `send_message`,
164+
`edit_post`, `delete_post`
165+
166+
> `search_chats` is **not** exposed in limited mode — the underlying
167+
> `/core/chat/search` endpoint requires a `session_id` field that service
168+
> tokens don't provide. Will be re-added once the mechanism is documented.
169+
144170
## MCP Client Configuration
145171

146172
### Claude Code (CLI)

packages/pararam-nexus-mcp/src/pararam_nexus_mcp/client.py

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44
import logging
55
from typing import Any
66

7-
from pararamio_aio import AsyncFileCookieManager, AsyncPararamio
8-
from pararamio_aio._core import PararamioException
7+
from pararamio_aio import AsyncFileCookieManager, AsyncPararamio, PararamioException
98

109
from pararam_nexus_mcp.auth import get_2fa_key
1110
from pararam_nexus_mcp.config import config
@@ -30,7 +29,13 @@ def __init__(self) -> None:
3029
if self._initialized:
3130
return
3231
self._client: AsyncPararamio | None = None
33-
self._cookie_manager: AsyncFileCookieManager = AsyncFileCookieManager(str(config.pararam_cookie_file))
32+
# Cookie manager is only used in full mode; limited (token) mode never
33+
# persists session data.
34+
self._cookie_manager: AsyncFileCookieManager | None = (
35+
None
36+
if config.mode == 'limited'
37+
else AsyncFileCookieManager(str(config.pararam_cookie_file))
38+
)
3439
self._initialized: bool = True
3540

3641
async def __aenter__(self) -> PararamClient:
@@ -47,22 +52,24 @@ async def connect(self) -> None:
4752
if self._client is not None:
4853
return
4954

50-
logger.info('Connecting to pararam.io')
51-
52-
# Get 2FA key from config
53-
tfa_key = get_2fa_key(config.pararam_2fa_key)
54-
55-
# Initialize client with cookie manager and enter async context
56-
client_context = AsyncPararamio(
57-
login=config.pararam_login,
58-
password=config.pararam_password,
59-
key=tfa_key,
60-
cookie_manager=self._cookie_manager,
61-
)
62-
63-
# Enter async context manager to initialize session
64-
# AsyncPararamio will automatically load cookies from cookie_manager
65-
# and authenticate only if needed
55+
logger.info('Connecting to pararam.io (%s mode)', config.mode)
56+
57+
if config.mode == 'limited':
58+
# Token mode: AsyncPararamio sets the X-UserToken header itself and
59+
# never needs cookies, login, or 2FA.
60+
client_context = AsyncPararamio(user_token=config.pararam_user_token)
61+
else:
62+
tfa_key = get_2fa_key(config.pararam_2fa_key)
63+
client_context = AsyncPararamio(
64+
login=config.pararam_login,
65+
password=config.pararam_password,
66+
key=tfa_key,
67+
cookie_manager=self._cookie_manager,
68+
)
69+
70+
# Enter async context manager to initialize session. In full mode
71+
# AsyncPararamio loads cookies from cookie_manager and authenticates
72+
# only if needed; in limited mode it just opens the httpx session.
6673
try:
6774
self._client = await client_context.__aenter__()
6875
logger.info('Successfully connected to pararam.io')
Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,29 @@
11
"""Configuration management for Pararam Nexus MCP."""
22

33
from pathlib import Path
4+
from typing import Literal
45

56
from pydantic import Field, ValidationError
67
from pydantic_settings import BaseSettings, SettingsConfigDict
78

9+
Mode = Literal['full', 'limited']
10+
811

912
class Config(BaseSettings):
10-
"""Configuration for Pararam Nexus MCP server."""
13+
"""Configuration for Pararam Nexus MCP server.
14+
15+
Two authentication modes are supported, selected at startup:
16+
17+
- **full** (default) — login + password + optional 2FA. All MCP tools
18+
are registered.
19+
- **limited** — set ``PARARAM_USER_TOKEN`` to an X-UserToken service
20+
token. Only the chat/message/post tools listed in the README are
21+
registered; file ops and broad search are excluded.
22+
23+
The two modes are mutually exclusive — providing both
24+
``PARARAM_USER_TOKEN`` and ``PARARAM_LOGIN``/``PARARAM_PASSWORD`` is a
25+
configuration error.
26+
"""
1127

1228
model_config = SettingsConfigDict(
1329
env_file='.env',
@@ -16,15 +32,21 @@ class Config(BaseSettings):
1632
extra='ignore',
1733
)
1834

19-
# Pararam.io credentials
20-
pararam_login: str = Field(..., description='Pararam.io login')
21-
pararam_password: str = Field(..., description='Pararam.io password')
22-
pararam_2fa_key: str | None = Field(None, description='Pararam.io 2FA key (optional)')
35+
# Pararam.io credentials — required in full mode, must be empty in limited mode.
36+
pararam_login: str | None = Field(None, description='Pararam.io login (full mode)')
37+
pararam_password: str | None = Field(None, description='Pararam.io password (full mode)')
38+
pararam_2fa_key: str | None = Field(None, description='Pararam.io 2FA key (full mode, optional)')
2339

24-
# Cookie storage
40+
# Pararam.io service token — selects limited mode when set.
41+
pararam_user_token: str | None = Field(
42+
None,
43+
description='Pararam.io X-UserToken service token (limited mode)',
44+
)
45+
46+
# Cookie storage (full mode only)
2547
pararam_cookie_file: Path = Field(
2648
default=Path('.pararam_cookies.json'),
27-
description='Path to store authentication cookies',
49+
description='Path to store authentication cookies (full mode only)',
2850
)
2951

3052
# MCP server settings
@@ -38,12 +60,36 @@ class Config(BaseSettings):
3860
description='MCP server instructions',
3961
)
4062

63+
@property
64+
def mode(self) -> Mode:
65+
"""Return 'limited' if a service token is set, else 'full'."""
66+
return 'limited' if self.pararam_user_token else 'full'
67+
4168
def validate_credentials(self) -> None:
42-
"""Validate that required credentials are provided."""
69+
"""Validate that exactly one auth mode is fully configured.
70+
71+
Raises:
72+
ValueError: If both modes are configured, neither mode is
73+
configured, or full mode is missing login/password.
74+
"""
75+
if self.pararam_user_token:
76+
if self.pararam_login or self.pararam_password:
77+
raise ValueError(
78+
'PARARAM_USER_TOKEN is mutually exclusive with '
79+
'PARARAM_LOGIN/PARARAM_PASSWORD. Unset one or the other.'
80+
)
81+
return # limited mode is fully configured
82+
4383
if not self.pararam_login:
44-
raise ValueError('PARARAM_LOGIN environment variable is required')
84+
raise ValueError(
85+
'PARARAM_LOGIN environment variable is required '
86+
'(or set PARARAM_USER_TOKEN for limited mode)'
87+
)
4588
if not self.pararam_password:
46-
raise ValueError('PARARAM_PASSWORD environment variable is required')
89+
raise ValueError(
90+
'PARARAM_PASSWORD environment variable is required '
91+
'(or set PARARAM_USER_TOKEN for limited mode)'
92+
)
4793

4894

4995
# Global config instance
@@ -53,7 +99,8 @@ def validate_credentials(self) -> None:
5399
# Config will fail if .env is missing or fields are invalid, which is expected during development
54100
# Will be validated at runtime in server.py
55101
config = Config(
56-
pararam_login='',
57-
pararam_password='',
102+
pararam_login=None,
103+
pararam_password=None,
58104
pararam_2fa_key=None,
105+
pararam_user_token=None,
59106
)

packages/pararam-nexus-mcp/src/pararam_nexus_mcp/models.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,3 +355,53 @@ class GetUserTeamStatusResponse(BaseModel):
355355
user_id: str = Field(..., description='User ID checked')
356356
teams_checked: int = Field(..., description='Number of teams checked')
357357
team_statuses: list[TeamStatus] = Field(..., description='List of team statuses')
358+
359+
360+
# Limited-mode (X-UserToken) tool payloads ------------------------------------
361+
362+
363+
class GetChatPayload(BaseModel):
364+
"""Payload for get_chat response."""
365+
366+
chat: ChatInfo = Field(..., description='Full chat metadata')
367+
368+
369+
class CreateChatPayload(BaseModel):
370+
"""Payload returned by every create_*_chat tool."""
371+
372+
chat_id: int = Field(..., description='ID of the newly created chat')
373+
title: str | None = Field(None, description='Chat title (None for private chats)')
374+
type: str = Field(..., description='Chat type: pm, group, or thread')
375+
376+
377+
class ReplyThreadPayload(BaseModel):
378+
"""Payload for get_reply_thread response (server-side rerere traversal)."""
379+
380+
chat_id: int = Field(..., description='ID of the chat')
381+
root_post_no: int = Field(..., description='Post number the thread was rooted at')
382+
post_nos: list[int] = Field(..., description='Post numbers in the thread, in server order')
383+
384+
385+
class RepliesToPostPayload(BaseModel):
386+
"""Payload for get_replies_to_post response (direct children only)."""
387+
388+
chat_id: int = Field(..., description='ID of the chat')
389+
post_no: int = Field(..., description='Post number replies were requested for')
390+
count: int = Field(..., description='Number of direct replies')
391+
replies: list[PostInfo] = Field(..., description='Hydrated reply posts')
392+
393+
394+
class EditPostPayload(BaseModel):
395+
"""Payload for edit_post response."""
396+
397+
chat_id: int = Field(..., description='ID of the chat')
398+
post_no: int = Field(..., description='Post number that was edited')
399+
text: str = Field(..., description='New post text after edit')
400+
401+
402+
class DeletePostPayload(BaseModel):
403+
"""Payload for delete_post response."""
404+
405+
chat_id: int = Field(..., description='ID of the chat')
406+
post_no: int = Field(..., description='Post number that was deleted')
407+
is_deleted: bool = Field(..., description='Whether the post is now marked deleted')

0 commit comments

Comments
 (0)