Repository navigation
v0.22.0
Fixed
-
The first Google Ads tool call no longer stalls the MCP server's event
loop while the Google Ads SDK is imported (#809). The API client, and the
account listing thatgoogle_ads_accounts_listruns without a
customer_id, now run in a worker thread (asyncio.to_thread), so other
calls in flight keep being served during the roughly 1.2 s of CPU that
import costs. -
The credential guard's Bash hook reads a command the way bash does in more
places (#806). It now agrees with bash about which brace groups a command
expands and where a here-document body, a shell expansion and a quoted string
begin and end, and it also reads a quoted string or a here-document body the
way a program that re-reads it would. That second reading is expanded over the
inner shell's own words — a space that shell acts on splits the content and
dissolves any brace group that holds it, while a space that is quoted,
escaped, or inside a substitution is kept, exactly as it does for bash — and
is
bounded per word, so ordinary data handed to a program in one quoted argument
— a JSON array, a dictionary in apython -cscript — is no longer refused for
its size, however long, as long as it has a space after each comma, which
json.dumpsand every pretty-printer write by default. What is left on the
deny side there is a run of comma-bearing brace groups packed into one word
with no space anywhere between them, which a shell re-reading the string would
multiply too, and a group whose space sits inside a group nested in it, which
is kept whole although bash would split it. What it cannot resolve is refused
rather than guessed at, in every reading set, and
each refusal states its own ground instead of claiming the command can reach
the credentials directory. The over-blocks this costs are listed in the module
docstring ofmureo/credential_guard.pyand pinned by tests. -
Both credential-guard hooks read their stdin as bytes (#806), so a host
that sends UTF-8 is read as UTF-8 under every Windows code page; before, a home
directory with a non-ASCII name could stop matching the protected path. An
empty stdin, and an exception inside a hook, are now refused with reasons of
their own. -
The MCP server's start no longer spends most of the client's connect budget
on work no tool call needs (#807). Against a default MCP connect budget of
30,000 ms in Claude Code, whether the server came up at all depended on how
much of the start was already cached. When it lost, the session had none
of themcp__mureo__*tools, and an unattended run — a headlessclaude -p,
a scheduled job, a fresh container — starts cold by definition. All of the
cost wasimport mureo.mcp.server: building the server object and
answeringtools/listcost 0.14 ms and 0.06 ms of CPU respectively once
that import was done (measured 2026-10-09 on the machine described below,
by calling_create_server()andhandle_list_tools()in-process right
after the import,time.process_time, min of 5).Two things paid for it, and neither was serving a tool call:
mureo/google_ads/__init__.py(and its Meta counterpart) eagerly imported
the API client, so a stdlib-only helper the tool schemas need — the GAQL
period whitelist behind everyperiodenum — dragged in the whole
generatedgoogle.ads.googleads.v25protobuf tree. The same clients
arrived by a second route in an installation that has a
mureo.runtime_context_factoryplugin registered, which is how #807 was
reported: plugin collection runs during the server's import, the Amazon
bridge asks for theRuntimeContext, the integrator's factory imports
mureo/adapters/*, and those imported their platform clients.- Every tool in the catalog had its
inputSchemavalidated against the JSON
Schema Draft 2020-12 metaschema at import, for hundreds of tools a session
never calls.
So the platform packages now resolve their public names and submodules on
first access (PEP 562) —from mureo.google_ads import GoogleAdsApiClient
andimport mureo.google_adsthen.clientboth still work exactly as
before, and a typo'd name is still amypyerror — the provider adapters
import their clients underTYPE_CHECKING, and a tool's validator is
compiled on that tool's first call, which is where it is enforced.Measured on one development machine (CPython 3.10, macOS, 228 built-in tools)
on 2026-10-08 as the import's own CPU time (time.process_timeinside a
child interpreter, bytecode cache warm, min of 5 runs) andlen(sys.modules)
after it. The import is timed whole,jsonschema's own import included;
the split below starts its clock afterjsonschemais imported. No wall
clock is quoted: on this machine the same work measured 7–9 s warm and 19 s
cold while its CPU time moved by hundredths, so a wall figure here would be a
load average.configuration before after no plugins installed 3.1 s / 2249 modules 1.1 s / 882 with a runtime_context_factoryplugin (#807's)3.1 s / 2259 1.2 s / 892 with a provider plugin that imports a platform client at module scope 3.2 s / 2250 2.4 s / 2229 1150 of the modules before were platform SDK, gRPC, crypto or protobuf; in
the first two configurations none is now. The third row is the one to read
if you run plugins: a provider that writesfrom mureo.google_ads import GoogleAdsApiClientat module scope pays for the SDK itself, and mureo cannot
defer an import a plugin makes.docs/plugin-authoring.mdnow says where to
put that import; moving it brings such an installation onto the first row.The saving splits between the two causes, so neither should be credited with
it alone: with the metaschema check stubbed out the old import still cost
2.26 s against its usual 3.07 s, puttingcheck_schemaat 0.8-0.9 s and the
imports it stopped doing at 1.2-1.3 s (two sittings on the same machine on
2026-10-08; the absolute figures move a few percent with its state, the
split less). That 0.8-0.9 s is the difference between two whole-server
imports; it is a separate measurement from the 0.82 s quoted elsewhere for
check_schema, which ischeck_schemacalled directly on each of the 228
schemas and summed.The issue reports 17–34 s. This is the same defect, but the figures above are
not claimed to account for all of it: that installation has more plugins than
were reproduced here, and what a plugin spends importing itself is the
plugin's own.tools/listis unchanged — not "equivalent", the same bytes: the sha256 of
the exacttools/listresult line is identical before and after, both with
credentials present and withHOMEpointed at an empty directory.
tests/test_mcp_startup_budget.pyfails if a platform SDK, API client, gRPC,
crypto or protobuf module is asked for on mureo's own import path, with or
without plugins installed: asys.meta_pathfinder records which module
asked for each of those modules first, and none of them may be mureo's. What
a plugin imports for itself is charged to the plugin. That is a judgement
about one import's stack rather than a comparison of two module sets — the
comparison this check used to be went quiet in exactly the installations that
have plugins, which is where #807 was reported from. -
Where mureo's own check is the one that sees it, a call that a tool's
inputSchemacannot check is refused with an error that names the tool
(#807).check_schemaaccepts a$ref, and resolving it is deferred to the
first validation, which then raisesreferencing'sUnresolvable/
PointerToNowherefor a pointer into nothing or a$refto a URL. Such a
call was already refused before the handler ran, and still is. Which layer
refuses it depends on the path the call takes:- Through the MCP framework (a client over stdio):
mcp(1.30.0
checked) validates the arguments against theinputSchemabefore mureo
sees the call, and turns any exception from that check into an error
result.
The call is refused there, with the third-party message, which does not
name the tool. mureo's layer is never reached, so nothing below happens
on this path: noToolSchemaUnusableError, no log record, no
PluginToolWarning, and strict mode does not see it. - Where mureo's layer is the only check: an
mcpwithout
validate_input(the declared range ismcp>=1.0,<2), a direct call to
handle_call_tool, and the rollback handlers, which apply every reversal
— a real-spend one such as a budget put back included — through that
direct call. What reached the caller there was the bare third-party
exception; it is now aToolSchemaUnusableErrornaming the tool and the
cause (chained), still not aValueError, because nothing the caller
sends can fix it. The handler still never runs: the bound the schema
declares cannot be checked, so the call is not served. The first refusal
per tool is logged in full, and for a plugin's tool also raised as a
PluginToolWarning, so the documented strict mode sees it on these
paths; repeats are logged at DEBUG with a running count, so an agent
retrying in a loop does not write a full warning per attempt.
- Through the MCP framework (a client over stdio):
Changed
-
CI: the whole-product credential-guard enumeration
(tests/test_credential_guard_product.py), which the per-PRtest-slowjob
leaves out for its two-hour run time, now runs nightly and on demand in a new
credential-guard-sweep.ymlworkflow that opens an issue when it fails or
times out (#808). -
Shipped files are English-only, and CI checks it (#810). The remaining
Japanese comments, docstrings and markdown prose are translated (Japanese
data literals stay), andscripts/check_english_only.pyruns in the lint
job. -
A built-in tool's
inputSchemais now compiled on that tool's first call
instead of at server start (#807), so a fault in one is found there. What
a fault costs depends on which of three kinds it is, and for calls, on the
path they take (see Fixed): through the MCP framework,mcp1.30.0 runs
the same metaschema check and refuses all three kinds before mureo's layer
is reached (for a schema that declares no$schemaor Draft 2020-12; an
older declared draft is judged by the framework under that draft, while
mureo's layer always judges by 2020-12); the cells below about calls
describe mureo's layer, which is
what anmcpwithoutvalidate_input, a directhandle_call_toolcall
and a rollback reversal get.fault before now not a valid JSON Schema ( check_schemaraisesSchemaError)warning at start; on a direct call the tool is served without input validation (through the framework, mcp1.30.0 refuses it for a schema that declares no$schemaor Draft 2020-12)the same, on the tool's first call: served without input validation on a direct call, refused by mcp1.30.0 through the framework (for a schema that declares no$schemaor Draft 2020-12)compiling raises anything else (a RecursionErrorfrom a$refcycle, ajsonschemabug)the server did not start calls to that tool are refused ( ToolSchemaUnusableErrorfrom mureo's layer; the framework's own error through the framework); the rest of the catalog is servedthe schema raises when applied to a call (a $refinto nothing)that call refused, with the third-party exception that call refused: with ToolSchemaUnusableErrorwhere mureo's layer checks it, with the third-party message as before through the framework (see Fixed)The first row keeps the pre-#807 behaviour on purpose. The second cannot:
with compilation deferred the server is already running when it happens, so
the nearest equivalent is that the one tool cannot be called — narrower than
before, and never a call served with bounds nobody checked. Each is
reported; the log record's logger name moves frommureo.mcp.serverto
mureo.mcp._tool_validation. Built-in schemas are checked for every tool in
CI (tests/test_mcp_strict_input_schemas.py, against both the authored
schema and the rebuilt one the server actually compiles), so an authoring
mistake still fails before it ships.One cost moved the same way and is worth knowing about: the Google Ads SDK
is no longer imported while the server starts, so the first Google Ads
tool call imports it. That call's client construction is synchronous and
runs on the event loop, so other calls in flight wait for it. Measured on the
same development machine on 2026-10-09: about 1.2 s of CPU (1.22-1.28 s
over 5 runs, against 0.93-0.99 s for the server's whole import in the same
runs;time.process_timein a child interpreter, timing the two imports
create_google_ads_clientperforms right afterimport mureo.mcp.server,
bytecode cache warm, one warm-up run discarded), takingsys.modulesfrom
882 to 2228. The Meta client costs under 10 ms after that. Moving the
Google import off the event loop is separate work.A plugin's schema is still checked while the server starts: a plugin
author gets no run of mureo's CI, and there are normally a handful of such
tools (a median of 3.1 ms each). Three things about that report changed. All
of a startup's faults now go out in one report that names every one of
them, in a fixed order — reported one at a time, a strict deployment raised
on the first and the operator never learned about the rest, and which one
that was changed between runs. The report goes out as aPluginToolWarning
and as a log record: the warning is the only channel a strict deployment
can promote to an error, and the log record is the only one an operator sees
over stdio, where nothing captures warnings by default. And the category now
lives in a new leaf module,mureo.plugin_warnings, with
mureo.mcp.tool_providerre-exporting it so existing imports keep working.That move is what makes the strict mode
docs/plugin-authoring.mddocuments
work at all.from mureo.mcp.tool_provider import PluginToolWarningimports
mureo.mcp, which imports the server, which collects the plugins and reports
the fault — so the
warnings.filterwarnings("error", category=PluginToolWarning)that followed
it was always installed too late, and the documented recipe was inert. Import
the category frommureo.plugin_warnings, install the filter, then import
the server, and an uncompilable plugin schema fails the start as documented. -
The Google Ads mutate-error wrapper moved out of
mureo/google_ads/client.py
intomureo/google_ads/_mutate_errors.py, so the lazily-imported package can
use it without pulling the SDK in. Its log records' logger name changes with
it, frommureo.google_ads.clienttomureo.google_ads._mutate_errors; the
messages, and the exceptions it raises, are unchanged. A log filter or alert
keyed on that logger name needs updating.
Docs
docs/authentication.ja.md— the authentication guide now exists in
Japanese. It was English-only, andREADME.ja.mdsaid so out loud
(the "(English)" marker), so the one document a Japanese operator needs
before anything else works was the one they could not read. Full
translation, not a summary; commands, field names, environment variables,
JSON and URLs stay as they are. The Japanese references now point at it —
README.ja.md(both places, minus that marker),
docs/getting-started.ja.md,docs/byod.ja.md(both places). English
references are unchanged.- The BYOD step links in
docs/getting-started.mdand
docs/getting-started.ja.mdnow land on their sections. They pointed at
#google-ads-setup/#meta-ads-setup, anchors that exist in neither
docs/byod.mdnordocs/byod.ja.md; they now target the Step 2a / Step 2b
headings of each language's BYOD guide. - Both language versions gained the operational facts that were missing
from the console walkthrough. (1) The OAuth client is created under
Google Auth Platform > Clients, the path Google's own help documents;
the guide only had the older APIs & Services > Credentials path. (2) A
refresh token issued by a project configured for the External user type
whose publishing status is still Testing expires in 7 days unless the
requested scopes are a subset of name / email address / user profile —
https://www.googleapis.com/auth/adwordsis not, so an install left on
Testing needs re-authentication weekly. That is the usual cause of "it
worked for a while and then the API stopped going through", and it was
documented nowhere. (3) Refresh tokens are capped at 100 per OAuth
client ID per Google account, and passing the cap invalidates the oldest
one without warning — which repeatedmureo auth setup/mureo configure
re-authentication consumes. (4) App Review is not needed to run your own
ad accounts: in development mode the ads /pages_*/leads_retrieval
permissions are offered to anyone holding an admin, developer or tester role
on the app; Meta's Access Levels page has Standard Access cover people
with a role on the app, while its App Modes page says a Live app can
request only App-Review-approved permissions, so the guide names the
conflict and says to check App Review > Permissions and Features before
going Live. Advanced Access (App Review plus Business Verification) is what
you need for users who hold no role. The guide listed
the scopes but never said when review enters the picture. (5) mureo's setup
has you registerhttp://localhostin Valid OAuth Redirect URIs and
calls back onhttp://localhost:<port>/callbackon a free port — stated as
mureo's own instruction, not as Meta's matching rule. Items (1) to (4) carry
their official Google / Meta source link in the prose. - "What you create by hand" opens the credential sections in both
languages. By hand: a Google OAuth client and a Meta app. Everything else
— the Googlerefresh_token, Meta's Long-Lived Token — mureo obtains from
that pair;developer_tokenis no longer needed at all, and
login_customer_idonly applies through an MCC. It also states the part the
OAuth client does not cover: on Google, the Cloud project needs an access
level before it can touch a production ad account, and that is a separate
application. - A signpost between "App Review not needed" and "Live app required". Both
statements are true and both were on the page, but a reader could take the
first as the whole answer: a development-mode app needs no review and can
complete the localhost browser OAuth, yet it cannot publish a new ad
creative (error subcode 1885183), while a Live app can create creatives but
cannot finish the localhost OAuth and therefore wants the system-user token
of Option C. Both language versions now close the App Review section with
that split as a table, and Option C opens with a pointer back to it. No new
claims — only the three facts already on the page, put next to each other. - Fixed: the Google Ads API access-level ladder was documented wrong, and
the error read as "you cannot touch production until Basic".
docs/authentication.mdanddocs/getting-started.md(plus
docs/getting-started.ja.md) said enabling the API grants Test access and
production accounts need Basic, which needs brand verification — omitting
the Explorer level entirely. Explorer does reach production accounts
(2,880 operations a day against production, 15,000 against test), is applied
for from the Google Ads API Overview page, and needs no brand
verification; Google calls it sufficient for most developers to get
started and build basic automation, with account creation, user management,
planning tools and billing services restricted. All four levels are now a
table — Test (15,000, test accounts only, granted on enabling the API),
Explorer, Basic (15,000 production and test, brand verification of the Cloud
project as a prerequisite before applying), Standard (unlimited, manual
audit against the Required Minimum Functionality) — in both languages, with
Google's access-level and developer-token pages linked. Also recorded from
the 2026-09 migration: the access level belongs to the Cloud project that
issued the OAuth credentials rather than to a developer token, a future
major API version is to reject a developer token rather than ignore it,
Basic applications still pending at the migration were all closed, and
applying for and managing API access moved to the Google Ads API Overview
page in the Google Cloud Console — the API Center page stays reachable for
historical developer details until Google sunsets it completely. Also from
Google's developer-token page: a new Standard application needs brand
verification as well as the manual audit, and Explorer / Basic applications
from a project on the Free Trial or with a suspended or disabled billing
account are rejected (listed as a known issue Google says it is working to
fix, with its workarounds).