sas-local-mcp
# sas-local-mcp
一个让大模型(LLM)直接读取**本地 SAS 数据集**的 MCP 服务器。
基于 [FastMCP](https://github.com/jlowin/fastmcp) 实现,经
[pyreadstat](https://github.com/Roche/pyreadstat) 直接读取本地 `.sas7bdat` / `.xpt` 文件,
**无需 Viya、无需联网、无需 SAS 授权**。
适合的场景:
- 让 LLM 勘察 `/` 浏览本地 `.sas7bdat` / `.xpt` 数据集的结构与取值
- TLF(Tables, Listings, Figures)QC:快速做计数、频数、跨数据集关联核对
- 在 IDE / Agent 中把 SAS 数据当作可查询的上下文来源
所有工具均为**只读**,不会修改任何数据集文件。
本次优化新增了局部读取、SQL 来源感知解码、独立缓存和响应字节预算。
**升级后需更新依赖并重启 MCP 服务**。`where` 现在使用受限表达式,先过滤后投影;
默认统计口径将字符空白计为 SAS 缺失。迁移说明与完整调用示例见
[优化 1–6 使用说明](docs/optimizations-1-6.md)。
## 功能特性
- 支持 SAS 两种常用格式:`.sas7bdat` 与传输格式 `.xpt`(XPORT)
- **统计语义层**:`summarize_dataset` 逐变量给缺失率/唯一值数/分布指标,`frequency` 给频数表并自动解码值标签——
模型无需手写 SQL 就能读懂一张表
- 路径相对 `SAS_DATA_ROOT` 或绝对路径传入,**越界路径会被拒绝**(安全沙箱)
- 读取时自动处理:缺失值(`NaN`→`null`)、日期/时间(`datetime`→ISO 字符串)、字符编码
- 行预览与聚合结果**分开限流**:`GROUP BY` 的基数不会被行预览上限截断(见环境变量)
- 内置 duckdb SQL 查询:仅执行单条 `SELECT`(支持 `WITH`/多表 JOIN),并支持结果值标签解码
- `search_variables` 跨数据集变量搜索:直接回答"哪个数据集里有 `USUBJID`"
- 预览直接读取所需行列,过滤按块扫描;SQL 仅物化当前页,精确计数可以关闭
- 元数据与数据独立缓存,按文件版本、编码及格式目录失效;数据缓存有内存预算和并发读取合并
- 支持外部 `.sas7bcat` 值标签;保留 SAS 特殊数值缺失,明确统计缺失口径
- 八个工具均公开输出 schema,长结果按行数与 JSON 字节预算分页
- 通过 MCP 标准协议以 stdio 方式接入任意兼容客户端(CodeBuddy、Claude Desktop、Cursor 等)
## 工具一览
所有工具路径参数 `path` 支持两种写法:
- 相对 `SAS_DATA_ROOT` 的路径,如 `02Sdtm/dm.sas7bdat`
- 绝对路径,但必须落在 `SAS_DATA_ROOT` 目录树内(否则被拒绝)
| 工具 | 说明 | 关键参数 |
| --- | --- | --- |
| `list_datasets` | 递归列出 `SAS_DATA_ROOT` 下所有数据集文件(`.sas7bdat` / `.xpt` / `.sas7bcat`) | `pattern`(文件名子串过滤,不区分大小写)、`limit`(默认 500)、`offset` |
| `get_dataset_metadata` | 行数、每变量 label/type/informat、文件标签、创建时间(不加载数据内容;`.xpt` 例外,见下) | `path` |
| `summarize_dataset` | 逐变量概要统计——了解一张表的首选入口:缺失率、唯一值数,按类型给分布指标 | `path`、`columns`、`apply_labels` |
| `frequency` | 单变量频数表:各取值的 `n` 与占比,**默认自动套用 SAS 值标签** | `path`、`column`、`limit`、`offset`、`apply_labels`、`include_missing` |
| `read_dataset` | 按 `offset`/`limit` 分页读取 JSON records(`NaN`→`null`,日期→ISO),支持列子集、`where` 过滤、套用值标签 | `path`、`limit`(默认 20)、`offset`、`columns`、`where`、`apply_labels` |
| `get_value_labels` | 变量的 SAS 格式值标签映射(QC 解码 coded 值用) | `path` |
| `query_dataset` | duckdb SQL 查询(仅允许单条 `SELECT` / `WITH` 查询),主表为 `dataset`,可追加多表做 JOIN | `path`、`sql`、`limit`(默认 100)、`offset`、`extra_paths`、`apply_labels` |
| `search_variables` | 跨数据集搜索含指定变量的数据集(只读元数据) | `variable`、`pattern`、`contains`、`limit`、`max_datasets` |
建议调用顺序:`list_datasets` / `search_variables` → `get_dataset_metadata` → `summarize_dataset` →
`frequency`(分类变量)→ `read_dataset` / `query_dataset`(按需取细节)。
### 各工具参数与返回
- **`list_datasets(pattern?, limit=500, offset=0)`**
- 返回 `{ root, count, datasets: [{ path, name, format, size_bytes, modified }] }`。
- `count` 为完整目录计数,`returned` 为当前页条数,按 `next_offset` 继续获取。
> **字符编码(除 `list_datasets` 外的 7 个工具通用)**:都可选传 `encoding`(如 `gb18030` / `cp1252` / `latin1`),只对本次调用生效。
> 不传时先按默认(或 `SAS_LOCAL_MCP_ENCODING`)解码,失败再按 `SAS_LOCAL_MCP_ENCODING_FALLBACKS` 依次回退,
> 成功的编码记在文件版本上供后续读取复用,并在返回的 `encoding_fallback` 里给出(`query_dataset` 为 表名 -> 编码 的映射)。
> 显式指定的编码解码失败时直接报 `ENCODING_ERROR`,不会静默回退。
- **`get_dataset_metadata(path)`**
- 返回 `{ number_rows, number_columns, file_label, created, modified, columns: [{ name, label, type, format, informat }] }`。
- `type` 的日期判定同时看 `informat` 与 `format`(SAS 日期列常只带 format,如 `DATE9.`)。
- **`.xpt` 需要额外计数**:pyreadstat 对 XPORT 的 metadata-only 读取不返回行数
(`meta.number_rows` 为 `None`、df 为空),直接采信会把 6 万行的数据集报成 0 行。
此时本工具单列一次性读取并计数(分块读取对 XPORT 是 O(n²)),只缓存计数;`include_row_count=False` 可跳过扫描,未知行数返回 `null`。
- 新增 `logical_type`(date/time/datetime/string/numeric)、`sas_unit` 和存储宽度;
变量元数据支持 `column_offset` / `column_limit` 分页。
- **`summarize_dataset(path, columns?, apply_labels=False)`**
- 每个变量返回 `{ name, label, type, n, n_missing, pct_missing, n_distinct, has_value_labels, stats }`。
- `stats` 按类型不同:
- 数值 → `{ mean, std, min, p25, median, p75, max }`
- 日期 → `{ min, max }`
- 字符 → `{ max_length, top_values: [{ value, label, n, pct }] }`(Top5)
- `has_value_labels` 为 `True` 时提示改用 `frequency` 看解码后的分布。
- `columns` 不传则选择全部变量,按 `offset` / `limit` 分页计算。`stats_mode="counts"` 跳过分布计算。
- **`frequency(path, column, limit=50, offset=0, apply_labels=True, include_missing=False)`**
- 返回 `{ total_rows, n_missing, n_distinct, denominator, value_labels_applied, levels, returned, truncated }`。
- `levels` 每项 `{ value, label, n, pct }`,按 `n` 降序;`value` 是原始码值,`label` 是解码后的文本。
- `apply_labels` 默认 `True`——coded 值本身不可读,默认直接译出标签。
- `denominator` 回传百分比分母:`include_missing=True` 时是总行数,否则是非缺失行数。
`include_missing=True` 会把缺失单列为一档(`value=null`、label 为 `<missing>`)。
- 结果上限取 `SAS_LOCAL_MCP_MAX_AGG_ROWS`(默认 1000),**不受行预览上限影响**。
- **`read_dataset(path, limit=20, offset=0, columns?, where?, apply_labels=False)`**
- `columns`:只返回指定变量列表。
- `where`:受限表达式,如 `"AGE > 18 and SEX == 'F'"`;支持比较、数值算术、`in/not in`、
`is None/is not None` 和布尔组合,不允许函数调用、属性访问或 `@` 外部变量。
- 过滤在 `columns` 投影**之前**执行,过滤列可以不出现在返回列中。
- 无过滤时直接局部读取;有过滤时分块扫描并仅保留当前页。
`include_row_count=False` 可在找到下一页后提前停止,未知的 `rows_after_filter` 返回 `null`。
- `apply_labels=True` 时把 coded 值替换为 SAS 值标签文本。
- `offset` 用于翻到数据中部/尾部取样——只看前几行容易误判(临床数据前几行常属同一受试者)。
- 返回 `{ columns, records, total_rows, rows_after_filter, offset, returned, truncated }`,
行数受 `SAS_LOCAL_MCP_MAX_ROWS` 上限约束。
- **`get_value_labels(path)`**
- 返回 `{ path, format, variables: { 变量: { 码值: 标签 } } }`。
- **`query_dataset(path, sql, limit=100, offset=0, extra_paths?, apply_labels=False)`**
- 只接受一条 `SELECT` 查询(可以使用 `WITH` CTE);多条语句和 `INSERT` / `UPDATE` / `DELETE` / DDL 会被拒绝,DuckDB 外部文件与网络访问也会关闭。
- 主数据集表名固定为 `dataset`,例如 `SELECT COUNT(*) AS n FROM dataset`。
- `extra_paths` 传入附加数据集,按文件名主干注册为额外表(`lb.sas7bdat` → `lb`),
冲突时回退 `dataset2`/`dataset3`;返回的 `tables` 给出 表名 → 路径 映射。跨数据集对比示例:
```sql
SELECT COUNT(*) AS n FROM dataset d INNER JOIN lb l USING (USUBJID)
```
- `apply_labels=True` 只自动解码来源明确的原始列,支持别名和简单多表投影;聚合、表达式、
CTE、子查询和歧义列保留原值。可通过 `label_sources` 显式指定来源。
- `label_decoding` 按列回传来源、状态与本页实际解码数量;未解码时 `value_labels_applied=false`。
- 返回查询结果 records,上限取 `SAS_LOCAL_MCP_MAX_AGG_ROWS`,支持 `offset` 翻页。
`include_total=False` 跳过精确计数,`total_rows=null`。分页在 DuckDB 中执行;
`order_by_present` 指示最外层是否带排序,稳定翻页需要唯一排序键。
- **`search_variables(variable, pattern?, contains=False, limit=50, max_datasets=200)`**
- 回答"哪个数据集里有某个变量"。默认**精确匹配**(不区分大小写),`contains=True` 改为子串匹配
(如 `AGE` 命中 `AGE` / `AGEU`)。
- `pattern` 先按文件名过滤数据集以缩小扫描范围;只读取元数据,不加载数据行。
- 返回 `{ datasets_scanned, datasets_matched, matches: [{ path, dataset, variable, label, type, number_rows }], scan_truncated, truncated, errors }`;
`scan_truncated=true` 表示达到 `max_datasets` 后仍有未扫描的数据集,结果可能不完整。
损坏文件记入 `errors` 而不中断整个搜索。
- 先使用 `next_offset` 取完当前扫描窗口的匹配结果,再以 `next_scan_offset` 继续扫描并重置 `offset=0`。
读取、元数据、摘要、频数和值标签工具均可传入 `catalog_path`;SQL 使用
`catalog_paths={"注册表名": "格式目录路径"}`。格式目录也必须位于 `SAS_DATA_ROOT` 内。
`missing_policy="sas"` 默认把字符空白纳入缺失;传入 `"pandas"` 可保留旧的字符缺失口径。
`.A`–`.Z`、`._` 在原始记录中保留为字符串,统计时计入缺失,细分数量见 `missing_details`;
SQL 将这些特殊缺失转为 `NULL`,并返回 `special_missing_values`。
所有成功响应包含 `response_bytes` 和 `response_truncated`。字节预算可能进一步缩短当前页,
请始终使用服务返回的下一页偏移;单条结果过大时返回 `[RESPONSE_TOO_LARGE]`,不会切断字段内容。
所有错误(文件不存在、格式损坏、路径越界、未知变量、SQL 语法错误)都会被转成 MCP `ToolError` 以
`[错误码] 消息` 返回给客户端,例如 `[UNKNOWN_VARIABLE]`、`[ENCODING_ERROR]`、`[QUERY_FAILED]`。
## 环境要求
- Python **>= 3.11**
- 支持的包(见 `pyproject.toml`):
- `fastmcp` 4.x
- `pandas` >= 2.0
- `pyreadstat` >= 1.2.8(主力读取器)
- `duckdb` >= 1.1.0(SQL 查询)
- `sqlglot` >= 26.0(保守的 SQL 列来源分析)
- `pydantic` >= 2.0(输入与输出 schema)
- 开发/测试依赖:`pytest`、`pytest-asyncio`
## 安装
克隆或下载本仓库后,在仓库根目录执行。
### 方式一:使用 `uv`(推荐)
```bash
# 安装依赖并进入环境
uv sync
# 运行
uv run sas-local-mcp
```
### 方式二:使用 `pip` + 虚拟环境
```bash
cd <repo> # 仓库根目录
python -m venv .venv
# Windows
.venv\Scripts\python.exe -m pip install -e .
# macOS / Linux
.venv/bin/python -m pip install -e .
```
安装后会在环境中生成一个 `sas-local-mcp` 可执行入口(脚本/控制台命令)。
> 若是把仓库从一个目录搬到另一个目录后再用既有 `.venv`,editable 安装记录的路径可能仍指向旧位置
> (`.venv/Lib/site-packages/sas_local_mcp.pth`)。此时重新执行上面的 `pip install -e .` 即可。
### 方式三:仅起本地开发服务(不安装)
```bash
# 确保已 pip install -e . 或依赖已就位
python -m sas_local_mcp
```
服务以 **stdio** 协议运行:通常由 MCP 客户端在配置后自动拉起,无需手动常驻。
如需手动调试,可直接运行入口可执行文件(如 `.venv/bin/sas-local-mcp` 或
`.venv\Scripts\sas-local-mcp.exe`)。
## 配置(环境变量)
服务通过环境变量配置,在 MCP 客户端配置中以 `env` 字段传入即可。
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `SAS_DATA_ROOT` | 进程当前工作目录(CWD) | 数据根目录,所有数据集路径必须落在其内(沙箱边界) |
| `SAS_LOCAL_MCP_MAX_ROWS` | `200` | **行预览**上限:`read_dataset` 一次最多返回多少条原始记录 |
| `SAS_LOCAL_MCP_MAX_AGG_ROWS` | `1000` | **聚合结果**上限:`frequency` 的取值个数、`query_dataset` 的返回行数 |
| `SAS_LOCAL_MCP_ENCODING` | 空(pyreadstat 默认) | 全局固定的字符编码;中文数据常需 `utf-8` 或 `gbk`。设置后不再自动回退,工具的 `encoding` 参数仍可按次覆盖 |
| `SAS_LOCAL_MCP_ENCODING_FALLBACKS` | `cp1252,latin1` | 未指定编码且默认解码失败时依次尝试的候选编码(逗号分隔,空字符串关闭回退)。默认不含 `gbk`/`gb18030`:它们会把 cp1252 的字节静默解成错误的汉字 |
| `SAS_LOCAL_MCP_CACHE_SIZE` | `8` | 缓存多少个已解析数据集(按 mtime/size 失效);`0` 关闭 |
| `SAS_LOCAL_MCP_METADATA_CACHE_SIZE` | `512` | 独立的元数据缓存条目数,元数据与格式目录各另有 32 MiB 预算 |
| `SAS_LOCAL_MCP_CACHE_MB` | `256` | 完整数据缓存预算,单位 MiB;超预算的单个数据集不保留 |
| `SAS_LOCAL_MCP_CHUNK_ROWS` | `10000` | sas7bdat 带 `where` 过滤时的读取块大小(XPORT 一次读取,不分块) |
| `SAS_LOCAL_MCP_MAX_RESPONSE_BYTES` | `524288` | 每份结构化 JSON 响应的 UTF-8 字节预算,协议封装另有开销 |
| `SAS_LOCAL_MCP_SQL_MEMORY_MB` | `512` | 每个 DuckDB 连接的内存上限,禁止临时磁盘溢写;不包含 pandas 数据 |
| `SAS_LOCAL_MCP_SQL_THREADS` | `2` | 每个 DuckDB 查询的工作线程数 |
| `SAS_LOCAL_MCP_SQL_CONCURRENCY` | `2` | 同时执行的整表操作数:SQL、`summarize_dataset`、`frequency` 及 XPORT 过滤读取共用此上限 |
| `SAS_LOCAL_MCP_NAME` | `sas-local-mcp` | 服务名(显示在客户端) |
> 两个上限分开的意义:`GROUP BY` 的基数常远超预览行数(例如 306 个受试者的 `USUBJID` 频数表)。
> 若聚合也被 200 行截断,模型会读到不完整的分布并可能得出错误结论。
>
> `SAS_DATA_ROOT` 支持 `~` 家目录展开;未设置时退化为启动进程的当前目录,启动时会在 stderr 打印警告。
>
> 超出 `SAS_LOCAL_MCP_CACHE_MB` 的大文件不整表读入:`frequency`、`summarize_dataset` 和 `query_dataset` 只读取用到的列(SQL 中出现 `*`、`COLUMNS()`、NATURAL JOIN、PIVOT 时仍读取全部列)。
## MCP 客户端配置示例
在标准 MCP 客户端配置文件(如 `mcp.json` / `claude_desktop_config.json` / 各 IDE 的 MCP 设置)中加入:
```json
{
"mcpServers": {
"sas-local-mcp": {
"type": "stdio",
"command": "/绝对/路径/到/sas-local-mcp",
"args": [],
"env": {
"SAS_DATA_ROOT": "/绝对/路径/到/你的数据根目录"
},
"description": "读取本地 SAS 数据集(sas7bdat/xpt)的只读 MCP 服务"
}
}
}
```
- `command`:填入上一步安装得到的 `sas-local-mcp` 入口绝对路径
- 使用 `uv`:`uv run sas-local-mcp` 的等效绝对路径,或直接 `"command": "uv", "args": ["--directory", "/绝对/路径/到/repo", "run", "sas-local-mcp"]`
- 使用 `pip` 虚拟环境:`.venv/bin/sas-local-mcp`(macOS/Linux)或 `.venv\Scripts\sas-local-mcp.exe`(Windows)
- `env.SAS_DATA_ROOT`:务必设为你的数据根目录,否则只能用 CWD 作为根。
保存后重启客户端,即可在工具列表中看到 `sas-local-mcp` 提供的八个工具。
## 测试
```bash
# 安装测试依赖(若尚未)
pip install pytest pytest-asyncio # 或 uv sync
# 运行全部测试
pytest
```
测试使用当前工作区 `tests/` 下的真实数据集(来源见下)。这些数据文件被 Git 忽略,
仅克隆源码后需要另外准备;`tests/test_optimizations.py` 的新增用例使用受控数据及临时 XPORT。
以数据目录作为 `SAS_DATA_ROOT`(由 `tests/conftest.py` 自动解析),通过内存客户端挂载本服务逐个调用全部工具,并覆盖:
- 概要统计的数值/字符分支、列子集、未知变量报错
- 频数表的降序、占比、分母语义(`include_missing`)
- **聚合基数不被行预览上限截断**
- `offset` 分页与越界行为
- 多表注册与跨数据集 JOIN、SQL 结果值标签解码
- 跨数据集变量搜索(精确/子串、文件名过滤、未命中)
- 读取缓存命中(重复读取不再解析)
- 值标签解码与缺失分档(注入 meta 覆盖)
- 路径越界被拦截
另有少量用例依赖仓库外的旧工作区数据,找不到时会自动 skip。
### 测试数据来源
`tests/` 下的测试数据取自 R Consortium 的 submissions-pilot5 公开示例提交包:
- 来源仓库:<https://github.com/RConsortium/submissions-pilot5-datasetjson>
- 取用目录:
- [`original-sdtmdata/`](https://github.com/RConsortium/submissions-pilot5-datasetjson/tree/main/original-sdtmdata)
— SDTM 域的 XPORT(V5)文件,22 个(`ae` / `cm` / `dm` / `lb` / `qs` / `supplb` ...)
- [`original-adamdata/`](https://github.com/RConsortium/submissions-pilot5-datasetjson/tree/main/original-adamdata)
— ADaM 数据集的 XPORT(V5)文件,12 个(`adsl` / `adlbc` / `adtte` ...)
在此之上派生出三套等价数据,用于交叉验证同一份数据在不同格式 / 编码下的读取结果:
| 目录 | 内容 | 说明 |
| --- | --- | --- |
| `tests/xpt/` | 原始 XPORT 文件 | 上述两个 `original-*data/` 目录直接拷贝;源带 Windows-1252 字节(如 `ts.xpt`) |
| `tests/sasdata/` | UTF-8 编码的 sas7bdat | 由 `tests/xpt2sas7bdat.sas` 转换 |
| `tests/sasdata_cn/` | EUC-CN(GBK)编码的 sas7bdat | 同上,用于中文 / 非 UTF-8 编码回归 |
文件规模跨度:4.8 KB(`xpt/sdtm/suppds.xpt`)~ 54.6 MB(`sasdata/sdtm/supplb.sas7bdat`,64403 × 10),
随机冒烟测试即按「每类别最大 + 最小」抽样来同时覆盖两端。
> 转换脚本需在 **WLATIN1** 会话下运行:`sas.exe -sysin tests/xpt2sas7bdat.sas -encoding wlatin1`,
> 由 SAS 在写出时经 `ENCODING=` 数据集选项转码。
### 随机冒烟测试
`tests/random_smoke.py` 与上面的单元测试互补:它**不走内存直连**,而是以
**stdio 子进程**拉起安装后的 MCP 服务,端到端验证「客户端 → 协议 → 工具」的完整链路。
```bash
python tests/random_smoke.py --seed 20261020 --per-category 3 --out README.md
```
- 自动发现 `tests/` 下所有类别目录,每类至少抽 2 个文件,**并强制包含该类中最小与最大的文件**
- 每个文件依次调用全部 8 个工具,limit / offset / columns / where / apply_labels /
include_missing / SQL 模板随机生成
- `--out README.md` 把结果写回下一节的两行标记之间(重复运行就地覆盖)
<!-- RANDOM-SMOKE-START -->
## 随机冒烟测试(tests 数据)
以 **stdio 子进程**方式拉起安装后的 MCP 服务(`.venv/Scripts/sas-local-mcp.exe`),对 `tests/` 下各类别目录随机抽检:每类强制包含该类中**最小**与**最大**的文件,每类至少 2 个文件;每个文件依次调用全部 8 个工具,limit / offset / columns / where / apply_labels / include_missing / SQL 模板均随机生成。
| 类别 | 文件 | 大小 | 行数 × 列数 | 工具调用(通过/总数) | 耗时 |
| --- | --- | --- | --- | --- | --- |
| `sasdata/adam` | `sasdata/adam/adsl.sas7bdat` | 192.0 KB | 254 × 49 | 8/8 | 1173 ms |
| `sasdata/adam` | `sasdata/adam/adlbcpv.sas7bdat` | 16.0 MB | 37132 × 46 | 8/8 | 819 ms |
| `sasdata/adam` | `sasdata/adam/adlbhpv.sas7bdat` | 11.2 MB | 24966 × 46 | 8/8 | 863 ms |
| `sasdata/sdtm` | `sasdata/sdtm/sc.sas7bdat` | 128.0 KB | 254 × 14 | 8/8 | 407 ms |
| `sasdata/sdtm` | `sasdata/sdtm/supplb.sas7bdat` | 54.6 MB | 64403 × 10 | 8/8 | 771 ms |
| `sasdata/sdtm` | `sasdata/sdtm/sv.sas7bdat` | 384.0 KB | 3559 × 8 | 8/8 | 406 ms |
| `sasdata_cn/adam` | `sasdata_cn/adam/adsl.sas7bdat` | 192.0 KB | 254 × 49 | 8/8 | 362 ms |
| `sasdata_cn/adam` | `sasdata_cn/adam/adlbcpv.sas7bdat` | 16.0 MB | 37132 × 46 | 8/8 | 862 ms |
| `sasdata_cn/adam` | `sasdata_cn/adam/adlbh.sas7bdat` | 9.9 MB | 24966 × 46 | 8/8 | 1005 ms |
| `sasdata_cn/sdtm` | `sasdata_cn/sdtm/sc.sas7bdat` | 128.0 KB | 254 × 14 | 8/8 | 396 ms |
| `sasdata_cn/sdtm` | `sasdata_cn/sdtm/supplb.sas7bdat` | 54.6 MB | 64403 × 10 | 8/8 | 939 ms |
| `sasdata_cn/sdtm` | `sasdata_cn/sdtm/ts.sas7bdat` | 128.0 KB | 33 × 6 | 8/8 | 389 ms |
| `xpt/adam` | `xpt/adam/adtte.xpt` | 71.8 KB | 254 × 26 | 8/8 | 576 ms |
| `xpt/adam` | `xpt/adam/adlbcpv.xpt` | 15.8 MB | 37132 × 46 | 8/8 | 1265 ms |
| `xpt/adam` | `xpt/adam/adcibc.xpt` | 283.7 KB | 730 × 36 | 8/8 | 551 ms |
| `xpt/sdtm` | `xpt/sdtm/suppds.xpt` | 4.8 KB | 3 × 10 | 8/8 | 434 ms |
| `xpt/sdtm` | `xpt/sdtm/supplb.xpt` | 54.1 MB | 64403 × 10 | 8/8 | 1441 ms |
| `xpt/sdtm` | `xpt/sdtm/cm.xpt` | 3.6 MB | 7510 × 21 | 8/8 | 458 ms |
<details>
<summary>逐条调用明细</summary>
**`sasdata/adam/adsl.sas7bdat`**(`sasdata/adam`,192.0 KB)
- [PASS] `list_datasets` 633 ms — pattern='ads' 命中 3 个
- [PASS] `get_dataset_metadata` 28 ms — 254 行 × 49 列
- [PASS] `summarize_dataset` 38 ms — 49 变量概要(全量)
- [PASS] `get_value_labels` 4 ms — 无值标签
- [PASS] `frequency` 6 ms — SEX: 2 取值 / 返回 2 / 分母 254 / include_missing=True
- [PASS] `read_dataset` 10 ms — 返回 1 行 / 过滤后 254 行 / offset=166
- [PASS] `query_dataset` 78 ms — 1 行结果(group_by)
- [PASS] `search_variables` 376 ms — AVGDD: 命中 3 个数据集 / contains=True
**`sasdata/adam/adlbcpv.sas7bdat`**(`sasdata/adam`,16.0 MB)
- [PASS] `list_datasets` 5 ms — pattern='adl' 命中 15 个
- [PASS] `get_dataset_metadata` 12 ms — 37132 行 × 46 列
- [PASS] `summarize_dataset` 230 ms — 3 变量概要(列子集)
- [PASS] `get_value_labels` 2 ms — 无值标签
- [PASS] `frequency` 4 ms — AENTMTFL: 2 取值 / 返回 2 / 分母 37132 / include_missing=True
- [PASS] `read_dataset` 47 ms — 返回 1 行 / 过滤后 37122 行 / offset=10368
- [PASS] `query_dataset` 159 ms — 1 行结果(group_by)
- [PASS] `search_variables` 359 ms — RACEN: 命中 36 个数据集 / contains=False
**`sasdata/adam/adlbhpv.sas7bdat`**(`sasdata/adam`,11.2 MB)
- [PASS] `list_datasets` 5 ms — pattern='adl' 命中 15 个
- [PASS] `get_dataset_metadata` 12 ms — 24966 行 × 46 列
- [PASS] `summarize_dataset` 332 ms — 46 变量概要(全量)
- [PASS] `get_value_labels` 3 ms — 无值标签
- [PASS] `frequency` 4 ms — SEX: 2 取值 / 返回 2 / 分母 24966 / include_missing=False
- [PASS] `read_dataset` 5 ms — 返回 3 行 / 过滤后 24966 行 / offset=3050
- [PASS] `query_dataset` 126 ms — COUNT(*)=24966(元数据 24966 行)
- [PASS] `search_variables` 377 ms — RACEN: 命中 36 个数据集 / contains=True
**`sasdata/sdtm/sc.sas7bdat`**(`sasdata/sdtm`,128.0 KB)
- [PASS] `list_datasets` 4 ms — pattern='sc' 命中 5 个
- [PASS] `get_dataset_metadata` 6 ms — 254 行 × 14 列
- [PASS] `summarize_dataset` 8 ms — 6 变量概要(列子集)
- [PASS] `get_value_labels` 2 ms — 无值标签
- [PASS] `frequency` 3 ms — SCDTC: 207 取值 / 返回 10 / 分母 254 / include_missing=False
- [PASS] `read_dataset` 4 ms — 返回 3 行 / 过滤后 254 行 / offset=1
- [PASS] `query_dataset` 22 ms — COUNT(*)=254(元数据 254 行)
- [PASS] `search_variables` 358 ms — SCSTRESN: 命中 3 个数据集 / contains=True
**`sasdata/sdtm/supplb.sas7bdat`**(`sasdata/sdtm`,54.6 MB)
- [PASS] `list_datasets` 4 ms — pattern='sup' 命中 12 个
- [PASS] `get_dataset_metadata` 29 ms — 64403 行 × 10 列
- [PASS] `summarize_dataset` 246 ms — 5 变量概要(列子集)
- [PASS] `get_value_labels` 3 ms — 无值标签
- [PASS] `frequency` 14 ms — QVAL: 61 取值 / 返回 50 / 分母 64403 / include_missing=False
- [PASS] `read_dataset` 4 ms — 返回 3 行 / 过滤后 64403 行 / offset=4184
- [PASS] `query_dataset` 126 ms — 100 行结果(projection)
- [PASS] `search_variables` 344 ms — USUBJID: 命中 87 个数据集 / contains=False
**`sasdata/sdtm/sv.sas7bdat`**(`sasdata/sdtm`,384.0 KB)
- [PASS] `list_datasets` 6 ms — pattern='sv' 命中 3 个
- [PASS] `get_dataset_metadata` 4 ms — 3559 行 × 8 列
- [PASS] `summarize_dataset` 20 ms — 3 变量概要(列子集)
- [PASS] `get_value_labels` 3 ms — 无值标签
- [PASS] `frequency` 5 ms — VISIT: 36 取值 / 返回 10 / 分母 3559 / include_missing=False
- [PASS] `read_dataset` 4 ms — 返回 0 行 / 过滤后 2803 行 / offset=3335
- [PASS] `query_dataset` 29 ms — 20 行结果(projection)
- [PASS] `search_variables` 335 ms — VISITNUM: 命中 54 个数据集 / contains=True
**`sasdata_cn/adam/adsl.sas7bdat`**(`sasdata_cn/adam`,192.0 KB)
- [PASS] `list_datasets` 3 ms — pattern='ads' 命中 3 个
- [PASS] `get_dataset_metadata` 5 ms — 254 行 × 49 列
- [PASS] `summarize_dataset` 9 ms — 2 变量概要(列子集)
- [PASS] `get_value_labels` 3 ms — 无值标签
- [PASS] `frequency` 2 ms — AGEGR1N: 3 取值 / 返回 3 / 分母 254 / include_missing=False
- [PASS] `read_dataset` 4 ms — 返回 1 行 / 过滤后 254 行 / offset=171
- [PASS] `query_dataset` 27 ms — 5 行结果(projection)
- [PASS] `search_variables` 309 ms — COMP16FL: 命中 3 个数据集 / contains=True
**`sasdata_cn/adam/adlbcpv.sas7bdat`**(`sasdata_cn/adam`,16.0 MB)
- [PASS] `list_datasets` 5 ms — pattern='adl' 命中 15 个
- [PASS] `get_dataset_metadata` 12 ms — 37132 行 × 46 列
- [PASS] `summarize_dataset` 216 ms — 2 变量概要(列子集)
- [PASS] `get_value_labels` 3 ms — 无值标签
- [PASS] `frequency` 4 ms — PARAMN: 18 取值 / 返回 18 / 分母 37132 / include_missing=True
- [PASS] `read_dataset` 5 ms — 返回 10 行 / 过滤后 37132 行 / offset=27397
- [PASS] `query_dataset` 160 ms — 1 行结果(group_by)
- [PASS] `search_variables` 458 ms — R2A1HI: 命中 15 个数据集 / contains=True
**`sasdata_cn/adam/adlbh.sas7bdat`**(`sasdata_cn/adam`,9.9 MB)
- [PASS] `list_datasets` 7 ms — pattern='adl' 命中 15 个
- [PASS] `get_dataset_metadata` 12 ms — 24966 行 × 46 列
- [PASS] `summarize_dataset` 461 ms — 46 变量概要(全量)
- [PASS] `get_value_labels` 4 ms — 无值标签
- [PASS] `frequency` 5 ms — SEX: 2 取值 / 返回 2 / 分母 24966 / include_missing=False
- [PASS] `read_dataset` 5 ms — 返回 25 行 / 过滤后 24966 行 / offset=3395
- [PASS] `query_dataset` 143 ms — 20 行结果(projection)
- [PASS] `search_variables` 369 ms — LBSEQ: 命中 15 个数据集 / contains=False
**`sasdata_cn/sdtm/sc.sas7bdat`**(`sasdata_cn/sdtm`,128.0 KB)
- [PASS] `list_datasets` 5 ms — pattern='sc' 命中 5 个
- [PASS] `get_dataset_metadata` 5 ms — 254 行 × 14 列
- [PASS] `summarize_dataset` 15 ms — 14 变量概要(全量)
- [PASS] `get_value_labels` 4 ms — 无值标签
- [PASS] `frequency` 3 ms — SCSTRESN: 19 取值 / 返回 19 / 分母 254 / include_missing=True
- [PASS] `read_dataset` 5 ms — 返回 3 行 / 过滤后 254 行 / offset=22
- [PASS] `query_dataset` 23 ms — 5 行结果(projection)
- [PASS] `search_variables` 336 ms — SCCAT: 命中 3 个数据集 / contains=True
**`sasdata_cn/sdtm/supplb.sas7bdat`**(`sasdata_cn/sdtm`,54.6 MB)
- [PASS] `list_datasets` 4 ms — pattern='sup' 命中 12 个
- [PASS] `get_dataset_metadata` 29 ms — 64403 行 × 10 列
- [PASS] `summarize_dataset` 377 ms — 6 变量概要(列子集)
- [PASS] `get_value_labels` 3 ms — 无值标签
- [PASS] `frequency` 13 ms — QVAL: 61 取值 / 返回 50 / 分母 64403 / include_missing=False
- [PASS] `read_dataset` 7 ms — 返回 10 行 / 过滤后 64403 行 / offset=43487
- [PASS] `query_dataset` 153 ms — COUNT(*)=64403(元数据 64403 行)
- [PASS] `search_variables` 353 ms — STUDYID: 命中 102 个数据集 / contains=True
**`sasdata_cn/sdtm/ts.sas7bdat`**(`sasdata_cn/sdtm`,128.0 KB)
- [PASS] `list_datasets` 4 ms — pattern='ts' 命中 3 个
- [PASS] `get_dataset_metadata` 4 ms — 33 行 × 6 列
- [PASS] `summarize_dataset` 8 ms — 6 变量概要(全量)
- [PASS] `get_value_labels` 4 ms — 无值标签
- [PASS] `frequency` 3 ms — TSSEQ: 4 取值 / 返回 4 / 分母 33 / include_missing=False
- [PASS] `read_dataset` 5 ms — 返回 10 行 / 过滤后 33 行 / offset=7
- [PASS] `query_dataset` 22 ms — COUNT(*)=33(元数据 33 行)
- [PASS] `search_variables` 340 ms — STUDYID: 命中 102 个数据集 / contains=True
**`xpt/adam/adtte.xpt`**(`xpt/adam`,71.8 KB)
- [PASS] `list_datasets` 9 ms — pattern='adt' 命中 3 个
- [PASS] `get_dataset_metadata` 25 ms — 254 行 × 26 列
- [PASS] `summarize_dataset` 33 ms — 26 变量概要(全量)
- [PASS] `get_value_labels` 3 ms — 无值标签
- [PASS] `frequency` 6 ms — SEX: 2 取值 / 返回 2 / 分母 254 / include_missing=True
- [PASS] `read_dataset` 7 ms — 返回 25 行 / 过滤后 254 行 / offset=153
- [PASS] `query_dataset` 35 ms — 100 行结果(projection)
- [PASS] `search_variables` 458 ms — TRTSDT: 命中 36 个数据集 / contains=True
**`xpt/adam/adlbcpv.xpt`**(`xpt/adam`,15.8 MB)
- [PASS] `list_datasets` 5 ms — pattern='adl' 命中 15 个
- [PASS] `get_dataset_metadata` 629 ms — 37132 行 × 46 列
- [PASS] `summarize_dataset` 19 ms — 6 变量概要(列子集)
- [PASS] `get_value_labels` 4 ms — 无值标签
- [PASS] `frequency` 6 ms — COMP24FL: 2 取值 / 返回 2 / 分母 37132 / include_missing=True
- [PASS] `read_dataset` 5 ms — 返回 10 行 / 过滤后 37132 行 / offset=19184
- [PASS] `query_dataset` 189 ms — 1 行结果(group_by)
- [PASS] `search_variables` 408 ms — ALBTRVAL: 命中 12 个数据集 / contains=True
**`xpt/adam/adcibc.xpt`**(`xpt/adam`,283.7 KB)
- [PASS] `list_datasets` 3 ms — pattern='adc' 命中 1 个
- [PASS] `get_dataset_metadata` 30 ms — 730 行 × 36 列
- [PASS] `summarize_dataset` 7 ms — 4 变量概要(列子集)
- [PASS] `get_value_labels` 4 ms — 无值标签
- [PASS] `frequency` 6 ms — DTYPE: 2 取值 / 返回 2 / 分母 730 / include_missing=False
- [PASS] `read_dataset` 6 ms — 返回 3 行 / 过滤后 730 行 / offset=673
- [PASS] `query_dataset` 51 ms — 1 行结果(group_by)
- [PASS] `search_variables` 444 ms — ADT: 命中 30 个数据集 / contains=True
**`xpt/sdtm/suppds.xpt`**(`xpt/sdtm`,4.8 KB)
- [PASS] `list_datasets` 3 ms — pattern='sup' 命中 12 个
- [PASS] `get_dataset_metadata` 8 ms — 3 行 × 10 列
- [PASS] `summarize_dataset` 6 ms — 3 变量概要(列子集)
- [PASS] `get_value_labels` 2 ms — 无值标签
- [PASS] `frequency` 2 ms — QEVAL: 1 取值 / 返回 1 / 分母 3 / include_missing=False
- [PASS] `read_dataset` 3 ms — 返回 2 行 / 过滤后 3 行 / offset=1
- [PASS] `query_dataset` 20 ms — COUNT(*)=3(元数据 3 行)
- [PASS] `search_variables` 390 ms — QNAM: 命中 12 个数据集 / contains=False
**`xpt/sdtm/supplb.xpt`**(`xpt/sdtm`,54.1 MB)
- [PASS] `list_datasets` 6 ms — pattern='sup' 命中 12 个
- [PASS] `get_dataset_metadata` 825 ms — 64403 行 × 10 列
- [PASS] `summarize_dataset` 108 ms — 4 变量概要(列子集)
- [PASS] `get_value_labels` 3 ms — 无值标签
- [PASS] `frequency` 12 ms — QLABEL: 2 取值 / 返回 2 / 分母 64403 / include_missing=False
- [PASS] `read_dataset` 10 ms — 返回 1 行 / 过滤后 64403 行 / offset=30747
- [PASS] `query_dataset` 127 ms — 5 行结果(projection)
- [PASS] `search_variables` 351 ms — QNAM: 命中 12 个数据集 / contains=True
**`xpt/sdtm/cm.xpt`**(`xpt/sdtm`,3.6 MB)
- [PASS] `list_datasets` 4 ms — pattern='cm' 命中 3 个
- [PASS] `get_dataset_metadata` 98 ms — 7510 行 × 21 列
- [PASS] `summarize_dataset` 15 ms — 5 变量概要(列子集)
- [PASS] `get_value_labels` 2 ms — 无值标签
- [PASS] `frequency` 5 ms — VISIT: 15 取值 / 返回 10 / 分母 7510 / include_missing=False
- [PASS] `read_dataset` 3 ms — 返回 3 行 / 过滤后 7510 行 / offset=3856
- [PASS] `query_dataset` 42 ms — 1 行结果(group_by)
- [PASS] `search_variables` 289 ms — CMSPID: 命中 3 个数据集 / contains=True
**跨数据集 JOIN**
- [PASS] `query_dataset(JOIN)` 398 ms — adsl ⋈ adlbcpv = 37132 行
**跨数据集 JOIN**
- [PASS] `query_dataset(JOIN)` 255 ms — sc ⋈ supplb = 64403 行
**跨数据集 JOIN**
- [PASS] `query_dataset(JOIN)` 400 ms — adsl ⋈ adlbcpv = 37132 行
**跨数据集 JOIN**
- [PASS] `query_dataset(JOIN)` 346 ms — sc ⋈ supplb = 64403 行
**跨数据集 JOIN**
- [PASS] `query_dataset(JOIN)` 1124 ms — adtte ⋈ adlbcpv = 37132 行
**跨数据集 JOIN**
- [PASS] `query_dataset(JOIN)` 778 ms — suppds ⋈ supplb = 162 行
</details>
**汇总**
- 随机种子:`20261020`(`--seed` 可复现本次抽样)
- 覆盖类别:6 个(`sasdata/adam`、`sasdata/sdtm`、`sasdata_cn/adam`、`sasdata_cn/sdtm`、`xpt/adam`、`xpt/sdtm`)
- 抽检文件:18 个
- 工具调用:150 次,成功 150 次,失败 0 次
- 端到端耗时:18.7 s
全部用例通过(无失败明细)。
<!-- RANDOM-SMOKE-END -->
## 与 `sassoftware/sas-mcp-server` 的差异
两个项目都用 Python + [FastMCP](https://github.com/jlowin/fastmcp) 实现 MCP 服务器,
其余在数据源、运行依赖、工具集、运行形态与安全模型上均不同。
| 维度 | sassoftware/sas-mcp-server | 本仓库(sas-local-mcp) |
| --- | --- | --- |
| 数据源 | Viya 平台(Compute / CAS / 模型 REST API) | 本地 `.sas7bdat` / `.xpt` 文件 |
| 认证 | OAuth2 PKCE 流程 + 本地 token 缓存 | 无需认证(无网络出口) |
| 运行形态 | HTTP(默认)、stdio、Docker、Kubernetes | 仅 stdio,由 MCP 客户端按需拉起 |
| 工具数量 | 92 个,按 10 个 tier 选择启用 | 8 个,全部只读(无写入 / 代码执行能力) |
| 代码组织 | `tools/` 下约 10 个模块 | 单个 `tools.py` + 核心 `dataset.py` |
| Python 版本 | `>= 3.12` | `>= 3.11` |
| 运行依赖 | `fastmcp`、`httpx`、`pydantic`、`uvicorn`、`anyio`、`python-dotenv` | `fastmcp`、`pandas`、`pyreadstat`、`duckdb`、`sqlglot`、`pydantic` |
| 统计语义层 | 无——需自行执行 SAS / SQL 取数后再推断 | `summarize_dataset` / `frequency` 直接给出缺失率、唯一值数、按类型的分布指标,并自动套用 SAS 值标签 |
| SQL 能力 | 依赖 Viya / CAS | 内置 duckdb:仅允许单条 `SELECT`(支持 `WITH`),关闭外部文件与网络访问 |
| 结果限流 | — | 行预览(`SAS_LOCAL_MCP_MAX_ROWS`)与聚合结果(`SAS_LOCAL_MCP_MAX_AGG_ROWS`)**分开**限流,聚合基数不被预览上限截断 |
| 字符编码 | 由 Viya 侧决定 | 全局 `SAS_LOCAL_MCP_ENCODING`、每个读取工具的 `encoding` 参数,以及解码失败时的自动回退(结果里回传 `encoding_fallback`) |
| 解析缓存 | — | 按 `(路径, mtime, size, metadata_only)` 失效的 LRU 缓存,多轮问答不重复解析大文件 |
| 安全边界 | OAuth + Viya 侧权限体系 | 路径沙箱(越界即拒绝)+ 只读工具 + SQL 白名单 |
| 错误处理 | — | `DatasetError` 统一转 MCP `ToolError`,返回干净消息而不崩溃进程 |
| 格式覆盖 | — | `.sas7bdat` / `.xpt` 可读;`.sas7bcat` 通过 `catalog_path` 作为外部值标签目录读取,也可被 `list_datasets` 列出 |
| 测试数据 | — | 取自 R Consortium [submissions-pilot5](https://github.com/RConsortium/submissions-pilot5-datasetjson) 公开数据,派生为 XPORT / UTF-8 sas7bdat / GBK sas7bdat 三套(见[测试数据来源](#测试数据来源)) |
## 目录结构
```
sas-local-mcp/
├── pyproject.toml # 依赖与构建配置
├── README.md
├── LICENSE # MIT
├── src/
│ └── sas_local_mcp/
│ ├── __init__.py
│ ├── __main__.py # python -m sas_local_mcp 入口
│ ├── cache.py # 有界 LRU 缓存(字节预算 + 单次加载)
│ ├── config.py # 环境变量配置(数据根、行数上限、缓存与 SQL 资源、编码)
│ ├── dataset.py # 数据集读取 + 统计核心(pyreadstat + pandas 回退)
│ ├── errors.py # DatasetError 统一错误类型
│ ├── filtering.py # where 表达式的受限求值器(不使用 eval)
│ ├── responses.py # 响应字节预算与输出 schema
│ ├── sql_labels.py # SQL 列裁剪与标签来源分析(sqlglot)
│ ├── stdio_server.py # FastMCP stdio 服务启动
│ └── tools.py # MCP 工具注册(register_tools)
└── tests/
├── conftest.py # 解析数据根(兼容根目录直放 / 分目录两种布局)
├── test_tools.py # 单元测试:in-memory Client 逐个调用全部工具
├── test_regressions.py # 不依赖数据集的定点回归(注入 meta)
├── test_bugfixes.py # 缺陷修复回归
├── test_optimizations.py # 性能与响应预算相关回归
├── test_random_smoke.py # 随机冒烟的 pytest 入口
├── random_smoke.py # 随机冒烟:stdio 子进程 + 各类别大小文件抽检
├── sasdata/ # 测试数据:英文 sdtm / adam
├── sasdata_cn/ # 测试数据:中文版(编码回归)
└── xpt/ # 测试数据:XPORT 版(格式回归)
```
## 许可证
本项目代码采用 **MIT License**,详见 [LICENSE](LICENSE)。
> 注意:随仓库附带的测试数据取自 R Consortium
> [submissions-pilot5-datasetjson](https://github.com/RConsortium/submissions-pilot5-datasetjson)
> 公开示例提交包,其许可与本仓库代码许可相互独立,请以该来源仓库的声明为准。
TDQS
Scored across 8 tools
Each tool targets a distinct step in the SAS data exploration workflow: discovery (list_datasets, search_variables), schema (get_dataset_metadata, get_value_labels), and content (read_dataset, summarize_dataset, frequency, query_dataset). The closest pair is summarize_dataset vs frequency, but the descriptions explicitly delineate overview-per-variable versus single-variable frequency table. read_dataset vs query_dataset also overlap slightly (both return rows), though paging vs SQL is clear enough.
Seven of eight tools follow a clean verb_noun snake_case pattern (list_datasets, get_dataset_metadata, read_dataset, summarize_dataset, get_value_labels, query_dataset, search_variables). The outlier is 'frequency', a bare noun that breaks the verb-first convention. Minor deviation, still readable and predictable overall.
Eight tools is well-scoped for a local SAS dataset inspection server, with each tool earning its place across discovery, metadata, reading, summarization, and querying. No redundant or filler tools are present.
The surface covers discovery, schema inspection, row reading, statistical summaries, value-label decoding, and arbitrary SQL querying — a full read-side lifecycle for local SAS data. Gaps are minor: no export/materialization of results (e.g., write to CSV/parquet) and no explicit write/modify operations, though the latter is plausibly intentional for a read-only analysis server.