Skip to content
Merged
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
24 changes: 2 additions & 22 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,30 +41,10 @@ jobs:
gourmand:
name: Code Quality (Gourmand)
runs-on: ubuntu-latest

container:
image: quay.io/crunchtools/gourmand:latest
steps:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable

- name: Cache Gourmand binary
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/
~/.cargo/git/
key: ${{ runner.os }}-cargo-gourmand-${{ hashFiles('gourmand.toml') }}
restore-keys: |
${{ runner.os }}-cargo-gourmand-

- name: Install Gourmand
run: |
if ! command -v gourmand &> /dev/null; then
cargo install --git https://codeberg.org/mattdm/gourmand.git
fi

- name: Run Gourmand
run: gourmand --full .

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ jobs:

- name: Build container image
run: |
podman build -t mcp-slack:scan .
docker build -f Containerfile -t mcp-slack:scan .

- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ dmypy.json
# ruff
.ruff_cache/

# gourmand
.gourmand-cache/

# uv
.uv/
uv.lock
Expand Down
26 changes: 19 additions & 7 deletions .specify/memory/constitution.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# mcp-slack-crunchtools Constitution

> **Version:** 1.1.0
> **Version:** 1.2.0
> **Ratified:** 2026-03-25
> **Status:** Active
> **Inherits:** [crunchtools/constitution](https://github.com/crunchtools/constitution) v1.0.0
Expand Down Expand Up @@ -127,7 +127,18 @@ All code MUST pass Gourmand checks before merge. Zero violations required.

---

## III. Testing Standards
## III. Containerfile Conventions

The container uses a multi-stage Hummingbird FIPS build:

1. **Builder stage** (`quay.io/hummingbird/python:latest-fips-builder`) — has shell, dnf, build tools. Creates a Python venv and installs all dependencies.
2. **Runtime stage** (`quay.io/hummingbird/python:latest-fips`) — distroless, no shell, no package manager. The venv is copied from the builder. No `RUN` commands in this stage.
Comment on lines +134 to +135
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using the latest tag for base images is discouraged as it can lead to non-reproducible builds. When the latest tag is updated, your builds might fail or behave differently without any changes to your Containerfile. It is a best practice to pin base images to a specific version tag or a digest to ensure build stability and predictability.

For example:

1. **Builder stage** (`quay.io/hummingbird/python:1.2.3-fips-builder`)
2. **Runtime stage** (`quay.io/hummingbird/python:1.2.3-fips`)

Could you please update this convention (and the corresponding Containerfile) to use specific version tags instead of latest?


Builder and runtime MUST be from the same Hummingbird ecosystem. Never mix with UBI, Fedora, or Alpine base images.

---

## IV. Testing Standards

### Mocked API Tests (MANDATORY)

Expand Down Expand Up @@ -156,7 +167,7 @@ Every Pydantic model in `models.py` MUST have tests in `test_validation.py`:

---

## IV. Gourmand (AI Slop Detection)
## V. Gourmand (AI Slop Detection)

All code MUST pass `gourmand --full .` with **zero violations** before merge. Gourmand is a CI gate in GitHub Actions.

Expand All @@ -180,7 +191,7 @@ Unacceptable reasons:

---

## V. Code Quality Gates
## VI. Code Quality Gates

Every code change must pass through these gates in order:

Expand All @@ -203,7 +214,7 @@ Every code change must pass through these gates in order:

---

## VI. Naming Conventions
## VII. Naming Conventions

| Context | Name |
|---------|------|
Expand All @@ -218,7 +229,7 @@ Every code change must pass through these gates in order:

---

## VII. Development Workflow
## VIII. Development Workflow

### Adding a New Tool

Expand All @@ -240,7 +251,7 @@ Every code change must pass through these gates in order:

---

## VIII. Governance
## IX. Governance

### Amendment Process

Expand All @@ -255,3 +266,4 @@ Every code change must pass through these gates in order:
|---------|------|---------|
| 1.0.0 | 2026-03-25 | Initial constitution |
| 1.1.0 | 2026-03-25 | Switch to Hummingbird distroless FIPS with multi-stage venv build pattern |
| 1.2.0 | 2026-03-25 | Add Section III (Containerfile Conventions), renumber to match parent profile |
30 changes: 1 addition & 29 deletions gourmand-exceptions.toml
Original file line number Diff line number Diff line change
@@ -1,41 +1,13 @@
# Gourmand Exceptions
# Documented exceptions for files that legitimately trigger checks

# Summary litter — CLAUDE.md is a standard Claude Code project instructions file
[[exceptions]]
check = "summary_litter"
path = "CLAUDE.md"
justification = "Claude Code project instructions for AI assistant orientation"

# Primitive obsession — HTTP status codes and response limits are standard API patterns
[[exceptions]]
check = "primitive_obsession"
path = "src/mcp_slack_crunchtools/client.py"
justification = "HTTP status codes (429) and response size limit (10MB) are standard well-known constants"

# Primitive obsession — port 8005 is the allocated MCP server port
[[exceptions]]
check = "primitive_obsession"
path = "src/mcp_slack_crunchtools/__init__.py"
justification = "Port 8005 is the allocated MCP server port for mcp-slack"

# Implicit state machine — error handling dispatches on error codes, not a state machine
[[exceptions]]
check = "implicit_state_machine"
path = "src/mcp_slack_crunchtools/client.py"
justification = "Error handling dispatches on Slack error codes — standard pattern, not a state machine"

# Implicit state machine — optional parameter builders use simple if-guards
[[exceptions]]
check = "implicit_state_machine"
path = "src/mcp_slack_crunchtools/tools/channels.py"
justification = "Optional API parameter builders use simple if-guards to add query params, not state machines"

[[exceptions]]
check = "implicit_state_machine"
path = "src/mcp_slack_crunchtools/tools/messages.py"
justification = "Optional API parameter builders use simple if-guards to add query params, not state machines"

[[exceptions]]
check = "implicit_state_machine"
path = "src/mcp_slack_crunchtools/tools/users.py"
justification = "Optional API parameter builders use simple if-guards to add query params, not state machines"
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ line-length = 100
target-version = "py310"

[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP", "S", "B", "C4", "DTZ", "T10", "ISC", "PIE", "PT", "RET", "SIM", "TID", "TCH", "ARG", "PLC", "PLE", "PLR", "PLW", "TRY", "RUF"]
select = ["E", "F", "I", "N", "W", "UP", "S", "B", "C4", "C901", "DTZ", "T10", "ISC", "PIE", "PT", "RET", "SIM", "TID", "TCH", "ARG", "PLC", "PLE", "PLR", "PLW", "TRY", "RUF"]
ignore = [
"S101", # assert allowed in tests
"TRY003", # long exception messages ok
Expand All @@ -61,6 +61,10 @@ ignore = [
"RUF022", # __all__ sorted by category, not alphabetically
]

[tool.ruff.lint.pylint]
max-branches = 10
max-statements = 50

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S105", "PLC0415", "PLR2004"]

Expand Down
24 changes: 1 addition & 23 deletions src/mcp_slack_crunchtools/__init__.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,4 @@
"""MCP Slack CrunchTools - Secure read-only MCP server for Slack.

A security-focused read-only MCP server for Slack workspaces.

Usage:
# Run directly
mcp-slack-crunchtools

# Or with Python module
python -m mcp_slack_crunchtools

# With uvx
uvx mcp-slack-crunchtools

Environment Variables (one of these auth modes):
SLACK_USER_TOKEN: Slack User OAuth Token (xoxp-).
SLACK_COOKIE_TOKEN + SLACK_COOKIE_D: Browser cookie auth (xoxc- + xoxd-).

Example with Claude Code:
claude mcp add mcp-slack-crunchtools \\
--env SLACK_USER_TOKEN=xoxp-your-token \\
-- uvx mcp-slack-crunchtools
"""
"""MCP Slack CrunchTools - Secure read-only MCP server for Slack."""

import argparse

Expand Down
50 changes: 11 additions & 39 deletions src/mcp_slack_crunchtools/client.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
"""Slack API client with security hardening.

This module provides a secure async HTTP client for the Slack Web API.
All requests go through this client to ensure consistent security practices.

Slack-specific notes:
- Slack uses POST for all API methods, even reads
- Responses use {"ok": false, "error": "code"} instead of HTTP status codes
Expand All @@ -26,13 +23,9 @@

logger = logging.getLogger(__name__)

# Response size limit to prevent memory exhaustion (10MB)
MAX_RESPONSE_SIZE = 10 * 1024 * 1024

# Request timeout in seconds
REQUEST_TIMEOUT = 30.0

# Map Slack error codes to specific exception types
_ERROR_MAP: dict[str, type[Exception]] = {
"not_authed": PermissionDeniedError,
"invalid_auth": PermissionDeniedError,
Expand All @@ -59,18 +52,15 @@ class SlackClient:
"""

def __init__(self) -> None:
"""Initialize the Slack client."""
self._config = get_config()
self._client: httpx.AsyncClient | None = None

async def _get_client(self) -> httpx.AsyncClient:
"""Get or create the async HTTP client."""
if self._client is None:
headers: dict[str, str] = {
"Authorization": f"Bearer {self._config.token}",
"Content-Type": "application/x-www-form-urlencoded",
}
# Cookie auth (xoxc-) requires the d cookie alongside the token
if self._config.cookie_d is not None:
headers["Cookie"] = f"d={self._config.cookie_d}"
self._client = httpx.AsyncClient(
Expand All @@ -82,7 +72,6 @@ async def _get_client(self) -> httpx.AsyncClient:
return self._client

async def close(self) -> None:
"""Close the HTTP client."""
if self._client is not None:
await self._client.aclose()
self._client = None
Expand Down Expand Up @@ -112,57 +101,41 @@ async def api_call(
MissingScopeError: When OAuth scope is missing
"""
client = await self._get_client()

# Log request (without sensitive data)
logger.debug("Slack API call: %s", method)

# Build form data, filtering out None values
data: dict[str, Any] = {}
form_params: dict[str, Any] = {}
if params:
for key, value in params.items():
if value is not None:
data[key] = value
form_params[key] = value

try:
response = await client.post(f"/{method}", data=data)
response = await client.post(f"/{method}", data=form_params)
except httpx.TimeoutException as e:
raise SlackApiError("timeout", f"Request timeout: {e}") from e
except httpx.RequestError as e:
raise SlackApiError("request_error", f"Request failed: {e}") from e

# Handle HTTP-level rate limiting (429)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
raise RateLimitError(int(retry_after) if retry_after else None)

# Check response size before parsing
content_length = response.headers.get("content-length")
if content_length and int(content_length) > MAX_RESPONSE_SIZE:
raise SlackApiError("response_too_large", "Response too large")

# Parse response
try:
result = response.json()
api_response: dict[str, Any] = response.json()
except ValueError as e:
raise SlackApiError("invalid_json", f"Invalid JSON response: {e}") from e

# Check Slack's ok field
if not result.get("ok"):
error_code = result.get("error", "unknown_error")
self._handle_slack_error(error_code, result)

return result # type: ignore[no-any-return]
if not api_response.get("ok"):
error_code = api_response.get("error", "unknown_error")
self._handle_slack_error(error_code, api_response)

def _handle_slack_error(self, error_code: str, data: dict[str, Any]) -> None:
"""Handle Slack API error responses.
return api_response

Args:
error_code: Slack error code string
data: Full response data

Raises:
Various UserError subclasses based on error code
"""
def _handle_slack_error(self, error_code: str, response_body: dict[str, Any]) -> None:
error_class = _ERROR_MAP.get(error_code)

if error_class is PermissionDeniedError:
Expand All @@ -172,16 +145,15 @@ def _handle_slack_error(self, error_code: str, data: dict[str, Any]) -> None:
if error_class is UserNotFoundError:
raise UserNotFoundError(error_code)
if error_class is RateLimitError:
retry_after = data.get("retry_after")
retry_after = response_body.get("retry_after")
raise RateLimitError(int(retry_after) if retry_after else None)
if error_class is MissingScopeError:
needed = data.get("needed", error_code)
needed = response_body.get("needed", error_code)
raise MissingScopeError(str(needed))

raise SlackApiError(error_code)


# Global client instance
_client: SlackClient | None = None


Expand Down
Loading
Loading