fix(mcp): execute MCP tool calls server-side in native function calling mode

When Ollama/Gemma returns finish_reason=tool_calls in a non-streaming
response, OWU was passing the response through unchanged — the tool was
never called and oracle-mcp only ever saw ListToolsRequest.

Root cause: the tool-execution loop lives entirely in
streaming_chat_response_handler, which is skipped for non-streaming
JSON responses.  non_streaming_chat_response_handler only checked for
choices[0].message.content and silently dropped tool_calls.

Fix:
- Add _execute_tool_calls_non_streaming() helper that extracts tool
  calls from a non-streaming LLM response, resolves each tool from
  metadata['tools'], executes it (MCP call_tool or builtin callable),
  and feeds the results back for a second LLM turn.
- Call it from non_streaming_chat_response_handler whenever
  function_calling==native and tool_calls are present in the response.
- Also normalize Ollama-prefixed tool names (e.g. "oracle:oracle_vault_ask"
  → "oracle_vault_ask") at execution time in both the streaming and
  non-streaming paths so the tools_dict lookup succeeds.

Tested: oracle-mcp now shows CallToolRequest after Gemma emits a
tool_call, and Gemma's final answer is grounded in vault data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Prometheus 2026-04-24 20:37:49 +02:00
parent 8dae237a0b
commit d6eb4544fb

View file

