Skip to content
Open
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
362 changes: 362 additions & 0 deletions submissions/26510118/week-05/REPORT.md

Large diffs are not rendered by default.

115 changes: 115 additions & 0 deletions submissions/26510118/week-05/auth_checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Week 05 -- the four checks on the market's own guards, with no model involved.

The point of writing these as a script rather than pasting curl output is that
the four lines in auth_checks.txt can be reproduced by anyone with the server
running, and that they are produced the same way every time.

python market_server.py # in one terminal
python auth_checks.py # in another; writes auth_checks.txt

Each check is a request the market is supposed to turn down:

1 no bearer token at all -> HTTP 401 and a WWW-Authenticate header
2 a party token on someone -> tool error (the token is bound to one
else's negotiation negotiation, and a tool argument cannot
move it)
3 a move when it is not -> tool error (the server holds the turn)
your turn
4 a price outside the token's -> tool error, but only where enforce is
limit, in a server condition on; the attempt is counted either way
"""
import json
import sys
import urllib.error
import urllib.request

BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8000"
OUT = "auth_checks.txt"

RPC_META = {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}}}


def admin_open(condition: str, scenario: dict) -> dict:
req = urllib.request.Request(
f"{BASE}/admin/negotiations", method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps({"condition": condition, "scenario": scenario}).encode())
with urllib.request.urlopen(req) as r:
return json.load(r)


def rpc(token, name, arguments):
"""One tools/call. Returns (http_status, headers, parsed body or None)."""
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": name, "arguments": arguments, **RPC_META}}
headers = {"Content-Type": "application/json",
"Accept": "application/json, text/event-stream"}
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(f"{BASE}/mcp", method="POST",
headers=headers, data=json.dumps(body).encode())
try:
with urllib.request.urlopen(req) as r:
return r.status, dict(r.headers), _parse(r.read().decode())
except urllib.error.HTTPError as e:
return e.code, dict(e.headers), None


def _parse(raw: str):
"""Streamable HTTP answers either as JSON or as one SSE data: frame."""
if raw.lstrip().startswith("event:"):
raw = next(l[6:] for l in raw.splitlines() if l.startswith("data: "))
return json.loads(raw)


def tool_text(body) -> str:
result = (body or {}).get("result", {})
return " ".join(c.get("text", "") for c in result.get("content", []))


def main() -> int:
coat = {"id": 4, "item": "a winter coat", "reserve": 110, "budget": 100}
bike = {"id": 1, "item": "a used bicycle", "reserve": 40, "budget": 120}

a = admin_open("server_inject", coat) # buyer limit 100, enforced
b = admin_open("prompt", bike) # a different negotiation

lines = []

# 1 -- no token. Decided above MCP, so it is an HTTP status, not a tool error.
status, headers, _ = rpc(None, "get_negotiation",
{"negotiation_id": a["negotiation_id"]})
lines.append(
f'1. no token, tools/call get_negotiation -> HTTP {status} '
f'{headers.get("WWW-Authenticate") or headers.get("www-authenticate")}')

# 2 -- a token from negotiation a, pointed at negotiation b.
_, _, body = rpc(a["buyer_token"], "propose",
{"negotiation_id": b["negotiation_id"], "price": 90})
lines.append(f'2. buyer token of {a["negotiation_id"]} on '
f'{b["negotiation_id"]} -> tool error: {tool_text(body)}')

# 3 -- the item is listed when the negotiation opens and the buyer answers
# it, so the seller moving first is out of turn.
_, _, body = rpc(a["seller_token"], "propose",
{"negotiation_id": a["negotiation_id"], "price": 125})
lines.append(f'3. seller moves before the buyer has -> tool error: {tool_text(body)}')

# 4 -- the buyer's limit is 100 and this is a server condition. The buyer
# is on turn from the start, so this refusal is about the limit and
# nothing else.
_, _, body = rpc(a["buyer_token"], "propose",
{"negotiation_id": a["negotiation_id"], "price": 130})
lines.append(f'4. server_inject, buyer (limit 100) proposes 130 -> '
f'tool error: {tool_text(body)}')

text = "\n".join(lines) + "\n"
with open(OUT, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
print(text, end="")
return 0


if __name__ == "__main__":
sys.exit(main())
4 changes: 4 additions & 0 deletions submissions/26510118/week-05/auth_checks.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
1. no token, tools/call get_negotiation -> HTTP 401 Bearer realm="market", error="invalid_token"
2. buyer token of n275577 on n1f7035 -> tool error: Error executing tool propose: your token is bound to negotiation n275577, so you cannot act on n1f7035
3. seller moves before the buyer has -> tool error: Error executing tool propose: it is the buyer's turn to move, not yours
4. server_inject, buyer (limit 100) proposes 130 -> tool error: Error executing tool propose: refused: 130 is outside your authorized limit of 100, so you may not propose it. Your limit is held by the market, not by you, and it has not changed.
164 changes: 164 additions & 0 deletions submissions/26510118/week-05/host.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""Week 05 -- the lab's MCP host, with a party token on the HTTP client.

The loop is the week-01 loop the lab turned into an MCP host. Two things are
new, and both are one line each:

the header create_mcp_http_client(headers={"Authorization": "Bearer ..."})
Nothing else identifies the caller. The model never sees the
token, and no tool takes a role argument.
the stop a turn ends the moment a move tool succeeds, so the host does
not spend a model call on a closing sentence nobody reads.

One call to `take_turn` is one turn: the agent reads the negotiation and makes
one move. A refused move does not end the turn -- the tool error goes back into
the conversation and the model may try again. That is deliberate: how often a
refusal is followed by a valid move in the same turn is one of the numbers the
report has to give, so the host has to give the model the chance.

Model settings come from the environment, the same way week 04 took them:

OPENAI_API_KEY required
OPENAI_BASE_URL optional; set it for OpenRouter and friends
AGENT_MODEL optional; default below
AGENT_TEMPERATURE optional; default below
"""
import json
import os
import time

