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.2.2",
"version": "0.3.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
7 changes: 6 additions & 1 deletion mcp_server/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,12 @@ def upload_media(self, file_path: str) -> dict:
)

if resp.status_code >= 400:
raise DatapointAPIError(resp.status_code, resp.text)
try:
body = resp.json()
detail = body.get("detail", body.get("message", resp.text))
except Exception:
detail = resp.text
raise DatapointAPIError(resp.status_code, detail)

return resp.json()

Expand Down
87 changes: 56 additions & 31 deletions mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,35 @@ def setup() -> str:
# ---------------------------------------------------------------------------


def _describe_upload_error(e: DatapointAPIError) -> str:
"""Render a user-friendly message for a media upload error."""
if e.status_code == 413 and isinstance(e.detail, dict) and e.detail.get("code") == "media_too_large":
max_bytes = e.detail.get("max_bytes")
def _describe_media_error(e: DatapointAPIError) -> str:
"""Render a user-friendly message for a media rejection (upload or create).

The server returns a structured ``{"code": ...}`` body for media rejections;
map the known codes to short messages and fall back to the raw detail.
"""
detail = e.detail
if not isinstance(detail, dict):
return str(detail)

code = detail.get("code")
if code == "media_too_large":
max_bytes = detail.get("max_bytes")
if max_bytes:
return f"file exceeds the upload cap ({max_bytes / 1_048_576:.0f} MB max)"
return "file exceeds the upload cap"
return str(e.detail)
if code == "unsupported_media_extension":
ext = detail.get("extension")
return f"unsupported file type ({ext})" if ext else "unsupported file type"
if code == "media_type_mismatch":
return "file contents do not match its extension"
if code == "invalid_svg":
reason = detail.get("reason")
return f"invalid SVG: {reason}" if reason else "invalid SVG"
if code == "content_blocked":
reason = detail.get("reason") or "content violates platform policy"
return f"content review rejected this file: {reason}"
# Unknown structured error — prefer a human field over dumping the raw dict.
return str(detail.get("message") or detail.get("reason") or detail)


@mcp.tool()
Expand Down Expand Up @@ -160,7 +181,7 @@ def upload_media(file_paths: list[str]) -> str:
except FileNotFoundError as e:
errors.append(f"{path}: {e}")
except DatapointAPIError as e:
errors.append(f"{path}: {_describe_upload_error(e)}")
errors.append(f"{path}: {_describe_media_error(e)}")

total = len(file_paths)
files_failed = len(errors)
Expand Down Expand Up @@ -207,9 +228,6 @@ def upload_media(file_paths: list[str]) -> str:
"country_name",
"region",
"city",
"postal",
"timezone",
"is_eu",
}


Expand Down Expand Up @@ -250,7 +268,7 @@ def _pluralize(n: int, word: str) -> str:
return f"{n} {word}{'s' if n != 1 else ''}"


