Your weekend agent can forget roles, get a plausible salary back, and confidently answer your question about a different job. Nothing crashes. That's the annoying part.

In salaryBenchmarks, an empty roles value selects a default accounting role. The adapter examples below are synthetic: they demonstrate a validation boundary, not salary accuracy.

Lab note 077: can we reject a missing subject before a helpful fallback turns it into an answer?

How does a missing role pass validation?

The Actor has 41 input properties and 5 mode values, but only mode is required. A salary request can therefore satisfy the input schema without specifying whose salary you wanted.

That makes execution success a weak signal. It tells your agent that a lookup completed, not that the lookup concerned the user's role.

For a weekend salary assistant, we'd give the agent a narrower job: extract the requested role and market, call the benchmark tool, then inspect the returned role before writing an answer.

If the user never supplied a role, the agent should ask for it. Guessing makes a tidy demo and a dreadful compensation comparison.

Put the guard before retrieval

Making roles required catches omission. It doesn't catch an empty string or a string containing only line breaks.

The adapter needs to check the content after trimming each line. That also preserves the Actor's multiline input convention without passing blank entries through.

Example adapter policy: normalize the requested roles, require an explicit market, and build the Actor input only after validation.

def prepare_salary_input(roles=None, country=None): if not isinstance(roles, str): raise ValueError( "Provide roles as text, with a role title or query per line." ) queries = [line.strip() for line in roles.splitlines() if line.strip()] if not queries: raise ValueError( "roles is empty. Ask the user which role to benchmark." ) if country not in ("my", "sg", "id", "ph", "th", "hk"): raise ValueError("Provide an explicit supported country code.") return { "mode": "salaryBenchmarks", "roles": "\n".join(queries), "country": country, "includeRegionalSalaries": False, "includeRoleDetails": False, "maxItems": 10, } 

The useful boundary is the function call. Validation happens before credentials are read or an Actor invocation is attempted.

A prompt saying “always include the role” can help extraction. We still want executable code deciding whether that requirement was met.

Give MCP callers only the question fields

The model should see the salary tool through your adapter. Giving it direct access to the broad Actor tool leaves a path around the guard.

The intended flow is small enough to draw:

Agent: roles + country | v MCP adapter: validate -> add pinned options | v Actor -> inspect returned roles -> compact response 

Example MCP tool registration data follows. Your MCP server must register this descriptor and dispatch calls to the Python handler below; this JSON alone doesn't launch a server.

{ "name": "salary_benchmark", "description": "Look up salary benchmarks for explicit role queries in a specified market.", "inputSchema": { "type": "object", "properties": { "roles": { "type": "string", "minLength": 1, "pattern": "\\S", "description": "Role titles or queries, one per line. Never infer a role that the user did not supply." }, "country": { "type": "string", "enum": ["my", "sg", "id", "ph", "th", "hk"] } }, "required": ["roles", "country"], "additionalProperties": false } } 

The schema makes the contract visible to the model. The Python check enforces it even when a caller bypasses schema validation.

Treat a validation error as a request for missing information. An automatic retry that invents a role would neatly undo the fix.

Pin the retrieval policy in the adapter

We'd divide control according to what changes the user's question and what changes the retrieval policy.

Setting Control Reason
roles, country Agent supplies These identify the subject and market the user requested.
mode Adapter pins This tool has a salary-benchmark contract.
includeRegionalSalaries, includeRoleDetails Adapter pins off Extra breakdowns and role descriptions need a deliberate reason to enter the context.
maxItems Adapter pins The model shouldn't expand retrieval just because it can.

Keeping the market explicit matters even when the role is clear. A correct title attached to the wrong market still produces the wrong compensation answer.

The network boundary can stay ordinary. This function accepts prepared Actor input and refuses to treat an unsuccessful run as usable data.

import os from apify_client import ApifyClient def fetch_salary_records(run_input): client = ApifyClient(os.environ["APIFY_TOKEN"]) run = client.actor( "vexaloom-labs/jobstreet-southeast-asia-job-search-by-keyword" ).call(run_input=run_input) if run["status"] != "SUCCEEDED": raise RuntimeError(f"Salary lookup ended with status {run['status']}.") return client.dataset(run["defaultDatasetId"]).list_items().items 

