sas-mcp
An MCP server that lets coding agents — Claude Code, Claude Desktop, GitHub Copilot, Cursor — write, run, and validate SAS 9.4 code through 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_sasreturnsstatus: "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_datasetremoves the guessing. - Guardrails. An agent improvising
proc datasets lib=prod kill;against a production libref is a career event. Writes are restricted toWORKby default.
Install
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:
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:
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:
sas-mcp init --deployment oda --region us1
Regions are us1, us2, eu1, ap1, ap2; your home region is shown at
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:
- An explicit
cfgfilepath (what--config-filepasses) - The saspy package directory inside site-packages
- The working directory (
sys.path[0]) ~/.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-fileis the only unambiguous way to pin it: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
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, 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
PWENCODEvalue 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 needschmod 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 doctorreports 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,
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
claude mcp add sas -- sas-mcp serve
Claude Desktop — in claude_desktop_config.json:
{
"mcpServers": {
"sas": { "command": "sas-mcp", "args": ["serve"] }
}
}
VS Code / GitHub Copilot — in .vscode/mcp.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"].
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:
{
"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.
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":
{
"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:
%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.
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.
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=,ODSto 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.
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
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:
# 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:
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 doctorreports the configured value. NONOTES. SASPy's IOM sessions suppress theNOTE:lines that log triage depends on. The session manager setsoptions notes source;on connect; if a Windows session somehow overrides that,run_saswould returnokwith emptystepsand nosuspicious_notes.
To confirm triage is really working rather than silently blind, run something that should be flagged:
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. Packaging metadata reads
it from there, so the two can't drift.
- Bump
__version__and add aCHANGELOG.mdentry. - Commit, and confirm CI is green on
main. - Publish a GitHub Release tagged
vX.Y.Z.
That triggers 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, so there is no API token to store or rotate. Before the first release, add a pending publisher at 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
Release files for sas-mcp 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| sas_mcp-0.1.0.tar.gz | 75.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sas_mcp-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 133.1 kB
Release files / sas_mcp-0.1.0.tar.gz
| Download URL | sas_mcp-0.1.0.tar.gz |
|---|---|
| Size | 75.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
324bfc604a43e7cd5fc81c2e3576c4bc2e70dcaa5dafab732c7e9263c6705ea2
|
|
BLAKE2b-256 checksum How to use checksums |
bfc66f1852d819b451259ed64bebb145c8e87aa9a6afc27eb01e189f69c0f34e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.
Transparency logRelease files / sas_mcp-0.1.0-py3-none-any.whl
| Download URL | sas_mcp-0.1.0-py3-none-any.whl |
|---|---|
| Size | 57.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
49d82a1a54f88ffa777a33844be843fed66d5bf42babeb900ce40673ba90b884
|
|
BLAKE2b-256 checksum How to use checksums |
7fe32a50980b7bb30d3aaa336f2efd3b0694ae603b5bf268de70567dab41e05b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.
Transparency log