@ -3342,6 +3342,144 @@ async def outlet_filter_handler(ctx):
log.debug(f'Error running outlet filters: {e}')
async def _execute_tool_calls_non_streaming(request, form_data, ctx, response_data):
"""Execute tool calls from a non-streaming LLM response and return the final response.
When a model returns finish_reason=tool_calls in a non-streaming response,
OWU needs to:
1. Execute each requested tool (MCP or builtin)
2. Append the assistant message and tool results to the message history
3. Issue a second LLM call and return its response
"""
user = ctx['user']
metadata = ctx['metadata']
tools = metadata.get('tools', {})
if not tools:
return None
choices = response_data.get('choices', [])
if not choices:
return None
message = choices[0].get('message', {})
tool_calls_raw = message.get('tool_calls', [])
if not tool_calls_raw:
return None
results = []
for tool_call in tool_calls_raw:
tool_call_id = tool_call.get('id', '')
tool_function_name = tool_call.get('function', {}).get('name', '')
tool_args = tool_call.get('function', {}).get('arguments', '{}')
# Normalize tool name: Ollama may prepend the MCP server id as a
# namespace (e.g. "oracle:oracle_vault_ask") but tools_dict keys use
# the underscore-prefixed form ("oracle_vault_ask").
if tool_function_name not in tools and ':' in tool_function_name:
normalized_name = tool_function_name.split(':', 1)[1]
if normalized_name in tools:
log.info(f'[MCP] Normalizing tool name "{tool_function_name}" -> "{normalized_name}"')
tool_function_name = normalized_name
tool_call.setdefault('function', {})['name'] = tool_function_name
# Parse arguments
tool_function_params = {}
if tool_args and tool_args.strip():
try:
tool_function_params = ast.literal_eval(tool_args)
except Exception:
try:
tool_function_params = json.loads(tool_args)
except Exception:
log.error(f'[MCP] Could not parse tool args for {tool_function_name}: {tool_args}')
results.append({'tool_call_id': tool_call_id, 'content': 'Error: could not parse arguments'})
continue
# Sanitize args to only allowed params
tool_result = None
if tool_function_name in tools:
tool = tools[tool_function_name]
spec = tool.get('spec', {})
allowed_params = spec.get('parameters', {}).get('properties', {}).keys()
tool_function_params = {k: v for k, v in tool_function_params.items() if k in allowed_params}
try:
log.info(f'[MCP] Executing tool "{tool_function_name}" params={tool_function_params}')
if tool.get('direct', False):
event_caller = ctx.get('event_caller')
if event_caller:
tool_result = await event_caller(
{
'type': 'execute:tool',
'data': {
'id': str(uuid4()),
'name': tool_function_name,
'params': tool_function_params,
'server': tool.get('server', {}),
'session_id': metadata.get('session_id', None),
},
}
)
else:
tool_function = await get_updated_tool_function(
function=tool['callable'],
extra_params={
'__messages__': form_data.get('messages', []),
'__files__': metadata.get('files', []),
},
)
tool_result = await tool_function(**tool_function_params)
log.info(f'[MCP] Tool "{tool_function_name}" result: {str(tool_result)[:200]}')
except Exception as e:
log.error(f'[MCP] Tool execution error for {tool_function_name}: {e}')
tool_result = str(e)
else:
log.warning(f'[MCP] Tool "{tool_function_name}" not found in tools_dict (keys: {list(tools.keys())[:5]}...)')
tool_result = f'Error: tool "{tool_function_name}" not found'
results.append({'tool_call_id': tool_call_id, 'content': str(tool_result) if tool_result else ''})
if not results:
return None
# Build updated messages: original + assistant msg with tool_calls + tool results
updated_messages = list(form_data.get('messages', []))
updated_messages.append({
'role': 'assistant',
'content': message.get('content') or '',
'tool_calls': tool_calls_raw,
})
for result in results:
updated_messages.append({
'role': 'tool',
'tool_call_id': result['tool_call_id'],
'content': result['content'],
})
# Second LLM call with tool results in context
new_form_data = {
**form_data,
'messages': updated_messages,
'stream': False,
}
# Remove tools list so the LLM just answers (avoids infinite loop)
new_form_data.pop('tools', None)
log.info(f'[MCP] Making second LLM call after tool execution')
try:
second_response = await generate_chat_completion(
request,
new_form_data,
user,
bypass_system_prompt=True,
)
return second_response
except Exception as e:
log.error(f'[MCP] Error in second LLM call: {e}')
return None
async def non_streaming_chat_response_handler(response, ctx):
request = ctx['request']
@ -3351,10 +3489,28 @@ async def non_streaming_chat_response_handler(response, ctx):
event_emitter = ctx['event_emitter']
form_data = ctx.get('form_data', {})
response, response_data = get_response_data(response)
if response_data is None:
return response
# Handle tool_calls from native function calling in non-streaming responses.
# The streaming_chat_response_handler's tool-execution loop only runs when
# the response IS a StreamingResponse. For non-streaming JSON responses
# (finish_reason="tool_calls"), we must execute tools here and make a second
# LLM call with the results before returning to the caller.
if metadata.get('params', {}).get('function_calling') == 'native':
choices = response_data.get('choices', [])
if choices and choices[0].get('message', {}).get('tool_calls'):
log.info('[MCP] non-streaming tool_calls detected — executing and re-calling LLM')
second_response = await _execute_tool_calls_non_streaming(
request, form_data, ctx, response_data
)
if second_response is not None:
# Recurse once so the second response goes through normal content handling
return await non_streaming_chat_response_handler(second_response, ctx)
if event_emitter:
try:
if 'error' in response_data:
@ -4461,6 +4617,20 @@ async def streaming_chat_response_handler(response, ctx):
tool_function_name = tool_call.get('function', {}).get('name', '')
tool_args = tool_call.get('function', {}).get('arguments', '{}')
# Normalize tool name: some backends (e.g. Ollama) prepend the MCP
# server id as a namespace (e.g. "oracle:oracle_vault_ask") but our
# tools_dict keys use only the underscore-prefixed form
# ("oracle_vault_ask"). Strip the "server_id:" prefix so the lookup
# below succeeds.
if tool_function_name not in tools and ':' in tool_function_name:
normalized_name = tool_function_name.split(':', 1)[1]
if normalized_name in tools:
log.info(
f'Normalizing tool name from "{tool_function_name}" to "{normalized_name}"'
)
tool_function_name = normalized_name
tool_call.setdefault('function', {})['name'] = tool_function_name
tool_function_params = {}
if tool_args and tool_args.strip():
try:
@ -4490,6 +4660,9 @@ async def streaming_chat_response_handler(response, ctx):
tool_type = None
direct_tool = False
log.info(
f'[MCP] tool_call: name="{tool_function_name}" found={tool_function_name in tools}'
)
if tool_function_name in tools:
tool = tools[tool_function_name]
spec = tool.get('spec', {})