Skip to main content
Glama
mcp-text-stats

mcp-text-stats

by Fastunbeaten
README.md
# mcp-text-stats

**只回数字、不回正文**的文本统计 MCP 服务器。

一个极小的 MCP 服务器(stdio 传输),对文件或文本给出行数、汉字数、词数、字节数等统计口径。
结果里**只有数字、路径与口径说明**,绝不返回任何文件正文片段。

- 纯 JavaScript(ESM),**单文件**实现:`index.mjs`
- 唯一运行时依赖:`@modelcontextprotocol/sdk@1.32.2`(精确锁版本)
- **零第三方分词依赖**:分词 / 字素 / 断句全部使用 Node 内置 `Intl.Segmenter`,脚本分类使用 Unicode 属性正则
- 不做 TypeScript 构建,运行时不联网
- 无状态:同一输入必得同一输出,不含时间戳与随机数

---

## 1. 功能与统计口径

`count_text` 工具返回每个文件的下列指标(以及全部文件的汇总 `totals`):

| 指标 | 口径定义 |
| --- | --- |
| `lines` | **物理行数**,与 POSIX `wc -l` 语义一致:`lines = 换行符个数 + (文件末尾存在非空内容则再加 1)`。`\n` 计一个换行、`\r\n` 计一个换行(不是两个);空行计入;若文件以换行结尾,末尾不额外产生一个空行;空文件 `lines = 0`。 |
| `paragraphs` | 段落数:按空行分隔的非空块数。 |
| `sentences` | 句子数:`Intl.Segmenter(locale, { granularity: 'sentence' })` 的段数。 |
| `words` | 词数:`Intl.Segmenter(locale, { granularity: 'word' })` 中 `isWordLike` 的段数(**近似值**,尤其对 CJK)。CJK 默认不返回(`words = 0`),需 `wordsForCjk: true`,此时 `wordsApproximate = true`。 |
| `graphemes` | 字素簇数:`Intl.Segmenter(undefined, { granularity: 'grapheme' })` 段数(emoji 组合 / 变体选择符算 1)。 |
| `codepoints` | **汉字数等码点级指标**:`[...text].length`,即 Unicode **码点**数(一个汉字 = 1)。 |
| `utf16Units` | UTF-16 代码单元数:`text.length`。 |
| `han` | **汉字数**:Unicode 属性正则 `\p{Script=Han}` 按码点计数。 |
| `kana` / `hangul` | 假名(`\p{Script=Hiragana}` + `\p{Script=Katakana}`)与谚文(`\p{Script=Hangul}`)码点数。 |
| `latin` / `cyrillic` / `arabic` | 拉丁 / 西里尔 / 阿拉伯文码点数。 |
| `letters` / `digits` / `spaces` / `punctuation` / `symbols` | 依次为 `\p{L}` / `\p{Nd}` / U+0020 / `\p{P}` / `\p{S}` 的计数。 |
| `nonWhitespace` | 去掉所有空白字符后的码点数。 |
| `bytes` | 文件字节数(UTF-8 编码后的长度)。 |
| `bom` | 是否存在 UTF-8 BOM(`EF BB BF`)。`bom = true` 时 BOM **不计入任何字符数**(统计前剥离)。 |

其它行为:

- **编码**:只支持 UTF-8 纯文本(txt / md / json / csv / log / 源码等)。用 `TextDecoder('utf-8', { fatal: true })` 解码,失败则该文件写入 warning `decode_failed`,**不中断整批**。
- **二进制格式**:`pdf` / `docx` / `xlsx` / 图片 / 压缩包等扩展名不统计,写入 warning `unsupported_format`。
- **大文件降级**:单文件 > `maxBytes`(默认 70MB)不读取(`file_too_large`);> `maxBytesForWordCount`(默认 20MB)仍统计字符级指标,但跳过 `words` 与 `sentences`(`skipped_word_segmentation`)。
- **目录递归**:自实现的极简 glob(支持 `**` 与 `*`,无第三方依赖)按 `include` / `exclude` 过滤,只收集文本类扩展名;单次任务最多 **200 个文件**,超出截断并写 warning `too_many_files`。
- **符号链接一律拒绝**:既不跟随、也不统计,请传入真实文件路径(warning `symlink_rejected`)。Windows 目录联接(junction / mount point)同样按链接处理并被拒绝。
- **服务端硬上限**:`maxBytes` 上限 **200MB**、`maxBytesForWordCount` 上限 **64MB**;模型传入更大值会被自动钳制(warning `max_bytes_clamped`)。

---

## 2. 安装与运行

