From 99c37f4cfb758d2c2848acdc87f36b25aef0c472 Mon Sep 17 00:00:00 2001 From: Shayanide Date: Mon, 18 May 2026 03:37:00 +0000 Subject: [PATCH 1/5] Render survey costs in credits --- .claude-plugin/plugin.json | 2 +- mcp_server/server.py | 42 +++++++++++++++++------------------ pyproject.toml | 2 +- server.json | 4 ++-- tests/test_client.py | 2 +- tests/test_error_mapping.py | 30 ++++++++++++------------- tests/test_plan_formatters.py | 32 +++++++++++++------------- tests/test_renderers.py | 23 ++++++++++++------- 8 files changed, 72 insertions(+), 65 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 2e8ace2..4a9f69d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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", diff --git a/mcp_server/server.py b/mcp_server/server.py index 835af49..9594dd2 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -250,7 +250,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", @@ -259,7 +259,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: @@ -269,14 +269,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. @@ -296,7 +296,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("") @@ -321,7 +321,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( @@ -385,7 +385,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"): @@ -464,9 +464,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 ( @@ -492,12 +492,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 @@ -581,7 +581,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)) @@ -713,16 +713,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 @@ -1035,9 +1035,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. @@ -1046,8 +1046,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) diff --git a/pyproject.toml b/pyproject.toml index a249ea1..6ff506f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/server.json b/server.json index d209f68..22c25b9 100644 --- a/server.json +++ b/server.json @@ -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" @@ -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" diff --git a/tests/test_client.py b/tests/test_client.py index 69ce71c..83903f5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -61,7 +61,7 @@ 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) diff --git a/tests/test_error_mapping.py b/tests/test_error_mapping.py index ad4a9e5..90c2d2c 100644 --- a/tests/test_error_mapping.py +++ b/tests/test_error_mapping.py @@ -162,7 +162,7 @@ def test_cancel_success_renders_settled_cost(self): "job_id": "job_x", "status": "cancelled", "is_paused": True, - "cost_usd": 2.50, + "cost_credits": 250, } with mock.patch("mcp_server.server._get_client", return_value=client): out = cancel_survey("job_x") @@ -170,7 +170,7 @@ def test_cancel_success_renders_settled_cost(self): self.assertIn("Cancelled survey job_x", out) self.assertIn("Status: cancelled", out) self.assertIn("is_paused: true", out) - self.assertIn("Settled cost: $2.50.", out) + self.assertIn("Settled cost: 250 credits.", out) def test_cancel_404_renders_not_found(self): client = mock.Mock() @@ -211,7 +211,7 @@ def _status(self, **overrides) -> dict: "failed_datapoints": 0, "total_responses": 10, "max_responses_per_datapoint": 2, - "cost_usd": 0.50, + "cost_credits": 50, "errors": [], "is_paused": False, } @@ -309,28 +309,28 @@ def test_503_queue_conflict_detail_preserved(self): self.assertIn("Service temporarily unavailable", out) self.assertIn("retry with a new name", out) - def test_402_insufficient_balance_unchanged(self): - err = DatapointAPIError(402, {"needed_usd": 5.0, "available_usd": 1.5}) + def test_402_insufficient_balance_renders_credit_amounts(self): + err = DatapointAPIError(402, {"needed_credits": 500, "available_credits": 150}) client = self._client_raising(err) with mock.patch("mcp_server.server._get_client", return_value=client): out = create_survey({"datapoints": [], "task_type": "comparison"}) self.assertIn("Insufficient balance", out) - self.assertIn("$5.00", out) - self.assertIn("$1.50", out) + self.assertIn("Need 500 credits", out) + self.assertIn("have 150 credits", out) class CheckBalanceTests(unittest.TestCase): def _balance_dict(self) -> dict: - return {"available_usd": 12.50, "reserved_usd": 1.25, "total_purchased_usd": 50.0} + return {"available_credits": 1250, "reserved_credits": 125, "total_purchased_credits": 5000} def test_renders_per_response_rate_when_pricing_succeeds(self): client = mock.Mock() client.get_balance.return_value = self._balance_dict() - client.get_pricing.return_value = {"per_response_usd": 0.0500} + client.get_pricing.return_value = {"credits_per_response": 5} with mock.patch("mcp_server.server._get_client", return_value=client): out = check_balance() - self.assertIn("Available: $12.50", out) - self.assertIn("Per-response rate: $0.0500", out) + self.assertIn("Available: 1250 credits", out) + self.assertIn("Per-response rate: 5 credits", out) def test_omits_rate_line_when_pricing_404s(self): client = mock.Mock() @@ -338,7 +338,7 @@ def test_omits_rate_line_when_pricing_404s(self): client.get_pricing.side_effect = DatapointAPIError(404, "Not Found") with mock.patch("mcp_server.server._get_client", return_value=client): out = check_balance() - self.assertIn("Available: $12.50", out) + self.assertIn("Available: 1250 credits", out) self.assertNotIn("Per-response rate", out) def test_omits_rate_line_when_pricing_response_missing_field(self): @@ -349,10 +349,10 @@ def test_omits_rate_line_when_pricing_response_missing_field(self): out = check_balance() self.assertNotIn("Per-response rate", out) - def test_omits_rate_line_when_pricing_per_response_usd_null(self): + def test_omits_rate_line_when_pricing_credits_per_response_null(self): client = mock.Mock() client.get_balance.return_value = self._balance_dict() - client.get_pricing.return_value = {"per_response_usd": None} + client.get_pricing.return_value = {"credits_per_response": None} with mock.patch("mcp_server.server._get_client", return_value=client): out = check_balance() self.assertNotIn("Per-response rate", out) @@ -363,7 +363,7 @@ def test_omits_rate_line_on_non_404_pricing_error(self): client.get_pricing.side_effect = DatapointAPIError(500, "internal error") with mock.patch("mcp_server.server._get_client", return_value=client): out = check_balance() - self.assertIn("Available: $12.50", out) + self.assertIn("Available: 1250 credits", out) self.assertNotIn("Per-response rate", out) self.assertNotIn("internal error", out) diff --git a/tests/test_plan_formatters.py b/tests/test_plan_formatters.py index b66690c..8f705ce 100644 --- a/tests/test_plan_formatters.py +++ b/tests/test_plan_formatters.py @@ -24,16 +24,16 @@ def test_standalone_shows_task_type_and_cost(self): "datapoints": [{}, {}, {}], "max_responses_per_datapoint": 10, } - out = "\n".join(_format_standalone_plan_output(plan, "A comparison survey.", 0.09, [])) + out = "\n".join(_format_standalone_plan_output(plan, "A comparison survey.", 9, [])) self.assertIn("Survey Plan Ready", out) self.assertIn("Task type: comparison", out) self.assertIn("Datapoints: 3", out) self.assertIn("Responses per datapoint: 10", out) - self.assertIn("Estimated cost: $0.09", out) + self.assertIn("Estimated cost: 9 credits", out) self.assertIn("WAIT for explicit confirmation", out) def test_standalone_warnings_rendered(self): - out = "\n".join(_format_standalone_plan_output({}, "s", 0.0, ["sample too small", "watch for bias"])) + out = "\n".join(_format_standalone_plan_output({}, "s", 0, ["sample too small", "watch for bias"])) self.assertIn("Warnings:", out) self.assertIn("sample too small", out) self.assertIn("watch for bias", out) @@ -64,48 +64,48 @@ def _make_chain_plan(**overrides) -> dict: class ChainFormatterTests(unittest.TestCase): def test_chain_header_shows_length_and_datapoint_count(self): - out = "\n".join(_format_chain_plan_output(_make_chain_plan(), "Chain summary", 0.48, [])) + out = "\n".join(_format_chain_plan_output(_make_chain_plan(), "Chain summary", 48, [])) self.assertIn("Chain Survey Plan Ready", out) self.assertIn("Chain length: 2 step(s) in order", out) self.assertIn("Datapoints: 2 (each answered by up to 8 annotators)", out) - self.assertIn("Estimated cost: $0.48 (upper bound — responses ended early via skip_if cost less)", out) + self.assertIn("Estimated cost: 48 credits (upper bound — responses ended early via skip_if cost less)", out) def test_chain_structure_renders_steps_in_list_order(self): - out = "\n".join(_format_chain_plan_output(_make_chain_plan(), "s", 0.1, [])) + out = "\n".join(_format_chain_plan_output(_make_chain_plan(), "s", 10, [])) q1_pos = out.index("1. [multiple_choice]") q2_pos = out.index("2. [rating]") self.assertLess(q1_pos, q2_pos, "chain steps must render in list order") def test_skip_if_predicate_rendered_inline(self): - out = "\n".join(_format_chain_plan_output(_make_chain_plan(), "s", 0.1, [])) + out = "\n".join(_format_chain_plan_output(_make_chain_plan(), "s", 10, [])) self.assertIn("↳ skip_if:", out) self.assertIn('{"==": [{"var": "choice"}, "opt_no"]}', out) def test_no_skip_if_on_any_step_skips_section(self): plan = _make_chain_plan() plan["steps"][0].pop("skip_if", None) - out = "\n".join(_format_chain_plan_output(plan, "s", 0.1, [])) + out = "\n".join(_format_chain_plan_output(plan, "s", 10, [])) self.assertNotIn("↳ skip_if:", out) def test_cost_upper_bound_note_in_confirmation(self): - out = "\n".join(_format_chain_plan_output(_make_chain_plan(), "s", 0.1, [])) + out = "\n".join(_format_chain_plan_output(_make_chain_plan(), "s", 10, [])) self.assertIn("upper bound", out) - self.assertIn("$0.10", out) + self.assertIn("10 credits", out) def test_chain_warnings_passthrough(self): out = "\n".join( _format_chain_plan_output( _make_chain_plan(), "s", - 0.1, + 10, ["sample size may be small for the screening step"], ) ) self.assertIn("sample size may be small for the screening step", out) def test_response_options_inline_with_question(self): - out = "\n".join(_format_chain_plan_output(_make_chain_plan(), "s", 0.1, [])) + out = "\n".join(_format_chain_plan_output(_make_chain_plan(), "s", 10, [])) self.assertIn("options: {'mode': 'single'}", out) self.assertIn("options: {'scale': [1, 2, 3, 4, 5]}", out) @@ -210,13 +210,13 @@ def test_standalone_renders_targeting_when_present(self): "annotator_filter": {"country": ["US"]}, "annotator_distribution": ["country"], } - out = "\n".join(_format_standalone_plan_output(plan, "s", 0.03, [])) + out = "\n".join(_format_standalone_plan_output(plan, "s", 3, [])) self.assertIn("Targeting: country in [US]", out) self.assertIn("Balanced by: country", out) def test_standalone_omits_targeting_when_absent(self): plan = {"task_type": "rating", "datapoints": [{}], "max_responses_per_datapoint": 10} - out = "\n".join(_format_standalone_plan_output(plan, "s", 0.03, [])) + out = "\n".join(_format_standalone_plan_output(plan, "s", 3, [])) self.assertNotIn("Targeting:", out) self.assertNotIn("Balanced by:", out) @@ -224,13 +224,13 @@ def test_standalone_omits_targeting_when_absent(self): class ChainPlanWithTargetingTests(unittest.TestCase): def test_chain_renders_targeting_above_chain_structure(self): plan = _make_chain_plan(annotator_filter={"is_eu": [True]}) - out = "\n".join(_format_chain_plan_output(plan, "s", 0.03, [])) + out = "\n".join(_format_chain_plan_output(plan, "s", 3, [])) targeting_pos = out.index("Targeting:") structure_pos = out.index("Chain structure:") self.assertLess(targeting_pos, structure_pos) def test_chain_omits_targeting_when_absent(self): - out = "\n".join(_format_chain_plan_output(_make_chain_plan(), "s", 0.03, [])) + out = "\n".join(_format_chain_plan_output(_make_chain_plan(), "s", 3, [])) self.assertNotIn("Targeting:", out) self.assertNotIn("Balanced by:", out) diff --git a/tests/test_renderers.py b/tests/test_renderers.py index b04f478..825628d 100644 --- a/tests/test_renderers.py +++ b/tests/test_renderers.py @@ -33,7 +33,7 @@ def _make_status(**overrides) -> dict: "failed_datapoints": 0, "total_responses": 0, "max_responses_per_datapoint": 10, - "cost_usd": 0.0, + "cost_credits": 0, "errors": [], } base.update(overrides) @@ -49,7 +49,7 @@ def _status(self, **overrides) -> dict: ready_datapoints=8, completed_datapoints=4, total_responses=42, - cost_usd=1.23, + cost_credits=123, **overrides, ) @@ -73,7 +73,7 @@ def test_renders_status_header(self): self.assertIn("Survey: logo-pref", out) self.assertIn("Status: active", out) self.assertIn("Progress: 42/100 responses", out) - self.assertIn("Cost so far: $1.23", out) + self.assertIn("Cost so far: 123 credits", out) def test_renders_comparison_results(self): out = _format_check_survey(self._status(), self._comparison_results()) @@ -161,7 +161,7 @@ def _status(self) -> dict: ready_datapoints=1, completed_datapoints=1, total_responses=18, - cost_usd=0.36, + cost_credits=36, ) def _chain_results(self) -> dict: @@ -589,19 +589,26 @@ def test_resumed_response(self): def test_cancelled_response_includes_settled_cost(self): out = _format_lifecycle_response( "Cancelled", - {"job_id": "job_z", "status": "cancelled", "is_paused": True, "cost_usd": 1.23}, + {"job_id": "job_z", "status": "cancelled", "is_paused": True, "cost_credits": 123}, ) self.assertEqual( out, - "Cancelled survey job_z. Status: cancelled, is_paused: true. Settled cost: $1.23.", + "Cancelled survey job_z. Status: cancelled, is_paused: true. Settled cost: 123 credits.", ) def test_cancelled_response_with_zero_cost(self): out = _format_lifecycle_response( "Cancelled", - {"job_id": "job_z", "status": "cancelled", "is_paused": True, "cost_usd": 0.0}, + {"job_id": "job_z", "status": "cancelled", "is_paused": True, "cost_credits": 0}, ) - self.assertIn("Settled cost: $0.00.", out) + self.assertIn("Settled cost: 0 credits.", out) + + def test_cancelled_response_with_singular_cost(self): + out = _format_lifecycle_response( + "Cancelled", + {"job_id": "job_z", "status": "cancelled", "is_paused": True, "cost_credits": 1}, + ) + self.assertIn("Settled cost: 1 credit.", out) def test_pause_response_omits_cost_when_absent(self): out = _format_lifecycle_response("Paused", {"job_id": "job_x", "status": "active", "is_paused": True}) From cbae27cffaf695658daf217fbdf04e64b87c3430 Mon Sep 17 00:00:00 2001 From: Shayanide Date: Thu, 28 May 2026 00:39:05 +0000 Subject: [PATCH 2/5] Trim unsupported geography keys from audience-targeting hints --- mcp_server/server.py | 3 --- tests/test_error_mapping.py | 4 ++-- tests/test_plan_formatters.py | 21 ++++++++++++++------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/mcp_server/server.py b/mcp_server/server.py index 9594dd2..6809d43 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -207,9 +207,6 @@ def upload_media(file_paths: list[str]) -> str: "country_name", "region", "city", - "postal", - "timezone", - "is_eu", } diff --git a/tests/test_error_mapping.py b/tests/test_error_mapping.py index 90c2d2c..50faf88 100644 --- a/tests/test_error_mapping.py +++ b/tests/test_error_mapping.py @@ -231,12 +231,12 @@ def test_renders_targeting_when_filter_present(self): def test_renders_distribution_when_present(self): client = mock.Mock() client.get_job_status.return_value = self._status( - annotator_distribution=["country", "is_eu"], + annotator_distribution=["country", "region"], ) client.get_job_results.return_value = {"results": [], "task_type": "comparison"} with mock.patch("mcp_server.server._get_client", return_value=client): out = check_survey("job_x") - self.assertIn("Balanced by: country, is_eu", out) + self.assertIn("Balanced by: country, region", out) def test_renders_response_options_when_present(self): client = mock.Mock() diff --git a/tests/test_plan_formatters.py b/tests/test_plan_formatters.py index 8f705ce..5b98e2e 100644 --- a/tests/test_plan_formatters.py +++ b/tests/test_plan_formatters.py @@ -150,13 +150,13 @@ def test_filter_with_multiple_columns_joined_with_semicolons(self): self.assertIn(";", out[0]) def test_distribution_only(self): - out = _format_audience_targeting({"annotator_distribution": ["country", "is_eu"]}) - self.assertEqual(out, ["Balanced by: country, is_eu"]) + out = _format_audience_targeting({"annotator_distribution": ["country", "region"]}) + self.assertEqual(out, ["Balanced by: country, region"]) def test_filter_and_distribution_both_rendered(self): out = _format_audience_targeting( { - "annotator_filter": {"is_eu": [True]}, + "annotator_filter": {"region": ["CA"]}, "annotator_distribution": ["country"], } ) @@ -178,7 +178,7 @@ def test_privacy_columns_marked_limited_support(self): def test_supported_filter_columns_have_no_annotation(self): out = _format_audience_targeting( - {"annotator_filter": {"country": ["US"], "is_eu": [True]}} + {"annotator_filter": {"country": ["US"], "region": ["CA"]}} ) self.assertNotIn("(limited support)", out[0]) @@ -197,8 +197,15 @@ def test_distribution_marks_unsupported_columns_only(self): self.assertEqual(out, ["Balanced by: country, asn (limited support)"]) def test_supported_distribution_columns_have_no_annotation(self): - out = _format_audience_targeting({"annotator_distribution": ["country", "is_eu"]}) - self.assertEqual(out, ["Balanced by: country, is_eu"]) + out = _format_audience_targeting({"annotator_distribution": ["country", "region"]}) + self.assertEqual(out, ["Balanced by: country, region"]) + + def test_dropped_geo_keys_marked_limited_support(self): + """is_eu / postal / timezone were removed from the client-supported set, + so a hand-edited plan using them must surface the limited-support hint.""" + for key, val in (("postal", ["94107"]), ("timezone", ["PST"]), ("is_eu", [True])): + out = _format_audience_targeting({"annotator_filter": {key: val}}) + self.assertIn("(limited support)", out[0], key) class StandalonePlanWithTargetingTests(unittest.TestCase): @@ -223,7 +230,7 @@ def test_standalone_omits_targeting_when_absent(self): class ChainPlanWithTargetingTests(unittest.TestCase): def test_chain_renders_targeting_above_chain_structure(self): - plan = _make_chain_plan(annotator_filter={"is_eu": [True]}) + plan = _make_chain_plan(annotator_filter={"region": ["CA"]}) out = "\n".join(_format_chain_plan_output(plan, "s", 3, [])) targeting_pos = out.index("Targeting:") structure_pos = out.index("Chain structure:") From 5aa986f682e927938f9fe2e979f667a8e90ea545 Mon Sep 17 00:00:00 2001 From: Shayanide Date: Thu, 28 May 2026 00:39:41 +0000 Subject: [PATCH 3/5] Render response labels in raw survey responses --- mcp_server/server.py | 4 +++- tests/test_renderers.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/mcp_server/server.py b/mcp_server/server.py index 6809d43..01074f3 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -860,7 +860,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) diff --git a/tests/test_renderers.py b/tests/test_renderers.py index 825628d..bd73ff2 100644 --- a/tests/test_renderers.py +++ b/tests/test_renderers.py @@ -269,6 +269,21 @@ def test_includes_annotator_timestamp_response(self): self.assertIn("'A'", out) self.assertIn("(4.8s)", out) + def test_prefers_response_label_over_raw_response(self): + row = { + "annotator_id": "anon_x", + "timestamp": "t", + "response": "opt_2", + "response_label": "Strongly agree", + } + out = _format_response_row(row) + self.assertIn("Strongly agree", out) + self.assertNotIn("opt_2", out) + + def test_falls_back_to_raw_response_when_label_absent(self): + out = _format_response_row({"annotator_id": "anon_x", "timestamp": "t", "response": "A"}) + self.assertIn("'A'", out) + def test_response_time_converted_to_seconds(self): out = _format_response_row({"response_time_ms": 12345, "response": "x"}) self.assertIn("(12.3s)", out) From ea1f44cb75169471f798fb026412b8f117962ec3 Mon Sep 17 00:00:00 2001 From: Shayanide Date: Thu, 28 May 2026 00:41:08 +0000 Subject: [PATCH 4/5] Surface clearer media upload errors --- mcp_server/client.py | 7 ++++++- mcp_server/server.py | 29 +++++++++++++++++++++++++---- tests/test_client.py | 20 +++++++++++++++++++- tests/test_error_mapping.py | 20 ++++++++++++++++++++ 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/mcp_server/client.py b/mcp_server/client.py index 05c0c08..949acb7 100644 --- a/mcp_server/client.py +++ b/mcp_server/client.py @@ -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() diff --git a/mcp_server/server.py b/mcp_server/server.py index 01074f3..718f27c 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -109,13 +109,34 @@ 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") + """Render a user-friendly message for a media upload error. + + 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() diff --git a/tests/test_client.py b/tests/test_client.py index 83903f5..075d5c6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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: @@ -67,5 +68,22 @@ def test_cancel_job_returns_request_payload(self): 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() diff --git a/tests/test_error_mapping.py b/tests/test_error_mapping.py index 50faf88..1f0934b 100644 --- a/tests/test_error_mapping.py +++ b/tests/test_error_mapping.py @@ -42,6 +42,26 @@ def test_413_without_dict_detail_falls_through(self): err = DatapointAPIError(413, "Payload Too Large") self.assertEqual(_describe_upload_error(err), "Payload Too Large") + def test_unsupported_extension_names_the_type(self): + err = DatapointAPIError( + 400, {"code": "unsupported_media_extension", "filename": "a.heic", "extension": ".heic"} + ) + self.assertEqual(_describe_upload_error(err), "unsupported file type (.heic)") + + def test_media_type_mismatch_is_explained(self): + err = DatapointAPIError( + 400, {"code": "media_type_mismatch", "extension": ".png", "declared": "image/gif"} + ) + self.assertIn("do not match", _describe_upload_error(err)) + + def test_invalid_svg_includes_reason(self): + err = DatapointAPIError(400, {"code": "invalid_svg", "filename": "logo.svg", "reason": "embedded script"}) + self.assertEqual(_describe_upload_error(err), "invalid SVG: embedded script") + + def test_content_blocked_includes_reason(self): + err = DatapointAPIError(422, {"code": "content_blocked", "reason": "Depicts violence."}) + self.assertIn("content review rejected this file: Depicts violence.", _describe_upload_error(err)) + class UploadMediaErrorTests(unittest.TestCase): def test_413_renders_friendly_message_in_failed_block(self): From 00c8acbb11cd10efc5bccbeb26e1b2f0ae869d37 Mon Sep 17 00:00:00 2001 From: Shayanide Date: Thu, 28 May 2026 01:18:47 +0000 Subject: [PATCH 5/5] Map create_survey media rejections to clear messages --- mcp_server/server.py | 11 ++++++++--- tests/test_error_mapping.py | 35 ++++++++++++++++++++++++++--------- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/mcp_server/server.py b/mcp_server/server.py index 718f27c..3f5c5d3 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -108,8 +108,8 @@ def setup() -> str: # --------------------------------------------------------------------------- -def _describe_upload_error(e: DatapointAPIError) -> str: - """Render a user-friendly message for a media upload error. +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. @@ -181,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) @@ -502,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 = [ diff --git a/tests/test_error_mapping.py b/tests/test_error_mapping.py index 1f0934b..3a39143 100644 --- a/tests/test_error_mapping.py +++ b/tests/test_error_mapping.py @@ -12,7 +12,7 @@ from mcp_server.client import DatapointAPIError from mcp_server.server import ( - _describe_upload_error, + _describe_media_error, cancel_survey, check_balance, check_survey, @@ -26,41 +26,41 @@ class DescribeUploadErrorTests(unittest.TestCase): def test_413_media_too_large_renders_human_cap(self): err = DatapointAPIError(413, {"code": "media_too_large", "max_bytes": 20 * 1024 * 1024}) self.assertEqual( - _describe_upload_error(err), + _describe_media_error(err), "file exceeds the upload cap (20 MB max)", ) def test_413_without_max_bytes_falls_back(self): err = DatapointAPIError(413, {"code": "media_too_large"}) - self.assertEqual(_describe_upload_error(err), "file exceeds the upload cap") + self.assertEqual(_describe_media_error(err), "file exceeds the upload cap") def test_other_error_falls_through_to_detail_string(self): err = DatapointAPIError(500, "internal error") - self.assertEqual(_describe_upload_error(err), "internal error") + self.assertEqual(_describe_media_error(err), "internal error") def test_413_without_dict_detail_falls_through(self): err = DatapointAPIError(413, "Payload Too Large") - self.assertEqual(_describe_upload_error(err), "Payload Too Large") + self.assertEqual(_describe_media_error(err), "Payload Too Large") def test_unsupported_extension_names_the_type(self): err = DatapointAPIError( 400, {"code": "unsupported_media_extension", "filename": "a.heic", "extension": ".heic"} ) - self.assertEqual(_describe_upload_error(err), "unsupported file type (.heic)") + self.assertEqual(_describe_media_error(err), "unsupported file type (.heic)") def test_media_type_mismatch_is_explained(self): err = DatapointAPIError( 400, {"code": "media_type_mismatch", "extension": ".png", "declared": "image/gif"} ) - self.assertIn("do not match", _describe_upload_error(err)) + self.assertIn("do not match", _describe_media_error(err)) def test_invalid_svg_includes_reason(self): err = DatapointAPIError(400, {"code": "invalid_svg", "filename": "logo.svg", "reason": "embedded script"}) - self.assertEqual(_describe_upload_error(err), "invalid SVG: embedded script") + self.assertEqual(_describe_media_error(err), "invalid SVG: embedded script") def test_content_blocked_includes_reason(self): err = DatapointAPIError(422, {"code": "content_blocked", "reason": "Depicts violence."}) - self.assertIn("content review rejected this file: Depicts violence.", _describe_upload_error(err)) + self.assertIn("content review rejected this file: Depicts violence.", _describe_media_error(err)) class UploadMediaErrorTests(unittest.TestCase): @@ -338,6 +338,23 @@ def test_402_insufficient_balance_renders_credit_amounts(self): self.assertIn("Need 500 credits", out) self.assertIn("have 150 credits", out) + def test_400_media_rejection_is_friendly_and_hides_url(self): + err = DatapointAPIError( + 400, + { + "code": "unsupported_media_extension", + "location": "datapoints[0].media", + "url": "https://example.com/secret-asset.heic", + "extension": ".heic", + }, + ) + client = self._client_raising(err) + with mock.patch("mcp_server.server._get_client", return_value=client): + out = create_survey({"datapoints": [], "task_type": "comparison"}) + self.assertIn("unsupported file type (.heic)", out) + self.assertNotIn("secret-asset", out) + self.assertNotIn("https://", out) + class CheckBalanceTests(unittest.TestCase): def _balance_dict(self) -> dict: