diff --git a/README.md b/README.md index 424440b1..004c0d2a 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ The JS and Python versions are kept in lock-step by `release-please` and guarded ## Extensions Catalog -This repository contains **2 marketplace(s)** with **64 extensions** (54 skills, 10 plugins). +This repository contains **2 marketplace(s)** with **65 extensions** (54 skills, 11 plugins). ### large-codebase @@ -108,7 +108,7 @@ OpenHands skills for interacting, improving, and refactoring large codebases Official skills and plugins for OpenHands — the open-source AI software engineer. -**60 extensions** (52 skills, 8 plugins) +**61 extensions** (52 skills, 9 plugins) | Name | Type | Description | Commands | |------|------|-------------|----------| @@ -166,6 +166,7 @@ Official skills and plugins for OpenHands — the open-source AI software engine | slack-standup-digest | skill | Create an automation that generates an async standup digest from Slack. Searches selected channels for messages since... | `/standup-digest:setup` | | ssh | skill | Establish and manage SSH connections to remote machines, including key generation, configuration, and file transfers.... | — | | swift-linux | skill | Install and configure Swift programming language on Debian Linux for server-side development. Use when building Swift... | — | +| taskmarket-delegation | plugin | Delegate well-scoped work to Taskmarket with bounded spending, explicit user authorization, and human review of submissions. | — | | technical-writing | skill | Write and revise technical explanations in flowing, direct, conversational prose that stays concise without becoming ... | — | | theme-factory | skill | Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc.... | — | | upstream-fork-sync | skill | Keep a long-lived fork in sync with its upstream. Creates a cron automation that fetches upstream changes, rebases lo... | `/upstream-fork-sync:setup` | diff --git a/marketplaces/openhands-extensions.json b/marketplaces/openhands-extensions.json index dfed7887..4bf1015b 100644 --- a/marketplaces/openhands-extensions.json +++ b/marketplaces/openhands-extensions.json @@ -529,6 +529,19 @@ "encryption" ] }, + { + "name": "taskmarket-delegation", + "source": "./plugins/taskmarket-delegation", + "description": "Delegate well-scoped work to Taskmarket with bounded spending, explicit user authorization, and human review of submissions.", + "category": "agent-authoring", + "keywords": [ + "taskmarket", + "delegation", + "bounty", + "agents", + "payments" + ] + }, { "name": "skill-creator", "source": "./skills/skill-creator", diff --git a/plugins/taskmarket-delegation/.claude-plugin b/plugins/taskmarket-delegation/.claude-plugin new file mode 120000 index 00000000..e6b429e8 --- /dev/null +++ b/plugins/taskmarket-delegation/.claude-plugin @@ -0,0 +1 @@ +.plugin diff --git a/plugins/taskmarket-delegation/.codex-plugin b/plugins/taskmarket-delegation/.codex-plugin new file mode 120000 index 00000000..e6b429e8 --- /dev/null +++ b/plugins/taskmarket-delegation/.codex-plugin @@ -0,0 +1 @@ +.plugin diff --git a/plugins/taskmarket-delegation/.mcp.json b/plugins/taskmarket-delegation/.mcp.json new file mode 100644 index 00000000..8dc0e793 --- /dev/null +++ b/plugins/taskmarket-delegation/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "taskmarket": { + "command": "python", + "args": ["scripts/taskmarket_mcp.py"] + } + } +} diff --git a/plugins/taskmarket-delegation/.plugin/plugin.json b/plugins/taskmarket-delegation/.plugin/plugin.json new file mode 100644 index 00000000..43dada87 --- /dev/null +++ b/plugins/taskmarket-delegation/.plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "taskmarket-delegation", + "version": "0.1.0", + "description": "Delegate well-scoped work to Taskmarket with bounded spending and explicit review gates", + "author": { + "name": "OpenHands Extensions Contributors" + }, + "homepage": "https://taskmarket.dev/", + "repository": "https://github.com/OpenHands/extensions", + "license": "MIT", + "keywords": ["taskmarket", "delegation", "bounty", "agents", "payments"] +} diff --git a/plugins/taskmarket-delegation/README.md b/plugins/taskmarket-delegation/README.md new file mode 100644 index 00000000..7580cd9c --- /dev/null +++ b/plugins/taskmarket-delegation/README.md @@ -0,0 +1,30 @@ +# Taskmarket Delegation Plugin + +This OpenHands plugin adds a small MCP bridge for the [Taskmarket](https://taskmarket.dev/) worker market. + +## What it provides + +- Read-only browsing of public tasks and task status. +- A preparation step that validates a description, duration, mode, visibility, and reward ceiling. +- A create step that requires a one-time preparation token plus the exact confirmation `CREATE_TASK`. +- Submission retrieval for human review; the plugin never accepts or rejects work automatically. + +Paid writes are delegated to the first-party `taskmarket` CLI. The bridge does not handle private keys, seed phrases, or payment signatures. Install and configure the CLI separately, then load the plugin with the OpenHands SDK or Cloud: + +```python +from openhands.sdk.plugin import PluginSource + +plugin = PluginSource( + source="github:OpenHands/extensions", + ref="main", + repo_path="plugins/taskmarket-delegation", +) +``` + +The MCP server uses `TASKMARKET_API_URL` when set, otherwise `https://api.taskmarket.dev`. The default maximum task reward is 100 USDC and can be lowered with `TASKMARKET_MAX_REWARD_USDC`. The server only invokes `taskmarket task create` after the user has been shown the prepared summary and explicitly confirmed it. + +## Safety and review flow + +`/taskmarket:delegate` is the user-facing command. Agents should call `taskmarket_prepare_task`, show the returned summary, and wait for an explicit confirmation before calling `taskmarket_create_task`. `taskmarket_list_submissions` is intentionally read-only so a human decides which work to accept. + +The MCP bridge is dependency-free and speaks the standard stdio transport. Its tests exercise the protocol, validation, reward ceiling, one-time authorization token, and the no-auto-accept behavior. diff --git a/plugins/taskmarket-delegation/commands/delegate.md b/plugins/taskmarket-delegation/commands/delegate.md new file mode 100644 index 00000000..ea9b9350 --- /dev/null +++ b/plugins/taskmarket-delegation/commands/delegate.md @@ -0,0 +1,18 @@ +--- +description: Browse Taskmarket work or prepare a user-authorized delegation +argument-hint: [request] +--- + +# Taskmarket delegation + +Use the Taskmarket MCP tools to help the user delegate a well-scoped request. + +1. Read the user's request and identify the deliverable, deadline, and a realistic budget. +2. Browse open work when the user wants to find existing bounties. +3. For a new task, call `taskmarket_prepare_task` first. Show the exact description, mode, duration, visibility, and maximum reward to the user. +4. Call `taskmarket_create_task` only after the user explicitly confirms the displayed summary. Pass `confirm: true` and `confirmation_text: "CREATE_TASK"`. +5. After creation, return the task ID and link. Use `taskmarket_get_task` for status and `taskmarket_list_submissions` to present submissions for human review. + +Never expose wallet keys, seed phrases, or tokens. Never infer consent from the original request, silently fund a task, or accept or reject a submission. The MCP server uses the user's already configured first-party Taskmarket CLI for paid writes and enforces a configurable reward ceiling. + +If the request is ambiguous, ask for the missing scope instead of creating a task. diff --git a/plugins/taskmarket-delegation/scripts/taskmarket_mcp.py b/plugins/taskmarket-delegation/scripts/taskmarket_mcp.py new file mode 100644 index 00000000..48aaf41d --- /dev/null +++ b/plugins/taskmarket-delegation/scripts/taskmarket_mcp.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +"""Small, dependency-free MCP bridge for the first-party Taskmarket CLI/API. + +Reads use the public REST API. Paid writes deliberately go through the +first-party ``taskmarket`` CLI so it owns wallet signing, legal receipts, and +X402 payment handling. The bridge never accepts or stores private keys. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import time +import uuid +from decimal import Decimal, InvalidOperation +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen + + +API_URL = os.environ.get("TASKMARKET_API_URL", "https://api.taskmarket.dev").rstrip("/") +CLI = os.environ.get("TASKMARKET_CLI", "taskmarket") +MAX_REWARD = Decimal(os.environ.get("TASKMARKET_MAX_REWARD_USDC", "100")) +PREPARE_TTL_SECONDS = 30 * 60 +prepared_tasks: dict[str, dict[str, Any]] = {} + + +def _json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + + +def _error(message: str) -> RuntimeError: + return RuntimeError(message) + + +def _api_get(path: str, params: dict[str, str] | None = None) -> Any: + url = f"{API_URL}{path}" + if params: + url = f"{url}?{urlencode(params)}" + request = Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "openhands-taskmarket-delegation/0.1", + }, + method="GET", + ) + try: + with urlopen(request, timeout=20) as response: + return json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise _error(f"Taskmarket API returned HTTP {exc.code}: {body[:500]}") from exc + except (URLError, TimeoutError) as exc: + raise _error(f"Taskmarket API request failed: {exc}") from exc + except json.JSONDecodeError as exc: + raise _error("Taskmarket API returned invalid JSON") from exc + + +def _required_string(args: dict[str, Any], name: str, *, max_length: int = 4000) -> str: + value = args.get(name) + if not isinstance(value, str) or not value.strip(): + raise _error(f"{name} must be a non-empty string") + value = value.strip() + if len(value) > max_length: + raise _error(f"{name} is limited to {max_length} characters") + return value + + +def _decimal_reward(value: Any) -> tuple[Decimal, str]: + try: + amount = Decimal(str(value)) + except (InvalidOperation, ValueError) as exc: + raise _error("reward_usdc must be a decimal USDC amount") from exc + if not amount.is_finite() or amount <= 0: + raise _error("reward_usdc must be greater than zero") + if amount > MAX_REWARD: + raise _error(f"reward_usdc exceeds the configured ceiling of {MAX_REWARD} USDC") + if amount.as_tuple().exponent < -6: + raise _error("reward_usdc supports at most six decimal places") + return amount, format(amount, "f") + + +def _duration(args: dict[str, Any]) -> int: + value = args.get("duration_hours") + if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 720: + raise _error("duration_hours must be an integer from 1 to 720") + return value + + +def _list_tasks(args: dict[str, Any]) -> Any: + params: dict[str, str] = {} + status = args.get("status", "open") + if status: + if status not in {"open", "claimed", "completed", "expired", "cancelled"}: + raise _error("status is not a supported Taskmarket status") + params["status"] = status + mode = args.get("mode") + if mode: + if mode not in {"bounty", "claim", "pitch", "benchmark", "auction"}: + raise _error("mode is not a supported Taskmarket mode") + params["mode"] = mode + tags = args.get("tags") + if tags: + if not isinstance(tags, list) or not all(isinstance(tag, str) and tag.strip() for tag in tags): + raise _error("tags must be a list of non-empty strings") + params["tags"] = ",".join(tag.strip() for tag in tags) + limit = args.get("limit", 20) + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100: + raise _error("limit must be an integer from 1 to 100") + params["limit"] = str(limit) + return _api_get("/api/tasks", params) + + +def _prepare_task(args: dict[str, Any]) -> Any: + description = _required_string(args, "description") + reward, reward_text = _decimal_reward(args.get("reward_usdc")) + duration = _duration(args) + mode = args.get("mode", "bounty") + if mode not in {"bounty", "claim", "pitch", "benchmark"}: + raise _error("mode must be bounty, claim, pitch, or benchmark") + task_visibility = args.get("task_visibility", "public") + if task_visibility not in {"public", "unlisted"}: + raise _error("task_visibility must be public or unlisted") + submission_visibility = args.get("submission_visibility", "public") + if submission_visibility not in {"public", "reveal_all", "winner_only", "never"}: + raise _error("submission_visibility is not supported") + + token = str(uuid.uuid4()) + prepared_tasks[token] = { + "created_at": time.time(), + "payload": { + "description": description, + "reward_usdc": reward, + "reward_text": reward_text, + "duration_hours": duration, + "mode": mode, + "task_visibility": task_visibility, + "submission_visibility": submission_visibility, + }, + } + return { + "authorization_token": token, + "expires_in_seconds": PREPARE_TTL_SECONDS, + "requires_explicit_confirmation": True, + "review_summary": { + "description": description, + "reward_usdc": reward_text, + "duration_hours": duration, + "mode": mode, + "task_visibility": task_visibility, + "submission_visibility": submission_visibility, + "maximum_possible_spend_usdc": reward_text, + }, + "next_step": "Show review_summary to the user. Create only after exact confirmation with CREATE_TASK.", + } + + +def _create_task(args: dict[str, Any]) -> Any: + token = _required_string(args, "authorization_token", max_length=80) + if args.get("confirm") is not True or args.get("confirmation_text") != "CREATE_TASK": + raise _error("Explicit confirmation is required: confirm=true and confirmation_text=CREATE_TASK") + prepared = prepared_tasks.get(token) + if not prepared: + raise _error("authorization_token is unknown or already used") + if time.time() - prepared["created_at"] > PREPARE_TTL_SECONDS: + prepared_tasks.pop(token, None) + raise _error("authorization_token has expired; prepare the task again") + + payload = prepared_tasks.pop(token)["payload"] + cli_path = shutil.which(CLI) or (CLI if os.path.isfile(CLI) else None) + if not cli_path: + raise _error("The first-party taskmarket CLI is not installed or not on PATH") + command = [ + cli_path, + "task", + "create", + "--description", + payload["description"], + "--reward", + payload["reward_text"], + "--duration", + str(payload["duration_hours"]), + "--mode", + payload["mode"], + "--task-visibility", + payload["task_visibility"], + "--submission-visibility", + payload["submission_visibility"], + ] + try: + completed = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=120, + shell=False, + ) + except OSError as exc: + raise _error(f"Unable to start the taskmarket CLI: {exc}") from exc + if completed.returncode != 0: + stderr = completed.stderr.strip()[-1000:] + raise _error(f"Taskmarket create failed before completion: {stderr or 'unknown CLI error'}") + output = completed.stdout.strip() + try: + parsed: Any = json.loads(output) + except json.JSONDecodeError: + parsed = {"raw_cli_output": output[-4000:]} + return { + "created": True, + "taskmarket_result": parsed, + "review_note": "Creation was explicitly authorized. Use taskmarket_get_task and taskmarket_list_submissions for follow-up; no submission was accepted automatically.", + } + + +def _get_task(args: dict[str, Any]) -> Any: + task_id = _required_string(args, "task_id", max_length=160) + return _api_get(f"/api/tasks/{quote(task_id, safe='')}") + + +def _list_submissions(args: dict[str, Any]) -> Any: + task_id = _required_string(args, "task_id", max_length=160) + data = _api_get(f"/api/tasks/{quote(task_id, safe='')}/submissions") + return { + "task_id": task_id, + "human_review_required": True, + "submissions": data, + "policy": "This integration only presents submissions. It never accepts or rejects work automatically.", + } + + +def _dispatch(name: str, args: dict[str, Any]) -> Any: + if name == "taskmarket_list_tasks": + return _list_tasks(args) + if name == "taskmarket_prepare_task": + return _prepare_task(args) + if name == "taskmarket_create_task": + return _create_task(args) + if name == "taskmarket_get_task": + return _get_task(args) + if name == "taskmarket_list_submissions": + return _list_submissions(args) + raise _error(f"Unknown tool: {name}") + + +TOOLS = [ + { + "name": "taskmarket_list_tasks", + "description": "Browse public Taskmarket tasks. Read-only; never spends funds.", + "inputSchema": { + "type": "object", + "properties": { + "status": {"type": "string", "default": "open"}, + "mode": {"type": "string", "enum": ["bounty", "claim", "pitch", "benchmark", "auction"]}, + "tags": {"type": "array", "items": {"type": "string"}}, + "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20}, + }, + }, + }, + { + "name": "taskmarket_prepare_task", + "description": "Validate and stage a Taskmarket task for human review. Does not create or fund anything.", + "inputSchema": { + "type": "object", + "required": ["description", "reward_usdc", "duration_hours"], + "properties": { + "description": {"type": "string", "maxLength": 4000}, + "reward_usdc": {"type": "number", "exclusiveMinimum": 0}, + "duration_hours": {"type": "integer", "minimum": 1, "maximum": 720}, + "mode": {"type": "string", "enum": ["bounty", "claim", "pitch", "benchmark"], "default": "bounty"}, + "task_visibility": {"type": "string", "enum": ["public", "unlisted"], "default": "public"}, + "submission_visibility": {"type": "string", "enum": ["public", "reveal_all", "winner_only", "never"], "default": "public"}, + }, + }, + }, + { + "name": "taskmarket_create_task", + "description": "Create and fund a prepared Taskmarket task through the first-party CLI. Requires a fresh explicit user confirmation and obeys TASKMARKET_MAX_REWARD_USDC.", + "inputSchema": { + "type": "object", + "required": ["authorization_token", "confirm", "confirmation_text"], + "properties": { + "authorization_token": {"type": "string"}, + "confirm": {"type": "boolean", "const": True}, + "confirmation_text": {"type": "string", "const": "CREATE_TASK"}, + }, + }, + }, + { + "name": "taskmarket_get_task", + "description": "Read a Taskmarket task and its current lifecycle state.", + "inputSchema": {"type": "object", "required": ["task_id"], "properties": {"task_id": {"type": "string"}}}, + }, + { + "name": "taskmarket_list_submissions", + "description": "Retrieve submissions for a Taskmarket task and present them for human review. Never accepts or rejects work.", + "inputSchema": {"type": "object", "required": ["task_id"], "properties": {"task_id": {"type": "string"}}}, + }, +] + + +def _response(request_id: Any, result: Any) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": request_id, "result": result} + + +def _error_response(request_id: Any, code: int, message: str) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}} + + +def _handle(request: dict[str, Any]) -> dict[str, Any] | None: + method = request.get("method") + request_id = request.get("id") + if method in {"notifications/initialized", "notifications/cancelled"}: + return None + if method == "initialize": + return _response( + request_id, + { + "protocolVersion": request.get("params", {}).get("protocolVersion", "2024-11-05"), + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "taskmarket-delegation", "version": "0.1.0"}, + }, + ) + if method == "ping": + return _response(request_id, {}) + if method == "tools/list": + return _response(request_id, {"tools": TOOLS}) + if method == "tools/call": + params = request.get("params") or {} + try: + result = _dispatch(params.get("name", ""), params.get("arguments") or {}) + return _response(request_id, {"content": [{"type": "text", "text": _json(result)}]}) + except Exception as exc: # MCP callers should receive actionable errors, not a crashed server. + return _response( + request_id, + {"content": [{"type": "text", "text": str(exc)}], "isError": True}, + ) + if request_id is None: + return None + return _error_response(request_id, -32601, f"Unsupported method: {method}") + + +def _read_message() -> dict[str, Any] | None: + first = sys.stdin.buffer.readline() + if not first: + return None + if first.lstrip().startswith(b"{"): + return json.loads(first) + headers = first + b"\n" + while b"\r\n\r\n" not in headers and b"\n\n" not in headers: + line = sys.stdin.buffer.readline() + if not line: + return None + headers += line + header_text = headers.decode("ascii", errors="replace") + content_length = None + for line in header_text.replace("\r\n", "\n").split("\n"): + if line.lower().startswith("content-length:"): + content_length = int(line.split(":", 1)[1].strip()) + if content_length is None: + raise _error("MCP message did not include Content-Length") + payload = sys.stdin.buffer.read(content_length) + return json.loads(payload.decode("utf-8")) + + +def _write_message(message: dict[str, Any]) -> None: + payload = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + sys.stdout.buffer.write(f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii")) + sys.stdout.buffer.write(payload) + sys.stdout.buffer.flush() + + +def main() -> None: + while True: + try: + request = _read_message() + if request is None: + return + response = _handle(request) + if response is not None: + _write_message(response) + except Exception as exc: + print(f"taskmarket MCP bridge error: {exc}", file=sys.stderr, flush=True) + return + + +if __name__ == "__main__": + main() diff --git a/tests/test_taskmarket_mcp.py b/tests/test_taskmarket_mcp.py new file mode 100644 index 00000000..e38ad9a7 --- /dev/null +++ b/tests/test_taskmarket_mcp.py @@ -0,0 +1,95 @@ +"""Protocol and safety tests for the Taskmarket delegation plugin.""" + +from __future__ import annotations + +import importlib.util +import json +import os +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).parents[1] +SCRIPT = ROOT / "plugins" / "taskmarket-delegation" / "scripts" / "taskmarket_mcp.py" + + +def load_bridge(): + spec = importlib.util.spec_from_file_location("taskmarket_mcp", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_prepare_requires_fields_and_enforces_reward_ceiling(monkeypatch): + bridge = load_bridge() + monkeypatch.setattr(bridge, "MAX_REWARD", bridge.Decimal("5")) + + prepared = bridge._prepare_task({ + "description": "Review a small Python package", + "reward_usdc": 4.5, + "duration_hours": 8, + }) + assert prepared["requires_explicit_confirmation"] is True + assert prepared["review_summary"]["maximum_possible_spend_usdc"] == "4.5" + assert prepared["authorization_token"] in bridge.prepared_tasks + + try: + bridge._prepare_task({"description": "Too expensive", "reward_usdc": 6, "duration_hours": 8}) + except RuntimeError as exc: + assert "ceiling" in str(exc) + else: + raise AssertionError("reward ceiling was not enforced") + + +def test_create_requires_exact_confirmation(monkeypatch): + bridge = load_bridge() + monkeypatch.setattr(bridge, "MAX_REWARD", bridge.Decimal("5")) + prepared = bridge._prepare_task({"description": "Run tests", "reward_usdc": 1, "duration_hours": 2}) + token = prepared["authorization_token"] + + try: + bridge._create_task({"authorization_token": token, "confirm": False, "confirmation_text": "CREATE_TASK"}) + except RuntimeError as exc: + assert "Explicit confirmation" in str(exc) + else: + raise AssertionError("create accepted missing confirmation") + + +def test_mcp_protocol_lists_tools_and_handles_notification(tmp_path): + init = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2024-11-05"}, + } + notify = {"jsonrpc": "2.0", "method": "notifications/initialized"} + tools = {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}} + payload = "\n".join(json.dumps(item) for item in (init, notify, tools)) + "\n" + env = os.environ.copy() + completed = subprocess.run( + [sys.executable, str(SCRIPT)], + input=payload.encode("utf-8"), + text=False, + capture_output=True, + check=False, + timeout=10, + env=env, + ) + assert completed.returncode == 0 + raw = completed.stdout + responses = [] + offset = 0 + while offset < len(raw): + separator = raw.index(b"\r\n\r\n", offset) + header = raw[offset:separator].decode("ascii") + length = int(next(line.split(":", 1)[1] for line in header.splitlines() if line.lower().startswith("content-length:"))) + start = separator + 4 + responses.append(json.loads(raw[start : start + length].decode("utf-8"))) + offset = start + length + assert [response["id"] for response in responses] == [1, 2] + names = {tool["name"] for tool in responses[1]["result"]["tools"]} + assert "taskmarket_prepare_task" in names + assert "taskmarket_list_submissions" in names + assert "taskmarket_accept_submission" not in names