Keep transport failures separate from argument failures in your MCP error handling. Retrying a temporary connection problem might help. Retrying an empty subject won't discover what the user meant.

Keep the query attached to the returned role

The next quiet failure happens when the agent sees a returned title and forgets the query that produced it.

Preserve both. For a multiline request, keep the requested queries beside the returned records as a batch. Don't pair them by position: the interface doesn't promise a positional correspondence.

The envelope keys below belong to our adapter. The selected record fields come from the Actor.

import json def salary_context(run_input, items, character_budget=12000): fields = ( "recordType", "roleId", "title", "country", "salaryMin", "salaryMax", "salaryMedian", "salaryCurrency", "salaryPeriod", "url", "scrapedAt", ) records = [] for item in items: if item.get("recordType") != "salaryBenchmark": raise ValueError("Expected salaryBenchmark records.") records.append({key: item[key] for key in fields if key in item}) envelope = { "requestedRoles": run_input["roles"].splitlines(), "requestedCountry": run_input["country"], "records": records, } serialized = json.dumps(envelope, ensure_ascii=False, separators=(",", ":")) if len(serialized) > character_budget: raise ValueError( "Salary context exceeds the adapter budget. Narrow the request." ) return serialized def salary_benchmark(roles=None, country=None, *, fetch=fetch_salary_records): run_input = prepare_salary_input(roles, country) items = fetch(run_input) return salary_context(run_input, items) 

This uses a character budget. Choose it within your app's actual context allocation, leaving room for the conversation and answer; use your model's tokenizer when you need an exact token limit.

Rejecting an oversized envelope is deliberate. Cutting a serialized response midway can detach an amount from its currency, period, or role. That saves space by spending meaning.

We also preserve omitted salary fields as omissions. A missing median isn't a zero salary, and a role identity without an amount isn't evidence for a numerical answer.

A valid string still needs a subject check

A nonempty query can still be wrong. The agent might extract the wrong phrase from the user's message, or the resolved role might be broader than the role the user intended.

A title difference alone doesn't prove a mismatch either. Abbreviations and normalized titles exist. Keep roleId and title available for inspection, and have the agent explain uncertainty instead of silently treating every returned role as equivalent.

The pinned record cap also means that receiving records doesn't establish that every requested role has been covered. Avoid statements such as “all requested roles were benchmarked” unless your adapter has checked that separately.

For salary comparisons, preserve salaryCurrency and salaryPeriod with each amount. A number stripped of its unit is excellent material for a confident mistake.

The worked example uses JobStreet Southeast Asia Job Search by Keyword & Country. Its compact option targets core job fields; we'd use the explicit salary projection above for this tool.

We'd require the subject before retrieval. That closes the empty-role fallback path; semantic review still belongs between the returned role and the final answer.

Reproduce the guard with a fake fetch

The useful local check is whether invalid input reaches the fetch function at all.

Example synthetic records and a fake fetch function let you check rejection and accepted multiline input without credentials or a network request.

captured_inputs = [] def fake_fetch(run_input): captured_inputs.append(run_input) return [ { "recordType": "salaryBenchmark", "roleId": "example-data-analyst", "title": "Example Data Analyst", }, { "recordType": "salaryBenchmark", "roleId": "example-accountant", "title": "Example Accountant", }, ] for invalid_roles in (None, "", " \n\t\n "): try: salary_benchmark( roles=invalid_roles, country="my", fetch=fake_fetch ) except ValueError: pass else: raise AssertionError("Empty roles should be rejected.") assert not captured_inputs example_context = salary_benchmark( roles=" data analyst \n\n accountant ", country="my", fetch=fake_fetch, ) assert captured_inputs[0]["roles"] == "data analyst\naccountant" assert "requestedRoles" in json.loads(example_context) 

To reproduce it:

  • Execute the Python blocks in order to check the guard locally.
  • Register the MCP descriptor with salary_benchmark as its handler.
  • When connecting the adapter to the Actor, set APIFY_TOKEN in the server environment and use the handler's default fetch function.

Example exact Actor input produced by the accepted request:

{ "mode": "salaryBenchmarks", "roles": "data analyst\naccountant", "country": "my", "includeRegionalSalaries": false, "includeRoleDetails": false, "maxItems": 10 }