Skip to content

Commit 7484a70

Browse files
committed
feat: deploy new endpoints
1 parent ca857ec commit 7484a70

12 files changed

Lines changed: 1452 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
66
name = "aind-metadata-viz"
77
description = "Generated from aind-library-template"
88
license = {text = "MIT"}
9-
requires-python = ">=3.10"
9+
requires-python = ">=3.11"
1010
authors = [
1111
{name = "Allen Institute for Neural Dynamics"}
1212
]
@@ -17,7 +17,7 @@ readme = "README.md"
1717
dynamic = ["version"]
1818

1919
dependencies = [
20-
'pydantic<=2.11.0',
20+
'pydantic',
2121
'fastapi',
2222
'uvicorn[standard]',
2323
'aind-data-schema>=2.6.0,<3',
@@ -29,6 +29,10 @@ dependencies = [
2929
'biodata-query @ git+https://github.com/AllenNeuralDynamics/biodata-query.git@v0.6.1',
3030
'boto3',
3131
'requests',
32+
"aind-data-mcp>=0.3.4",
33+
"pytest>=9.0.3",
34+
"httpx>=0.28.1",
35+
"coverage>=7.14.1",
3236
]
3337

3438
[project.optional-dependencies]

scripts/run_all_tests.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def main():
7070
"test_individual_fields.py",
7171
"test_files_endpoint.py",
7272
"test_gather_integration.py",
73+
"test_chat_integration.py",
7374
]
7475

7576
results = []

