●MCPCONN — Gemini in Google Workspace now connects to Asana, Atlassian Rovo, HubSpot, Mailchimp, QuickBooks, Monday and Salesforce over MCP. It is on by default and managed from the admin console●GLOBAL — The Gmail Search AI Overviews rollout described as global comes with conditions. The announcement itself requires English as the display language and excludes personal accounts in Japan●10/02 — gemini-2.5-flash-image shuts down on October 2, ten days away. The replacement is gemini-3.1-flash-image-preview●402 — Depleted prepay credits now return HTTP 402 instead of 429. The status field still reads RESOURCE_EXHAUSTED, so retry logic that branches on status will never stop●NEW — The day I first disconnected an MCP server: how I choose which tools stay enabled●SKILLS — In-app notices point from Gems to Skills, but Skills is described as unavailable on work and school accounts, and the dates have not been confirmed officially●MCPCONN — Gemini in Google Workspace now connects to Asana, Atlassian Rovo, HubSpot, Mailchimp, QuickBooks, Monday and Salesforce over MCP. It is on by default and managed from the admin console●GLOBAL — The Gmail Search AI Overviews rollout described as global comes with conditions. The announcement itself requires English as the display language and excludes personal accounts in Japan●10/02 — gemini-2.5-flash-image shuts down on October 2, ten days away. The replacement is gemini-3.1-flash-image-preview●402 — Depleted prepay credits now return HTTP 402 instead of 429. The status field still reads RESOURCE_EXHAUSTED, so retry logic that branches on status will never stop●NEW — The day I first disconnected an MCP server: how I choose which tools stay enabled●SKILLS — In-app notices point from Gems to Skills, but Skills is described as unavailable on work and school accounts, and the dates have not been confirmed officially
When Function Calls Turn Into Plain Text, Suspect the Property Names in Your Tool Declarations
A record of chasing MALFORMED_FUNCTION_CALL after output limits and schema conflicts were ruled out, bisecting nine tool declarations down to one property name, and adding a linter that stops bad names before they ship.
I added one small tool to the classification pipeline behind my ukiyo-e wallpaper app, and the next morning calls that had worked all week came back with finishReason: MALFORMED_FUNCTION_CALL. The new tool only returned a colour histogram. I had not touched a single line of the existing declarations.
My first suspicion was the output token limit. My second was a conflict between the schema and the natural-language instructions. Both were wrong — doubling the limit changed nothing, and emptying the instruction text changed nothing either. The calls kept collapsing into prose at exactly the same point.
What it came down to was neither the description nor the argument type of the new tool. It was the name of its property. One trailing underscore, and the problem stopped that same night. Here is how I narrowed it down, and the line I drew afterwards.
What Came Back Was Not an Error, It Was Prose
MALFORMED_FUNCTION_CALL is not an HTTP error. The status is 200 and only the body is broken, which is part of why it took me so long to look in the right place.
There is no functionCall in parts. Instead there is a function call written out as characters in a text part. The model tried to call a tool, could not finish assembling the call, and fell back into writing prose about it.
The part that mattered most: classify_palette was not the only casualty. The two other tools that should have fired in the same turn came back as prose too. One broken declaration takes the whole turn with it.
One declaration is broken, but every call in that turn shows the symptom. That is exactly why starting from "the tool I added last" leads you astray.
Rule Out the Output Limit and the Instruction Conflict First
Before you start suspecting names, clear the two well-known causes out of the way. Both take a few minutes, and skipping the order costs more time than it saves.
Checking the limit
Raise maxOutputTokens generously and send the same input again. If the limit was the cause, you get STOP back here. If you do not, the limit is irrelevant. On models that spend a thinking budget, that budget comes out of the same allowance, so leave real headroom when you test.
Checking the instructions
Temporarily strip anything from the system instruction and user input that constrains the output shape — return JSON, wrap it in a code block, and so on. Structured output and tool declarations do not always sit well together. That layer is the same territory I covered in When responseSchema Ignores Your enum in the Gemini API.
Once those two were out, I accepted that the problem lived inside the declarations themselves. That is where this gets interesting.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to narrow nine tool declarations down to the single guilty one in four API calls, instead of guessing your way through them
✦You will have a short list of names you refuse to put in a schema, so the day you add one more tool does not quietly break the calls that worked yesterday
✦You will stop treating MALFORMED_FUNCTION_CALL as something that just happens, and separate it into three layers: output limits, conflicting instructions, and the names in your declarations
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Removing declarations one at a time takes up to nine attempts when you have nine tools. Splitting the set in half takes four. I went with the second.
# tool_bisect.py — narrows down which declaration triggers the collapse into prose.# Before running, make sure you have one input that reproduces the failure every time.import osfrom google import genaifrom google.genai import typesfrom my_tools import ALL_DECLARATIONS # list[types.FunctionDeclaration]client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY", "YOUR_API_KEY"))MODEL = "gemini-3.8-flash"PROMPT = "Classify one new wallpaper and write the result back to the catalogue."def reproduces(subset): """Return True when this subset alone makes the call collapse into prose.""" resp = client.models.generate_content( model=MODEL, contents=PROMPT, config=types.GenerateContentConfig( tools=[types.Tool(function_declarations=subset)], # Pinned high so the output limit stays out of the experiment. max_output_tokens=4096, ), ) return str(resp.candidates[0].finish_reason).endswith("MALFORMED_FUNCTION_CALL")def bisect(decls): """Keep only the half that still reproduces, and halve again.""" calls = 0 while len(decls) > 1: half = len(decls) // 2 left, right = decls[:half], decls[half:] calls += 1 if reproduces(left): decls = left continue calls += 1 if reproduces(right): decls = right continue print("Neither half reproduces on its own. Suspect a pair of declarations.") break print("calls used:", calls) return declsif __name__ == "__main__": suspects = bisect(list(ALL_DECLARATIONS)) print("suspects:", [d.name for d in suspects])
The only thing this script assumes is that the failure reproduces every single time. Run it while the symptom is intermittent and it will mark an innocent half as clean and throw the culprit away. I sent the same input five times and confirmed five collapses before I started halving.
If neither half reproduces, you are not looking at a lone offender. Switch to testing pairs. While the total number of declarations is still small, brute force finishes in a reasonable number of calls.
The Fix Was a Single Property Name
Bisection stopped at classify_palette. It takes exactly one argument, a file path. That argument was named in.
{ "name": "classify_palette", "parameters": { "type": "object", "properties": { "in": { "type": "string", "description": "path of the image to classify" } }, "required": ["in"] }}
I renamed in to in_ and resent the same input. finishReason came back as STOP, and all three tools arrived as real functionCall parts. Descriptions, types, required — nothing else changed by a character.
To confirm it on your own setup, two declarations that differ only in that name are enough.
# Two declarations that differ only by the property name, to compare finishReason.BROKEN = {"name": "probe", "parameters": {"type": "object", "properties": {"in": {"type": "string"}}}}FIXED = {"name": "probe", "parameters": {"type": "object", "properties": {"in_": {"type": "string"}}}}print(list(BROKEN["parameters"]["properties"]), list(FIXED["parameters"]["properties"]))
Why One Name Breaks the Entire Turn
What follows is not an explanation from the official documentation. It comes from a report that chased the same symptom by bisection and a related issue on the gemini-cli side. I was able to verify the symptom and the reproduction conditions; the internals remain someone's inference, including mine. With that caveat, here is the account I found most coherent.
The model appears to read tool declarations not as raw JSON Schema but as function signatures. The prose that came back — classify_palette(in="…") — is circumstantial evidence for that. In Python, in is a reserved word, so that signature is not a legal one. If one illegal signature is enough to spoil the set of calls being assembled for that turn, the fact that all three tools degraded together fits neatly.
If that is right, the rule falls out on its own. Anything that surfaces in the shape of a call will object to a name that cannot be written in the shape of a call.
Whether the account is correct, I honestly cannot verify. But even if it is wrong, nothing bad happens from avoiding reserved words. So I chose to fix the name rather than wait for a definitive explanation.
Names I Have Decided to Keep Out of Schemas
That night I wrote out the list. Reserved words alone were not enough — anything that might be read as a first parameter or a builtin went onto the same shelf.
Name to avoid
Why
What I use instead
in / from / class / is / not / lambda / global
Python keywords. They cannot appear in a signature
source / origin / category / is_active
self / cls
Can be read as the first parameter
target / owner
args / kwargs
Collide with variadic argument notation
options / extra
type / id / input / list / dict
Builtins. Not fatal, but they invite ambiguity
kind / item_id / payload
Names starting with a digit, or containing hyphens
Not valid identifiers
Move to underscore separation
A trailing underscore like in_ is the long-standing Python convention for dodging a keyword, and it reads as deliberate, so I recommend it over inventing a fresh word. One caveat: if your schema mirrors an external API response, renaming breaks the mapping. In that case I rename only in the declaration and translate back to the original key at the entry point of the implementation, which caused the least damage in my case.
A Small Linter That Runs Before the Declarations Ship
Fixing it once is not enough. The next person to add a tool — myself in six months, most likely — will reach for the same name, and the same morning comes round again. So I put a check in front of the call to Gemini.
# schema_lint.py — refuses tool declarations whose property names are risky.# assert_clean() is called both at startup and in CI.import keywordRESERVED = set(keyword.kwlist) | set(keyword.softkwlist) | { "self", "cls", "args", "kwargs", "type", "id", "input", "list", "dict", "object",}class SchemaNameError(ValueError): """Raised when a tool declaration uses a property name that collides with a keyword."""def walk(schema, path): """Walk properties, items and $defs recursively, yielding each name and its path.""" if not isinstance(schema, dict): return for name, child in (schema.get("properties") or {}).items(): yield name, path + [name] yield from walk(child, path + [name]) items = schema.get("items") if items is not None: yield from walk(items, path + ["[]"]) for key in ("$defs", "definitions"): for name, child in (schema.get(key) or {}).items(): yield from walk(child, path + [key, name])def lint(declarations): """List violations as (tool name, path, offending name).""" found = [] for decl in declarations: for prop, path in walk(decl.get("parameters") or {}, []): if prop in RESERVED: found.append((decl["name"], ".".join(path), prop)) return founddef assert_clean(declarations): """Stop immediately if there is even one violation.""" found = lint(declarations) if found: lines = [" {}: {} is the reserved word {}".format(t, p, n) for t, p, n in found] raise SchemaNameError("unusable names in tool declarations\n" + "\n".join(lines))if __name__ == "__main__": sample = [{ "name": "search_catalog", "parameters": { "type": "object", "properties": {"in": {"type": "string"}, "limit": {"type": "integer"}}, }, }] try: assert_clean(sample) except SchemaNameError as err: print(err)
It also runs at startup because tools can be added from a config file after CI has passed. Narrow the check down to one place and you guarantee a path that bypasses it. The reason it raises instead of logging follows the same policy I described in Reading Gemini API Errors by status, and Keeping Production Alive: failing loudly at startup is cheaper than failing quietly in production.
Where This Bites
Stop at the top level and you see half of it
A check that only reads the top-level properties misses array elements and nested objects. I shipped that version first and the same symptom came back two days later. Since I started walking items and $defs, it has not returned.
Generated schemas are the safer case
If you build schemas from Pydantic models or dataclasses, you cannot write a field called in in Python to begin with, so the problem rarely appears. The risk lives in hand-written JSON Schema that mirrors an external spec. In a mixed codebase, audit the hand-written side first.
Name conversion makes the repo disagree with the wire
If you convert between camelCase and snake_case at serialisation time, the name in your repository is not the name that reaches the API. Run the check against the schema after conversion. I managed to create the worst possible state here once: green checks over a broken payload.
Only keys matter, values do not
An enum containing the value "in" produced no symptom at all. What needs fixing is the key, not the value. Replace values as well and you change the classification results themselves.
One Thing to Do Next
Pick one declaration you already ship and hold it up against the RESERVED set in schema_lint.py. If nothing comes out, keep this article in your back pocket. If something does, that name is waiting for the day you add your next tool.
As an indie developer running several apps and sites on my own, the most expensive hours are the ones spent finding what broke overnight. That is why, on the day I finally understand a failure, I try to leave exactly one thing behind so the next version of me does not have to walk the same road.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.