```bash
npm install          # 安装 @modelcontextprotocol/sdk@1.32.2
npm start            # 等价于 node index.mjs(stdio 传输)
```

直接运行:

```bash
node /absolute/path/to/mcp-text-stats/index.mjs
```

MCP 客户端配置示例(stdio)。任何兼容 MCP 的客户端通用(如 Claude Desktop):

```json
{
  "mcpServers": {
    "text-stats": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-text-stats/index.mjs"],
      "env": {
        "TEXT_STATS_ROOTS": "/absolute/path/to/allowed/root"
      }
    }
  }
}
```

> Windows 下 `env` 中的路径用正斜杠或双反斜杠均可;`TEXT_STATS_ROOTS` 支持用 `;`(也接受 `,` 与换行)分隔多个绝对路径。

### 环境变量 `TEXT_STATS_ROOTS`

服务端固定白名单,**模型传入的任何参数都不能放宽这个边界**。

- 值为分号分隔的**绝对路径**白名单,例如 `/srv/work/tools;/srv/work/data`(Windows 用 `;`,也接受 `,` 与换行)。
- 判定方式:先 `path.resolve` 规范化,再判断目标是否等于根目录或位于根目录之下(Windows 下大小写不敏感)。
- **不设置(或设为空)时一律拒绝所有路径**:每个路径都会得到 `outside_allowed_roots` 警告且不会被读取。这是安全默认,请显式配置后再使用。
- 不在白名单内的路径同样不统计,写入 warning `outside_allowed_roots`。

---

## 3. 使用

工具名:`count_text`

### 3.1 输入参数

| 参数 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `paths` | `string[]` | — | 要统计的文件路径数组(可含目录);与 `text` **二选一**。目录会递归并按 `include` / `exclude` 过滤。 |
| `text` | `string` | — | 直接统计一段文本;与 `paths` **二选一**。 |
| `unit` | `'grapheme' \| 'codepoint' \| 'utf16'` | `'grapheme'` | 字符计量单位。字符级指标都会给出,`rules.unit` 反映该选择。 |
| `language` | `'auto' \| 'zh' \| 'ja' \| 'ko' \| 'en'` | `'auto'` | 分词 / 断句所用 locale。`auto` 时文本含 CJK 用 `zh`,否则 `und`。 |
| `wordsForCjk` | `boolean` | `false` | 中文 / 日文 / 韩文是否也返回分词数(近似)。 |
| `include` | `string[]` | `[]` | 目录递归时的包含模式(支持 `**` 与 `*`)。 |
| `exclude` | `string[]` | `[]` | 目录递归时的排除模式(支持 `**` 与 `*`)。 |
| `maxBytes` | `number` | `73400320`(70MB) | 单文件字节数上限,超过则不读取(warning `file_too_large`)。服务端硬上限 200MB,超过会被钳制。 |
| `maxBytesForWordCount` | `number` | `20971520`(20MB) | 单文件字节数上限,超过则跳过 `words` 与 `sentences`(warning `skipped_word_segmentation`)。服务端硬上限 64MB,超过会被钳制。 |

### 3.2 返回结构

只含数字、路径与口径说明,**没有正文**:

```json
{
  "files": [
    {
      "path": "/absolute/path/to/example.txt",
      "bytes": 42, "bom": false,
      "lines": 1, "paragraphs": 1, "sentences": 2,
      "words": 0, "wordsApproximate": false,
      "graphemes": 14, "codepoints": 14, "utf16Units": 14,
      "han": 12, "kana": 0, "hangul": 0,
      "latin": 0, "cyrillic": 0, "arabic": 0,
      "letters": 12, "digits": 0, "spaces": 0,
      "punctuation": 2, "symbols": 0, "nonWhitespace": 14
    }
  ],
  "totals": { "…": "同上各字段求和" },
  "rules": {
    "unit": "grapheme",
    "lines": "物理行数:与 wc -l 一致……",
    "bom": "检测 UTF-8 BOM(EF BB BF)……",
    "words": "words = Intl.Segmenter……",
    "binaryFormats": "本期只支持 UTF-8 纯文本……"
  },
  "warnings": []
}
```

`files[].path` 为规范化后的绝对路径;`text` 模式下的伪路径为 `(inline text)`。

---

## 4. 注意事项

