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
32 changes: 18 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,20 @@ standard SAS (DATA steps, PROCs, macros) and it runs as-is.

## Quickstart

The server speaks MCP over stdio. The fastest way to run it is with
[`uv`](https://docs.astral.sh/uv/) (no manual install step — `uvx` fetches and
runs it on demand):
The server speaks MCP over stdio. Install directly from this repository with
[`uv`](https://docs.astral.sh/uv/) (no manual install step):

```bash
uvx jenner-sas-mcp
uvx --from git+https://github.com/JennerAnalytics/jenner-sas-mcp jenner-sas-mcp
```

Or install it into an environment with pip:
Or install with pip:

```bash
pip install jenner-sas-mcp
pip install git+https://github.com/JennerAnalytics/jenner-sas-mcp
jenner-sas-mcp # starts the stdio server
```

> Until the package is published to PyPI, install from this repository:
> `uvx --from git+https://github.com/JennerAnalytics/jenner-sas-mcp jenner-sas-mcp`
> or `pip install git+https://github.com/JennerAnalytics/jenner-sas-mcp`.

## Configuration

All configuration is via environment variables:
Expand All @@ -63,7 +58,7 @@ Windows: `%APPDATA%\Claude\claude_desktop_config.json`) and add:
"mcpServers": {
"jenner-sas": {
"command": "uvx",
"args": ["jenner-sas-mcp"],
"args": ["--from", "git+https://github.com/JennerAnalytics/jenner-sas-mcp", "jenner-sas-mcp"],
"env": {
"JENNER_API_KEY": "your-api-key-optional"
}
Expand All @@ -77,16 +72,25 @@ Restart Claude Desktop. The `jenner-sas` tools appear in the tools menu.

### Claude Code

Add the server with your API key (omit `--env` if running anonymously):

```bash
claude mcp add jenner-sas --env JENNER_API_KEY=your-key -- uvx jenner-sas-mcp
claude mcp add jenner-sas --env JENNER_API_KEY=your-key -- uvx --from git+https://github.com/JennerAnalytics/jenner-sas-mcp jenner-sas-mcp
```

Or, for a project-scoped server checked into the repo, add it to `.mcp.json`:
For a project-scoped server checked into the repo, add to `.mcp.json` (set
`JENNER_API_KEY` in your shell environment or via `claude mcp add` above):

```json
{
"mcpServers": {
"jenner-sas": { "command": "uvx", "args": ["jenner-sas-mcp"] }
"jenner-sas": {
"command": "uvx",
"args": ["--from", "git+https://github.com/JennerAnalytics/jenner-sas-mcp", "jenner-sas-mcp"],
"env": {
"JENNER_API_KEY": "your-api-key-optional"
}
}
}
}
```
Expand Down
7 changes: 4 additions & 3 deletions src/jenner_sas_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

import os
from typing import Any
from urllib.parse import quote as _quote

import httpx
from mcp.server.fastmcp import FastMCP
Expand Down Expand Up @@ -216,7 +217,7 @@ async def validate_sas(script: str) -> dict[str, Any]:
script: The SAS/Jenner source to validate.

Returns:
``{"valid": bool, "diagnostics": [{"severity", "message"}, ...]}``.
``{"valid": bool, "diagnostics": [{"severity": str, "message": str}, ...]}``.
An invalid script is a normal result, not an error — read the
diagnostics to see what to fix.
"""
Expand Down Expand Up @@ -253,7 +254,7 @@ async def get_run(run_id: str, access_token: str) -> dict[str, Any]:
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
resp = await client.get(
f"{_api_url()}/v1/run/{run_id}",
f"{_api_url()}/v1/run/{_quote(run_id, safe='')}",
params={"token": access_token},
headers=_auth_headers(),
)
Expand Down Expand Up @@ -290,7 +291,7 @@ async def dataset_preview(
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
resp = await client.get(
f"{_api_url()}/v1/run/{run_id}/datasets/{dataset}",
f"{_api_url()}/v1/run/{_quote(run_id, safe='')}/datasets/{_quote(dataset, safe='')}",
params={"token": access_token},
headers=_auth_headers(),
)
Expand Down
19 changes: 19 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,25 @@ async def test_get_run_returns_unclipped(monkeypatch):
assert cap["params"]["token"] == "tok"


@pytest.mark.asyncio
async def test_dataset_preview(monkeypatch):
monkeypatch.delenv("JENNER_API_KEY", raising=False)
payload = {"rows": 2, "columns": ["id", "x"], "data": [{"id": 1, "x": 9}]}
with _stub_httpx(payload) as cap:
out = await server.dataset_preview("r_42", "my dataset", "tok")
assert out["rows"] == 2
assert cap["url"].endswith("/v1/run/r_42/datasets/my%20dataset")
assert cap["params"]["token"] == "tok"


@pytest.mark.asyncio
async def test_get_run_url_encodes_run_id(monkeypatch):
monkeypatch.delenv("JENNER_API_KEY", raising=False)
with _stub_httpx({"run_id": "r/1", "exit_code": 0, "log": "", "output": ""}) as cap:
await server.get_run("r/1", "tok")
assert "/r%2F1" in cap["url"]


@pytest.mark.asyncio
async def test_api_url_override(monkeypatch):
monkeypatch.setenv("JENNER_API_URL", "http://localhost:3000/")
Expand Down
Loading