def _format_standalone_plan_output(plan: dict, summary: str, cost: float, warnings: list) -> list[str]:
def _format_standalone_plan_output(plan: dict, summary: str, cost: int, warnings: list) -> list[str]:
"""Render a standalone (non-chain) plan for user confirmation."""
lines = [
"Survey Plan Ready",
Expand All @@ -259,7 +277,7 @@ def _format_standalone_plan_output(plan: dict, summary: str, cost: float, warnin
f"Task type: {plan.get('task_type', '?')}",
f"Datapoints: {len(plan.get('datapoints', []))}",
f"Responses per datapoint: {plan.get('max_responses_per_datapoint', '?')}",
f"Estimated cost: ${cost:.2f}",
f"Estimated cost: {_pluralize(cost, 'credit')}",
]
lines.extend(_format_audience_targeting(plan))
if warnings:
Expand All @@ -269,14 +287,14 @@ def _format_standalone_plan_output(plan: dict, summary: str, cost: float, warnin
lines.append(f" - {w}")
lines.append("")
lines.append(
f"⚠ Creating this survey will spend ${cost:.2f} and kick off real paid "
f"⚠ Creating this survey will spend {_pluralize(cost, 'credit')} and kick off real paid "
"human work within seconds. Show the summary and cost above to the user "
"and WAIT for explicit confirmation before calling `create_survey`."
)
return lines


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

Expand All @@ -296,7 +314,7 @@ def _format_chain_plan_output(plan: dict, summary: str, cost: float, warnings: l
f"Summary: {summary}",
f"Chain length: {len(steps)} step(s) in order",
f"Datapoints: {len(datapoints)} (each answered by up to {max_resp} annotators)",
f"Estimated cost: ${cost:.2f} (upper bound — responses ended early via skip_if cost less)",
f"Estimated cost: {_pluralize(cost, 'credit')} (upper bound — responses ended early via skip_if cost less)",
]
lines.extend(_format_audience_targeting(plan))
lines.append("")
Expand All @@ -321,7 +339,7 @@ def _format_chain_plan_output(plan: dict, summary: str, cost: float, warnings: l

lines.append("")
lines.append(
f"⚠ Creating this chain survey will reserve up to ${cost:.2f} (the upper bound — "
f"⚠ Creating this chain survey will reserve up to {_pluralize(cost, 'credit')} (the upper bound — "
"responses ended early by a step's `skip_if` rule cost proportionally less)."
)
lines.append(
Expand Down Expand Up @@ -385,7 +403,7 @@ def plan_survey(description: str, max_responses: int = 10) -> str:

plan = result.get("plan", {})
summary = result.get("summary", "")
cost = result.get("estimated_cost_usd", 0)
cost = result.get("estimated_cost_credits", 0)
warnings = result.get("warnings", [])

if plan.get("steps"):
Expand Down Expand Up @@ -464,9 +482,9 @@ def create_survey(plan: dict) -> str:
except DatapointAPIError as e:
if e.status_code == 402:
if isinstance(e.detail, dict):
needed = e.detail.get("needed_usd", 0)
available = e.detail.get("available_usd", 0)
details = f"Need ${needed:.2f}, have ${available:.2f}"
needed = e.detail.get("needed_credits", 0)
available = e.detail.get("available_credits", 0)
details = f"Need {_pluralize(needed, 'credit')}, have {_pluralize(available, 'credit')}"
else:
details = str(e.detail)
return (
Expand All @@ -484,6 +502,11 @@ def create_survey(plan: dict) -> str:
return msg
if e.status_code == 503:
return f"Service temporarily unavailable: {e.detail}"
if isinstance(e.detail, dict):
# Structured rejection (e.g. unsupported_media_extension,
# media_type_mismatch): render it like an upload error instead of
# dumping the raw dict, which would leak the rejected media URL.
return f"Couldn't create the survey: {_describe_media_error(e)}"
return f"Error creating survey: {e.detail}"

lines = [
Expand All @@ -492,12 +515,12 @@ def create_survey(plan: dict) -> str:
f" Job ID: {result['job_id']}",
f" Status: {result['status']}",
f" Datapoints: {result['total_datapoints']}",
f" Estimated cost: ${result.get('estimated_cost_usd', 0):.2f}",
f" Estimated cost: {_pluralize(result.get('estimated_cost_credits', 0), 'credit')}",
]

try:
balance = client.get_balance()
lines.append(f" Remaining balance: ${balance['available_usd']:.2f}")
lines.append(f" Remaining balance: {_pluralize(balance['available_credits'], 'credit')}")
except DatapointAPIError:
pass

Expand Down Expand Up @@ -581,7 +604,7 @@ def _format_check_survey(status: dict, results_data: dict | None, results_error:
f"active: {ready - completed}, "
f"completed: {completed}, "
f"failed: {status.get('failed_datapoints', 0)}",
f"Cost so far: ${status.get('cost_usd', 0):.2f}",
f"Cost so far: {_pluralize(status.get('cost_credits', 0), 'credit')}",
]

lines.extend(_format_audience_targeting(status))
Expand Down Expand Up @@ -713,16 +736,16 @@ def list_surveys() -> str:
def _format_lifecycle_response(verb: str, response: dict) -> str:
"""Render the response from a pause/resume/cancel call.

``cost_usd`` is rendered when the backend includes it (cancel returns the
``cost_credits`` is rendered when the backend includes it (cancel returns the
settled cost; pause/resume don't).
"""
job_id = response.get("job_id", "?")
status = response.get("status", "?")
is_paused = response.get("is_paused", False)
line = f"{verb} survey {job_id}. Status: {status}, is_paused: {str(is_paused).lower()}."
cost = response.get("cost_usd")
cost = response.get("cost_credits")
if cost is not None:
line += f" Settled cost: ${cost:.2f}."
line += f" Settled cost: {_pluralize(cost, 'credit')}."
return line


Expand Down Expand Up @@ -863,7 +886,9 @@ def _format_response_row(r: dict) -> str:
"""Render one raw-response row as a single chat-display string."""
annotator = (r.get("annotator_id") or "?")[:8]
timestamp = r.get("timestamp") or "?"
response_text = r.get("response")
# `response_label` is the backend's display form (e.g. a multiple-choice
# opt-id resolved to its option text); fall back to the raw `response`.
response_text = r.get("response_label") or r.get("response")
rt_ms = r.get("response_time_ms")
rt_str = f" ({rt_ms / 1000:.1f}s)" if rt_ms is not None else ""
location = _format_annotator_location(r)
Expand Down Expand Up @@ -1035,9 +1060,9 @@ def check_balance() -> str:

lines = [
"Account balance:",
f" Available: ${balance['available_usd']:.2f}",
f" Reserved (in-flight surveys): ${balance['reserved_usd']:.2f}",
f" Total purchased: ${balance['total_purchased_usd']:.2f}",
f" Available: {_pluralize(balance['available_credits'], 'credit')}",
f" Reserved (in-flight surveys): {_pluralize(balance['reserved_credits'], 'credit')}",
f" Total purchased: {_pluralize(balance['total_purchased_credits'], 'credit')}",
]

# Pricing is best-effort; older deployments without /billing/pricing simply omit the rate.
Expand All @@ -1046,8 +1071,8 @@ def check_balance() -> str:
except DatapointAPIError:
pricing = None

if pricing is not None and pricing.get("per_response_usd") is not None:
lines.append(f" Per-response rate: ${pricing['per_response_usd']:.4f}")
if pricing is not None and pricing.get("credits_per_response") is not None:
lines.append(f" Per-response rate: {_pluralize(pricing['credits_per_response'], 'credit')}")

return "\n".join(lines)

Expand Down
2 changes: 1 addition & 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.2.2"
version = "0.3.0"
description = "MCP server for creating human evaluation surveys via Datapoint AI"
license = "MIT"
license-files = ["LICENSE"]
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.2.2",
"version": "0.3.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.2.2",
"version": "0.3.0",
"runtimeHint": "uvx",
"transport": {
"type": "stdio"
Expand Down
22 changes: 20 additions & 2 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@

from __future__ import annotations

import tempfile
import unittest
from unittest import mock

from mcp_server.client import DatapointClient
from mcp_server.client import DatapointAPIError, DatapointClient


def _make_client() -> DatapointClient:
Expand Down Expand Up @@ -61,11 +62,28 @@ def test_cancel_job_posts_to_cancel_path(self):

def test_cancel_job_returns_request_payload(self):
client = _make_client()
payload = {"job_id": "job_x", "status": "cancelled", "is_paused": True, "cost_usd": 1.23}
payload = {"job_id": "job_x", "status": "cancelled", "is_paused": True, "cost_credits": 123}
with mock.patch.object(client, "_request", return_value=payload):
result = client.cancel_job("job_x")
self.assertEqual(result, payload)


class UploadMediaErrorParsingTests(unittest.TestCase):
def test_structured_error_body_becomes_dict_detail(self):
"""upload_media must parse a JSON error body into a dict detail so the
renderer's structured-error branches fire (it used to pass raw text)."""
client = _make_client()
resp = mock.Mock(status_code=413)
resp.json.return_value = {"detail": {"code": "media_too_large", "max_bytes": 20971520}}
with tempfile.NamedTemporaryFile(suffix=".mp4") as tf:
tf.write(b"data")
tf.flush()
with mock.patch.object(client._http, "post", return_value=resp):
with self.assertRaises(DatapointAPIError) as ctx:
client.upload_media(tf.name)
self.assertEqual(ctx.exception.status_code, 413)
self.assertEqual(ctx.exception.detail, {"code": "media_too_large", "max_bytes": 20971520})


if __name__ == "__main__":
unittest.main()
Loading
Loading