Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "datapoint",
"version": "0.1.4",
"version": "0.2.0",
"description": "Get real human opinions — surveys, preference comparisons, ratings, and rankings — from inside Claude Code, powered by Datapoint AI.",
"author": {
"name": "Datapoint AI",
Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: CI

on:
pull_request:
push:
branches: [main]

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: pytest + ruff
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: pyproject.toml

- name: Install Python
run: uv python install 3.12

- name: Install project + dev dependencies
run: |
uv venv
uv pip install -e '.[dev]'

- name: ruff check
run: uv run ruff check mcp_server tests

- name: pytest
run: uv run pytest tests/ -v
7 changes: 7 additions & 0 deletions mcp_server/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ def get_job_responses(
def list_jobs(self) -> dict:
return self._request("GET", "/jobs")

def retry_job(self, job_id: str, datapoint_indices: list[int] | None = None) -> dict:
"""POST /jobs/{job_id}/retry — re-queue failed datapoints."""
body: dict = {}
if datapoint_indices is not None:
body["datapoint_indices"] = datapoint_indices
return self._request("POST", f"/jobs/{job_id}/retry", json=body)

def pause_job(self, job_id: str) -> dict:
"""POST /jobs/{job_id}/pause — stops serving new tasks."""
return self._request("POST", f"/jobs/{job_id}/pause")
Expand Down
27 changes: 26 additions & 1 deletion mcp_server/sanitize.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,47 @@
"""Sanitize annotator-sourced text to prevent prompt injection.

All text from annotator responses that flows back through MCP tools
must be sanitized before being returned to Claude's context.
must be sanitized before being returned to the agent's context.

The sanitizer applies, in order:
- Unicode NFKC normalization to fold lookalike codepoints to a canonical form
(e.g. fullwidth digits and Cyrillic homoglyphs).
- Stripping of zero-width characters and bidi/RTL override codepoints
commonly used to hide text or visually spoof content.
- Stripping of C0/C1 control characters except tab and newline.
- Removal of XML/HTML-like tags so script-style payloads don't ride along.
- Defanging of Markdown link / image syntax so URLs are not auto-clickable
when the agent renders annotator text to a human.
- Truncation to a fixed cap so a runaway response cannot crowd the context.
"""

import re
import unicodedata

MAX_RESPONSE_LENGTH = 500

_TAG_RE = re.compile(r"<[^>]*>")
# Zero-width spaces, bidi/RTL overrides, BOM, and other invisible formatters.
_INVISIBLE_RE = re.compile(
"[​-‏‪-‮⁠-⁤]"
)
# C0 controls except tab (\x09) and newline (\x0a); plus C1 controls (\x80-\x9f).
_CONTROL_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")


def sanitize_text(text: str) -> str:
"""Sanitize a single text string from annotator data."""
if not isinstance(text, str):
return str(text)

text = unicodedata.normalize("NFKC", text)
text = _INVISIBLE_RE.sub("", text)
text = _CONTROL_RE.sub("", text)
text = _TAG_RE.sub("", text)
# Defang Markdown links / images (including nested-bracket variants) by
# escaping the `(` that follows `]`, so URLs aren't auto-clickable when
# the agent renders annotator text to a human.
text = text.replace("](", "]\\(")

if len(text) > MAX_RESPONSE_LENGTH:
text = text[:MAX_RESPONSE_LENGTH] + "..."
Expand Down
90 changes: 71 additions & 19 deletions mcp_server/server.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Datapoint AI MCP Server.

Provides tools for creating human evaluation surveys, checking results,
and managing credits — all from within Claude Code conversations.
and managing credits — all from inside any MCP-capable agent.
"""

import atexit
Expand Down Expand Up @@ -145,22 +145,37 @@ def upload_media(file_paths: list[str]) -> str:
"""
client = _get_client()

if not file_paths:
return "No files provided."

uploaded = []
errors = []
files_succeeded = 0
for path in file_paths:
try:
result = client.upload_media(path)
# Response shape: {"media": [{"filename","media_ref","type","size_bytes"}]}
for item in result.get("media", []):
uploaded.append(item)
uploaded.extend(result.get("media", []))
files_succeeded += 1
except FileNotFoundError as e:
errors.append(f"{path}: {e}")
except DatapointAPIError as e:
errors.append(f"{path}: {_describe_upload_error(e)}")

lines: list[str] = []
total = len(file_paths)
files_failed = len(errors)
if files_failed == 0:
summary = f"Uploaded {_pluralize(files_succeeded, 'file')}."
elif files_succeeded == 0:
summary = f"All {_pluralize(total, 'file')} failed to upload."
else:
summary = f"Uploaded {files_succeeded} of {total} files; {files_failed} failed."

lines: list[str] = [summary]

if uploaded:
lines.append(f"Uploaded {len(uploaded)} file(s):")
lines.append("")
lines.append("Media references:")
for item in uploaded:
lines.append(
f" {item.get('filename', '?')} → {item.get('media_ref', '?')} "
Expand All @@ -170,13 +185,12 @@ def upload_media(file_paths: list[str]) -> str:
lines.append("Pass the media_ref values (dp://…) inside the plan_survey description.")

if errors:
if lines:
lines.append("")
lines.append("")
lines.append(f"Failed ({len(errors)}):")
for err in errors:
lines.append(f" {err}")

return "\n".join(lines) if lines else "No files provided."
return "\n".join(lines)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -231,6 +245,11 @@ def _render_filter_values(vals: list) -> str:
return ", ".join(rendered)


def _pluralize(n: int, word: str) -> str:
"""English plural for `word` based on count `n` (no irregular forms)."""
return f"{n} {word}{'s' if n != 1 else ''}"


def _format_standalone_plan_output(plan: dict, summary: str, cost: float, warnings: list) -> list[str]:
"""Render a standalone (non-chain) plan for user confirmation."""
lines = [
Expand Down Expand Up @@ -259,7 +278,7 @@ def _format_standalone_plan_output(plan: dict, summary: str, cost: float, warnin

def _format_chain_plan_output(plan: dict, summary: str, cost: float, warnings: list) -> list[str]:
"""Render a chain plan so the user sees the full flow + skip conditions
before confirming. Claude should show this to the user verbatim.
before confirming. The agent should show this to the user verbatim.

Chain plans are served atomically: an annotator who picks up the chain
walks through every step in order, possibly terminated early by a step's
Expand Down Expand Up @@ -382,7 +401,7 @@ def plan_survey(description: str, max_responses: int = 10) -> str:

except DatapointAPIError as e:
# 422 carries a structured {"message", "warnings"} body from _validate_plan —
# surface the warnings so the user sees what the LLM got wrong.
# surface the warnings so the user sees what the planner got wrong.
if e.status_code == 422 and isinstance(e.detail, dict):
message = e.detail.get("message", "Plan failed validation")
warnings = e.detail.get("warnings") or []
Expand Down Expand Up @@ -565,6 +584,12 @@ def _format_check_survey(status: dict, results_data: dict | None, results_error:
f"Cost so far: ${status.get('cost_usd', 0):.2f}",
]

lines.extend(_format_audience_targeting(status))

response_options = status.get("response_options")
if response_options:
lines.append(f"Response options: {response_options}")

errors = status.get("errors", [])
if errors:
lines.append(f"\nErrors ({len(errors)}):")
Expand Down Expand Up @@ -737,6 +762,38 @@ def resume_survey(job_id: str) -> str:
return _run_lifecycle_action("resume", client.resume_job, job_id)


@mcp.tool()
def retry_failed_datapoints(job_id: str, datapoint_indices: list[int] | None = None) -> str:
"""Re-queue failed datapoints on a survey.

Each retried datapoint reserves credit again, the same way the original
submission did — only call this when the failures are worth recovering.

Args:
job_id: The job ID returned by create_survey.
datapoint_indices: Specific failed indices to retry. Omit to retry
every failed datapoint in the survey.
"""
client = _get_client()
try:
result = client.retry_job(job_id, datapoint_indices=datapoint_indices)
except DatapointAPIError as e:
if e.status_code == 400:
return f"Cannot retry: {e.detail}"
if e.status_code == 404:
return f"Survey not found: {job_id}"
return f"Error: {e.detail}"

retried = result.get("retried", 0)
indices = result.get("datapoint_indices", [])
if retried == 0:
return f"No failed datapoints to retry on survey {job_id}."
return (
f"Re-queued {_pluralize(retried, 'datapoint')} on survey {job_id}: "
f"{indices}. Use check_survey to monitor progress."
)


# ---------------------------------------------------------------------------
# Tool: get_survey_responses
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -785,11 +842,6 @@ def _format_response_row(r: dict) -> str:
return f"{annotator} @ {timestamp}: {response_text!r}{rt_str}{loc_str}{walk_str}"


def _pluralize(n: int, word: str) -> str:
"""English plural for `word` based on count `n` (no irregular forms)."""
return f"{n} {word}{'s' if n != 1 else ''}"


def _format_responses_page(
data: dict,
job_id: str,
Expand Down Expand Up @@ -978,12 +1030,12 @@ def check_balance() -> str:
def add_credits(product_id: str | None = None) -> str:
"""Open a checkout link to purchase Datapoint AI credits.

Returns a Polar.sh checkout URL. The user completes payment in their
browser; the credits land on their account once Polar's webhook fires.
Returns a hosted checkout URL. The user completes payment in their
browser; credits land on their account once payment confirms.

Args:
product_id: Optional Polar product ID. Omit to use the default credit
bundle configured on the server.
product_id: Optional product identifier. Omit to use the default
credit bundle configured on the server.
"""
client = _get_client()

Expand Down
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "datapoint-mcp"
version = "0.1.5"
version = "0.2.0"
description = "MCP server for creating human evaluation surveys via Datapoint AI"
license = "MIT"
license-files = ["LICENSE"]
Expand All @@ -14,6 +14,13 @@ dependencies = [
"httpx>=0.27.0",
]

[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.21",
"ruff>=0.5",
]

[project.scripts]
datapoint-mcp = "mcp_server.server:main"

Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "io.github.impel-intelligence/datapoint-mcp",
"title": "Datapoint",
"description": "MCP server for human-in-the-loop surveys, A/B preference tests, ratings, and rankings. Get real human opinions on text, images, audio, and video — directly from any MCP client.",
"version": "0.1.4",
"version": "0.2.0",
"repository": {
"url": "https://github.com/impel-intelligence/datapoint-mcp",
"source": "github"
Expand All @@ -14,7 +14,7 @@
"registryType": "pypi",
"registryBaseUrl": "https://pypi.org",
"identifier": "datapoint-mcp",
"version": "0.1.4",
"version": "0.2.0",
"runtimeHint": "uvx",
"transport": {
"type": "stdio"
Expand Down
Loading
Loading