# PowerBI MCP Server - Developer Guide

This guide is for developers who want to contribute to or modify the PowerBI MCP server. If you just want to use the server, see [README.md](README.md).

## Table of Contents

- [Development Setup](#development-setup)
- [Running the Server Locally](#running-the-server-locally)
- [Entry Points Explained](#entry-points-explained)
- [Environment Variable Loading](#environment-variable-loading)
- [Testing](#testing)
- [Quick Reference for Common Tasks](#quick-reference-for-common-tasks)

## Development Setup

### 1. Prerequisites

- Python 3.13+
- [uv](https://github.com/astral-sh/uv) package manager
- Git

### 2. Clone and Install

```bash
# Clone the repository
git clone https://github.com/gurvinder-dhillon/powerbi-mcp.git
cd powerbi-mcp

# Install dependencies using uv
# uv will automatically create a virtual environment
uv sync
```

### 3. Configure Environment

```bash
# Copy the example environment file
cp .env.example .env

# Edit .env and add your Azure AD credentials
nano .env
```

Update `.env` with your actual values:

```env
POWERBI_TENANT_ID=your-tenant-id-here
POWERBI_CLIENT_ID=your-client-id-here
POWERBI_CLIENT_SECRET=your-client-secret-here
```

See [README.md](README.md) for Azure AD setup instructions.

## Running the Server Locally

### Method 1: Using the run-server Script

The `run-server` script is defined in `pyproject.toml`:

```bash
uv run run-server
```

This is the same entry point used when users install from GitHub or locally.

### Method 2: Development Mode with MCP Inspector

For interactive testing and debugging:

```bash
npx @modelcontextprotocol/inspector uv --directory /path/to/powerbi-mcp run run-server
```

Replace `/path/to/powerbi-mcp` with the actual path to your local clone.

This opens the MCP Inspector in your browser where you can:
- View available tools
- Test tool calls interactively
- See request/response payloads
- Debug authentication and errors

## Entry Points Explained

Understanding how the server starts is important for development and debugging.

### Entry Point Chain

When a user runs the server, the following chain of calls happens:

1. **User's MCP client configuration** specifies the command:
   ```json
   {
     "command": "uvx",
     "args": ["--from", "git+https://github.com/...", "run-server"]
   }
   ```

2. **pyproject.toml** defines the `run-server` script:
   ```toml
   [project.scripts]
   run-server = "powerbi_mcp:main"
   ```

3. **powerbi_mcp/__init__.py** contains the `main()` function:
   ```python
   def main():
       """Main entry point for the PowerBI MCP server"""
       import os
       import logging

       logger = logging.getLogger(__name__)
       transport = os.getenv("MCP_TRANSPORT", "stdio")
       logger.info(f"Starting PowerBI MCP Server via transport: {transport}")
       mcp.run(transport=transport)
   ```

4. **mcp.run()** starts the FastMCP server and registers all tools.

## Environment Variable Loading

### How .env Files Are Loaded

The server loads environment variables from a `.env` file using this logic in `powerbi_mcp/auth.py`:

```python
from dotenv import load_dotenv
import os

# Load .env from current working directory
load_dotenv(dotenv_path=os.path.join(os.getcwd(), '.env'), override=False)
logger.info(f"Attempted to load .env from: {os.getcwd()}")
```

### Key Points

1. **Current Working Directory**: The server looks for `.env` in `os.getcwd()`, which is the directory where the user started their MCP client (not where the server code is installed).

2. **Project-Specific Credentials**: This allows users to have different `.env` files in different project directories, each with their own PowerBI credentials.

3. **No Override**: The `override=False` parameter means that environment variables already set in the shell or MCP client configuration take precedence over the `.env` file.

4. **Fallback to MCP Config**: If no `.env` file is found, the server will use environment variables from the MCP client configuration's `env` section.

### Testing with Different .env Files

```bash
# Project A
cd ~/projects/project-a
cat .env
# POWERBI_TENANT_ID=tenant-a
# POWERBI_CLIENT_ID=client-a
# POWERBI_CLIENT_SECRET=secret-a

# Project B
cd ~/projects/project-b
cat .env
# POWERBI_TENANT_ID=tenant-b
# POWERBI_CLIENT_ID=client-b
# POWERBI_CLIENT_SECRET=secret-b
```

When the MCP client starts from each directory, it will load the corresponding `.env` file.

### Debugging Environment Loading

Check the server logs to see where it attempted to load the `.env` file:

```
Attempted to load .env from: /Users/username/projects/project-a
```

## Testing

### Manual Testing with MCP Inspector

Start the MCP Inspector:

```bash
npx @modelcontextprotocol/inspector uv --directory /path/to/powerbi-mcp run run-server
```

Replace `/path/to/powerbi-mcp` with your actual project path.

Open the browser and test each tool:
1. `get_workspaces` - Verify authentication and workspace listing
2. `get_datasets` - Test with and without workspace_id
3. `get_dataset` - Test dataset details and schema
4. `query_dataset` - Test DAX query execution

### Integration Testing

Test the server with actual MCP clients:

1. **Claude Desktop**: Add to configuration and test with real conversations
2. **Claude Code**: Add to `.mcp.json` and test in a project

## Quick Reference for Common Tasks

### Changing Configuration Values

**API Endpoints:**
Edit `powerbi_mcp/constants.py`:
```python
API_BASE_URL = "https://api.powerbi.com/v1.0/myorg"
AUTH_URL_TEMPLATE = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
```

**Timeouts:**
Edit `powerbi_mcp/constants.py`:
```python
AUTH_TIMEOUT = 30.0  # Authentication request timeout
API_TIMEOUT = 60.0   # API request timeout
TOKEN_REFRESH_BUFFER = 60  # Refresh token N seconds before expiry
```

**Response Limits:**
Edit `powerbi_mcp/constants.py`:
```python
CHARACTER_LIMIT = 25000      # Max characters in response
MAX_ROWS_DISPLAY = 100       # Max rows shown in query results
MAX_WORKSPACES_TOP = 5000    # Max workspaces per request
MIN_WORKSPACES_TOP = 1       # Min workspaces per request
```

---

### Improving Error Messages

All HTTP error handling is in **one place**: `powerbi_mcp/exceptions.py`

**Example: Improve 404 error message**
```python
# Before: Generic message
elif status_code == 404:
    return APIError("Resource not found (404)...")

# After: More specific guidance
elif status_code == 404:
    endpoint = error.request.url.path
    return APIError(
        f"Resource not found (404) at {endpoint}. "
        "This usually means the ID is incorrect or the resource was deleted. "
        "Double-check your workspace_id or dataset_id."
    )
```

**One change applies to:**
- ✅ All workspace operations
- ✅ All dataset operations  
- ✅ All query operations
- ✅ Authentication flows

---

### Adding New Validation Rules

**For DAX queries:**
Edit `powerbi_mcp/validation.py`:
```python
def validate_dax_query(query: str) -> None:
    """Validate DAX query"""
    if not query.strip().upper().startswith("EVALUATE"):
        raise ValueError("Query must start with EVALUATE")

    # Add new validation here:
    if len(query) > 10000:
        raise ValueError("Query too long (max 10000 characters)")
```

**For other validations:**
Add new functions to `powerbi_mcp/validation.py`:
```python
def validate_workspace_id(workspace_id: str) -> None:
    """Validate workspace ID format"""
    if not workspace_id or len(workspace_id) < 10:
        raise ValueError("Invalid workspace ID format")
```

Then use in tools:
```python
from ..validation import validate_workspace_id

validate_workspace_id(workspace_id)
```

---

### Adding New Item Type Formatter

**Example: Add "Reports" formatter**

1. Create detail formatter in `powerbi_mcp/formatters.py`:
```python
def _format_report_details(report: dict[str, Any]) -> list[str]:
    """Format detailed fields for a report"""
    lines = []
    if report.get('webUrl'):
        lines.append(f"- **Web URL**: {report.get('webUrl')}")
    if report.get('embedUrl'):
        lines.append(f"- **Embed URL**: {report.get('embedUrl')}")
    if report.get('datasetId'):
        lines.append(f"- **Dataset ID**: {report.get('datasetId')}")
    return lines
```

2. Create public formatter function:
```python
def format_reports_markdown(reports: list[dict[str, Any]], detail: DetailLevel) -> str:
    """Format reports as markdown"""
    return _format_items_markdown(reports, "Reports", detail, _format_report_details)
```

That's it! The generic `_format_items_markdown()` handles:
- Empty list check
- Header formatting
- ID display
- Detail level switching
- Markdown structure

---

### Adding Custom Exception Types

Edit `powerbi_mcp/exceptions.py`:

```python
class RateLimitError(APIError):
    """Rate limiting exception with retry information"""
    
    def __init__(self, message: str, retry_after: int):
        super().__init__(message)
        self.retry_after = retry_after
```

Use in error handler:
```python
elif status_code == 429:
    retry_after = int(error.response.headers.get("Retry-After", "60"))
    return RateLimitError(
        f"Rate limit exceeded. Please wait {retry_after} seconds.",
        retry_after=retry_after
    )
```

---

### Module Responsibilities

| Module | Responsibility | What to Put Here |
|--------|---------------|------------------|
| `powerbi_mcp/__init__.py` | Entry point | Main function for server startup |
| `powerbi_mcp/__main__.py` | Module execution | Enables `python -m powerbi_mcp` |
| `powerbi_mcp/server.py` | FastMCP initialization | Server setup and tool registration |
| `powerbi_mcp/constants.py` | Configuration values | API URLs, timeouts, limits, any hardcoded value |
| `powerbi_mcp/validation.py` | Input validation | Validation functions that are used in multiple places |
| `powerbi_mcp/exceptions.py` | Error handling | Custom exceptions, HTTP error mapping |
| `powerbi_mcp/client.py` | API client | PowerBI REST API interactions only |
| `powerbi_mcp/auth.py` | Authentication | OAuth2 flow, token management |
| `powerbi_mcp/formatters.py` | Response formatting | Markdown/JSON formatters |
| `powerbi_mcp/models.py` | Data models | Pydantic models for request/response |
| `powerbi_mcp/tools/*.py` | MCP tools | Tool definitions, tool-specific logic |

---

### Import Patterns

**Constants:**
```python
from .constants import API_BASE_URL, API_TIMEOUT
```

**Validation:**
```python
from .validation import validate_dax_query
```

**Exceptions:**
```python
from .exceptions import handle_http_error, PowerBIError, APIError
```

**For external imports or testing, use absolute imports:**
```python
# For external imports or testing, use absolute imports:
from powerbi_mcp.constants import API_BASE_URL
from powerbi_mcp.validation import validate_dax_query
```

**Avoid circular imports:**
- ✅ `constants.py` has no imports from other modules
- ✅ `validation.py` has no imports from other modules
- ✅ `exceptions.py` only imports external libraries
- ✅ Core modules (`auth`, `client`) import from utilities
- ✅ Tools import from core modules

---

### Testing Strategy

**Test constants:**
```python
from powerbi_mcp.constants import API_TIMEOUT
assert API_TIMEOUT == 60.0
```

**Test validation:**
```python
from powerbi_mcp.validation import validate_dax_query
import pytest

# Valid query
validate_dax_query("EVALUATE Sales")  # Should not raise

# Invalid query
with pytest.raises(ValueError):
    validate_dax_query("SELECT * FROM Sales")
```

**Test error handling:**
```python
from powerbi_mcp.exceptions import handle_http_error
import httpx

# Mock HTTP 404 error
error = httpx.HTTPStatusError(...)
exception = handle_http_error(error, "API request")
assert isinstance(exception, APIError)
assert "404" in str(exception)
```

**Test formatters:**
```python
from powerbi_mcp.formatters import format_workspaces_markdown
from powerbi_mcp.models import DetailLevel

workspaces = [{"id": "123", "name": "Test WS"}]
output = format_workspaces_markdown(workspaces, DetailLevel.CONCISE)
assert "Test WS" in output
assert "123" in output
```

---

## Best Practices

### DO:
- ✅ Put all configuration values in `constants.py`
- ✅ Reuse validation functions across modules
- ✅ Use centralized error handling
- ✅ Keep modules focused on single responsibility
- ✅ Add docstrings to new functions
- ✅ Preserve backward compatibility

### DON'T:
- ❌ Hardcode magic numbers in business logic
- ❌ Duplicate error handling code
- ❌ Copy/paste formatter logic
- ❌ Create circular dependencies
- ❌ Mix concerns (e.g., formatting in client.py)
- ❌ Break existing tool signatures

---

## Quick Start for New Features

1. **Define constants** in `constants.py` if needed
2. **Add validation** in `validation.py` if reusable
3. **Implement feature** in appropriate module
4. **Add error handling** using existing exceptions
5. **Add formatting** reusing existing patterns
6. **Test** with MCP Inspector

---

## Common Pitfalls

### Pitfall 1: Hardcoding Values
❌ **Before:**
```python
timeout = 60.0
limit = 25000
```

✅ **After:**
```python
from .constants import API_TIMEOUT, CHARACTER_LIMIT
timeout = API_TIMEOUT
limit = CHARACTER_LIMIT
```

### Pitfall 2: Duplicate Validation
❌ **Before:**
```python
# In models.py
if not query.startswith("EVALUATE"):
    raise ValueError("...")

# In queries.py  
if not query.startswith("EVALUATE"):
    raise ValueError("...")
```

✅ **After:**
```python
# In both files
from .validation import validate_dax_query
validate_dax_query(query)
```

### Pitfall 3: Inconsistent Error Messages
❌ **Before:**
```python
# In client.py
raise Exception("Auth failed")

# In auth.py
raise Exception("Authentication error")
```

✅ **After:**
```python
# In both files
from .exceptions import handle_http_error
raise handle_http_error(e, context)
```

---

## Need Help?

- **Constants not found?** Check import path: `from .constants import ...`
- **Circular import?** Move shared code to utility module
- **Error message needs improvement?** Edit `exceptions.handle_http_error()`
- **New formatter needed?** Follow pattern in `formatters.py`

Remember: The goal is **maintainability**. One change should update everywhere automatically.

---

## Publishing to PyPI

### Prerequisites

1. Install build tools:
   ```bash
   uv pip install build twine
   ```

2. Create accounts:
   - [PyPI](https://pypi.org/account/register/)
   - [TestPyPI](https://test.pypi.org/account/register/) (for testing)

### Build and Test

1. Update version in `pyproject.toml`:
   ```toml
   [project]
   version = "0.2.0"  # Increment version
   ```

2. Build the package:
   ```bash
   uv run python -m build
   ```

3. Test on TestPyPI first:
   ```bash
   uv run twine upload --repository testpypi dist/*
   ```

4. Install and test from TestPyPI:
   ```bash
   uvx --index-url https://test.pypi.org/simple/ powerbi-mcp
   ```

### Publish to PyPI

Once tested, publish to PyPI:

```bash
uv run twine upload dist/*
```

### Post-Publication

Update installation instructions to use PyPI:

```json
{
  "command": "uvx",
  "args": ["powerbi-mcp"]
}
```

Instead of:

```json
{
  "command": "uvx",
  "args": ["--from", "git+https://github.com/..."]
}
```

---

## Contributing Guidelines

### Code Style

- Follow PEP 8 for Python code
- Use type hints for all function signatures
- Add docstrings to all public functions and classes
- Keep functions focused and single-purpose

### Commit Messages

Use conventional commits format:

```
feat: add support for PowerBI reports
fix: handle timeout errors gracefully
docs: update README with new installation method
refactor: consolidate error handling
test: add integration tests for datasets
```

### Pull Request Process

1. Fork the repository
2. Create a feature branch:
   ```bash
   git checkout -b feature/your-feature-name
   ```

3. Make your changes following the code style
4. Test your changes with MCP Inspector:
   ```bash
   npx @modelcontextprotocol/inspector uv --directory /path/to/powerbi-mcp run run-server
   ```

5. Commit your changes:
   ```bash
   git add .
   git commit -m "feat: your feature description"
   ```

6. Push to your fork:
   ```bash
   git push origin feature/your-feature-name
   ```

7. Create a pull request on GitHub

### What to Contribute

**High Priority:**
- Bug fixes and error handling improvements
- Additional PowerBI API tools (reports, dashboards, etc.)
- Performance optimizations
- Unit and integration tests
- Documentation improvements

**Medium Priority:**
- Support for additional authentication methods
- Caching and optimization features
- CLI utilities for testing
- Example queries and use cases

**Low Priority:**
- Code refactoring
- Style improvements
- Minor documentation tweaks

### Getting Help

- **Questions**: Open a GitHub Discussion
- **Bug Reports**: Open a GitHub Issue
- **Feature Requests**: Open a GitHub Issue with the "enhancement" label
- **Security Issues**: See SECURITY.md (if exists) or contact maintainers directly

---

## Development Workflow

### Typical Development Cycle

1. **Start with an issue or feature request**
   - Check existing issues or create a new one
   - Discuss the approach before implementing

2. **Set up development environment**
   ```bash
   git clone https://github.com/gurvinder-dhillon/powerbi-mcp.git
   cd powerbi-mcp
   uv sync
   cp .env.example .env
   # Edit .env with your credentials
   ```

3. **Make changes**
   - Follow the module responsibilities (see above)
   - Add constants to `powerbi_mcp/constants.py`
   - Add validation to `powerbi_mcp/validation.py`
   - Update error handling in `powerbi_mcp/exceptions.py`
   - Implement features in appropriate modules

4. **Test changes**
   ```bash
   # Test with MCP Inspector
   npx @modelcontextprotocol/inspector uv --directory /path/to/powerbi-mcp run run-server
   ```

5. **Document changes**
   - Update README.md if user-facing changes
   - Update DEVELOPER_GUIDE.md if developer-facing changes
   - Add docstrings and comments

6. **Submit pull request**
   - Follow the PR process above
   - Respond to code review feedback
   - Update based on maintainer comments

### Release Process

1. Update version in `pyproject.toml`
2. Update CHANGELOG.md (if exists)
3. Create a git tag:
   ```bash
   git tag -a v0.2.0 -m "Release version 0.2.0"
   git push origin v0.2.0
   ```
4. Build and publish to PyPI (see Publishing section)
5. Create GitHub release with release notes
