Metadata-Version: 2.5
Name: sas-mcp
Version: 0.2.0
Summary: MCP server for writing, running, and validating SAS 9.4 code via SASPy
Project-URL: Homepage, https://github.com/matise-joe-norc/sas-mcp
Project-URL: Repository, https://github.com/matise-joe-norc/sas-mcp
Project-URL: Issues, https://github.com/matise-joe-norc/sas-mcp/issues
Author-email: Joe Matise <matise-joe@norc.org>
License: MIT
License-File: LICENSE
Keywords: llm,mcp,model-context-protocol,sas,saspy
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Requires-Python: >=3.10
Requires-Dist: mcp>=2.0
Requires-Dist: pandas>=1.5
Requires-Dist: saspy>=5.4
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == 'dev'
Description-Content-Type: text/markdown

# sas-mcp

An [MCP](https://modelcontextprotocol.io) server that lets coding agents —
Claude Code, Claude Desktop, GitHub Copilot, Cursor — write, run, and validate
**SAS 9.4** code through [SASPy](https://sassoftware.github.io/saspy/).

It runs locally as a stdio subprocess on your own machine. Nothing is hosted,
and your code and data never leave your environment: SASPy connects to
whatever SAS you already have.

## Why not just let the agent call `sas.submit()`?

Wrapping `submit()` is thirty lines. The reason this package exists is the
three layers around it:

- **Log triage.** SAS logs are enormous and the signal is buried. Worse, SAS
  routinely *succeeds* while being semantically wrong. A misspelled variable,
  a many-to-many merge, a silent character-to-numeric conversion — all of
  these return zero errors and a wrong answer. `run_sas` returns
  `status: "suspicious"` for exactly that class, so an agent can't mistake it
  for success.
- **Schema discovery.** Hallucinated column names are the most common way an
  LLM writes broken SAS. `describe_dataset` removes the guessing.
- **Guardrails.** An agent improvising `proc datasets lib=prod kill;` against
  a production libref is a career event. Writes are restricted to `WORK` by
  default.

## Install

```bash
pip install sas-mcp     # or: uv tool install sas-mcp
```

## First run: create your SAS configuration

SASPy needs a `sascfg_personal.py` describing where your SAS lives. If you
already run SASPy you have one and can skip ahead — this uses it as-is.

If you don't, **don't hand-write it.** Run:

```bash
sas-mcp init
```

It asks where your SAS runs, finds a working Java runtime for you, and writes
a correct config to the right place:

```
Where does your SAS run?
  1. SAS OnDemand for Academics (free, cloud)
  2. SAS installed locally on this Linux/UNIX machine
  3. SAS installed locally on this Windows machine
  4. SAS server on my network (IOM / Workspace Server)
  5. SAS on a remote UNIX host over SSH
Choice [1-5]: 1

Which ODA home region? (shown at welcome.oda.sas.com)
  1. United States (Home Region 1)
  ...
  Found a working Java runtime: /Library/Java/.../bin/java

Wrote /Users/you/.config/saspy/sascfg_personal.py
Save your SAS credentials to /Users/you/.authinfo now? [Y/n]:
```

For ODA and intranet IOM servers it also offers to write your credentials to
`~/.authinfo` (`_authinfo` on Windows). The password is prompted without echo
and the file is created owner-only — SASPy needs it in the clear, so
permissions are the only thing protecting it, and `init` sets them rather than
trusting you to remember.

Then verify:

```bash
sas-mcp doctor   # is the configuration sane? (never connects)
sas-mcp check    # does it actually work? (starts a real SAS session)
```

`doctor` is deliberately offline, so it still works when the connection is
exactly what's broken. `check` runs those same checks and then connects:

```
[PASS] connect: Connected (SAS 9.04.01M8P02222023, encoding utf-8)
[PASS] submit: DATA step ran; WORK._SASMCP_PROBE = 19 rows.
[PASS] log_notes: Log triage is working: flagged suspicious
       (missing_values_generated, uninitialized_variable).
[PASS] schema: Schema discovery works (5 columns, 19 rows).
[PASS] encoding: Non-ASCII round-trip is clean ('café').
```

The `log_notes` probe is the important one. It submits code with a misspelled
variable — code that *must* be flagged — and fails if it isn't. A session
with `NONOTES` set runs everything successfully while the triage layer sees
nothing, so results look like clean successes while being wrong. A passing
`submit` does not prove triage works; only this does.

The `encoding` probe round-trips a non-ASCII string, since an encoding
mismatch corrupts character data silently rather than raising.

`check` uses a real SAS session, which counts against concurrency limits on
ODA and licensed servers, and it cleans up the WORK tables it creates.
`sas-mcp doctor --connect` is the same thing.

It can also run unattended, for scripted or team setup:

```bash
sas-mcp init --deployment oda --region us1
```

Regions are `us1`, `us2`, `eu1`, `ap1`, `ap2`; your home region is shown at
[welcome.oda.sas.com](https://welcome.oda.sas.com). `init` never overwrites an
existing config or credential entry without `--force`.

### Where the config goes

`init` writes to your home directory, which works the same on every platform:

| Platform | Location |
| --- | --- |
| macOS / Linux | `~/.config/saspy/sascfg_personal.py` |
| Windows | `%USERPROFILE%\.config\saspy\sascfg_personal.py` |

SASPy calls `expanduser("~/.config/saspy/")` on all platforms, so Windows uses
that same `.config` folder under your user directory — not `AppData`.

Be aware that this is the **lowest-priority** location SASPy searches:

1. An explicit `cfgfile` path (what `--config-file` passes)
2. The **saspy package directory** inside site-packages
3. The working directory (`sys.path[0]`)
4. `~/.config/saspy/`

So a `sascfg_personal.py` left in site-packages or in your project folder will
silently win over your home copy. Two consequences worth knowing:

- A config inside site-packages **is deleted when you rebuild your virtualenv
  or reinstall saspy.** Keep it in your home directory instead.
- There is **no environment variable** for this. SASPy reads none when
  resolving a config, so `--config-file` is the only unambiguous way to pin it:

  ```bash
  sas-mcp serve --config-file ~/.config/saspy/sascfg_personal.py --config oda
  ```

`sas-mcp doctor` reports which file actually wins, warns when several exist,
and flags a config living somewhere a reinstall will destroy.

## Check your setup first

```bash
sas-mcp doctor
```

This is the fastest way past the usual configuration problems, and it runs
without connecting to SAS — so it still works when the connection is what's
broken. It checks the config file and access method, the Java runtime that IOM
requires, `~/.authinfo` presence and permissions, ODA hostname validity,
network reachability, and encoding. Every failure comes with the fix.

```
[FAIL] java: /usr/bin/java exists but no Java runtime is installed.
         fix: On macOS /usr/bin/java is only a stub. Install a real JRE, e.g.
              `brew install --cask temurin`, then set 'java' to the full path
              from `/usr/libexec/java_home`.
[FAIL] authinfo_permissions: ~/.authinfo is readable by group or others
       (mode 0o644). SASPy refuses to use it and your SAS password is exposed
       to other local accounts.
         fix: chmod 600 ~/.authinfo
```

When anything fails, the report also links
[SASPy's troubleshooting guide](https://sassoftware.github.io/saspy/troubleshooting.html),
which covers the IOM, Java, and encryption problems this tool can detect but
not fix for you.

### If your password "doesn't work"

SASPy parses `~/.authinfo` by requiring a line to split into **exactly five
whitespace-separated fields**:

```
<key> user <username> password <password>
```

A password containing a **space** produces six fields, so SASPy skips the line
entirely and reports *"did not find key"* — which sends you looking for a
missing entry rather than a mis-parsed one. Trailing comments break it the
same way.

Two fixes:

- Use a password with no spaces, or
- Store a SAS `PWENCODE` value instead — `{SAS004}...` is always a single
  token, so it sidesteps the problem. These authenticate fine against both
  ODA and intranet IOM servers. Note it's obfuscation, not encryption: the
  file still needs `chmod 600`.

`sas-mcp doctor` checks the field count directly and names this cause.

**On Windows, also check the file's encoding.** SASPy opens `_authinfo` with
`open(pwf, mode='r')` — the *locale* default, typically cp1252, not UTF-8. If
your editor saved it as UTF-8 and the password contains a non-ASCII character,
the bytes decode into something longer: `à` becomes `Ã` + `\xa0`, and `\xa0`
(NBSP) counts as whitespace to `split()`, so the line gains a field and is
skipped. Save the file as ANSI/cp1252, or use an ASCII-only password.

### SAS encryption jars (needed for ODA)

**SASPy does not ship the SAS encryption jars** — `sas.rutil.jar`,
`sas.rutil.nls.jar`, and `sastpj.rutil.jar` are absent from a clean install,
though SASPy puts them on the IOM classpath regardless. They are a manual
download.

Whether you need them depends on the server:

- **SAS ODA always requires an encrypted connection.** Without these jars it
  fails even when everything else is correct — as a Java error that never
  mentions a missing file. `sas-mcp doctor` reports this as a **failure**.
- **An intranet IOM server** may not require encryption, so doctor reports it
  as **information** rather than treating a fresh install as broken.

Either way, doctor names the missing files, links the
[SAS download](https://sassoftware.github.io/saspy/configuration.html#attn-as-of-saspy-version-3-3-3-the-classpath-is-no-longer-required),
and prints the exact destination. They must go in SASPy's own
`saspy/java/iomclient/` directory — that path is hardcoded where SASPy builds
the IOM classpath, so no other location will be found. Doctor prints the
resolved absolute path for your install.

## Connect your agent

**Claude Code**

```bash
claude mcp add sas -- sas-mcp serve
```

**Claude Desktop** — in `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "sas": { "command": "sas-mcp", "args": ["serve"] }
  }
}
```

**VS Code / GitHub Copilot** — in `.vscode/mcp.json`:

```json
{
  "servers": {
    "sas": { "type": "stdio", "command": "sas-mcp", "args": ["serve"] }
  }
}
```

Add policy flags to `args` as needed, e.g.
`["serve", "--config", "oda", "--writable-libs", "STAGE"]`.

### Choosing between multiple SAS environments

If your `sascfg_personal.py` defines more than one configuration, decide who
picks:

**Pin it in the client config** — the agent uses this one and cannot switch:

```json
{
  "servers": {
    "sas": { "command": "sas-mcp", "args": ["serve", "--config", "oda"] }
  }
}
```

Add `--allow-config-switch` to make it a starting point the agent may change
instead of a restriction.

**Or leave `--config` off**, and the agent chooses: `list_sas_configs` shows
what's available with each one's access method and target server, and
`use_sas_config` selects one. With exactly one configuration defined it's used
automatically and nothing is asked.

Either way the server never blocks waiting for an answer. Left to itself,
SASPy prompts on **stdin** for a configuration name — and on a stdio MCP
server stdin is the JSON-RPC stream, so the prompt would consume protocol
bytes and hang the client. `sas-mcp` disables SASPy's prompting entirely and
returns the choice as data instead.

## Tools

| Tool | Purpose |
| --- | --- |
| `sas_doctor` | Diagnose configuration without connecting |
| `session_status` | Connection, SAS version, librefs, WORK contents, policy |
| `run_sas` | Submit code; returns triaged status, findings, row counts, output |
| `get_last_log` | Full raw log, when triage isn't enough |
| `reset_session` | Clear WORK |
| `list_libraries` | Assigned librefs with paths and writability |
| `list_datasets` | Tables in a library with row/column counts |
| `describe_dataset` | Columns with type, length, format, label |
| `sample_rows` | First N rows as records |
| `compare_datasets` | PROC COMPARE as a structured diff |
| `run_sas_tests` | Run code with assertion macros; report pass/fail |

### What `run_sas` returns

Not a log — a verdict:

```json
{
  "status": "suspicious",
  "summary": "Ran, but results may be wrong: 2 suspicious notes; created WORK.B=19 obs.",
  "suspicious_notes": [
    {
      "rule": "uninitialized_variable",
      "text": "NOTE: Variable weigth is uninitialized.",
      "explanation": "Variable was read before being assigned, so it evaluates to missing. Almost always a misspelled variable name.",
      "line_no": 5
    }
  ],
  "steps": [{"step": "DATA statement", "dataset": "WORK.B", "obs_out": 19}],
  "output": "..."
}
```

`status` is `ok`, `suspicious`, or `error`. **`suspicious` means the code ran
and the answer is probably wrong** — it is not a success.

Every result also carries `log_file`: a path to a saved file holding the
findings *and* the complete SAS log, so "check the log" is something you can
actually act on. Logs go to a temporary directory by default; `--log-dir PATH`
puts them somewhere you choose, and the 25 most recent are kept.

## Validating code

Rather than teach an agent a niche SAS test framework, this builds on the tool
SAS developers already use — and which happens to be machine-readable.
`PROC COMPARE` sets `&SYSINFO` to a bitmask where each bit names a specific
*kind* of difference, so `compare_datasets` can distinguish "the values
disagree" from "only a format differs":

```json
{
  "identical": false,
  "data_differs": true,
  "metadata_only": false,
  "findings": [
    {"code": "base_obs", "meaning": "Base data set has observations not in comparison"},
    {"code": "value",    "meaning": "At least one value comparison was unequal"}
  ],
  "summary": "Data sets differ: ..."
}
```

That makes it a real assertion an agent can iterate against — the natural way
to verify that a rewritten step reproduces the original result.

`run_sas_tests` adds a small assertion macro library, loaded into the session
on first use:

```sas
%assert_exists(work.out);
%assert_rows(work.out, 19);
%assert_not_empty(work.out);
%assert_no_missing(work.out, age);
%assert_unique(work.out, id);
%assert_equal_datasets(work.expected, work.out);
%assert_condition(&n > 0, detail=n must be positive);
```

Each writes a marker to the log that comes back as structured pass/fail. A
failed assertion sets `status: "assertions_failed"` even when the log itself
is clean — a green log with a red assertion is not a pass.

## Getting files out of SAS

SAS ODA runs in AWS and an intranet SAS server sits on another machine, so
neither can see your local disk. SASPy transfers over the SAS connection
itself, which works regardless:

```
run_sas:           proc export data=sashelp.class
                     outfile="~/report.xlsx" dbms=xlsx replace; run;
list_sas_files:    ~            -> report.xlsx
download_from_sas: ~/report.xlsx -> /tmp/sas-mcp-files-xxxx/report.xlsx
```

`upload_to_sas` goes the other way, for sending a CSV or workbook in.

Everything the server writes for you lands in your working folder, so it
shows up in the editor's file tree:

```
sas-mcp/
  .gitignore        excludes this directory automatically
  files/            downloads land here; uploads may only read from here
  logs/             one annotated log per submission
```

Override with `--file-dir PATH` and `--log-dir PATH`. If the client starts
the server somewhere unwritable, both fall back to a temporary directory.

Transfers are confined to that one directory, so the agent can neither
overwrite arbitrary local files nor send arbitrary local files to a remote
server. To upload something else, copy it in first.

## Safety

Writes are restricted to `WORK` by default. Also blocked unless you opt in:

- **OS escapes** — `X`, `%SYSEXEC`, `SYSTASK COMMAND`, `CALL SYSTEM`,
  `FILENAME PIPE`, `PROC PYTHON/LUA/GROOVY`
- **Destructive DDL** — `PROC DATASETS KILL`/`DELETE`, `DROP TABLE`,
  `PROC DELETE`, `FDELETE`
- **Libref rebinding** — re-pointing an allowlisted libref somewhere else

Reads are never restricted; an agent can `set prod.sales` freely.

```bash
sas-mcp serve --writable-libs STAGE,SCRATCH   # widen the write allowlist
sas-mcp serve --allow-destructive             # permit DROP/KILL/DELETE
sas-mcp serve --allow-os-escape               # permit X, PIPE, etc.
```

### Scope of the guarantee

**This is a defense against model error, not a security boundary.** SAS can
generate code at run time through `CALL EXECUTE`, `DOSUBL`, and macro
expansion, so no static scan can be complete, and a determined bypass is
always possible. It reliably stops the common accident. Do not rely on it as
your only control on a system where an agent could do real damage — use a SAS
account whose own permissions match what you want to allow.

Two known gaps, stated plainly:

- Filesystem writes (`PROC EXPORT ... OUTFILE=`, `ODS` to a path) are **not**
  restricted, only SAS library writes.
- Code assembled at run time from fragments that are individually innocuous
  will not be caught.

## Supported deployments

The SAS 9.4 setups SASPy handles, selected by your `sascfg_personal.py`:

| Deployment | Access method | Needs | Verified |
| --- | --- | --- | --- |
| SAS OnDemand for Academics | IOM | Java, `~/.authinfo` | ✅ macOS + Windows |
| Intranet SAS server | IOM | Java, `~/.authinfo` | ✅ Windows |
| Intranet SAS server | SSH | SSH keys | not yet |
| Local Windows install | COM | pywin32 (no Java) | not yet |
| Local Windows install | IOM | Java | not yet |
| Local Linux/UNIX install | STDIO | `saspath` | not yet |

"Not yet" means generated and unit-tested but not exercised against a running
SAS — run `sas-mcp check` and it will tell you whether your setup works. If
one of these fails for you, that's a bug worth
[reporting](https://github.com/matise-joe-norc/sas-mcp/issues).

SAS ODA is free but its terms are **academic and non-commercial use only**.

Note that each MCP client starts its own server process and therefore its own
SAS session, which counts against concurrent-session limits on both ODA and
licensed servers.

## Development

```bash
uv pip install -e ".[dev]"
pytest
```

The log parser and guardrails are pure functions with no SAS dependency, and
the server tests run against a fake session — so the full suite runs anywhere.
CI runs them on Linux, macOS, and Windows against Python 3.10, 3.12, and 3.14.

### Testing on Windows

CI runs the suite on Windows, but that cannot reach a SAS installation. To
test against real Windows SAS, on the Windows machine:

```powershell
# No PyPI release needed -- install straight from the repo
pip install git+https://github.com/matise-joe-norc/sas-mcp

sas-mcp init      # choose the COM option first; it needs no Java
sas-mcp doctor
```

Local Windows SAS has two access methods, and **COM is worth trying first**:
it needs no Java at all, which removes the most common Windows setup failure.
It does need `pip install pywin32`. The IOM option is the fallback if COM
gives trouble.

Then exercise the server end to end:

```powershell
python -c "from sas_mcp.session import SASSessionManager as M; m=M(cfgname='wincom'); r=m.submit('data work.a; set sashelp.class; run;'); print(r.triage.status, r.triage.summary)"
```

Expect `ok  Ran: created WORK.A=19 obs.` A result of `ok` with **no steps
reported** means the log has no NOTEs — see the `options notes` note below.

The two things most likely to differ from the verified Linux/ODA path:

- **Encoding.** Windows SAS 9.4 typically runs `wlatin1`, not UTF-8. A
  mismatch shows up as mojibake in character columns rather than an error.
  `sas-mcp doctor` reports the configured value.
- **`NONOTES`.** SASPy's IOM sessions suppress the `NOTE:` lines that log
  triage depends on. The session manager sets `options notes source;` on
  connect; if a Windows session somehow overrides that, `run_sas` would
  return `ok` with empty `steps` and no `suspicious_notes`.

To confirm triage is really working rather than silently blind, run something
that *should* be flagged:

```powershell
python -c "from sas_mcp.session import SASSessionManager as M; m=M(cfgname='wincom'); r=m.submit('data work.b; set sashelp.class; bmi=weigth/height; run;'); print(r.triage.status, [n.rule for n in r.triage.suspicious_notes])"
```

Expect `suspicious ['uninitialized_variable', 'missing_values_generated']`.
If that returns `ok` with an empty list, triage is not seeing NOTEs and the
result is untrustworthy — report it as a bug.

### Releasing

The version lives in exactly one place: `__version__` in
[`src/sas_mcp/__init__.py`](src/sas_mcp/__init__.py). Packaging metadata reads
it from there, so the two can't drift.

1. Bump `__version__` and add a `CHANGELOG.md` entry.
2. Commit, and confirm CI is green on `main`.
3. Publish a GitHub Release tagged `vX.Y.Z`.

That triggers [`release.yml`](.github/workflows/release.yml), which builds,
runs `twine check`, installs the built wheel into a clean virtualenv to
confirm it actually runs, **verifies the tag matches `__version__`**, and only
then publishes. The tag check matters because PyPI will not let you re-upload
a filename — a mismatched tag is not recoverable.

`workflow_dispatch` runs the same build and verification without publishing,
if you want a dry run first.

#### One-time PyPI setup

Publishing uses [Trusted Publishing](https://docs.pypi.org/trusted-publishers/),
so there is no API token to store or rotate. Before the first release, add a
*pending publisher* at
[pypi.org/manage/account/publishing](https://pypi.org/manage/account/publishing/):

| Field | Value |
| --- | --- |
| PyPI project name | `sas-mcp` |
| Owner | `matise-joe-norc` |
| Repository name | `sas-mcp` |
| Workflow name | `release.yml` |
| Environment name | `pypi` |

Then create a `pypi` environment under the repository's
Settings → Environments. Adding a required reviewer there gives you a manual
approval gate before anything reaches PyPI.

## License

MIT
