Conversation
rcv-mcp-v3.0.0 - workflow prompts
… 0.1.3 The rcv_v3 merge kept the recovery server's uv.lock from the feature branch, so it still pinned the oracle-mcp-common workspace member at 0.1.2 while src/common had been bumped to 0.1.3 by oracle#421. This made the `Sync` step (`uv sync --locked --all-extras --dev`) fail in build (oci-recovery-mcp-server), which cancelled the rest of the build matrix.
uv.lock fix
dustin-sale
left a comment
There was a problem hiding this comment.
Review by @dustin-sale via Codex.
Requested changes
No blocking changes requested.
Additional review notes
src/oci-recovery-mcp-server/README.md:100— [P2] Correct the version-pinneduvxexample.
Validation
make lintandmake test project=oci-recovery-mcp-serverpassed.- The packaged wheel includes the new prompt files.
See the inline comments in this review for evidence, impact, and suggested remediation.
Readme version changed.
| @@ -0,0 +1,2221 @@ | |||
| """ | |||
| Copyright (c) 2025, 2026 Oracle and/or its affiliates. | |||
There was a problem hiding this comment.
2000 lines of code in this module; are they all necessary? can we break this file up into specific categories of tests? is it named coverage because the stated purpose from the prompt was to increase test coverage?
| return request_id | ||
| installation_id = _mcp_installation_id() | ||
| actor_id = _MCP_ACTOR_ID_CONTEXT.get()[:_MCP_ACTOR_ID_LENGTH].ljust(_MCP_ACTOR_ID_LENGTH, "0") | ||
| tool_code = _MCP_TOOL_CODES.get(_MCP_TOOL_ID_CONTEXT.get(), "unk") |
There was a problem hiding this comment.
This is a small segment of code we have added to keep telemetry of API calls being made to our service via the mcp server.
By adding a prefix to opc-request-id.
|
|
||
| Required: | ||
| - ORACLE_MCP_AUTH_METHOD: "session" or "apikey" | ||
| def _legacy_auth_type_override() -> Optional[AuthType]: |
There was a problem hiding this comment.
why is this called "legacy auth"?
There was a problem hiding this comment.
We are now using oci-commons auth and in the prior version we had implemented it in this service-space. To keep backward compatibility we this has been modified but its actually deprecated.
We will phase it in future commits.
| for n in names: | ||
| v = os.getenv(n) | ||
| if v is not None and v.strip() != "": | ||
| return v.strip() |
There was a problem hiding this comment.
we're calling v.strip() twice.
| if m in ("apikey", "api_key", "api-key"): | ||
| return "apikey" | ||
| return "session" | ||
| raw = (os.getenv("ORACLE_MCP_AUTH_METHOD") or "").strip().lower() |
There was a problem hiding this comment.
Will:
OCI_MCP_AUTH_TYPE=security_token
ORACLE_MCP_AUTH_METHOD=apikeyresult in rejected requests after successful authentication?
|
|
||
| qualified = _qualify(audience, scopes) | ||
| provider.update_default_scopes(qualified) | ||
| provider.required_scopes = qualified |
There was a problem hiding this comment.
this changes the scopes enforced by RequireAuthMiddleware (FastMCP). IDCS returns a bare resource scope (not qualified), and the token verifier keeps that scope (from build_idcs_http_auth()).
This is another case where an authenticated session can result in request failures (insufficient_scope). There's two forms that have to be accounted for, and this falls through the gap between them: IDCS expects audience qualified in the requests but emits the bare form in the token scope claim (https://docs.oracle.com/en/cloud/saas/marketing/audience-develop/docs/authentication/authenticating-oauth.htm)
Can you confirm?
|
gsharini#11 |
Review comments on tools, code modularisation, auth is addressed.
| if now - float(cached.get("fetched_at") or 0.0) >= ttl: | ||
| entries.pop(key, None) | ||
| return None | ||
| entries[key] = entries.pop(key) |
There was a problem hiding this comment.
this isn't thread safe and fastmcp executes synchronous tools in worker threads.
| # - We try to be resilient to SDK shape differences by using getattr/__dict__/to_dict | ||
| # wherever possible, especially for pagination and nested model fields. | ||
| # - We log key milestones and counts for better operability and diagnostics. | ||
|
|
||
| import configparser | ||
| import hashlib |
There was a problem hiding this comment.
this file is huge. can you break it up? I see a lot of different candidates for submodules in here (caching is one example).
| _CACHE_MAX_ENTRIES = int(os.getenv("ORACLE_MCP_CACHE_MAX_ENTRIES", "256")) | ||
|
|
||
|
|
||
| def _cache_get(entries: dict[str, Any], key: str, *, ttl: float, now: float) -> Optional[Any]: |
There was a problem hiding this comment.
why did we implement our our caching layer and code?
There was a problem hiding this comment.
We originally built a custom cache to explore a different staleness and expiration algorithm. That work is now on hold pending internal review of its potential novelty. We have added the missing synchronization, but we can replace the current implementation with a standard TTL cache library if that is preferred for this PR. Our preference is to keep the cache implementation so the underlying strategy can be replaced without affecting the tools.
| tenancy_id = get_tenancy() | ||
| cache_key = f"iam:list_region_subscriptions:{tenancy_id}" | ||
| cached = _cache_get(items, cache_key, ttl=ttl, now=now) | ||
| if cached: |
There was a problem hiding this comment.
cache is consulted before authorization check happens below. This can result in someone being able to list region subscriptions even if they lose permission to do so (TENANCY_INSPECT).
There was a problem hiding this comment.
For region caching, OCI performs authorization during the API call, there is no separate local check to move before the cache lookup. Always calling OCI would ensure immediate authz check but would defeat our goal of preventing agents from repeatedly hitting production APIs. We therefore made the region cache caller-scoped, as our other caches already are, and will use a short TTL to limit permission staleness. ( list_**region was wrongly scoped to tenancy that has been fixed)
| @@ -834,19 +1424,19 @@ def _iam_subscribed_regions_with_status(*, request_id: str) -> list[dict]: | |||
| Returns the tenancy's subscribed regions from IAM (IdentityClient.list_region_subscriptions). | |||
| Output items are: {"region": "<region_name>", "status": "<READY|...>"}. | |||
|
|
|||
| Cached in-process for ORACLE_MCP_REGION_CACHE_TTL_SECONDS to avoid repeated IAM calls. | |||
| Cached in-process for ORACLE_MCP_REGION_CACHE_TTL_SECONDS, partitioned per tenant. | |||
There was a problem hiding this comment.
why was this comment changed? it still behaves this way:
to avoid repeated IAM calls
correct?
Cache related issues fixed.
| } | ||
|
|
||
|
|
||
| def get_compartment_by_name_tool( |
| """ | ||
| if coll is None: | ||
| return None | ||
| data = _oci_to_dict(coll) or {} |
There was a problem hiding this comment.
There are a few unused definitions in this file please take care.
| return resolved_id | ||
|
|
||
|
|
||
| def fetch_child_compartments( |
There was a problem hiding this comment.
Unused definition please take care
There was a problem hiding this comment.
Removed the four unused functions.
| ) | ||
|
|
||
|
|
||
| def get_onesubscription_client(region: str | None = None, *, request_id: Optional[str] = None): |
| the client factories need to know about. | ||
| """ | ||
|
|
||
| import logging |
There was a problem hiding this comment.
There are a few unused imports here please take care.
|
|
||
| """MCP tools available in this server: | ||
| - fetch_regions_subscribed |
There was a problem hiding this comment.
Can we further split this file based on tools? May be one module per tool family?
There was a problem hiding this comment.
Done. server.py is now 94 lines (module docstring + main() + registration imports). Split into recovery_tools.py (12 tools), database_tools.py (6), summarise_tools.py (4), prompt_tools.py (3), plus app.py holding the FastMCP instance and shared tool constants - that last one exists to break the import cycle, since the family modules need mcp to register and server.py needs the families imported. All 25 tools still register; verified by comparing the registered tool list bef
|
|
||
| def get_compartment_by_name(compartment_name: str): | ||
| """Internal function to get compartment by name with caching""" | ||
| compartments = list_all_compartments_internal(False) |
There was a problem hiding this comment.
Do we want to use cache here or directly call OCI ?
docstring mentions cache but code directly pulls from OCI
There was a problem hiding this comment.
Good catch - the docstring was right about the intent and the code wasn't. get_compartment_by_name now goes through _list_all_compartments_cached().
| """ | ||
| # An explicit override always wins. Over HTTP it is the only source: there is | ||
| # no local OCI config file on a hosted deployment to read a tenancy from. | ||
| override = _first_env("TENANCY_ID_OVERRIDE", "ORACLE_MCP_TENANCY_ID") |
There was a problem hiding this comment.
Should we use ?
override = _first_env(
"OCI_MCP_TENANCY_ID_OVERRIDE", # canonical, matches oracle-mcp-common
"ORACLE_MCP_TENANCY_ID",
"TENANCY_ID_OVERRIDE",
)
There was a problem hiding this comment.
Applied as suggested. Confirmed OCI_MCP_TENANCY_ID_OVERRIDE is the canonical name in oracle_mcp_common/auth.py:243; the other two stay as deprecated fallbacks.
There was a problem hiding this comment.
region is ignored, can we honor this argument?
There was a problem hiding this comment.
Fixed. Precedence is now caller-supplied region → configured region → error. A blank string counts as "not provided" and falls through rather than erroring.
| @@ -2364,10 +1233,9 @@ def check_recovery_service_limits( | |||
| """ | |||
| try: | |||
| request_id = uuid.uuid4().hex | |||
| config = _load_oci_config_for_server() | |||
| resolved_compartment_id = get_tenancy() | |||
| target_region = (config.get("region") or "us-ashburn-1").strip() | |||
There was a problem hiding this comment.
Can we raise an error if region cannot be derived and assume us-ashburn-1 .
There was a problem hiding this comment.
Fixed together with the above - no default region is assumed anywhere now. If neither the argument nor the config yields one, the tool raises with a message naming both ways to supply it. (There was an existing test asserting the old behaviour by passing region="ignored"; updated.)
| @@ -2430,37 +1299,44 @@ def _as_dict(obj: Any) -> dict[str, Any]: | |||
|
|
|||
|
|
|||
| @mcp.tool( | |||
| annotations=_READ_ONLY_TOOL, | |||
| description=( | |||
| "Lists the tenancy's subscribed regions and their status using " | |||
| "IdentityClient.list_region_subscriptions(). " | |||
| "NOTE: The 'service' parameter is accepted for backward compatibility but is " | |||
There was a problem hiding this comment.
Can we review and rewrite the tool description ?
There was a problem hiding this comment.
Rewritten. It documented a service parameter that doesn't exist, and described tenancy_id as a compartment OCID.
There was a problem hiding this comment.
Confirmed - has_backups_db_names was never appended to. Fixed, with a test.
| @@ -3667,13 +2743,22 @@ def summarize_protected_database_backup_destination( | |||
| max_db_homes: Annotated[Optional[int], "Max number of DB Homes to scan."] = None, | |||
| max_total_databases: Annotated[Optional[int], "Global cap on databases to scan."] = None, | |||
There was a problem hiding this comment.
Not honoured well, databases list can reach beyond this number.
There was a problem hiding this comment.
Fixed. The cap is now enforced inside the scan loop and the result sliced to it, so the list can't exceed max_total_databases.
There was a problem hiding this comment.
Is this sorting by time ?
There was a problem hiding this comment.
It wasn't - it was comparing a mix of datetime objects and raw strings, so ordering was lexicographic and wrong whenever the two forms met on the same day. Added _as_instant(), which parses to a tz-aware datetime (assuming UTC when naive) before comparing.
There was a problem hiding this comment.
Is there a reason for doing de-dup in the end ? Will that not affect the other list entries?
There was a problem hiding this comment.
You're right that it affected the other lists - the count and the name lists were built from the un-deduplicated rows, so total_databases could exceed the number of entries actually reported. Moved the de-dup up to the scan (seen_database_ids), so every downstream list and count derives from the same de-duplicated set.
| ) | ||
| ) | ||
| @_tool_logger("summarize_protected_database_backup_destination") | ||
| @telemetry._tool_logger("summarize_protected_database_backup_destination") | ||
| def summarize_protected_database_backup_destination( |
There was a problem hiding this comment.
This is a huge method with many nested methods and loops. Can we simplify this code ?
There was a problem hiding this comment.
Simplified: 443 → 172 lines, 10 nested functions → 0, max loop depth 3 → 1. The shape-reading helpers are now module-level, and the body is three named phases - _scan_available_databases, _backup_destinations_for, _latest_backup_time.
There was a problem hiding this comment.
Can we add deadline check here as well?
There was a problem hiding this comment.
Added. summarize_backup_space_used now takes the same deadline, and reports scanned_compartments / compartmentIdsInScope / truncated so a partial result is visible rather than silent. The destination summary sets truncated from the deadline as well.
Removed custom cache and included standard cache Split server.py And few other review comments
| def _fetch_all_compartments(*, request_id: Optional[str] = None) -> list[Any]: | ||
| """ | ||
| Return all accessible ACTIVE compartments in the tenancy (plus root tenancy), | ||
| cached in-process so repeated Identity scans in one session cost one call. |
There was a problem hiding this comment.
what if permissions are revoked during the session? we shouldn't cache anything that requires permission to access without a permission revalidation.
There was a problem hiding this comment.
This cache never decides what a caller can access. It holds compartment metadata only, kept separately for each caller and expired after ORACLE_MCP_COMPARTMENT_CACHE_TTL_SECONDS. Every call that returns data (protected databases, backups, DB homes) is signed with the caller's own credentials, so a caller whose access was revoked gets nothing back. The most they can see during the TTL is compartment names and OCIDs they could already see a few minutes earlier. Here the listing only sets scope, and checking permissions again would mean the same full subtree scan the cache exists to avoid, on every call.
| aggregated=aggregated, | ||
| per_compartment=per_compartment, | ||
| compartmentIdsScanned=scanned_compartments, | ||
| truncated=deadline.expired, |
There was a problem hiding this comment.
this is missing on ProtectedDatabaseHealthCounts; can result in truncated=False even when the wrapping response contains True.
There was a problem hiding this comment.
Fixed. The aggregated counts now get partial=deadline.expired in both the health and redo summaries. The deadline test checks the aggregated flag as well as the per-compartment one.
| continue | ||
| if sid in seen_database_ids: | ||
| continue | ||
| seen_database_ids.add(sid) |
There was a problem hiding this comment.
the database is added before _backup_destinations_for runs. If _backup_destinations_for fails, what will happen?
There was a problem hiding this comment.
Fixed. The ID is added to seen_database_ids only after _backup_destinations_for succeeds, so a database that fails to read stays out of every count, and a later duplicate of it gets retried.
| `ORACLE_MCP_AUTH_PROFILE` remain supported, so existing configurations keep working, | ||
| but they are optional and no longer needed. | ||
|
|
||
| Configuration comes from environment variables, which may also be placed in a `.env` |
There was a problem hiding this comment.
does this still work? I think it might have been broken in the refactor
There was a problem hiding this comment.
Akshat can you please check this ? Perhaps we are still not using .env ?
There was a problem hiding this comment.
corresponding doc has been updated now.
| tool_id_token = _MCP_TOOL_ID_CONTEXT.set(tool_name) | ||
| logging_setup._log_event( | ||
| "tool_call", | ||
| request_id=request_id, |
There was a problem hiding this comment.
this is generated per tool, so the tool and OCI events/opc-request-id aren't correlated.
There was a problem hiding this comment.
Fixed. _tool_logger now puts its request ID in a ContextVar. The tool body, the helpers and _make_client all read it instead of generating their own, so the tool_call events, the oci_call events and the opc-request-id all carry the same ID. A new test checks this end to end, including a client built without an explicit ID.
|
|
||
|
|
||
| def _mcp_actor_id() -> str: | ||
| """Return a privacy-safe opaque identifier for the active MCP user/session.""" |
There was a problem hiding this comment.
I don't understand this statement: if sub is returned, than principal contains a value that uniquely identifies the user. How is that privacy protecting?
There was a problem hiding this comment.
It was a documentation issue, has been now resolved.
The ID goes only into opc-request-id, and every request carrying it is already signed with that caller's own credentials, so OCI learns nothing it doesn't already know. The hash keeps a raw sub (often a username or email) out of a value that gets copied into logs and support tickets, while distinct callers can still be counted.
|
|
||
| # Two OCI calls per database on top of the compartment/home/page walk, so this | ||
| # is the heaviest fan-out of the four summaries and needs the same budget. | ||
| deadline = app._Deadline() |
There was a problem hiding this comment.
there's a ton of work that's happening before we start measuring against the deadline; could this cause discovery to go over the 120s budget on a cold cache?
There was a problem hiding this comment.
Are we considering the deadline now along with compartment expansion ? Akshat could you please check ?
There was a problem hiding this comment.
Fixed. All four summary tools now start the deadline before compartment and DB home discovery, and the discovery loop checks it, so a large scope stops and returns truncated=True instead of using up the budget before it starts. A new test checks that the deadline starts before discovery in all four tools and that no DB home lookup happens once it has run out.
| ) | ||
| except Exception: | ||
| # Continue on per-DB errors to maximize overall coverage | ||
| continue |
There was a problem hiding this comment.
how does this handle spurious failures? retries? if a db has an intermittent early failure it will be skipped for how long?
There was a problem hiding this comment.
A failure isn't stored anywhere, so a database is skipped only for the call that failed. The next call reads it again, and a duplicate later in the same scan is retried. The OCI client wrapper also logs each failure at ERROR, and with the request-ID fix that log entry now links to its tool call. On retries: get_database and list_backups don't retry by default in the SDK, so they now use a limited strategy covering 429, timeouts and transient 5xx, with at most 3 attempts and 10 seconds in total.
request_id fixes
removed env file related code.
This change delivers Recovery MCP Server v3.0.0, expanded Recovery Service and Database Service read coverage, and guided operational workflows for Cloud Protect onboarding.
Fixes # (issue)
Type of change
Please delete options that are not relevant.
Bug fix (non-breaking change which fixes an issue)
New feature (non-breaking change which adds functionality)
Breaking change (fix or feature that would cause existing functionality to not work as expected)
This change requires a documentation update
How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration
Added targeted unit tests covering:
Guidance-tool availability and input validation.
Onboard testing :
Prompt
codex> "Using recovery service mcp server onboard database DB0729 with ip 100.102.44.215 to recovery service."
Final Result:
DB0729 is onboarded to Recovery Service.
Protected database: db0729_xxm_iad
Status: ACTIVE
Policy: Bronze, 14-day retention
Recovery Service subnet: the OCID you supplied
Scheduled Cloud Protect backup task: every 15 minutes
The initial health is WARNING — Waiting for archive logs, which is expected immediately after onboarding; the first scheduled task is due at 13:20 UTC. Real-time redo remains disabled. I did not enable it because that is a separate configuration change and requires a
Checklist:
My code follows the style guidelines of this project
I have performed a self-review of my own code
I have commented my code, particularly in hard-to-understand areas
I have made corresponding changes to the documentation
My changes generate no new warnings
I have added tests that prove my fix is effective or that my feature works
New and existing unit tests pass locally with my changes
Any dependent changes have been merged and published in downstream modules