A guarded GPT powered agent that decides whether to call a PubMed / NCBI E-utilities tool exposed via an MCP server and, when appropriate, returns a structured response describing one or several scientific articles. It composes a prompt from a personality and a format directive, then uses the GPT output as parameters for an MCP tool call to pubmed_handle_request. If no PubMed call is needed, it returns a small diagnostic payload instead.
It demonstrates how to:
- subclass
SummonerClient, - use receive/send hooks with a buffer,
- integrate cost/token guardrails (see
safeguards.py), - load prompts from
gpt_config.json, - use GPT to decide whether to call an external tool and with which ranking (
latestvsmost relevant), - call the PubMed tool through an MCP server (which performs
esearch.fcgi+efetch.fcgi) and return normalized article summaries.
The agent also uses an identity tag from id.json and is designed to interoperate with agents that send structured content (e.g., InputAgent).
Note
The overall structure is inspired by EchoAgent_2 and built from its GPT-based adaptation GPTRespondAgent by changing its prompt and moving the PubMed E-utilities call layer into an MCP server to fulfill biomedical literature lookup requests.
Important
OpenAI credentials required. The agent calls load_dotenv() and expects an environment variable named OPENAI_API_KEY. Put a .env file at the project root (or set the variable in your shell/CI) so it is available at runtime:
-
.env:
OPENAI_API_KEY=sk-...your_key... -
macOS/Linux terminal:
export OPENAI_API_KEY="sk-...your_key..." -
Windows (PowerShell) terminal:
$env:OPENAI_API_KEY="sk-...your_key..."
If the key is missing, the agent will raise: RuntimeError("OPENAI_API_KEY missing in environment.").
Note
Optional NCBI_API_KEY for PubMed.
PubMed works without an API key, but with lower rate limits. To increase limits, you can obtain an NCBI API key:
-
Follow the instructions here: https://support.nlm.nih.gov/kbArticle/?pn=KA-05317.
-
Log into your NCBI account.
-
Click your username/email (top right) → Account Settings.
-
Scroll to the bottom and generate an API key.
-
Store it in your
.envas:NCBI_API_KEY=your_ncbi_api_key_here
Ensure your .env is in .gitignore. When NCBI_API_KEY is not set, the agent still works, but within unauthenticated rate limits.
In the MCP version, this environment variable is read by the MCP server (which calls load_dotenv()), not by the agent.
(Click to expand) The agent goes through these steps:
-
On startup, the
setupcoroutine initializes anasyncio.Queuenamedmessage_buffer. -
MyAgent, a subclass ofSummonerClient, loads:-
OpenAI API key from environment (via
dotenvif present), -
GPT config from
gpt_config.json(or--gpt <path>), including:model,output_parsing,max_chat_input_tokens,max_chat_output_tokens,personality_prompt,format_prompt,sleep_seconds,cost_limit_usd,debug,- the MCP endpoint URL (for example
mcp_url),
-
An identity UUID (
my_id) fromid.json(or--id <path>).
-
-
Incoming messages invoke the receive-hook (
@agent.hook(Direction.RECEIVE)):-
If it is a string starting with
"Warning:", logs a warning and drops it. -
If it is not a dict with
"remote_addr"and"content", logs:[hook:recv] missing address/contentand drops it.
-
Otherwise, logs:
[hook:recv] <addr> passed validationand forwards the message to the receive handler.
-
-
The receive handler (
@agent.receive(route="")) enqueuesmsg["content"]intomessage_bufferand logs:Buffered message from:(SocketAddress=<addr>). -
Before sending, the send-hook (
@agent.hook(Direction.SEND)) logs:[hook:send] sign <uuid>It wraps raw strings into
{"message": ...}, adds{"from": my_id}, and forwards the message to the send handler. -
The send handler (
@agent.send(route="")) dequeues the payload (content) and builds a single user message:<personality_prompt> <format_prompt> Content: <JSON-serialized payload>This works whether
contentis a raw string or a JSON-like object; in the string case it is serialized as a JSON string.It then:
-
Calls
gpt_call_async(...)withoutput_parsing="json". -
Interprets the GPT output as a tool argument dictionary
tool_args:- If GPT returns a string, it tries to
json.loadsit. - If the result is not a dict, it falls back to
{}.
- If GPT returns a string, it tries to
-
-
The GPT output is expected to be either:
-
an empty object
{}meaning do not call PubMed, or -
a dict describing a PubMed operation, for example:
{ "action": "search_fetch", "term": "combinatorial genome-wide association study", "retmax": 1, "sort": "pub_date" }or
{ "action": "search_fetch", "term": "Alzheimer's disease synaptic loss", "retmax": 3, "sort": "relevance" }
The send handler then:
-
checks if
tool_argsis non-empty and contains a non-empty"action"string, -
if yes, calls the MCP server tool:
mcp_result = await call_tool_async( mcp_url=<from gpt_config.json>, tool_name="pubmed_handle_request", arguments=tool_args, ) api_result = mcp_result performed_call = True
-
if no, it sets:
api_result = { "error": "no_pubmed_call_requested_or_missing_action", "tool_args": tool_args, } performed_call = False
-
-
The MCP server tool
pubmed_handle_requestinternally:-
normalizes
retmaxandsort:sort="pub_date"→ latest by publication date,sort="relevance"→ most relevant (best match),
-
if explicit PMIDs are provided (
pmidorpmids), it calls EFetch directly, -
otherwise, it calls ESearch with
term, then EFetch on returned PMIDs, -
normalizes each article to:
{ "pmid": "12345678", "title": "Article title...", "journal": "Journal name", "date": "2024", "authors": ["First Author", "Second Author"], "abstract": "Abstract text...", "link": "https://pubmed.ncbi.nlm.nih.gov/12345678/" }
-
-
The agent sends back a normalized response of the form:
{ "tool": "pubmed", "performed_call": true, "result": { "action": "search_fetch", "term": "combinatorial genome-wide association study", "pmids": ["12345678"], "retmax": 1, "sort": "pub_date", "count": 1, "articles": [ { "pmid": "12345678", "title": "Example combinatorial GWAS paper title", "journal": "Example Journal", "date": "2023", "authors": ["First Author", "Second Author"], "abstract": "Short abstract text...", "link": "https://pubmed.ncbi.nlm.nih.gov/12345678/" } ], "timestamp_utc": "2025-12-04T12:00:00Z" }, "tool_args": { "action": "search_fetch", "term": "combinatorial genome-wide association study", "retmax": 1, "sort": "pub_date" }, "to": "<uuid of sender>" }If no lookup is performed or if inputs are incomplete,
resultcontains an error payload such as:{ "error": "missing_term_and_pmids", "tool_args": { ... } }The agent logs a summary:
[respond] model=<model> id=<uuid> cost=<usd_or_none> performed_call=<True|False> -
Sleeps for
sleep_secondsand repeats until stopped (Ctrl+C).
| Feature | Description |
|---|---|
class MyAgent(SummonerClient) |
Subclasses SummonerClient to load configs, identity, and manage state |
@agent.hook(Direction.RECEIVE) |
Validates or drops incoming messages before main handling |
@agent.hook(Direction.SEND) |
Signs outgoing messages by adding a from field with UUID |
@agent.receive(route=...) |
Buffers validated messages into the queue |
@agent.send(route=...) |
Builds the GPT prompt, interprets output as tool args, conditionally calls PubMed, returns result |
agent.logger |
Logs hook activity, buffering, PubMed calls, and send lifecycle events |
agent.loop.run_until_complete(...) |
Runs the setup coroutine to initialize the message queue |
agent.run(...) |
Connects to the server and starts the asyncio event loop |
First, start the Summoner server:
python server.pyTip
You can use the option --config configs/server_config_nojsonlogs.json for cleaner terminal output and log files.
Start the MCP server for PubMed (the agent expects it to be reachable before it can call PubMed tools):
python agents/agent_MCPPubMedAgent/mcp_server.pyPrepare gpt_config.json and id.json in agents/agent_MCPPubMedAgent/.
A typical gpt_config.json for MCPPubMedAgent (only the important parts):
{
"model": "gpt-4o-mini",
"sleep_seconds": 0.5,
"output_parsing": "json",
"cost_limit_usd": 0.004,
"debug": true,
"max_chat_input_tokens": 4000,
"max_chat_output_tokens": 1500,
"mcp_url": "http://localhost:8000/mcp",
"personality_prompt": "You are a helpful, concise assistant. Tone: neutral and objective. How you operate: answer directly and completely; prefer clarity over verbosity; avoid speculation and state assumptions briefly only when unavoidable; keep outputs deterministic and free of meta-commentary.",
"format_prompt": "You will receive ONE JSON object under the label \"Content:\". This object may include fields such as \"question\", \"instruction\", \"keywords\", \"pmid\", \"pmids\", \"mode\" (e.g. \"latest\" or \"most relevant\"), or other context describing what the user wants.\n\nYour task:\n1) Decide whether the user is asking to look up scientific articles in PubMed or would clearly benefit from such a lookup. Cues include disease/biology topics plus terms like \"PubMed\", \"recent paper\", \"latest trial\", \"most relevant article\", or explicit PMIDs.\n2) If a PubMed lookup IS appropriate and you can identify the necessary parameters, construct arguments for the PubMed tool and OUTPUT a JSON object with the following keys:\n - \"action\": always the string \"search_fetch\".\n - One of:\n * \"term\": a STRING keyword query suitable for PubMed ESearch (you may include PubMed field tags like [tiab], [dp], etc. if the request implies them), OR\n * \"pmid\": a single PMID as a string, OR\n * \"pmids\": a LIST of PMIDs as strings.\n - \"retmax\": an INTEGER between 1 and 20 (optional; default is 5). Use this when the user asks for the \"top 1\", \"top 3\", \"a few papers\", etc.\n - \"sort\": a STRING describing the ranking, with allowed values:\n * \"pub_date\" for latest/newest by publication date,\n * \"relevance\" for most relevant / best match.\n Map user language like \"latest\", \"most recent\", \"newest\" to \"pub_date\" and \"most relevant\", \"best match\" to \"relevance\".\n3) If a PubMed lookup is NOT appropriate, or if you cannot reliably infer either a search term or any PMIDs, OUTPUT an EMPTY JSON object: {}.\n\nRules:\n- Output MUST be a single JSON object.\n- If you decide to call PubMed, you MUST include \"action\": \"search_fetch\" and at least one of \"term\", \"pmid\", or \"pmids\".\n- You MAY include \"retmax\" and \"sort\" when the user implies a number of results or a preference for latest vs most relevant; otherwise you may omit them.\n- Do NOT include any keys other than: \"action\", \"term\", \"pmid\", \"pmids\", \"retmax\", \"sort\".\n- Do NOT add explanations, comments, or natural-language text outside the JSON. The entire response must be valid JSON.\n- Use only the information present in Content and general reasoning. You do not call the API; you only prepare the parameters.\n\nExamples:\n- User asks: \"Give me the latest paper on CRISPR therapy for sickle cell disease\" → {\"action\": \"search_fetch\", \"term\": \"CRISPR therapy sickle cell disease\", \"retmax\": 1, \"sort\": \"pub_date\"}\n- User asks: \"Find the most relevant recent papers on machine learning for cancer prognosis, maybe 3 articles\" → {\"action\": \"search_fetch\", \"term\": \"machine learning cancer prognosis\", \"retmax\": 3, \"sort\": \"relevance\"}\n- User asks: \"Look up PMID 34567890 on PubMed\" → {\"action\": \"search_fetch\", \"pmid\": \"34567890\", \"retmax\": 1}\n- User asks: \"Explain what PubMed is\" (no lookup requested) → {}"
}The agent identity is defined in id.json and only requires a "uuid" key:
// agents/agent_MCPPubMedAgent/id.json
{"uuid": "6fb3fedd-ebca-43b8-b915-fd25a6ecf78a"}Start the agent:
python agents/agent_MCPPubMedAgent/agent.pyOptional CLI flags:
--gpt <path>: Use a customgpt_config.jsonpath.--id <path>: Use a customid.jsonpath.--config <path>: Summoner client config path (defaults toconfigs/client_config.json).
These scenarios show how MCPPubMedAgent consumes input from InputAgent and either:
- calls PubMed when a literature lookup is appropriate, or
- returns a small diagnostic payload when it is not.
All scenarios use InputAgent so you can type requests interactively and inspect the resulting payloads.
# Terminal 1: server
python server.py
# Terminal 2: InputAgent (multi-line input)
python agents/agent_InputAgent/agent.py --multiline 1
# Terminal 3: PubMed MCP server
python agents/agent_MCPPubMedAgent/mcp_server.py
# Terminal 4: MCPPubMedAgent
python agents/agent_MCPPubMedAgent/agent.pyIn Terminal 2 (InputAgent), type:
> Give me the latest PubMed paper on combinatorial GWAS in complex disease.
MCPPubMedAgent should:
-
Receive this as
content(a string). -
Have GPT map this to something like:
{ "action": "search_fetch", "term": "combinatorial GWAS complex disease", "retmax": 1, "sort": "pub_date" } -
Call the PubMed MCP tool (which performs ESearch + EFetch internally).
-
Return a payload similar to:
[Received] {
'tool': 'pubmed',
'performed_call': True,
'result': {
'action': 'search_fetch',
'term': 'combinatorial genome-wide association study complex disease',
'pmids': ['12345678'],
'retmax': 1,
'sort': 'pub_date',
'count': 1,
'articles': [
{
'pmid': '12345678',
'title': 'Combinatorial genome-wide association approaches for complex trait mapping',
'journal': 'Example Journal of Genetics',
'date': '2023',
'authors': ['First Author', 'Second Author'],
'abstract': 'Short abstract text summarizing the combinatorial GWAS approach...',
'link': 'https://pubmed.ncbi.nlm.nih.gov/12345678/'
}
],
'timestamp_utc': '2025-12-04T12:00:00Z'
},
'tool_args': {
'action': 'search_fetch',
'term': 'combinatorial genome-wide association study complex disease',
'retmax': 1,
'sort': 'pub_date'
},
'from': '6fb3fedd-ebca-43b8-b915-fd25a6ecf78a'
}
>
This scenario checks that "latest" is correctly mapped to sort="pub_date" and retmax=1.
In Terminal 2, type:
> Find the most relevant PubMed papers on Alzheimer's disease focusing on synaptic loss. Show me about 3 articles.
MCPPubMedAgent should interpret this as a PubMed search with a relevance ranking and GPT should output tool args similar to:
{
"action": "search_fetch",
"term": "Alzheimer's disease synaptic loss",
"retmax": 3,
"sort": "relevance"
}The agent calls the PubMed MCP tool, which performs ESearch with sort=relevance and then EFetches up to 3 PMIDs. You should see a response like:
[Received] {
'tool': 'pubmed',
'performed_call': True,
'result': {
'action': 'search_fetch',
'term': "Alzheimer's disease synaptic loss",
'pmids': ['34567890', '34567901', '34567912'],
'retmax': 3,
'sort': 'relevance',
'count': 3,
'articles': [
{
'pmid': '34567890',
'title': 'Synaptic degeneration in Alzheimer\'s disease: mechanisms and therapeutic targets',
'journal': 'Example Neuroscience Journal',
'date': '2022',
'authors': ['First Author', 'Second Author'],
'abstract': 'Short abstract focusing on synaptic loss mechanisms...',
'link': 'https://pubmed.ncbi.nlm.nih.gov/34567890/'
},
{
'pmid': '34567901',
'title': 'Early synaptic changes in Alzheimer\'s disease models',
'journal': 'Brain Research',
'date': '2021',
'authors': ['Third Author'],
'abstract': 'Abstract text...',
'link': 'https://pubmed.ncbi.nlm.nih.gov/34567901/'
},
{
'pmid': '34567912',
'title': 'Linking amyloid and synaptic dysfunction in Alzheimer\'s disease',
'journal': 'Neurobiology of Disease',
'date': '2020',
'authors': ['Fourth Author', 'Fifth Author'],
'abstract': 'Abstract text...',
'link': 'https://pubmed.ncbi.nlm.nih.gov/34567912/'
}
],
'timestamp_utc': '2025-12-04T12:00:00Z'
},
'tool_args': {
'action': 'search_fetch',
'term': "Alzheimer's disease synaptic loss",
'retmax': 3,
'sort': 'relevance'
},
'from': '6fb3fedd-ebca-43b8-b915-fd25a6ecf78a'
}
>
This scenario checks that the agent can:
- recognize "most relevant" →
sort="relevance", - map "about 3 articles" →
retmax=3.
In Terminal 2 (InputAgent), type:
> {"instruction":"Explain in simple terms what a genome-wide association study (GWAS) is."}
Here the user is asking for a general explanation, not a specific PubMed lookup. The format_prompt tells GPT to only request a PubMed call when an actual literature search clearly makes sense.
In this case, GPT should output {} as tool args, the agent will not call PubMed, and the response will look like:
[Received] {
'tool': 'pubmed',
'performed_call': False,
'result': {
'error': 'no_pubmed_call_requested_or_missing_action',
'tool_args': {}
},
'tool_args': {},
'from': '6fb3fedd-ebca-43b8-b915-fd25a6ecf78a'
}
>
You can use these three scenarios to verify:
- that PubMed is called when the intent is clearly "look up biomedical papers" (with appropriate latest vs most relevant behavior), and
- that no call is made when the request is purely explanatory and does not require a PubMed lookup.