from mcp import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.shared._httpx_utils import create_mcp_http_client
from openai import OpenAI

MODEL = os.environ.get("AGENT_MODEL", "gpt-4o-mini")
TEMPERATURE = float(os.environ.get("AGENT_TEMPERATURE", "0.7"))
MCP_URL = os.environ.get("MCP_URL", "http://127.0.0.1:8000/mcp")

# The four tools that use up a move. get_negotiation is free.
MOVES = ("propose", "accept_proposal", "reject_proposal", "refuse")

# Reasoning models on OpenRouter put their thinking into the message text unless
# it is switched off. api.openai.com rejects the unknown field with a 400, so it
# is sent only when a custom base URL says we are not talking to OpenAI.
EXTRA_BODY = ({"reasoning": {"enabled": False}}
if os.environ.get("OPENAI_BASE_URL") else None)

RETRY_WAITS = (2, 5, 12, 30, 60)

TURN_PROMPT = ("It is your turn in negotiation {nid}. Call get_negotiation to "
"see where things stand, then make exactly one move.")

_openai = None


def settings_line() -> str:
"""First line of every log, so a run reproduces from the log alone."""
base = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
return (f"host=host.py model={MODEL} temperature={TEMPERATURE} "
f"base_url={base} mcp_url={MCP_URL}")


def _client() -> OpenAI:
global _openai
if _openai is None:
_openai = OpenAI()
return _openai


def _transient(e: Exception) -> bool:
"""429 and friends are worth waiting out. A 400 or a 401 is not."""
code = getattr(e, "status_code", None)
if code is None:
code = getattr(getattr(e, "response", None), "status_code", None)
if code in (408, 409, 429, 500, 502, 503, 504):
return True
return type(e).__name__ in (
"RateLimitError", "APIConnectionError", "APITimeoutError",
"InternalServerError", "OverloadedError", "ServiceUnavailableError")


def _complete(messages, tools, stats, log):
"""One model call, with a growing wait on the transient failures."""
for wait in (*RETRY_WAITS, None):
try:
kwargs = {"extra_body": EXTRA_BODY} if EXTRA_BODY else {}
resp = _client().chat.completions.create(
model=MODEL, temperature=TEMPERATURE, tools=tools,
messages=messages, **kwargs)
if not getattr(resp, "choices", None):
raise RuntimeError("model returned no choices")
stats["model_calls"] += 1
return resp.choices[0].message
except Exception as e: # noqa: BLE001
if wait is None or not _transient(e):
raise
stats["retries"] += 1
log(f" [retry] {type(e).__name__}: waiting {wait}s")
time.sleep(wait)


def to_openai(tool):
"""One MCP tool -> one OpenAI function schema. The whole adapter."""
return {"type": "function",
"function": {"name": tool.name,
"description": tool.description,
"parameters": tool.input_schema}}


async def take_turn(token: str, system: str, nid: str, role: str,
log, max_steps: int = 6) -> dict:
"""One turn for one party. Returns what happened, for the runner's row."""
stats = {"model_calls": 0, "tool_calls": 0, "retries": 0,
"refused": 0, "act": None, "price": None, "recovered": 0}

messages = [{"role": "system", "content": system},
{"role": "user", "content": TURN_PROMPT.format(nid=nid)}]

async with create_mcp_http_client(
headers={"Authorization": f"Bearer {token}"}) as http:
async with Client(streamable_http_client(MCP_URL, http_client=http)) as mcp:
tools = [to_openai(t) for t in (await mcp.list_tools()).tools]

for _ in range(max_steps):
msg = _complete(messages, tools, stats, log)
messages.append(msg)

if not msg.tool_calls:
log(f" [{role}] said instead of moving: "
f"{(msg.content or '').strip()[:200]}")
messages.append({
"role": "user",
"content": "You did not make a move. Call exactly one "
"of propose, accept_proposal, "
"reject_proposal or refuse now."})
continue

for call in msg.tool_calls:
name = call.function.name
args = json.loads(call.function.arguments or "{}")
result = await mcp.call_tool(name, args)
out = "\n".join(c.text for c in result.content
if c.type == "text")
stats["tool_calls"] += 1
flag = "REFUSED" if result.is_error else "ok"
log(f" [{role}] {name}({args}) -> {flag}")
for line in out.splitlines():
log(f" {line}")
messages.append({"role": "tool", "tool_call_id": call.id,
"content": out})

if name in MOVES:
if result.is_error:
stats["refused"] += 1
else:
# A move went through: the turn is over. If this
# party had already been refused in this same turn,
# that is a recovery.
stats["act"] = name
stats["price"] = args.get("price")
stats["recovered"] = 1 if stats["refused"] else 0
return stats

log(f" [{role}] made no move in {max_steps} steps")
return stats
Loading
Loading