1. **只回统计数字,不回文件正文。** 返回体里只有数字、路径与口径说明,不会出现任何一行原文。
2. **返回体含绝对路径,会暴露本机目录结构。** 对外共享工具输出前请自行脱敏。
3. **必须位于 `TEXT_STATS_ROOTS` 白名单之内**,否则只会得到 `outside_allowed_roots` 警告,文件不会被读取。
4. **不设置 `TEXT_STATS_ROOTS` 则一律拒绝**(安全默认):所有路径都会被拒绝并给出 `outside_allowed_roots`。
5. **非 UTF-8 文件会被跳过**,并给出 `decode_failed` 警告,不会中断整批任务。
6. **大文件有上限**:单文件 > 70MB 不读取;> 20MB 跳过分词与断句统计(仍给出字符级指标)。
7. **二进制扩展名不支持**(pdf / docx / xlsx / 图片 / 压缩包等),需先抽取为文本再统计。
8. **单任务最多 200 个文件**,超出则截断并给出 `too_many_files` 警告。
9. **任何符号链接一律拒绝**:既不跟随、也不统计;请传入真实文件路径。直接传入软链接时给出 warning `symlink_rejected`,其中只包含链接自身的路径,不解析链接目标。Windows 目录联接(junction / mount point)同样被拒绝;穿过链接的路径(如 `root/linkdir/file.txt`)也会被拒绝。
10. **服务端对 `maxBytes` / `maxBytesForWordCount` 有硬上限**(200MB / 64MB):模型传入更大的值会被自动钳制到上限,并给出 warning `max_bytes_clamped`;请通过 `TEXT_STATS_ROOTS` 控制可访问范围。

### warning code 一览

| code | 含义 |
| --- | --- |
| `outside_allowed_roots` | 路径不在白名单根目录之下,未统计 |
| `file_unreadable` | 文件不存在或无权限,已跳过 |
| `file_too_large` | 超过 `maxBytes`,未读取 |
| `skipped_word_segmentation` | 超过 `maxBytesForWordCount`,跳过 words / sentences |
| `decode_failed` | UTF-8 解码失败,已跳过该文件 |
| `unsupported_format` | 二进制 / 多格式文件,需先抽取为文本 |
| `too_many_files` | 超过 200 个文件上限,已截断 |
| `symlink_rejected` | 路径是符号链接,一律拒绝(不跟随、不统计) |
| `max_bytes_clamped` | 请求的 `maxBytes` / `maxBytesForWordCount` 超过服务端硬上限,已钳制为 200MB / 64MB |

---

## 5. 与其它计数方式的对比

- **不用 `grep` 的匹配数**:`grep -c` / `grep` 输出的 `Found N matches` 是**匹配行数**,不是字数,也不区分汉字与 ASCII 字母,更没有码点 / 字素簇口径。想用 grep 统计"汉字数"只能靠 `[一-龥]` 这类范围匹配,既不可靠也随 locale 变化。
- **不用 `wc`**:`wc -l` / `-m` / `-c` 只给行数 / 字符数 / 字节数三件套;它**给不出汉字数**,也说不清"字符"是码点还是字素簇,更没有句数、段落数、脚本分类与 BOM 标记。
- **`Intl.Segmenter` 零依赖**:`Intl.Segmenter` 已能给出字素簇、词边界与句边界;引入 `nodejieba` / `@node-rs/jieba` 之类会带来原生编译、二进制分发与平台差异问题,也会破坏"单文件 + 不联网 + 零构建"的目标。
- **默认不给近似中文词数**:ICU 对中文的切分是按词典规则做的近似,词数既不可靠又会随 ICU 版本漂移。默认关闭(CJK 下 `words = 0`),需要时显式传 `wordsForCjk: true`,并用 `wordsApproximate = true` 明确标注近似值,避免被当作事实引用。字符级指标才是稳定可比的。

---

## 6. 开发说明

**本项目在 AI 协助下开发。** AI 参与了项目的设计取舍、实现与文档编写:统计口径的定义与边界条件、Unicode 口径的选择、大文件降级策略、白名单安全默认以及本文档,均由人与 AI 协作完成。**全部代码与文档由人工逐条复核**,并在本机真实环境(真实文件、真实 MCP stdio 握手、真实 Unicode 文本)下验证通过。

开发与测试:

```bash
npm install
npm test        # node --test test/
```

仓库内保留的是**可复现的最小测试集**:`test/count.test.mjs`,共 **5 个用例**,分别覆盖:中文样本的计数正确性、"返回体不含正文"契约、"未设置 `TEXT_STATS_ROOTS` 时拒绝一切路径"的安全默认、`maxBytes` 超过服务端硬上限时被钳制、以及符号链接/目录联接一律拒绝。开发期曾使用一套规模更大的临时测试(53 项)做交叉验证,该临时套件未随包发布;运行 `npm test` 即可复现当前保留的全部用例。

---

## 7. License

MIT —— 见 [LICENSE](./LICENSE)。