Skip to content

Commit bd9d7f5

Browse files
authored
Merge pull request modelcontextprotocol#21 from jkoelker/jk/discord
feat(tools): surface order metadata from placements
2 parents 6494e08 + 60bc739 commit bd9d7f5

File tree

3 files changed

+124
-8
lines changed

3 files changed

+124
-8
lines changed

src/schwab_mcp/tools/orders.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
import copy
66
import datetime
77
from mcp.server.fastmcp import FastMCP
8+
from schwab.utils import (
9+
AccountHashMismatchException,
10+
UnsuccessfulOrderException,
11+
Utils as SchwabUtils,
12+
)
813
from schwab.orders.common import first_triggers_second as trigger_builder
914
from schwab.orders.common import one_cancels_other as oco_builder
1015
from schwab.orders.options import OptionSymbol
@@ -30,7 +35,7 @@
3035
option_sell_to_open_limit,
3136
option_sell_to_open_market,
3237
)
33-
from schwab_mcp.tools.utils import JSONType, call
38+
from schwab_mcp.tools.utils import JSONType, ResponseHandler, call
3439

3540

3641
# Internal helper function to apply session and duration settings
@@ -168,6 +173,33 @@ def _build_option_order_spec(
168173
)
169174

170175

176+
def _order_response_handler(ctx: SchwabContext, account_hash: str) -> ResponseHandler:
177+
utils = SchwabUtils(ctx.client, account_hash)
178+
179+
def handler(response: Any) -> tuple[bool, JSONType]:
180+
headers = getattr(response, "headers", {})
181+
location = headers.get("Location") if headers else None
182+
183+
try:
184+
order_id = utils.extract_order_id(response)
185+
except (AccountHashMismatchException, UnsuccessfulOrderException):
186+
order_id = None
187+
188+
if order_id is None and location is None:
189+
return False, None
190+
191+
payload: dict[str, Any] = {}
192+
if order_id is not None:
193+
payload["orderId"] = order_id
194+
payload["accountHash"] = account_hash
195+
if location is not None:
196+
payload["location"] = location
197+
198+
return True, payload
199+
200+
return handler
201+
202+
171203
async def get_order(
172204
ctx: SchwabContext,
173205
account_hash: Annotated[str, "Account hash for the Schwab account"],
@@ -286,7 +318,10 @@ async def place_equity_order(
286318

287319
# Place the order
288320
return await call(
289-
client.place_order, account_hash=account_hash, order_spec=order_spec_dict
321+
client.place_order,
322+
account_hash=account_hash,
323+
order_spec=order_spec_dict,
324+
response_handler=_order_response_handler(ctx, account_hash),
290325
)
291326

292327

@@ -332,7 +367,10 @@ async def place_option_order(
332367

333368
# Place the order
334369
return await call(
335-
client.place_order, account_hash=account_hash, order_spec=order_spec_dict
370+
client.place_order,
371+
account_hash=account_hash,
372+
order_spec=order_spec_dict,
373+
response_handler=_order_response_handler(ctx, account_hash),
336374
)
337375

338376

@@ -433,7 +471,10 @@ async def place_one_cancels_other_order(
433471
client = ctx.orders
434472

435473
return await call(
436-
client.place_order, account_hash=account_hash, order_spec=oco_order_spec
474+
client.place_order,
475+
account_hash=account_hash,
476+
order_spec=oco_order_spec,
477+
response_handler=_order_response_handler(ctx, account_hash),
437478
)
438479

439480

@@ -491,6 +532,7 @@ async def place_first_triggers_second_order(
491532
client.place_order,
492533
account_hash=account_hash,
493534
order_spec=trigger_order_dict,
535+
response_handler=_order_response_handler(ctx, account_hash),
494536
)
495537

496538

@@ -604,6 +646,7 @@ async def place_bracket_order(
604646
client.place_order,
605647
account_hash=account_hash,
606648
order_spec=bracket_order_dict,
649+
response_handler=_order_response_handler(ctx, account_hash),
607650
)
608651

609652

@@ -669,7 +712,10 @@ async def place_option_combo_order(
669712
)
670713

671714
return await call(
672-
ctx.orders.place_order, account_hash=account_hash, order_spec=builder.build()
715+
ctx.orders.place_order,
716+
account_hash=account_hash,
717+
order_spec=builder.build(),
718+
response_handler=_order_response_handler(ctx, account_hash),
673719
)
674720

675721

src/schwab_mcp/tools/utils.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
JSONType: TypeAlias = JSONPrimitive | dict[str, Any] | list[Any]
99

1010

11+
ResponseHandler: TypeAlias = Callable[[Any], tuple[bool, JSONType]]
12+
13+
1114
class SchwabAPIError(Exception):
1215
"""Represents an error response returned from the Schwab API."""
1316

@@ -23,8 +26,18 @@ def __init__(
2326
)
2427

2528

26-
async def call(func: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> JSONType:
27-
"""Call a Schwab client endpoint and return its JSON payload."""
29+
async def call(
30+
func: Callable[..., Awaitable[Any]],
31+
*args: Any,
32+
response_handler: ResponseHandler | None = None,
33+
**kwargs: Any,
34+
) -> JSONType:
35+
"""Call a Schwab client endpoint and return its JSON payload.
36+
37+
When ``response_handler`` is provided, it can opt to handle the response
38+
by returning ``(True, payload)``. Returning ``(False, _)`` delegates back to
39+
the default JSON parsing behavior.
40+
"""
2841

2942
response = await func(*args, **kwargs)
3043
try:
@@ -36,6 +49,11 @@ async def call(func: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -
3649
body=response.text,
3750
) from exc
3851

52+
if response_handler is not None:
53+
handled, payload = response_handler(response)
54+
if handled:
55+
return payload
56+
3957
# Handle responses with no content
4058
# 204 No Content: explicit no-content response
4159
# 201 Created: order placement endpoints return empty body with Location header
@@ -55,4 +73,4 @@ async def call(func: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -
5573
raise ValueError("Expected JSON response from Schwab endpoint") from exc
5674

5775

58-
__all__ = ["call", "JSONType", "SchwabAPIError"]
76+
__all__ = ["call", "JSONType", "SchwabAPIError", "ResponseHandler"]

tests/test_orders.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,55 @@ async def fake_call(func, *args, **kwargs):
116116
client.Order.Status.FILLED,
117117
client.Order.Status.CANCELED,
118118
]
119+
120+
121+
def test_place_equity_order_returns_order_metadata():
122+
account_hash = "abc123"
123+
order_id = 987654321
124+
location = (
125+
f"https://api.schwabapi.com/trader/v1/accounts/{account_hash}/orders/{order_id}"
126+
)
127+
128+
class DummyResponse:
129+
status_code = 201
130+
url = f"https://api.schwabapi.com/trader/v1/accounts/{account_hash}/orders"
131+
text = ""
132+
content = b""
133+
headers = {"Location": location}
134+
is_error = False
135+
136+
def raise_for_status(self) -> None:
137+
return None
138+
139+
class DummyPlaceOrderClient(DummyOrdersClient):
140+
def __init__(self) -> None:
141+
self.captured: dict[str, Any] | None = None
142+
143+
async def place_order(self, *args, **kwargs):
144+
self.captured = {"args": args, "kwargs": kwargs}
145+
return DummyResponse()
146+
147+
client = DummyPlaceOrderClient()
148+
ctx = make_ctx(client)
149+
150+
result = run(
151+
orders.place_equity_order(
152+
ctx,
153+
account_hash,
154+
"SPY",
155+
1,
156+
"buy",
157+
"market",
158+
)
159+
)
160+
161+
assert result == {
162+
"orderId": order_id,
163+
"accountHash": account_hash,
164+
"location": location,
165+
}
166+
167+
captured = client.captured
168+
assert captured is not None
169+
assert captured["kwargs"]["account_hash"] == account_hash
170+
assert captured["kwargs"]["order_spec"]["orderStrategyType"] == "SINGLE"

0 commit comments

Comments
 (0)