scripts/test_chat_integration.py

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Integration test for the /chat endpoint and the mounted /mcp server.
4+
5+
The /chat tests run the real Bedrock-backed agent end-to-end. Requires
6+
AWS credentials with bedrock-runtime access to be available to the
7+
running server (typically via BEDROCK_ROLE_ARN + an STS-assumable role).
8+
9+
The /mcp tests speak raw streamable-HTTP MCP and verify the audited
10+
allowlist plus rate limiting.
11+
12+
Tests
13+
-----
14+
/chat:
15+
1. Valid simple message -> 200 with non-empty response.
16+
2. Tool-using message (asks about top-level nodes) -> 200, at least
17+
one tool_call recorded, and the response references real fields.
18+
3. Empty message -> 400.
19+
4. Oversize message -> 400.
20+
5. Invalid history shape -> 400.
21+
22+
/mcp:
23+
6. tools/list returns the audited set (no NWB tools).
24+
7. tools/call against `get_top_level_nodes` returns expected content.
25+
26+
Usage:
27+
python test_chat_integration.py --env local
28+
python test_chat_integration.py --env prod
29+
"""
30+
31+
from __future__ import annotations
32+
33+
import json
34+
import sys
35+
import uuid
36+
37+
import requests
38+
39+
from test_config import parse_test_args
40+
41+
42+
def check(label: str, condition: bool, detail: str = "") -> bool:
43+
status = "OK " if condition else "FAIL"
44+
print(f"[{status}] {label}" + (f": {detail}" if detail else ""))
45+
return condition
46+
47+
48+
def _post_json(url: str, body: dict, timeout: int = 120) -> requests.Response:
49+
return requests.post(url, json=body, timeout=timeout)
50+
51+
52+
def run_chat_tests(base_url: str) -> bool:
53+
all_passed = True
54+
chat_url = f"{base_url}/chat"
55+
56+
print("\n--- /chat tests ---")
57+
58+
print("\n[Test 1] Simple message")
59+
resp = _post_json(chat_url, {"message": "Say hello in five words."})
60+
passed = check("Status 200", resp.status_code == 200, str(resp.status_code))
61+
if passed:
62+
body = resp.json()
63+
passed &= check("response is non-empty string", bool(body.get("response")))
64+
passed &= check("tool_calls present", "tool_calls" in body)
65+
passed &= check("stop_reason present", "stop_reason" in body)
66+
all_passed &= passed
67+
68+
print("\n[Test 2] Tool-using message")
69+
resp = _post_json(
70+
chat_url,
71+
{
72+
"message": (
73+
"Use the available tools to list the top-level nodes "
74+
"of the AIND metadata schema, then answer in one short "
75+
"sentence."
76+
)
77+
},
78+
timeout=180,
79+
)
80+
passed = check("Status 200", resp.status_code == 200, str(resp.status_code))
81+
if passed:
82+
body = resp.json()
83+
passed &= check(
84+
"at least one tool call",
85+
len(body.get("tool_calls", [])) >= 1,
86+
f"{len(body.get('tool_calls', []))}",
87+
)
88+
passed &= check(
89+
"response mentions a schema field",
90+
any(
91+
f in body.get("response", "").lower()
92+
for f in ("subject", "acquisition", "data_description")
93+
),
94+
)
95+
all_passed &= passed
96+
97+
print("\n[Test 3] Missing message -> 400")
98+
resp = _post_json(chat_url, {})
99+
all_passed &= check("Status 400", resp.status_code == 400, str(resp.status_code))
100+
101+
print("\n[Test 4] Oversize message -> 400")
102+
resp = _post_json(chat_url, {"message": "x" * 10_000})
103+
all_passed &= check("Status 400", resp.status_code == 400, str(resp.status_code))
104+
105+
print("\n[Test 5] Invalid history shape -> 400")
106+
resp = _post_json(chat_url, {"message": "hi", "history": "not a list"})
107+
all_passed &= check("Status 400", resp.status_code == 400, str(resp.status_code))
108+
109+
return all_passed
110+
111+
112+
def _mcp_request(base_url: str, body: dict, session_id: str | None = None) -> tuple[int, dict | str]:
113+
headers = {
114+
"content-type": "application/json",
115+
"accept": "application/json, text/event-stream",
116+
}
117+
if session_id:
118+
headers["mcp-session-id"] = session_id
119+
resp = requests.post(
120+
f"{base_url}/mcp/", json=body, headers=headers, timeout=60
121+
)
122+
text = resp.text
123+
# streamable-http returns SSE for streaming endpoints; for the single-
124+
# response calls we make here it returns a JSON body.
125+
if text.startswith("event:") or "data:" in text:
126+
# parse the first data: line
127+
for line in text.splitlines():
128+
if line.startswith("data:"):
129+
try:
130+
return resp.status_code, json.loads(line[5:].strip())
131+
except json.JSONDecodeError:
132+
pass
133+
return resp.status_code, text
134+
try:
135+
return resp.status_code, resp.json()
136+
except json.JSONDecodeError:
137+
return resp.status_code, text
138+
139+
140+
def run_mcp_tests(base_url: str) -> bool:
141+
all_passed = True
142+
print("\n--- /mcp tests ---")
143+
144+
# initialize handshake
145+
init_body = {
146+
"jsonrpc": "2.0",
147+
"id": 1,
148+
"method": "initialize",
149+
"params": {
150+
"protocolVersion": "2024-11-05",
151+
"capabilities": {},
152+
"clientInfo": {"name": "integration-test", "version": "0.1"},
153+
},
154+
}
155+
status, init_resp = _mcp_request(base_url, init_body)
156+
passed = check("initialize status 200", status == 200, str(status))
157+
session_id = None
158+
if passed:
159+
# session id is in response headers; refetch with headers visible
160+
resp = requests.post(
161+
f"{base_url}/mcp/",
162+
json=init_body,
163+
headers={
164+
"content-type": "application/json",
165+
"accept": "application/json, text/event-stream",
166+
},
167+
timeout=30,
168+
)
169+
session_id = resp.headers.get("mcp-session-id")
170+
passed &= check(
171+
"session id returned",
172+
bool(session_id),
173+
session_id or "(none)",
174+
)
175+
if session_id:
176+
# Initialized notification
177+
requests.post(
178+
f"{base_url}/mcp/",
179+
json={
180+
"jsonrpc": "2.0",
181+
"method": "notifications/initialized",
182+
"params": {},
183+
},
184+
headers={
185+
"content-type": "application/json",
186+
"accept": "application/json, text/event-stream",
187+
"mcp-session-id": session_id,
188+
},
189+
timeout=15,
190+
)
191+
all_passed &= passed
192+
if not session_id:
193+
return all_passed
194+
195+
print("\n[Test 6] tools/list excludes NWB tools")
196+
status, body = _mcp_request(
197+
base_url,
198+
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
199+
session_id=session_id,
200+
)
201+
passed = check("tools/list status 200", status == 200, str(status))
202+
if passed and isinstance(body, dict):
203+
names = [t.get("name") for t in body.get("result", {}).get("tools", [])]
204+
passed &= check(
205+
"tool list non-empty", len(names) > 0, f"{len(names)} tools"
206+
)
207+
passed &= check(
208+
"no identify_nwb_contents_with_s3_link",
209+
"identify_nwb_contents_with_s3_link" not in names,
210+
)
211+
passed &= check(
212+
"no identify_nwb_contents_in_code_ocean",
213+
"identify_nwb_contents_in_code_ocean" not in names,
214+
)
215+
passed &= check("get_top_level_nodes present", "get_top_level_nodes" in names)
216+
all_passed &= passed
217+
218+
print("\n[Test 7] tools/call get_top_level_nodes")
219+
status, body = _mcp_request(
220+
base_url,
221+
{
222+
"jsonrpc": "2.0",
223+
"id": 3,
224+
"method": "tools/call",
225+
"params": {"name": "get_top_level_nodes", "arguments": {}},
226+
},
227+
session_id=session_id,
228+
)
229+
passed = check("tools/call status 200", status == 200, str(status))
230+
if passed and isinstance(body, dict):
231+
content = body.get("result", {}).get("content", [])
232+
passed &= check("content non-empty", len(content) > 0)
233+
text = json.dumps(content)
234+
passed &= check("mentions 'subject'", "subject" in text)
235+
all_passed &= passed
236+
237+
return all_passed
238+
239+
240+
def main() -> None:
241+
args = parse_test_args()
242+
base_url = (
243+
"https://metadata-portal.allenneuraldynamics-test.org"
244+
if args.env == "prod"
245+
else "http://localhost:5006"
246+
)
247+
248+
print(f"Testing /chat and /mcp against: {base_url}")
249+
print("=" * 80)
250+
251+
chat_ok = run_chat_tests(base_url)
252+
mcp_ok = run_mcp_tests(base_url)
253+
254+
print("\n" + "=" * 80)
255+
if chat_ok and mcp_ok:
256+
print("All chat + mcp integration tests passed.")
257+
else:
258+
print("Some integration tests failed.")
259+
sys.exit(1)
260+
261+
262+
if __name__ == "__main__":
263+
main()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Chat agent module - Bedrock-backed agent using aind-data-mcp tools."""
2+
3+
from .handlers import chat_router
4+
from .mcp_app import mount_mcp_server
5+
6+
__all__ = ["chat_router", "mount_mcp_server"]

0 commit comments

Comments
 (0)