pangram-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pangram_mcp-0.1.0/.github/workflows/release.yml +49 -0
- pangram_mcp-0.1.0/.github/workflows/test.yml +23 -0
- pangram_mcp-0.1.0/.gitignore +12 -0
- pangram_mcp-0.1.0/LICENSE +21 -0
- pangram_mcp-0.1.0/PKG-INFO +95 -0
- pangram_mcp-0.1.0/README.md +70 -0
- pangram_mcp-0.1.0/pyproject.toml +42 -0
- pangram_mcp-0.1.0/src/pangram_mcp/__init__.py +3 -0
- pangram_mcp-0.1.0/src/pangram_mcp/server.py +223 -0
- pangram_mcp-0.1.0/tests/test_server.py +115 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- name: Install uv
|
|
13
|
+
uses: astral-sh/setup-uv@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.12"
|
|
16
|
+
- run: uv build
|
|
17
|
+
- uses: actions/upload-artifact@v4
|
|
18
|
+
with:
|
|
19
|
+
name: dist
|
|
20
|
+
path: dist/
|
|
21
|
+
|
|
22
|
+
publish:
|
|
23
|
+
needs: build
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
environment: release
|
|
26
|
+
permissions:
|
|
27
|
+
id-token: write # Required for PyPI trusted publishing
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/download-artifact@v4
|
|
30
|
+
with:
|
|
31
|
+
name: dist
|
|
32
|
+
path: dist/
|
|
33
|
+
- name: Publish to PyPI
|
|
34
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
35
|
+
# Trusted publishing — no token needed.
|
|
36
|
+
# Configure at: https://pypi.org/manage/project/pangram-mcp/settings/publishing/
|
|
37
|
+
|
|
38
|
+
github-release:
|
|
39
|
+
needs: publish
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
permissions:
|
|
42
|
+
contents: write
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v4
|
|
45
|
+
- name: Create GitHub Release
|
|
46
|
+
env:
|
|
47
|
+
GH_TOKEN: ${{ github.token }}
|
|
48
|
+
TAG: ${{ github.ref_name }}
|
|
49
|
+
run: gh release create "$TAG" --title "$TAG" --generate-notes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- name: Install uv
|
|
17
|
+
uses: astral-sh/setup-uv@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: uv sync --extra dev
|
|
21
|
+
- run: uv run ruff check .
|
|
22
|
+
- run: uv run mypy src
|
|
23
|
+
- run: uv run pytest -q
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Consiliency
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pangram-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Pangram Labs AI-generated-text detection
|
|
5
|
+
Project-URL: Repository, https://github.com/Consiliency/pangram-mcp
|
|
6
|
+
Project-URL: Homepage, https://github.com/Consiliency/pangram-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/Consiliency/pangram-mcp/issues
|
|
8
|
+
Author: Consiliency
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-detection,llm,mcp,model-context-protocol,pangram
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: httpx>=0.27
|
|
17
|
+
Requires-Dist: mcp>=1.2.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
22
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# pangram-mcp
|
|
27
|
+
|
|
28
|
+
An [MCP](https://modelcontextprotocol.io) server for [Pangram Labs](https://pangram.com)
|
|
29
|
+
AI-generated-text detection. Exposes a single `analyze` tool that classifies text as
|
|
30
|
+
human-written, AI-generated, or AI-assisted — with an overall verdict, per-class
|
|
31
|
+
fractions, and a per-segment breakdown.
|
|
32
|
+
|
|
33
|
+
The API key stays in the server's environment and is sent as the `x-api-key` header; it
|
|
34
|
+
is never returned to the model or logged. Callers pass text and receive a classification
|
|
35
|
+
— they never handle the credential.
|
|
36
|
+
|
|
37
|
+
## Install / run
|
|
38
|
+
|
|
39
|
+
Requires an API key from [Pangram Labs](https://pangram.com):
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
export PANGRAM_API_KEY="your-key"
|
|
43
|
+
uvx pangram-mcp
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### MCP client config
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"mcpServers": {
|
|
51
|
+
"pangram": {
|
|
52
|
+
"command": "uvx",
|
|
53
|
+
"args": ["pangram-mcp"],
|
|
54
|
+
"env": { "PANGRAM_API_KEY": "your-key" }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
| Env var | Required | Default | Purpose |
|
|
63
|
+
|---|---|---|---|
|
|
64
|
+
| `PANGRAM_API_KEY` | yes | — | Pangram Labs API key. |
|
|
65
|
+
| `PANGRAM_API_BASE` | no | `https://text.api.pangram.com/v3` | Endpoint override. |
|
|
66
|
+
| `PANGRAM_TIMEOUT` | no | `60` | Request timeout (seconds). |
|
|
67
|
+
|
|
68
|
+
## Tool: `analyze`
|
|
69
|
+
|
|
70
|
+
Detect AI-generated text. Provide **either** `text` **or** `file`.
|
|
71
|
+
|
|
72
|
+
| Parameter | Type | Description |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `text` | string | Raw text to classify. |
|
|
75
|
+
| `file` | string | Path to a local UTF-8 text file to read and classify. |
|
|
76
|
+
| `public_dashboard_link` | bool | Request a shareable Pangram dashboard link (default `false`). |
|
|
77
|
+
|
|
78
|
+
Returns structured output: `prediction`, `prediction_short`, `headline`,
|
|
79
|
+
`fraction_ai` / `fraction_ai_assisted` / `fraction_human` (0.0–1.0),
|
|
80
|
+
`num_ai_segments` / `num_ai_assisted_segments` / `num_human_segments`, a `windows`
|
|
81
|
+
array (per-segment `label`, `ai_assistance_score`, `confidence`, indices), and an
|
|
82
|
+
optional `dashboard_link`.
|
|
83
|
+
|
|
84
|
+
## Development
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
uv sync --extra dev
|
|
88
|
+
uv run pytest
|
|
89
|
+
uv run ruff check .
|
|
90
|
+
uv run mypy src
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# pangram-mcp
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server for [Pangram Labs](https://pangram.com)
|
|
4
|
+
AI-generated-text detection. Exposes a single `analyze` tool that classifies text as
|
|
5
|
+
human-written, AI-generated, or AI-assisted — with an overall verdict, per-class
|
|
6
|
+
fractions, and a per-segment breakdown.
|
|
7
|
+
|
|
8
|
+
The API key stays in the server's environment and is sent as the `x-api-key` header; it
|
|
9
|
+
is never returned to the model or logged. Callers pass text and receive a classification
|
|
10
|
+
— they never handle the credential.
|
|
11
|
+
|
|
12
|
+
## Install / run
|
|
13
|
+
|
|
14
|
+
Requires an API key from [Pangram Labs](https://pangram.com):
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
export PANGRAM_API_KEY="your-key"
|
|
18
|
+
uvx pangram-mcp
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### MCP client config
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"mcpServers": {
|
|
26
|
+
"pangram": {
|
|
27
|
+
"command": "uvx",
|
|
28
|
+
"args": ["pangram-mcp"],
|
|
29
|
+
"env": { "PANGRAM_API_KEY": "your-key" }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Configuration
|
|
36
|
+
|
|
37
|
+
| Env var | Required | Default | Purpose |
|
|
38
|
+
|---|---|---|---|
|
|
39
|
+
| `PANGRAM_API_KEY` | yes | — | Pangram Labs API key. |
|
|
40
|
+
| `PANGRAM_API_BASE` | no | `https://text.api.pangram.com/v3` | Endpoint override. |
|
|
41
|
+
| `PANGRAM_TIMEOUT` | no | `60` | Request timeout (seconds). |
|
|
42
|
+
|
|
43
|
+
## Tool: `analyze`
|
|
44
|
+
|
|
45
|
+
Detect AI-generated text. Provide **either** `text` **or** `file`.
|
|
46
|
+
|
|
47
|
+
| Parameter | Type | Description |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `text` | string | Raw text to classify. |
|
|
50
|
+
| `file` | string | Path to a local UTF-8 text file to read and classify. |
|
|
51
|
+
| `public_dashboard_link` | bool | Request a shareable Pangram dashboard link (default `false`). |
|
|
52
|
+
|
|
53
|
+
Returns structured output: `prediction`, `prediction_short`, `headline`,
|
|
54
|
+
`fraction_ai` / `fraction_ai_assisted` / `fraction_human` (0.0–1.0),
|
|
55
|
+
`num_ai_segments` / `num_ai_assisted_segments` / `num_human_segments`, a `windows`
|
|
56
|
+
array (per-segment `label`, `ai_assistance_score`, `confidence`, indices), and an
|
|
57
|
+
optional `dashboard_link`.
|
|
58
|
+
|
|
59
|
+
## Development
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
uv sync --extra dev
|
|
63
|
+
uv run pytest
|
|
64
|
+
uv run ruff check .
|
|
65
|
+
uv run mypy src
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pangram-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server for Pangram Labs AI-generated-text detection"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Consiliency" }]
|
|
9
|
+
keywords = ["mcp", "model-context-protocol", "pangram", "ai-detection", "llm"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"mcp>=1.2.0",
|
|
17
|
+
"httpx>=0.27",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Repository = "https://github.com/Consiliency/pangram-mcp"
|
|
22
|
+
Homepage = "https://github.com/Consiliency/pangram-mcp"
|
|
23
|
+
Issues = "https://github.com/Consiliency/pangram-mcp/issues"
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
pangram-mcp = "pangram_mcp.server:main"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "respx>=0.21", "ruff>=0.6", "mypy>=1.11"]
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["hatchling"]
|
|
33
|
+
build-backend = "hatchling.build"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/pangram_mcp"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
asyncio_mode = "auto"
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
line-length = 100
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Pangram MCP server — AI-generated-text detection via the Pangram Labs API.
|
|
2
|
+
|
|
3
|
+
Exposes a single ``analyze`` tool that classifies text as human-written,
|
|
4
|
+
AI-generated, or AI-assisted, with an overall verdict, per-class fractions, and a
|
|
5
|
+
per-segment breakdown.
|
|
6
|
+
|
|
7
|
+
Configuration (environment variables):
|
|
8
|
+
- ``PANGRAM_API_KEY`` (required): a Pangram Labs API key (https://pangram.com).
|
|
9
|
+
- ``PANGRAM_API_BASE`` (optional): override the endpoint. Defaults to the canonical
|
|
10
|
+
synchronous v3 endpoint ``https://text.api.pangram.com/v3``.
|
|
11
|
+
- ``PANGRAM_TIMEOUT`` (optional): request timeout in seconds (default 60).
|
|
12
|
+
|
|
13
|
+
The API key is read from the environment and sent as the ``x-api-key`` header. It is
|
|
14
|
+
never logged or returned in tool output.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from typing import Annotated, Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
from mcp.server.fastmcp import FastMCP
|
|
24
|
+
from mcp.types import ToolAnnotations
|
|
25
|
+
from pydantic import BaseModel, Field
|
|
26
|
+
|
|
27
|
+
DEFAULT_API_BASE = "https://text.api.pangram.com/v3"
|
|
28
|
+
API_KEY_ENV = "PANGRAM_API_KEY"
|
|
29
|
+
# Guard against accidentally shipping a whole book; Pangram bounds request size and
|
|
30
|
+
# very long inputs waste credits. Callers should chunk larger documents themselves.
|
|
31
|
+
MAX_CHARS = 100_000
|
|
32
|
+
|
|
33
|
+
mcp = FastMCP("pangram")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Window(BaseModel):
|
|
37
|
+
"""A per-segment classification returned by Pangram."""
|
|
38
|
+
|
|
39
|
+
text: str = Field(description="The segment of the input this window covers.")
|
|
40
|
+
label: str = Field(description="Human-readable segment label, e.g. 'AI' / 'Human'.")
|
|
41
|
+
ai_assistance_score: float = Field(
|
|
42
|
+
description="AI-assistance score for this segment (0.0-1.0)."
|
|
43
|
+
)
|
|
44
|
+
confidence: str = Field(description="Confidence bucket, e.g. 'High'/'Medium'/'Low'.")
|
|
45
|
+
start_index: int | None = None
|
|
46
|
+
end_index: int | None = None
|
|
47
|
+
word_count: int | None = None
|
|
48
|
+
token_length: int | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AnalyzeResult(BaseModel):
|
|
52
|
+
"""Structured result of a Pangram AI-detection call."""
|
|
53
|
+
|
|
54
|
+
prediction: str = Field(description="Overall verdict for the whole text.")
|
|
55
|
+
prediction_short: str | None = Field(
|
|
56
|
+
default=None, description="Short form of the verdict."
|
|
57
|
+
)
|
|
58
|
+
headline: str | None = Field(default=None, description="One-line human summary.")
|
|
59
|
+
version: str | None = Field(
|
|
60
|
+
default=None, description="Pangram model version that produced this result."
|
|
61
|
+
)
|
|
62
|
+
fraction_ai: float = Field(
|
|
63
|
+
description="Fraction of the text classified as AI-generated (0.0-1.0)."
|
|
64
|
+
)
|
|
65
|
+
fraction_ai_assisted: float = Field(
|
|
66
|
+
description="Fraction classified as AI-assisted (0.0-1.0)."
|
|
67
|
+
)
|
|
68
|
+
fraction_human: float = Field(
|
|
69
|
+
description="Fraction classified as human-written (0.0-1.0)."
|
|
70
|
+
)
|
|
71
|
+
num_ai_segments: int | None = None
|
|
72
|
+
num_ai_assisted_segments: int | None = None
|
|
73
|
+
num_human_segments: int | None = None
|
|
74
|
+
windows: list[Window] = Field(
|
|
75
|
+
default_factory=list, description="Per-segment breakdown."
|
|
76
|
+
)
|
|
77
|
+
dashboard_link: str | None = Field(
|
|
78
|
+
default=None, description="Shareable dashboard URL (only if requested)."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_input(text: str | None, file: str | None) -> str:
|
|
83
|
+
"""Return the text to classify from exactly one of ``text`` or ``file``."""
|
|
84
|
+
provided = [x for x in (text, file) if x]
|
|
85
|
+
if len(provided) != 1:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
"Provide exactly one of `text` or `file` (got "
|
|
88
|
+
f"{'both' if len(provided) == 2 else 'neither'})."
|
|
89
|
+
)
|
|
90
|
+
if file:
|
|
91
|
+
try:
|
|
92
|
+
with open(file, encoding="utf-8") as fh:
|
|
93
|
+
content = fh.read()
|
|
94
|
+
except FileNotFoundError:
|
|
95
|
+
raise ValueError(f"File not found: {file}") from None
|
|
96
|
+
except OSError as exc:
|
|
97
|
+
raise ValueError(f"Could not read {file}: {exc}") from None
|
|
98
|
+
else:
|
|
99
|
+
content = text or ""
|
|
100
|
+
content = content.strip()
|
|
101
|
+
if not content:
|
|
102
|
+
raise ValueError("The text to analyze is empty.")
|
|
103
|
+
if len(content) > MAX_CHARS:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"Input is {len(content)} chars; the limit is {MAX_CHARS}. "
|
|
106
|
+
"Chunk the document and analyze segments separately."
|
|
107
|
+
)
|
|
108
|
+
return content
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _explain_http_error(status: int, body: str) -> str:
|
|
112
|
+
"""Map a Pangram HTTP status to an actionable message."""
|
|
113
|
+
hints = {
|
|
114
|
+
400: "Bad request — check the text payload.",
|
|
115
|
+
401: f"Unauthorized — the {API_KEY_ENV} is missing or invalid.",
|
|
116
|
+
402: "Payment required — the Pangram account is out of credits.",
|
|
117
|
+
404: "Endpoint not found — verify PANGRAM_API_BASE.",
|
|
118
|
+
422: "Unprocessable input — the text may be too short or malformed.",
|
|
119
|
+
429: "Rate limited by Pangram — retry after a short backoff.",
|
|
120
|
+
500: "Pangram server error — retry later.",
|
|
121
|
+
}
|
|
122
|
+
hint = hints.get(status, "Unexpected Pangram API error.")
|
|
123
|
+
snippet = body.strip()[:300]
|
|
124
|
+
return f"Pangram API {status}: {hint}" + (f" ({snippet})" if snippet else "")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@mcp.tool(
|
|
128
|
+
annotations=ToolAnnotations(
|
|
129
|
+
title="Detect AI-generated text",
|
|
130
|
+
readOnlyHint=True,
|
|
131
|
+
destructiveHint=False,
|
|
132
|
+
idempotentHint=True,
|
|
133
|
+
openWorldHint=True,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
async def analyze(
|
|
137
|
+
text: Annotated[
|
|
138
|
+
str | None,
|
|
139
|
+
Field(description="Raw text to classify. Provide this OR `file`, not both."),
|
|
140
|
+
] = None,
|
|
141
|
+
file: Annotated[
|
|
142
|
+
str | None,
|
|
143
|
+
Field(
|
|
144
|
+
description="Path to a local UTF-8 text file to read and classify. "
|
|
145
|
+
"Provide this OR `text`, not both."
|
|
146
|
+
),
|
|
147
|
+
] = None,
|
|
148
|
+
public_dashboard_link: Annotated[
|
|
149
|
+
bool,
|
|
150
|
+
Field(description="Request a shareable Pangram dashboard link for the result."),
|
|
151
|
+
] = False,
|
|
152
|
+
) -> AnalyzeResult:
|
|
153
|
+
"""Detect AI-generated text with Pangram Labs.
|
|
154
|
+
|
|
155
|
+
Classifies the input as human-written, AI-generated, or AI-assisted and returns an
|
|
156
|
+
overall verdict (`prediction`), per-class fractions 0.0-1.0
|
|
157
|
+
(`fraction_ai` / `fraction_ai_assisted` / `fraction_human`), segment counts, and a
|
|
158
|
+
per-segment breakdown (`windows`). Provide either `text` or `file`.
|
|
159
|
+
|
|
160
|
+
Requires the PANGRAM_API_KEY environment variable.
|
|
161
|
+
"""
|
|
162
|
+
content = _resolve_input(text, file)
|
|
163
|
+
|
|
164
|
+
api_key = os.environ.get(API_KEY_ENV)
|
|
165
|
+
if not api_key:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"{API_KEY_ENV} is not set. Configure a Pangram API key "
|
|
168
|
+
"(https://pangram.com) in the server environment."
|
|
169
|
+
)
|
|
170
|
+
api_base = os.environ.get("PANGRAM_API_BASE", DEFAULT_API_BASE)
|
|
171
|
+
timeout = float(os.environ.get("PANGRAM_TIMEOUT", "60"))
|
|
172
|
+
|
|
173
|
+
payload: dict[str, Any] = {"text": content}
|
|
174
|
+
if public_dashboard_link:
|
|
175
|
+
payload["public_dashboard_link"] = True
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
179
|
+
resp = await client.post(
|
|
180
|
+
api_base, headers={"x-api-key": api_key}, json=payload
|
|
181
|
+
)
|
|
182
|
+
except httpx.TimeoutException:
|
|
183
|
+
raise ValueError(
|
|
184
|
+
f"Pangram request timed out after {timeout}s (PANGRAM_TIMEOUT)."
|
|
185
|
+
) from None
|
|
186
|
+
except httpx.HTTPError as exc:
|
|
187
|
+
raise ValueError(f"Could not reach Pangram: {exc}") from None
|
|
188
|
+
|
|
189
|
+
if resp.status_code != 200:
|
|
190
|
+
raise ValueError(_explain_http_error(resp.status_code, resp.text))
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
data = resp.json()
|
|
194
|
+
except ValueError:
|
|
195
|
+
raise ValueError("Pangram returned a non-JSON response.") from None
|
|
196
|
+
|
|
197
|
+
# Drop the echoed input text to keep the result compact; tolerate schema drift by
|
|
198
|
+
# ignoring unknown fields (Pydantic default) and filling known ones.
|
|
199
|
+
data.pop("text", None)
|
|
200
|
+
windows = [Window(**w) for w in data.get("windows", []) if isinstance(w, dict)]
|
|
201
|
+
return AnalyzeResult(
|
|
202
|
+
prediction=str(data.get("prediction", "unknown")),
|
|
203
|
+
prediction_short=data.get("prediction_short"),
|
|
204
|
+
headline=data.get("headline"),
|
|
205
|
+
version=data.get("version"),
|
|
206
|
+
fraction_ai=float(data.get("fraction_ai", 0.0)),
|
|
207
|
+
fraction_ai_assisted=float(data.get("fraction_ai_assisted", 0.0)),
|
|
208
|
+
fraction_human=float(data.get("fraction_human", 0.0)),
|
|
209
|
+
num_ai_segments=data.get("num_ai_segments"),
|
|
210
|
+
num_ai_assisted_segments=data.get("num_ai_assisted_segments"),
|
|
211
|
+
num_human_segments=data.get("num_human_segments"),
|
|
212
|
+
windows=windows,
|
|
213
|
+
dashboard_link=data.get("dashboard_link"),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def main() -> None:
|
|
218
|
+
"""Console-script entry point: run the server over stdio."""
|
|
219
|
+
mcp.run()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if __name__ == "__main__":
|
|
223
|
+
main()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Unit tests for the pangram-mcp server (API mocked with respx)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
import respx
|
|
8
|
+
|
|
9
|
+
from pangram_mcp import server
|
|
10
|
+
from pangram_mcp.server import AnalyzeResult, _explain_http_error, _resolve_input, analyze
|
|
11
|
+
|
|
12
|
+
API = server.DEFAULT_API_BASE
|
|
13
|
+
|
|
14
|
+
SAMPLE = {
|
|
15
|
+
"text": "echoed back input",
|
|
16
|
+
"version": "3.3.2",
|
|
17
|
+
"prediction": "Likely AI-generated",
|
|
18
|
+
"prediction_short": "AI",
|
|
19
|
+
"headline": "This text is likely AI-generated.",
|
|
20
|
+
"fraction_ai": 0.92,
|
|
21
|
+
"fraction_ai_assisted": 0.05,
|
|
22
|
+
"fraction_human": 0.03,
|
|
23
|
+
"num_ai_segments": 3,
|
|
24
|
+
"num_ai_assisted_segments": 0,
|
|
25
|
+
"num_human_segments": 1,
|
|
26
|
+
"windows": [
|
|
27
|
+
{
|
|
28
|
+
"text": "A segment.",
|
|
29
|
+
"label": "AI",
|
|
30
|
+
"ai_assistance_score": 0.9,
|
|
31
|
+
"confidence": "High",
|
|
32
|
+
"start_index": 0,
|
|
33
|
+
"end_index": 10,
|
|
34
|
+
"word_count": 2,
|
|
35
|
+
"token_length": 3,
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --- input resolution -------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def test_resolve_text():
|
|
44
|
+
assert _resolve_input(" hello ", None) == "hello"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_resolve_file(tmp_path):
|
|
48
|
+
p = tmp_path / "doc.txt"
|
|
49
|
+
p.write_text("from a file")
|
|
50
|
+
assert _resolve_input(None, str(p)) == "from a file"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_resolve_requires_exactly_one():
|
|
54
|
+
with pytest.raises(ValueError, match="exactly one"):
|
|
55
|
+
_resolve_input("a", "b")
|
|
56
|
+
with pytest.raises(ValueError, match="exactly one"):
|
|
57
|
+
_resolve_input(None, None)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_resolve_empty_text():
|
|
61
|
+
with pytest.raises(ValueError, match="empty"):
|
|
62
|
+
_resolve_input(" ", None)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_resolve_missing_file():
|
|
66
|
+
with pytest.raises(ValueError, match="File not found"):
|
|
67
|
+
_resolve_input(None, "/no/such/file.txt")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_resolve_too_long():
|
|
71
|
+
with pytest.raises(ValueError, match="limit"):
|
|
72
|
+
_resolve_input("x" * (server.MAX_CHARS + 1), None)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --- error mapping ----------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
@pytest.mark.parametrize(
|
|
78
|
+
"status,needle",
|
|
79
|
+
[(401, "missing or invalid"), (402, "out of credits"), (429, "Rate limited")],
|
|
80
|
+
)
|
|
81
|
+
def test_explain_http_error(status, needle):
|
|
82
|
+
assert needle in _explain_http_error(status, "body")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --- analyze tool -----------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
@respx.mock
|
|
88
|
+
async def test_analyze_happy_path(monkeypatch):
|
|
89
|
+
monkeypatch.setenv("PANGRAM_API_KEY", "test-key")
|
|
90
|
+
route = respx.post(API).mock(return_value=httpx.Response(200, json=SAMPLE))
|
|
91
|
+
result = await analyze(text="Some text to classify.")
|
|
92
|
+
assert isinstance(result, AnalyzeResult)
|
|
93
|
+
assert result.prediction == "Likely AI-generated"
|
|
94
|
+
assert result.version == "3.3.2"
|
|
95
|
+
assert result.fraction_ai == pytest.approx(0.92)
|
|
96
|
+
assert len(result.windows) == 1
|
|
97
|
+
assert result.windows[0].confidence == "High"
|
|
98
|
+
# request carried the key + payload, and did not leak text back into the result
|
|
99
|
+
sent = route.calls.last.request
|
|
100
|
+
assert sent.headers["x-api-key"] == "test-key"
|
|
101
|
+
assert b'"text"' in sent.content
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def test_analyze_requires_key(monkeypatch):
|
|
105
|
+
monkeypatch.delenv("PANGRAM_API_KEY", raising=False)
|
|
106
|
+
with pytest.raises(ValueError, match="PANGRAM_API_KEY is not set"):
|
|
107
|
+
await analyze(text="hello")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@respx.mock
|
|
111
|
+
async def test_analyze_http_error(monkeypatch):
|
|
112
|
+
monkeypatch.setenv("PANGRAM_API_KEY", "test-key")
|
|
113
|
+
respx.post(API).mock(return_value=httpx.Response(402, text="no credits"))
|
|
114
|
+
with pytest.raises(ValueError, match="402"):
|
|
115
|
+
await analyze(text="hello")
|